feat(teamsbot): implement Mein Account login with MFA relay

Made-with: Cursor
This commit is contained in:
patrick-motsch 2026-03-01 08:51:16 +01:00
parent 1f529568f5
commit 603e319f15
4 changed files with 241 additions and 6 deletions

View file

@ -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)
# ============================================================================

View file

@ -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
# =========================================================================

View file

@ -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
# =========================================================================

View file

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