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}")