diff --git a/app.py b/app.py index ad2d1309..ad932e9a 100644 --- a/app.py +++ b/app.py @@ -57,7 +57,8 @@ def initLogging(): '_send_single_request', 'httpcore.http11', 'httpx._client', - 'HTTP Request' + 'HTTP Request', + 'multipart.multipart' ] return not any(pattern in record.msg for pattern in http_debug_patterns) return True @@ -253,4 +254,8 @@ from modules.routes.routeVoiceGoogle import router as voiceGoogleRouter app.include_router(voiceGoogleRouter) from modules.routes.routeVoiceStreaming import router as voiceStreamingRouter -app.include_router(voiceStreamingRouter) \ No newline at end of file +app.include_router(voiceStreamingRouter) + +# Admin security routes (token listing and revocation, logs, db tools) +from modules.routes.routeSecurityAdmin import router as adminSecurityRouter +app.include_router(adminSecurityRouter) \ No newline at end of file diff --git a/debug_audio/audio_google_interpreter_recording.webm b/debug_audio/audio_google_interpreter_recording.webm new file mode 100644 index 00000000..e9ecfe7c Binary files /dev/null and b/debug_audio/audio_google_interpreter_recording.webm differ diff --git a/env_dev.env b/env_dev.env index 29c59f98..24a15187 100644 --- a/env_dev.env +++ b/env_dev.env @@ -48,7 +48,7 @@ DB_MANAGEMENT_PASSWORD_SECRET=dev_password DB_MANAGEMENT_PORT=5432 # Security Configuration -APP_JWT_SECRET_SECRET=dev_jwt_secret_token +APP_JWT_SECRET_SECRET=rotated_jwt_secret_2025_09_17_f8a3b6c2-7d4e-45b6-9a1f-3c0b9a1d2e7f APP_TOKEN_EXPIRY=300 # CORS Configuration diff --git a/env_int.env b/env_int.env index 54c9878c..416a5b9e 100644 --- a/env_int.env +++ b/env_int.env @@ -27,7 +27,7 @@ DB_MANAGEMENT_PASSWORD_SECRET=VkAjgECESbEVQ$Tu DB_MANAGEMENT_PORT=5432 # Security Configuration -APP_JWT_SECRET_SECRET=dev_jwt_secret_token +APP_JWT_SECRET_SECRET=rotated_jwt_secret_2025_09_17_2c5f8e7a-1b3d-49c7-ae5d-9f0a2c3d4b5e APP_TOKEN_EXPIRY=300 # CORS Configuration diff --git a/env_prod.env b/env_prod.env index 3127352a..9410cea6 100644 --- a/env_prod.env +++ b/env_prod.env @@ -27,7 +27,7 @@ DB_MANAGEMENT_PASSWORD_SECRET=prod_password_very_secure.2025 DB_MANAGEMENT_PORT=5432 # Security Configuration -APP_JWT_SECRET_SECRET=dev_jwt_secret_token +APP_JWT_SECRET_SECRET=rotated_jwt_secret_2025_09_17_prod_e1a9c4d7-6b8f-4f2e-9c1a-7e3d2a1b9c5f APP_TOKEN_EXPIRY=300 # CORS Configuration diff --git a/modules/connectors/connectorDbPostgre.py b/modules/connectors/connectorDbPostgre.py index 9af0123d..c6b254e7 100644 --- a/modules/connectors/connectorDbPostgre.py +++ b/modules/connectors/connectorDbPostgre.py @@ -57,14 +57,6 @@ class DatabaseConnector: Uses PostgreSQL with JSONB columns for flexible data storage. """ def __init__(self, dbHost: str, dbDatabase: str, dbUser: str = None, dbPassword: str = None, dbPort: int = None, userId: str = None): - # DEBUG: Log constructor parameters - logger.info(f"DEBUG: DatabaseConnector constructor called with:") - logger.info(f" dbHost: '{dbHost}' (type: {type(dbHost)})") - logger.info(f" dbDatabase: '{dbDatabase}' (type: {type(dbDatabase)})") - logger.info(f" dbUser: '{dbUser}' (type: {type(dbUser)})") - logger.info(f" dbPort: {dbPort} (type: {type(dbPort)})") - logger.info(f" userId: '{userId}' (type: {type(userId)})") - # Store the input parameters self.dbHost = dbHost self.dbDatabase = dbDatabase @@ -109,10 +101,6 @@ class DatabaseConnector: def _create_database_if_not_exists(self): """Create the database if it doesn't exist.""" try: - # DEBUG: Log all database configuration values - logger.info(f"DEBUG: Database configuration - Host: {self.dbHost}, Database: {self.dbDatabase}, User: {self.dbUser}, Port: {self.dbPort}") - logger.info(f"DEBUG: Database name type: {type(self.dbDatabase)}, value: '{self.dbDatabase}'") - # Use the configured user for database creation conn = psycopg2.connect( host=self.dbHost, @@ -132,11 +120,8 @@ class DatabaseConnector: if not exists: # Create database with proper quoting for names with hyphens quoted_db_name = f'"{self.dbDatabase}"' - logger.info(f"DEBUG: Creating database with quoted name: {quoted_db_name}") cursor.execute(f"CREATE DATABASE {quoted_db_name}") logger.info(f"Created database: {self.dbDatabase}") - else: - logger.info(f"Database {self.dbDatabase} already exists") conn.close() @@ -171,9 +156,6 @@ class DatabaseConnector: _modifiedAt DOUBLE PRECISION ) """) - - logger.info("System table created successfully") - conn.close() except Exception as e: @@ -195,7 +177,6 @@ class DatabaseConnector: cursor_factory=psycopg2.extras.RealDictCursor ) self.connection.autocommit = False # Use transactions - logger.info(f"Connected to PostgreSQL database: {self.dbDatabase}") except Exception as e: logger.error(f"Failed to connect to PostgreSQL: {e}") raise @@ -299,8 +280,6 @@ class DatabaseConnector: if '_modifiedAt' not in existing_columns: cursor.execute(f'ALTER TABLE "{self._systemTableName}" ADD COLUMN "_modifiedAt" DOUBLE PRECISION') - logger.info("Added _modifiedAt column to existing system table") - return True except Exception as e: @@ -610,15 +589,8 @@ class DatabaseConnector: raise ValueError("userId must be provided") self.userId = userId - logger.info(f"Updated database context: userId={self.userId}") - # No cache to clear - database handles data consistency - def clearTableCache(self, model_class: type) -> None: - """No-op: Database handles data consistency automatically.""" - # No caching with proper database - PostgreSQL handles consistency - pass - # Public API def getTables(self) -> List[str]: diff --git a/modules/interfaces/interfaceAppModel.py b/modules/interfaces/interfaceAppModel.py index 15e2fbcb..e8ca0caa 100644 --- a/modules/interfaces/interfaceAppModel.py +++ b/modules/interfaces/interfaceAppModel.py @@ -29,6 +29,11 @@ class ConnectionStatus(str, Enum): REVOKED = "revoked" PENDING = "pending" +class TokenStatus(str, Enum): + """Status of an issued gateway JWT access token""" + ACTIVE = "active" + REVOKED = "revoked" + class Mandate(BaseModel, ModelMixin): """Data model for a mandate""" id: str = Field( @@ -321,6 +326,13 @@ class Token(BaseModel, ModelMixin): expiresAt: float = Field(description="When the token expires (UTC timestamp in seconds)") tokenRefresh: Optional[str] = None createdAt: Optional[float] = Field(None, description="When the token was created (UTC timestamp in seconds)") + # Revocation and session tracking (for LOCAL gateway JWTs) + status: TokenStatus = Field(default=TokenStatus.ACTIVE, description="Token status: active/revoked") + revokedAt: Optional[float] = Field(None, description="When the token was revoked (UTC timestamp in seconds)") + revokedBy: Optional[str] = Field(None, description="User ID who revoked the token (admin/self)") + reason: Optional[str] = Field(None, description="Optional revocation reason") + sessionId: Optional[str] = Field(None, description="Logical session grouping for logout revocation") + mandateId: Optional[str] = Field(None, description="Mandate ID for tenant scoping of the token") class Config: useEnumValues = True @@ -338,7 +350,13 @@ register_model_labels( "tokenType": {"en": "Token Type", "fr": "Type de jeton"}, "expiresAt": {"en": "Expires At", "fr": "Expire le"}, "tokenRefresh": {"en": "Refresh Token", "fr": "Jeton de rafraîchissement"}, - "createdAt": {"en": "Created At", "fr": "Créé le"} + "createdAt": {"en": "Created At", "fr": "Créé le"}, + "status": {"en": "Status", "fr": "Statut"}, + "revokedAt": {"en": "Revoked At", "fr": "Révoqué le"}, + "revokedBy": {"en": "Revoked By", "fr": "Révoqué par"}, + "reason": {"en": "Reason", "fr": "Raison"}, + "sessionId": {"en": "Session ID", "fr": "ID de session"}, + "mandateId": {"en": "Mandate ID", "fr": "ID de mandat"} } ) diff --git a/modules/interfaces/interfaceAppObjects.py b/modules/interfaces/interfaceAppObjects.py index bc786fe1..ed8fdca1 100644 --- a/modules/interfaces/interfaceAppObjects.py +++ b/modules/interfaces/interfaceAppObjects.py @@ -20,7 +20,7 @@ from modules.interfaces.interfaceAppAccess import AppAccess from modules.interfaces.interfaceAppModel import ( User, Mandate, UserInDB, UserConnection, AuthAuthority, UserPrivilege, - ConnectionStatus, Token, AuthEvent, + ConnectionStatus, Token, AuthEvent, TokenStatus, DataNeutraliserConfig, DataNeutralizerAttributes ) @@ -949,6 +949,99 @@ class AppObjects: logger.error(f"Error deleting connection token for connectionId {connectionId}: {str(e)}") raise + # ===================== + # Token revocation (LOCAL gateway JWTs) + # ===================== + def findActiveTokenById(self, tokenId: str, userId: str, authority: AuthAuthority, sessionId: str = None, mandateId: str = None) -> Optional[Token]: + """Find an active access token by its id (jti) with optional session/tenant scoping.""" + try: + recordFilter = { + "id": tokenId, + "userId": userId, + "authority": authority.value if hasattr(authority, 'value') else str(authority), + "status": TokenStatus.ACTIVE, + } + if sessionId is not None: + recordFilter["sessionId"] = sessionId + if mandateId is not None: + recordFilter["mandateId"] = mandateId + tokens = self.db.getRecordset(Token, recordFilter=recordFilter) + if not tokens: + return None + return Token(**tokens[0]) + except Exception as e: + logger.error(f"Error finding active token by id {tokenId}: {str(e)}") + return None + + def revokeTokenById(self, tokenId: str, revokedBy: str, reason: str = None) -> bool: + """Revoke a single token by id by setting status fields (no delete).""" + try: + existing = self.db.getRecordset(Token, recordFilter={"id": tokenId}) + if not existing: + return False + token = existing[0] + if token.get("status") == TokenStatus.REVOKED: + return True + tokenUpdate = { + "status": TokenStatus.REVOKED, + "revokedAt": get_utc_timestamp(), + "revokedBy": revokedBy, + "reason": reason or "revoked" + } + self.db.recordModify(Token, tokenId, tokenUpdate) + return True + except Exception as e: + logger.error(f"Error revoking token {tokenId}: {str(e)}") + return False + + def revokeTokensBySessionId(self, sessionId: str, userId: str, authority: AuthAuthority, revokedBy: str, reason: str = None) -> int: + """Revoke all tokens of a session for a user/authority.""" + try: + tokens = self.db.getRecordset(Token, recordFilter={ + "userId": userId, + "authority": authority.value if hasattr(authority, 'value') else str(authority), + "sessionId": sessionId, + "status": TokenStatus.ACTIVE + }) + count = 0 + for t in tokens: + self.db.recordModify(Token, t["id"], { + "status": TokenStatus.REVOKED, + "revokedAt": get_utc_timestamp(), + "revokedBy": revokedBy, + "reason": reason or "session logout" + }) + count += 1 + return count + except Exception as e: + logger.error(f"Error revoking tokens for session {sessionId}: {str(e)}") + return 0 + + def revokeTokensByUser(self, userId: str, authority: AuthAuthority = None, mandateId: str = None, revokedBy: str = None, reason: str = None) -> int: + """Revoke all active tokens for a user, optionally filtered by authority/mandate.""" + try: + # Fetch all active tokens for user (optionally filtered by authority) + recordFilter = { + "userId": userId, + "status": TokenStatus.ACTIVE, + } + if authority is not None: + recordFilter["authority"] = authority.value if hasattr(authority, 'value') else str(authority) + tokens = self.db.getRecordset(Token, recordFilter=recordFilter) + count = 0 + for t in tokens: + self.db.recordModify(Token, t["id"], { + "status": TokenStatus.REVOKED, + "revokedAt": get_utc_timestamp(), + "revokedBy": revokedBy, + "reason": reason or "admin revoke" + }) + count += 1 + return count + except Exception as e: + logger.error(f"Error revoking tokens for user {userId}: {str(e)}") + return 0 + def cleanupExpiredTokens(self) -> int: """Clean up expired tokens for all connections, returns count of cleaned tokens""" try: diff --git a/modules/routes/routeSecurityAdmin.py b/modules/routes/routeSecurityAdmin.py new file mode 100644 index 00000000..2b509a0e --- /dev/null +++ b/modules/routes/routeSecurityAdmin.py @@ -0,0 +1,292 @@ +from fastapi import APIRouter, HTTPException, Depends, status, Request, Body +from fastapi.responses import FileResponse, JSONResponse +from typing import Optional, Dict, Any, List +import os +import logging + +from modules.security.auth import getCurrentUser, limiter +from modules.interfaces.interfaceAppObjects import getInterface, getRootInterface +from modules.interfaces.interfaceAppModel import User, UserInDB, AuthAuthority, Token +from modules.shared.configuration import APP_CONFIG + +logger = logging.getLogger(__name__) + +router = APIRouter( + prefix="/api/admin", + tags=["Admin"], + responses={ + 404: {"description": "Not found"}, + 400: {"description": "Bad request"}, + 401: {"description": "Unauthorized"}, + 403: {"description": "Forbidden"}, + 500: {"description": "Internal server error"} + } +) + +def _ensure_admin_scope(current_user: User, target_mandate_id: Optional[str] = None) -> None: + if current_user.privilege not in ("admin", "sysadmin"): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required") + if current_user.privilege == "admin": + if target_mandate_id and str(target_mandate_id) != str(current_user.mandateId): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden for target mandate") + + +# ---------------------- +# Token listing and revocation +# ---------------------- + +@router.get("/tokens") +@limiter.limit("30/minute") +async def list_tokens( + request: Request, + currentUser: User = Depends(getCurrentUser), + userId: Optional[str] = None, + authority: Optional[str] = None, + sessionId: Optional[str] = None, + statusFilter: Optional[str] = None, + connectionId: Optional[str] = None, +) -> List[Dict[str, Any]]: + try: + appInterface = getRootInterface() + target_mandate = currentUser.mandateId + _ensure_admin_scope(currentUser, target_mandate) + + recordFilter: Dict[str, Any] = {} + if userId: + recordFilter["userId"] = userId + if authority: + recordFilter["authority"] = authority + if sessionId: + recordFilter["sessionId"] = sessionId + if connectionId: + recordFilter["connectionId"] = connectionId + if statusFilter: + recordFilter["status"] = statusFilter + if currentUser.privilege == "admin": + recordFilter["mandateId"] = str(currentUser.mandateId) + + tokens = appInterface.db.getRecordset(Token, recordFilter=recordFilter) + return tokens + except HTTPException: + raise + except Exception as e: + logger.error(f"Error listing tokens: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to list tokens") + + +@router.post("/tokens/revoke/user") +@limiter.limit("30/minute") +async def revoke_tokens_by_user( + request: Request, + currentUser: User = Depends(getCurrentUser), + payload: Dict[str, Any] = Body(...) +) -> Dict[str, Any]: + try: + userId = payload.get("userId") + authority = payload.get("authority") + reason = payload.get("reason", "admin revoke") + if not userId: + raise HTTPException(status_code=400, detail="userId is required") + + appInterface = getRootInterface() + # Tenant scope check + target_user = appInterface.db.getRecordset(User, recordFilter={"id": userId}) + target_mandate = target_user[0].get("mandateId") if target_user else None + _ensure_admin_scope(currentUser, target_mandate) + + count = appInterface.revokeTokensByUser( + userId=userId, + authority=AuthAuthority(authority) if authority else None, + mandateId=None if currentUser.privilege == "sysadmin" else str(currentUser.mandateId), + revokedBy=currentUser.id, + reason=reason + ) + return {"revoked": count} + except HTTPException: + raise + except Exception as e: + logger.error(f"Error revoking tokens by user: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to revoke tokens") + + +@router.post("/tokens/revoke/session") +@limiter.limit("30/minute") +async def revoke_tokens_by_session( + request: Request, + currentUser: User = Depends(getCurrentUser), + payload: Dict[str, Any] = Body(...) +) -> Dict[str, Any]: + try: + userId = payload.get("userId") + sessionId = payload.get("sessionId") + authority = payload.get("authority", "local") + reason = payload.get("reason", "admin session revoke") + if not userId or not sessionId: + raise HTTPException(status_code=400, detail="userId and sessionId are required") + + appInterface = getRootInterface() + target_user = appInterface.db.getRecordset(User, recordFilter={"id": userId}) + target_mandate = target_user[0].get("mandateId") if target_user else None + _ensure_admin_scope(currentUser, target_mandate) + + count = appInterface.revokeTokensBySessionId( + sessionId=sessionId, + userId=userId, + authority=AuthAuthority(authority), + revokedBy=currentUser.id, + reason=reason + ) + return {"revoked": count} + except HTTPException: + raise + except Exception as e: + logger.error(f"Error revoking tokens by session: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to revoke session tokens") + + +@router.post("/tokens/revoke/id") +@limiter.limit("30/minute") +async def revoke_token_by_id( + request: Request, + currentUser: User = Depends(getCurrentUser), + payload: Dict[str, Any] = Body(...) +) -> Dict[str, Any]: + try: + tokenId = payload.get("tokenId") + reason = payload.get("reason", "admin revoke") + if not tokenId: + raise HTTPException(status_code=400, detail="tokenId is required") + appInterface = getRootInterface() + # Load token to check tenant scope for admins + tokens = appInterface.db.getRecordset(Token, recordFilter={"id": tokenId}) + if not tokens: + return {"revoked": 0} + target_mandate = tokens[0].get("mandateId") + _ensure_admin_scope(currentUser, target_mandate) + + ok = appInterface.revokeTokenById(tokenId, revokedBy=currentUser.id, reason=reason) + return {"revoked": 1 if ok else 0} + except HTTPException: + raise + except Exception as e: + logger.error(f"Error revoking token by id: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to revoke token") + + +@router.post("/tokens/revoke/mandate") +@limiter.limit("10/minute") +async def revoke_tokens_by_mandate( + request: Request, + currentUser: User = Depends(getCurrentUser), + payload: Dict[str, Any] = Body(...) +) -> Dict[str, Any]: + try: + mandateId = payload.get("mandateId") + authority = payload.get("authority", "local") + reason = payload.get("reason", "admin mandate revoke") + if not mandateId: + raise HTTPException(status_code=400, detail="mandateId is required") + + _ensure_admin_scope(currentUser, mandateId) + + # Revoke for all users in mandate + appInterface = getRootInterface() + # IMPORTANT: user rows are stored as UserInDB in the database + users = appInterface.db.getRecordset(UserInDB, recordFilter={"mandateId": mandateId}) + total = 0 + for u in users: + # Revoke regardless of token.mandateId to also catch legacy tokens without mandateId + total += appInterface.revokeTokensByUser( + userId=u["id"], + authority=AuthAuthority(authority), + mandateId=None, + revokedBy=currentUser.id, + reason=reason + ) + return {"revoked": total} + except HTTPException: + raise + except Exception as e: + logger.error(f"Error revoking tokens by mandate: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to revoke mandate tokens") + + +# ---------------------- +# Logs download +# ---------------------- + +@router.get("/logs/{log_name}") +@limiter.limit("60/minute") +async def download_log( + request: Request, + currentUser: User = Depends(getCurrentUser), + log_name: str = "poweron" +): + _ensure_admin_scope(currentUser) + base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + # base_dir -> gateway + if log_name == "poweron": + file_path = os.path.join(base_dir, "poweron.log") + elif log_name == "audit": + file_path = os.path.join(base_dir, "audit.log") + else: + raise HTTPException(status_code=400, detail="Unsupported log name") + + if not os.path.exists(file_path): + raise HTTPException(status_code=404, detail=f"{log_name}.log not found") + return FileResponse(path=file_path, filename=f"{log_name}.log") + + +# ---------------------- +# Database admin +# ---------------------- + +@router.get("/databases") +@limiter.limit("10/minute") +async def list_databases( + request: Request, + currentUser: User = Depends(getCurrentUser) +) -> Dict[str, Any]: + _ensure_admin_scope(currentUser) + # For safety, expose only configured database name + db_name = APP_CONFIG.get("DB_DATABASE") or APP_CONFIG.get("DB_NAME") or "poweron" + return {"databases": [db_name]} + + +@router.post("/databases/drop") +@limiter.limit("5/minute") +async def drop_database( + request: Request, + currentUser: User = Depends(getCurrentUser), + payload: Dict[str, Any] = Body(...) +) -> Dict[str, Any]: + _ensure_admin_scope(currentUser) + db_name = payload.get("database") + configured_db = APP_CONFIG.get("DB_DATABASE") or APP_CONFIG.get("DB_NAME") or "poweron" + if not db_name or db_name != configured_db: + raise HTTPException(status_code=400, detail="Invalid database name") + + try: + appInterface = getRootInterface() + conn = appInterface.db.connection + with conn.cursor() as cursor: + # Drop all user tables (public schema) except system table + cursor.execute(""" + SELECT table_name FROM information_schema.tables + WHERE table_schema = 'public' AND table_type = 'BASE TABLE' + """) + tables = [row['table_name'] for row in cursor.fetchall()] + dropped = [] + for tbl in tables: + cursor.execute(f'DROP TABLE IF EXISTS "{tbl}" CASCADE') + dropped.append(tbl) + conn.commit() + logger.warning(f"Admin drop_database executed by {currentUser.id}: dropped tables: {dropped}") + return {"droppedTables": dropped} + except Exception as e: + logger.error(f"Error dropping database tables: {str(e)}") + if appInterface and appInterface.db and appInterface.db.connection: + appInterface.db.connection.rollback() + raise HTTPException(status_code=500, detail="Failed to drop database tables") + + diff --git a/modules/routes/routeSecurityGoogle.py b/modules/routes/routeSecurityGoogle.py index 2064f725..2967e1fc 100644 --- a/modules/routes/routeSecurityGoogle.py +++ b/modules/routes/routeSecurityGoogle.py @@ -160,12 +160,36 @@ async def login( scope=SCOPES ) + extra_params = { + "access_type": "offline", + "include_granted_scopes": "true", + "state": state_param + } + # If targeting specific connection, add login_hint and hd to preselect account + try: + if connectionId: + rootInterface = getRootInterface() + from modules.interfaces.interfaceAppModel import UserConnection + records = rootInterface.db.getRecordset(UserConnection, recordFilter={"id": connectionId}) + if records: + record = records[0] + login_hint = record.get("externalEmail") or record.get("externalUsername") + if login_hint: + extra_params["login_hint"] = login_hint + if "@" in login_hint: + extra_params["hd"] = login_hint.split("@", 1)[1] + # Avoid account picker when targeting a known account + extra_params["prompt"] = "consent" + else: + extra_params["prompt"] = "consent select_account" + else: + extra_params["prompt"] = "consent select_account" + except Exception: + extra_params["prompt"] = "consent select_account" + auth_url, state = oauth.authorization_url( "https://accounts.google.com/o/oauth2/auth", - access_type="offline", - include_granted_scopes="true", - state=state_param, - prompt="consent select_account" + **extra_params ) logger.info(f"Generated Google OAuth URL using OAuth2Session: {auth_url}") @@ -646,20 +670,34 @@ async def refresh_token( request: Request, currentUser: User = Depends(getCurrentUser) ) -> Dict[str, Any]: - """Refresh Google OAuth token for current user""" + """Refresh Google OAuth token for current user. Accepts optional { connectionId } to target a specific connection.""" try: appInterface = getInterface(currentUser) + # Optional: use provided connectionId to target a specific connection + payload = {} + try: + payload = await request.json() + except Exception: + payload = {} + requested_connection_id = payload.get("connectionId") if isinstance(payload, dict) else None + # Find Google connection for this user logger.debug(f"Looking for Google connection for user {currentUser.id}") - connections = appInterface.getUserConnections(currentUser.id) google_connection = None - - for conn in connections: - if conn.authority == AuthAuthority.GOOGLE: - google_connection = conn - break + if requested_connection_id: + for conn in connections: + if conn.id == requested_connection_id and conn.authority == AuthAuthority.GOOGLE: + google_connection = conn + break + if not google_connection: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Requested Google connection not found for current user") + else: + for conn in connections: + if conn.authority == AuthAuthority.GOOGLE: + google_connection = conn + break if not google_connection: raise HTTPException( diff --git a/modules/routes/routeSecurityLocal.py b/modules/routes/routeSecurityLocal.py index 4f86d36e..c0b176b0 100644 --- a/modules/routes/routeSecurityLocal.py +++ b/modules/routes/routeSecurityLocal.py @@ -8,10 +8,12 @@ import logging from typing import Dict, Any, Optional from datetime import datetime, timedelta from fastapi.responses import JSONResponse, HTMLResponse, RedirectResponse +import uuid +from jose import jwt from pydantic import BaseModel # Import auth modules -from modules.security.auth import createAccessToken, getCurrentUser, limiter +from modules.security.auth import createAccessToken, getCurrentUser, limiter, SECRET_KEY, ALGORITHM from modules.interfaces.interfaceAppObjects import getInterface, getRootInterface from modules.interfaces.interfaceAppModel import User, UserInDB, AuthAuthority, UserPrivilege, Token from modules.shared.attributeUtils import ModelMixin @@ -33,7 +35,7 @@ router = APIRouter( ) @router.post("/login") -@limiter.limit("5/minute") +@limiter.limit("30/minute") async def login( request: Request, formData: OAuth2PasswordRequestForm = Depends(), @@ -84,6 +86,10 @@ async def login( "authenticationAuthority": AuthAuthority.LOCAL } + # Create session id and include in token claims for session-scoped logout + session_id = str(uuid.uuid4()) + token_data["sid"] = session_id + # Create access token access_token, expires_at = createAccessToken(token_data) if not access_token: @@ -95,13 +101,24 @@ async def login( # Get user-specific interface for token operations userInterface = getInterface(user) + # Decode JWT to get jti for DB persistence + try: + payload = jwt.decode(access_token, SECRET_KEY, algorithms=[ALGORITHM]) + jti = payload.get("jti") + except Exception as e: + logger.error(f"Failed to decode created JWT: {str(e)}") + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to finalize token") + # Create token token = Token( + id=jti, userId=user.id, authority=AuthAuthority.LOCAL, tokenAccess=access_token, tokenType="bearer", - expiresAt=expires_at.timestamp() + expiresAt=expires_at.timestamp(), + sessionId=session_id, + mandateId=str(user.mandateId) ) # Save access token @@ -111,7 +128,8 @@ async def login( response_data = { "type": "local_auth_success", "access_token": access_token, - "token_data": token.dict() + "token_data": token.dict(), + "authenticationAuthority": "local" } return response_data @@ -215,12 +233,29 @@ async def logout(request: Request, currentUser: User = Depends(getCurrentUser)) try: # Get user interface with current user context appInterface = getInterface(currentUser) - - # Note: JWT tokens are stateless, so no server-side cleanup needed - # The client should discard the JWT token on logout - + # Read bearer token from Authorization header to obtain session id / jti + auth_header = request.headers.get("Authorization") + if not auth_header or not auth_header.lower().startswith("bearer "): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing Authorization header") + raw_token = auth_header.split(" ", 1)[1].strip() + try: + payload = jwt.decode(raw_token, SECRET_KEY, algorithms=[ALGORITHM]) + session_id = payload.get("sid") or payload.get("sessionId") + jti = payload.get("jti") + except Exception as e: + logger.error(f"Failed to decode JWT on logout: {str(e)}") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid token") + + revoked = 0 + if session_id: + revoked = appInterface.revokeTokensBySessionId(session_id, currentUser.id, AuthAuthority.LOCAL, revokedBy=currentUser.id, reason="logout") + elif jti: + appInterface.revokeTokenById(jti, revokedBy=currentUser.id, reason="logout") + revoked = 1 + return JSONResponse({ - "message": "Successfully logged out" + "message": "Successfully logged out", + "revokedTokens": revoked }) except Exception as e: diff --git a/modules/routes/routeSecurityMsft.py b/modules/routes/routeSecurityMsft.py index d14a0555..efde94a3 100644 --- a/modules/routes/routeSecurityMsft.py +++ b/modules/routes/routeSecurityMsft.py @@ -77,12 +77,40 @@ async def login( "connectionId": connectionId }) + # If a specific connection is targeted, set login_hint/domain_hint to preselect that account + login_kwargs = {} + if connectionId: + try: + rootInterface = getRootInterface() + # Fetch the connection by ID directly + records = rootInterface.db.getRecordset(UserConnection, recordFilter={"id": connectionId}) + if records: + record = records[0] + login_hint = record.get("externalEmail") or record.get("externalUsername") + if login_hint: + login_kwargs["login_hint"] = login_hint + # Derive domain hint from email/UPN + if "@" in login_hint: + domain = login_hint.split("@", 1)[1] + # Use common MSAL guidance: pass domain_hint to reduce account switching + login_kwargs["domain_hint"] = domain + # When targeting a specific account, avoid account picker + login_kwargs["prompt"] = "login" # force re-auth for that account only + else: + # Fall back to default behavior if connection not found + login_kwargs["prompt"] = "select_account" + except Exception: + login_kwargs["prompt"] = "select_account" + else: + # Generic login/connect flow: allow choosing account + login_kwargs["prompt"] = "select_account" + # MSAL automatically adds openid, profile, offline_access - we just need to provide our business scopes auth_url = msal_app.get_authorization_request_url( scopes=SCOPES, # Only our business scopes - MSAL adds the required ones automatically redirect_uri=REDIRECT_URI, state=state_param, - prompt="select_account" # Force account selection screen + **login_kwargs ) return RedirectResponse(auth_url) @@ -478,16 +506,36 @@ async def refresh_token( try: appInterface = getInterface(currentUser) + # Optional: use provided connectionId to target a specific connection + payload = {} + try: + payload = await request.json() + except Exception: + payload = {} + requested_connection_id = payload.get("connectionId") if isinstance(payload, dict) else None + # Find Microsoft connection for this user logger.debug(f"Looking for Microsoft connection for user {currentUser.id}") - connections = appInterface.getUserConnections(currentUser.id) msft_connection = None - - for conn in connections: - if conn.authority == AuthAuthority.MSFT: - msft_connection = conn - break + + if requested_connection_id: + # Validate the requested connection belongs to current user and is MSFT + for conn in connections: + if conn.id == requested_connection_id and conn.authority == AuthAuthority.MSFT: + msft_connection = conn + break + if not msft_connection: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Requested Microsoft connection not found for current user" + ) + else: + # Fallback: first MSFT connection + for conn in connections: + if conn.authority == AuthAuthority.MSFT: + msft_connection = conn + break if not msft_connection: raise HTTPException( diff --git a/modules/security/auth.py b/modules/security/auth.py index e82edddf..f314b065 100644 --- a/modules/security/auth.py +++ b/modules/security/auth.py @@ -4,6 +4,7 @@ Handles JWT-based authentication, token generation, and user context. """ from datetime import datetime, timedelta, timezone +import uuid from typing import Optional, Dict, Any, Tuple from fastapi import Depends, HTTPException, status, Request from fastapi.security import OAuth2PasswordBearer @@ -15,7 +16,7 @@ from slowapi.util import get_remote_address from modules.shared.configuration import APP_CONFIG from modules.shared.timezoneUtils import get_utc_now, get_utc_timestamp from modules.interfaces.interfaceAppObjects import getRootInterface -from modules.interfaces.interfaceAppModel import User +from modules.interfaces.interfaceAppModel import User, AuthAuthority, Token # Get Config Data SECRET_KEY = APP_CONFIG.get("APP_JWT_SECRET_SECRET") @@ -44,6 +45,9 @@ def createAccessToken(data: dict, expiresDelta: Optional[timedelta] = None) -> T Tuple of (JWT Token as string, expiration datetime) """ toEncode = data.copy() + # Ensure a token id (jti) exists for revocation tracking (only required for local, harmless otherwise) + if "jti" not in toEncode or not toEncode.get("jti"): + toEncode["jti"] = str(uuid.uuid4()) if expiresDelta: expire = get_utc_now() + expiresDelta @@ -86,6 +90,9 @@ def _getUserBase(token: str = Depends(oauth2Scheme)) -> User: # Extract mandate ID and user ID from token mandateId: str = payload.get("mandateId") userId: str = payload.get("userId") + authority: str = payload.get("authenticationAuthority") + tokenId: Optional[str] = payload.get("jti") + sessionId: Optional[str] = payload.get("sid") or payload.get("sessionId") if not mandateId or not userId: logger.error(f"Missing context in token: mandateId={mandateId}, userId={userId}") @@ -118,6 +125,50 @@ def _getUserBase(token: str = Depends(oauth2Scheme)) -> User: detail="User context has changed. Please log in again.", headers={"WWW-Authenticate": "Bearer"}, ) + + # For LOCAL gateway JWTs, enforce DB-backed token validity and revocation + try: + # Normalize authority to string for comparison + normalized_authority = (str(authority).lower() if authority is not None else None) + + # If we have a token id, check if a corresponding DB token exists for local authority + db_tokens = [] + if tokenId: + try: + db_tokens = appInterface.db.getRecordset( + Token, recordFilter={"id": tokenId} + ) + except Exception: + db_tokens = [] + + if db_tokens: + # There is a server record for this token; enforce status and context when local + db_token = db_tokens[0] + token_authority = str(db_token.get("authority", "")).lower() + if token_authority == str(AuthAuthority.LOCAL.value): + # Must be active and match user/session/mandate + active_token = appInterface.findActiveTokenById( + tokenId=tokenId, + userId=user.id, + authority=AuthAuthority.LOCAL, + sessionId=sessionId, + mandateId=str(mandateId) if mandateId else None, + ) + if not active_token: + logger.info( + f"Local JWT db record not active/valid: jti={tokenId}, userId={user.id}, mandateId={mandateId}, sessionId={sessionId}" + ) + raise credentialsException + else: + # No DB record for this token. If the claim says local (or missing/unknown), require DB record. + if normalized_authority in (None, "", str(AuthAuthority.LOCAL.value)): + logger.info("Local JWT without server record or missing authority claim") + raise credentialsException + except HTTPException: + raise + except Exception as e: + logger.error(f"Error during local token validation: {str(e)}") + raise credentialsException return user diff --git a/notes/readme.md b/notes/readme.md index 14233338..894a3910 100644 --- a/notes/readme.md +++ b/notes/readme.md @@ -1,91 +1,12 @@ -# Data Platform - Multi-Agent Service +### Launch APP -Eine Full-Stack-Webapplikation für die Ausführung von Multi-Agent-Workflows zur Verarbeitung und Analyse von Daten basierend auf natürlichsprachlichen Benutzeranfragen. +cd .\frontend_agents\ +cls; python ./server.py -Hier: http://localhost:8000/docs +conda activate C:\Users\pmots\anaconda3\envs\poweron +cd .\gateway\ +cls; uvicorn app:app --host 0.0.0.0 --port 8000 -## Übersicht - -Das System ermöglicht Benutzern: -- Hochladen und Verwalten verschiedener Datendateien -- Definieren von Prompts/Anweisungen für KI-Agenten -- Auswählen und Kombinieren spezialisierter Agenten -- Ausführen von Workflows mit Echtzeit-Protokollierung -- Visualisieren und Verwalten der Ergebnisse - -## Projektstruktur - -Das Projekt besteht aus zwei Hauptkomponenten: - -### Frontend (HTML/CSS/JavaScript) - -- `index.html` - Hauptstruktur der Benutzeroberfläche -- `styles.css` - Umfangreiches CSS für das responsive Design -- `script.js` - Client-seitige Logik für Interaktionen - -### Backend (Python/FastAPI) - -- `app.py` - Hauptanwendung mit API-Endpunkten -- `models.py` - Datenmodelle und Validierungsschemas -- `database.py` - Datenpersistenz (JSON-basiert für Demo) -- `agent_service.py` - Multi-Agent-Orchestrierung -- `requirements.txt` - Python-Abhängigkeiten - -### Backend-Installation Lokal -0. lokal dev: (anaconda environment) conda activate poweron - -1. Virtuelle Umgebung erstellen und aktivieren: - ```bash - python -m venv venv - source venv/bin/activate # Linux/Mac - venv\Scripts\activate # Windows - ``` - -2. Abhängigkeiten installieren: - ```bash - pip install -r requirements.txt - ``` - -3. Server starten: - ```bash - uvicorn app:app --reload --host 0.0.0.0 --port 8000 - ``` - -4. Zugangspunkte: - - Frontend: `http://localhost:8080` - - Backend API: `http://localhost:8000` - - API-Dokumentation: `http://localhost:8000/docs` - -### Installation inazure WEBAPP -Configuration: -- Python version <= 3.11 -- Startup command: cd gwserver && uvicorn app:app --host 0.0.0.0 --port 8000 -Environment varibales: -- Neue Variable PORT=8000 - -### DEV TOOLS -Kill all processes on port 8000 -netsh advfirewall firewall add rule name="Close_Port_8000" dir=in action=block protocol=TCP localport=8000 -netsh advfirewall firewall delete rule name="Close_Port_8000" - - -### Datenbank-Migration -Für größere Installationen die JSON-basierte Datenbank ersetzen durch: -- PostgreSQL für relationale Daten -- MongoDB für Dokumente und unstrukturierte Daten -- Redis für Caching und Workflow-Status - -## Technische Details - -### Frontend-Architektur -- Vanilla JavaScript ohne Framework-Abhängigkeiten -- Modularer CSS-Ansatz für einfache Anpassungen -- Responsive Design für Desktop und mobile Nutzung - -### Backend-Architektur -- FastAPI für hohe Performance und automatische API-Dokumentation -- Asynchrone Verarbeitung für parallele Agent-Ausführung -- Erweiterbare Service-Struktur für einfache Integration neuer Agententypen ### git permanent login with vs code git remote set-url origin https://valueon@github.com/valueonag/gateway @@ -116,10 +37,3 @@ foreach ($run in $runs) { Write-Host "Deleting run $run" echo "y" | gh run delete $run } - - -## Lizenz - -PRIVATE LICENSE PATRICK MOTSCH ValueOn AG ---- -Für Fragen oder Unterstützung wenden Sie sich bitte an p.motsch@valueon.ch \ No newline at end of file