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")
|
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)
|
# Per-User Settings (stored in PostgreSQL, per user per instance)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ from .datamodelTeamsbot import (
|
||||||
TeamsbotBotResponse,
|
TeamsbotBotResponse,
|
||||||
TeamsbotSystemBot,
|
TeamsbotSystemBot,
|
||||||
TeamsbotUserSettings,
|
TeamsbotUserSettings,
|
||||||
|
TeamsbotUserAccount,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -252,6 +253,33 @@ class TeamsbotObjects:
|
||||||
"""Delete user settings (reset to defaults)."""
|
"""Delete user settings (reset to defaults)."""
|
||||||
return self.db.recordDelete(TeamsbotUserSettings, settingsId)
|
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
|
# Stats / Aggregation
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ from .datamodelTeamsbot import (
|
||||||
TeamsbotJoinMode,
|
TeamsbotJoinMode,
|
||||||
TeamsbotSystemBot,
|
TeamsbotSystemBot,
|
||||||
TeamsbotUserSettings,
|
TeamsbotUserSettings,
|
||||||
|
TeamsbotUserAccount,
|
||||||
TeamsbotResponseChannel,
|
TeamsbotResponseChannel,
|
||||||
TeamsbotResponseMode,
|
TeamsbotResponseMode,
|
||||||
)
|
)
|
||||||
|
|
@ -202,8 +203,6 @@ async def startSession(
|
||||||
botAccountPassword = None
|
botAccountPassword = None
|
||||||
|
|
||||||
# System bot access: only SysAdmin can use the system bot account.
|
# 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:
|
if context.isSysAdmin and joinMode == TeamsbotJoinMode.SYSTEM_BOT:
|
||||||
systemBot = interface.getActiveSystemBot(mandateId)
|
systemBot = interface.getActiveSystemBot(mandateId)
|
||||||
if not systemBot:
|
if not systemBot:
|
||||||
|
|
@ -234,16 +233,41 @@ async def startSession(
|
||||||
botAccountPassword = decryptValue(encryptedPwd, userId=str(context.user.id), keyName="systemBotPassword")
|
botAccountPassword = decryptValue(encryptedPwd, userId=str(context.user.id), keyName="systemBotPassword")
|
||||||
logger.info(f"System bot credentials loaded and decrypted for: {botAccountEmail}")
|
logger.info(f"System bot credentials loaded and decrypted for: {botAccountEmail}")
|
||||||
except Exception as e:
|
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
|
botAccountEmail = None
|
||||||
botAccountPassword = None
|
botAccountPassword = None
|
||||||
else:
|
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:
|
else:
|
||||||
if body.joinMode == TeamsbotJoinMode.SYSTEM_BOT:
|
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
|
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:
|
if not effectiveBotName:
|
||||||
effectiveBotName = effectiveConfig.botName
|
effectiveBotName = effectiveConfig.botName
|
||||||
|
|
@ -662,6 +686,123 @@ async def deleteSystemBot(
|
||||||
return {"deleted": True}
|
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
|
# Voice Test Endpoint
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -382,6 +382,54 @@ class TeamsbotService:
|
||||||
"bytesBase64": playback.get("bytesBase64"),
|
"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:
|
except Exception as e:
|
||||||
if "disconnect" not in str(e).lower():
|
if "disconnect" not in str(e).lower():
|
||||||
logger.error(f"[WS] Error for session {sessionId}: {type(e).__name__}: {e}")
|
logger.error(f"[WS] Error for session {sessionId}: {type(e).__name__}: {e}")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue