feat(teamsbot): implement Mein Account login with MFA relay
Made-with: Cursor
This commit is contained in:
parent
1f529568f5
commit
603e319f15
4 changed files with 241 additions and 6 deletions
|
|
@ -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)
|
||||
# ============================================================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# =========================================================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# =========================================================================
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
Loading…
Reference in a new issue