From 603e319f159b745071fc0a94a69b9d6181ced43b Mon Sep 17 00:00:00 2001 From: patrick-motsch Date: Sun, 1 Mar 2026 08:51:16 +0100 Subject: [PATCH] feat(teamsbot): implement Mein Account login with MFA relay Made-with: Cursor --- .../features/teamsbot/datamodelTeamsbot.py | 18 +++ .../teamsbot/interfaceFeatureTeamsbot.py | 28 ++++ .../features/teamsbot/routeFeatureTeamsbot.py | 153 +++++++++++++++++- modules/features/teamsbot/service.py | 48 ++++++ 4 files changed, 241 insertions(+), 6 deletions(-) diff --git a/modules/features/teamsbot/datamodelTeamsbot.py b/modules/features/teamsbot/datamodelTeamsbot.py index 14579722..612cf986 100644 --- a/modules/features/teamsbot/datamodelTeamsbot.py +++ b/modules/features/teamsbot/datamodelTeamsbot.py @@ -141,6 +141,24 @@ class TeamsbotSystemBot(BaseModel): lastModified: Optional[str] = Field(default=None, description="ISO timestamp of last modification") +# ============================================================================ +# User Account Credentials (stored in PostgreSQL, credentials encrypted) +# ============================================================================ + +class TeamsbotUserAccount(BaseModel): + """Saved Microsoft credentials for 'Mein Account' joins. + Each user can store their own MS credentials per mandate. + Password is encrypted; on login only MFA confirmation is needed.""" + id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Record ID") + userId: str = Field(description="Poweron user ID (FK)") + mandateId: str = Field(description="Mandate ID (FK)") + email: str = Field(description="Microsoft account email") + encryptedPassword: str = Field(description="Encrypted Microsoft account password") + displayName: Optional[str] = Field(default=None, description="Display name derived from MS account") + creationDate: Optional[str] = Field(default=None, description="ISO timestamp of creation") + lastModified: Optional[str] = Field(default=None, description="ISO timestamp of last modification") + + # ============================================================================ # Per-User Settings (stored in PostgreSQL, per user per instance) # ============================================================================ diff --git a/modules/features/teamsbot/interfaceFeatureTeamsbot.py b/modules/features/teamsbot/interfaceFeatureTeamsbot.py index 4640b45b..f230a024 100644 --- a/modules/features/teamsbot/interfaceFeatureTeamsbot.py +++ b/modules/features/teamsbot/interfaceFeatureTeamsbot.py @@ -20,6 +20,7 @@ from .datamodelTeamsbot import ( TeamsbotBotResponse, TeamsbotSystemBot, TeamsbotUserSettings, + TeamsbotUserAccount, ) logger = logging.getLogger(__name__) @@ -252,6 +253,33 @@ class TeamsbotObjects: """Delete user settings (reset to defaults).""" return self.db.recordDelete(TeamsbotUserSettings, settingsId) + # ========================================================================= + # User Account Credentials (per-user per-mandate) + # ========================================================================= + + def getUserAccount(self, userId: str, mandateId: str) -> Optional[Dict[str, Any]]: + """Get saved MS credentials for a user in a mandate.""" + records = self.db.getRecordset( + TeamsbotUserAccount, + recordFilter={"userId": userId, "mandateId": mandateId}, + ) + return records[0] if records else None + + def createUserAccount(self, data: Dict[str, Any]) -> Dict[str, Any]: + """Create saved MS credentials.""" + data["creationDate"] = getIsoTimestamp() + data["lastModified"] = getIsoTimestamp() + return self.db.recordCreate(TeamsbotUserAccount, data) + + def updateUserAccount(self, accountId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Update saved MS credentials.""" + updates["lastModified"] = getIsoTimestamp() + return self.db.recordModify(TeamsbotUserAccount, accountId, updates) + + def deleteUserAccount(self, accountId: str) -> bool: + """Delete saved MS credentials.""" + return self.db.recordDelete(TeamsbotUserAccount, accountId) + # ========================================================================= # Stats / Aggregation # ========================================================================= diff --git a/modules/features/teamsbot/routeFeatureTeamsbot.py b/modules/features/teamsbot/routeFeatureTeamsbot.py index 7388bff1..302ef889 100644 --- a/modules/features/teamsbot/routeFeatureTeamsbot.py +++ b/modules/features/teamsbot/routeFeatureTeamsbot.py @@ -33,6 +33,7 @@ from .datamodelTeamsbot import ( TeamsbotJoinMode, TeamsbotSystemBot, TeamsbotUserSettings, + TeamsbotUserAccount, TeamsbotResponseChannel, TeamsbotResponseMode, ) @@ -202,8 +203,6 @@ async def startSession( botAccountPassword = None # System bot access: only SysAdmin can use the system bot account. - # Credentials are loaded only when joinMode explicitly requests SYSTEM_BOT. - # When joinMode is ANONYMOUS, the bot joins without auth regardless of SysAdmin. if context.isSysAdmin and joinMode == TeamsbotJoinMode.SYSTEM_BOT: systemBot = interface.getActiveSystemBot(mandateId) if not systemBot: @@ -234,16 +233,41 @@ async def startSession( botAccountPassword = decryptValue(encryptedPwd, userId=str(context.user.id), keyName="systemBotPassword") logger.info(f"System bot credentials loaded and decrypted for: {botAccountEmail}") except Exception as e: - logger.warning(f"Could not decrypt system bot password: {e} — falling back to anonymous join") + logger.warning(f"Could not decrypt system bot password: {e} -- falling back to anonymous join") botAccountEmail = None botAccountPassword = None else: - logger.info("No system bot found in DB — using anonymous join") + logger.info("No system bot found in DB -- using anonymous join") + + # User Account access: load saved MS credentials for the current user + elif joinMode == TeamsbotJoinMode.USER_ACCOUNT: + userAccount = interface.getUserAccount(userId, mandateId) + if userAccount: + botAccountEmail = userAccount.get("email") + encryptedPwd = userAccount.get("encryptedPassword") + if botAccountEmail and encryptedPwd: + try: + from modules.shared.configuration import decryptValue + botAccountPassword = decryptValue(encryptedPwd, userId=userId, keyName="userAccountPassword") + logger.info(f"User account credentials loaded for: {botAccountEmail}") + if not effectiveBotName: + effectiveBotName = userAccount.get("displayName") + if not effectiveBotName and "@" in botAccountEmail: + emailPrefix = botAccountEmail.split("@")[0] + effectiveBotName = " ".join(part.capitalize() for part in emailPrefix.split(".")) + except Exception as e: + logger.warning(f"Could not decrypt user account password: {e}") + botAccountEmail = None + botAccountPassword = None + else: + logger.warning(f"No saved credentials for user {userId} -- falling back to anonymous join") + joinMode = TeamsbotJoinMode.ANONYMOUS + else: if body.joinMode == TeamsbotJoinMode.SYSTEM_BOT: - logger.warning(f"Non-SysAdmin user {context.user.id} attempted to use system bot — forced to anonymous join") + logger.warning(f"Non-SysAdmin user {context.user.id} attempted to use system bot -- forced to anonymous join") joinMode = TeamsbotJoinMode.ANONYMOUS - logger.info(f"Non-SysAdmin user {context.user.id}: using anonymous join (system bot restricted to SysAdmin)") + logger.info(f"User {context.user.id}: using anonymous join") if not effectiveBotName: effectiveBotName = effectiveConfig.botName @@ -662,6 +686,123 @@ async def deleteSystemBot( return {"deleted": True} +# ========================================================================= +# User Account Endpoints (Mein Account) +# ========================================================================= + +@router.get("/{instanceId}/user-account") +@limiter.limit("30/minute") +async def getUserAccount( + request: Request, + instanceId: str, + context: RequestContext = Depends(getRequestContext), +): + """Get saved MS credentials status for the current user (password is never returned).""" + mandateId = _validateInstanceAccess(instanceId, context) + interface = _getInterface(context, instanceId) + userId = str(context.user.id) + account = interface.getUserAccount(userId, mandateId) + if not account: + return {"hasSavedCredentials": False} + return { + "hasSavedCredentials": True, + "email": account.get("email"), + "displayName": account.get("displayName"), + } + + +@router.post("/{instanceId}/user-account") +@limiter.limit("5/minute") +async def saveUserAccount( + request: Request, + instanceId: str, + context: RequestContext = Depends(getRequestContext), +): + """Save or update MS credentials for 'Mein Account' login.""" + mandateId = _validateInstanceAccess(instanceId, context) + interface = _getInterface(context, instanceId) + userId = str(context.user.id) + + body = await request.json() + email = body.get("email") + password = body.get("password") + displayName = body.get("displayName") + + if not email or not password: + raise HTTPException(status_code=400, detail="Email and password are required") + + from modules.shared.configuration import encryptValue + encryptedPassword = encryptValue(password, userId=userId, keyName="userAccountPassword") + + existing = interface.getUserAccount(userId, mandateId) + if existing: + interface.updateUserAccount(existing["id"], { + "email": email, + "encryptedPassword": encryptedPassword, + "displayName": displayName or existing.get("displayName"), + }) + logger.info(f"User account updated for user {userId}: {email}") + else: + interface.createUserAccount(TeamsbotUserAccount( + userId=userId, + mandateId=mandateId, + email=email, + encryptedPassword=encryptedPassword, + displayName=displayName, + ).model_dump()) + logger.info(f"User account created for user {userId}: {email}") + + return {"saved": True, "email": email} + + +@router.delete("/{instanceId}/user-account") +@limiter.limit("5/minute") +async def deleteUserAccount( + request: Request, + instanceId: str, + context: RequestContext = Depends(getRequestContext), +): + """Delete saved MS credentials for the current user.""" + mandateId = _validateInstanceAccess(instanceId, context) + interface = _getInterface(context, instanceId) + userId = str(context.user.id) + account = interface.getUserAccount(userId, mandateId) + if account: + interface.deleteUserAccount(account["id"]) + logger.info(f"User account deleted for user {userId}") + return {"deleted": True} + + +# ========================================================================= +# MFA Code Submission (relayed to active bot session) +# ========================================================================= + +_mfaCodeQueues: dict = {} + +@router.post("/{instanceId}/sessions/{sessionId}/mfa") +@limiter.limit("10/minute") +async def submitMfaCode( + request: Request, + instanceId: str, + sessionId: str, + context: RequestContext = Depends(getRequestContext), +): + """Submit MFA code/confirmation from the frontend to the active bot session.""" + _validateInstanceAccess(instanceId, context) + body = await request.json() + mfaCode = body.get("code", "") + mfaAction = body.get("action", "code") + + logger.info(f"MFA submission for session {sessionId}: action={mfaAction}, codeLen={len(mfaCode)}") + + queue = _mfaCodeQueues.get(sessionId) + if queue: + await queue.put({"action": mfaAction, "code": mfaCode}) + return {"submitted": True} + else: + raise HTTPException(status_code=404, detail="No active MFA challenge for this session") + + # ========================================================================= # Voice Test Endpoint # ========================================================================= diff --git a/modules/features/teamsbot/service.py b/modules/features/teamsbot/service.py index 8ca4b595..f0fca9eb 100644 --- a/modules/features/teamsbot/service.py +++ b/modules/features/teamsbot/service.py @@ -382,6 +382,54 @@ class TeamsbotService: "bytesBase64": playback.get("bytesBase64"), }) + elif msgType == "mfaChallenge": + mfaData = message.get("mfa", {}) + mfaType = mfaData.get("type", "unknown") + displayNumber = mfaData.get("displayNumber") + prompt = mfaData.get("prompt", "") + logger.info(f"[WS] MFA challenge: type={mfaType}, number={displayNumber}, prompt={prompt[:60]}") + + await _emitSessionEvent(sessionId, "mfaChallenge", { + "mfaType": mfaType, + "displayNumber": displayNumber, + "prompt": prompt, + "timestamp": getIsoTimestamp(), + }) + + from .routeFeatureTeamsbot import _mfaCodeQueues + mfaQueue = asyncio.Queue() + _mfaCodeQueues[sessionId] = mfaQueue + + try: + mfaResponse = await asyncio.wait_for(mfaQueue.get(), timeout=120.0) + logger.info(f"[WS] MFA response received for session {sessionId}: action={mfaResponse.get('action')}") + await websocket.send_text(json.dumps({ + "type": "mfaResponse", + "sessionId": sessionId, + "mfa": mfaResponse, + })) + except asyncio.TimeoutError: + logger.warning(f"[WS] MFA response timeout for session {sessionId}") + await websocket.send_text(json.dumps({ + "type": "mfaResponse", + "sessionId": sessionId, + "mfa": {"action": "timeout"}, + })) + await _emitSessionEvent(sessionId, "mfaChallenge", { + "mfaType": "timeout", + "prompt": "MFA-Zeitlimit ueberschritten. Bitte erneut versuchen.", + }) + finally: + _mfaCodeQueues.pop(sessionId, None) + + elif msgType == "mfaResolved": + success = message.get("success", False) + logger.info(f"[WS] MFA resolved: success={success}") + await _emitSessionEvent(sessionId, "mfaResolved", { + "success": success, + "timestamp": getIsoTimestamp(), + }) + except Exception as e: if "disconnect" not in str(e).lower(): logger.error(f"[WS] Error for session {sessionId}: {type(e).__name__}: {e}")