diff --git a/pyproject.toml b/pyproject.toml index 2914a6ec6..04692deb8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,6 +83,7 @@ dependencies = [ "litellm>=1.0.0", "valkey-glide>=2.4.1,<3.0.0; sys_platform != 'win32'", # No Windows wheels are published "webauthn>=3.0.0", + "httpx[socks]>=0.28.1", ] keywords = [ "bot", diff --git a/src/langbot/pkg/api/http/controller/groups/user.py b/src/langbot/pkg/api/http/controller/groups/user.py index 31e6d7daf..9dce0eca4 100644 --- a/src/langbot/pkg/api/http/controller/groups/user.py +++ b/src/langbot/pkg/api/http/controller/groups/user.py @@ -15,6 +15,7 @@ from .....entity.errors import account as account_errors from ...context import RequestContext from .....cloud.launch import SpaceLaunchError from ...service.user import ControlPlaneDirectoryRequiredError, PublicRegistrationClosedError +from ...service import totp as totp_module # Fixed-window admission quota for the unauthenticated reset-password endpoint (#2392). # The admission check and slot bump share ONE synchronous critical section with no await @@ -112,15 +113,93 @@ class UserRouterGroup(group.RouterGroup): return self.http_status(403, 'password_login_disabled', 'Password login is disabled on LangBot Cloud') json_data = await quart.request.json + user_email = json_data['user'] + password = json_data['password'] + totp_code = json_data.get('totp_code') or json_data.get('code') + try: - token = await self.ap.user_service.authenticate(json_data['user'], json_data['password']) + token = await self.ap.user_service.authenticate(user_email, password, totp_code) except argon2.exceptions.VerifyMismatchError: return self.fail(1, 'Invalid username or password') + except totp_module.TotpRequiredError: + # Primary factors passed, but the second factor is still missing. + # Issue a challenge instead of a session token. + challenge_token = await self.ap.user_service.issue_totp_login_challenge(user_email) + if challenge_token is None: + return self.fail(1, 'A second factor is required') + return ( + quart.jsonify( + { + 'code': 'totp_required', + 'msg': 'A TOTP second factor is required', + 'data': {'challenge_token': challenge_token}, + } + ), + 401, + ) + except totp_module.TotpInvalidCodeError: + return self.http_status(401, 'totp_invalid_code', 'Invalid TOTP or recovery code') except ValueError as e: return self.fail(1, str(e)) return self.success(data={'token': token}) + # ---- TOTP second factor (login challenge) ---- + + @self.route('/totp/challenge', methods=['POST'], auth_type=group.AuthType.NONE) + async def _() -> str: + """Start a login second-factor challenge. + + Always answers with a token, even for unknown or non-enrolled + Accounts: a distinguishable reply would let an unauthenticated caller + enumerate which emails have a second factor. + """ + json_data = (await quart.request.json) or {} + user_email = json_data.get('user') + + if not isinstance(user_email, str) or not user_email: + return self.fail(1, 'Missing user parameter') + + challenge_token = await self.ap.user_service.issue_uniform_totp_login_challenge(user_email) + return self.success(data={'challenge_token': challenge_token}) + + @self.route('/totp/verify', methods=['POST'], auth_type=group.AuthType.NONE) + async def _() -> str: + """Complete the login second factor and return a session token. + + A ``challenge_token`` is mandatory: it proves the password step ran + for this Account, so a bare code can never mint a session on its own. + """ + json_data = (await quart.request.json) or {} + user_email = json_data.get('user') + code = json_data.get('code') + challenge_token = json_data.get('challenge_token') + + if ( + not isinstance(user_email, str) + or not user_email + or not isinstance(code, str) + or not code + or not isinstance(challenge_token, str) + or not challenge_token + ): + return self.fail(1, 'Missing user, code or challenge_token parameter') + + verified = await self.ap.user_service.verify_totp_second_factor( + user_email, + code, + challenge_token=challenge_token, + ) + if not verified: + return self.http_status(401, 'totp_invalid_code', 'Invalid TOTP or recovery code') + + user_obj = await self.ap.user_service.get_user_by_email(user_email) + if user_obj is None: + return self.http_status(401, 'totp_invalid_code', 'Invalid TOTP or recovery code') + + token = await self.ap.user_service.generate_jwt_token(user_obj) + return self.success(data={'token': token, 'user': user_obj.user}) + @self.route('/check-token', methods=['GET'], auth_type=group.AuthType.ACCOUNT_TOKEN) async def _(account) -> str: token = await self.ap.user_service.generate_jwt_token(account) @@ -138,8 +217,10 @@ class UserRouterGroup(group.RouterGroup): json_data = await quart.request.json user_email = json_data['user'] - recovery_key = json_data['recovery_key'] new_password = json_data['new_password'] + # Second-factor method: 'recovery_key' (instance-wide, default), + # 'totp' (an authenticator code) or 'recovery_code' (a one-time code). + method = json_data.get('method') or 'recovery_key' # hard sleep 3s for security await asyncio.sleep(3) @@ -152,19 +233,35 @@ class UserRouterGroup(group.RouterGroup): if user_obj is None: return self.http_status(400, -1, 'User not found') - stored_key = self.ap.instance_config.data['system']['recovery_key'] - try: - key_matches = ( - isinstance(recovery_key, str) - and isinstance(stored_key, str) - and hmac.compare_digest(recovery_key.encode(), stored_key.encode()) - ) - except UnicodeEncodeError: - # JSON can contain lone surrogates, which are not valid UTF-8. - key_matches = False + if method in ('totp', 'recovery_code'): + # Account-scoped second factor: a live TOTP code or, for the + # recovery_code method, a one-time recovery code. + allow_recovery = method == 'recovery_code' + second_factor = json_data.get('totp_code') or json_data.get('code') + if not isinstance(second_factor, str) or not second_factor: + return self.http_status(400, -1, 'Missing TOTP or recovery code') - if not key_matches: - return self.http_status(403, -1, 'Invalid recovery key') + if not await self.ap.user_service.verify_totp_second_factor( + user_email, + second_factor, + allow_recovery=allow_recovery, + ): + return self.http_status(403, -1, 'Invalid TOTP or recovery code') + else: + recovery_key = json_data.get('recovery_key') + stored_key = self.ap.instance_config.data['system']['recovery_key'] + try: + key_matches = ( + isinstance(recovery_key, str) + and isinstance(stored_key, str) + and hmac.compare_digest(recovery_key.encode(), stored_key.encode()) + ) + except UnicodeEncodeError: + # JSON can contain lone surrogates, which are not valid UTF-8. + key_matches = False + + if not key_matches: + return self.http_status(403, -1, 'Invalid recovery key') await self.ap.user_service.reset_password(user_email, new_password) @@ -674,6 +771,272 @@ class UserRouterGroup(group.RouterGroup): return self.http_status(404, -1, 'Passkey not found') return self.success() + # ---- TOTP second factor (account settings) ---- + + # The second factor belongs to the Account, so these routes are + # ACCOUNT_TOKEN scoped. Requiring a Workspace would lock out an Account + # that has not been added to one yet. + @self.route('/totp/status', methods=['GET'], auth_type=group.AuthType.ACCOUNT_TOKEN) + async def _(account) -> str: + """Report second-factor state without ever returning the secret.""" + totp_service = self.ap.totp_service + credential = await totp_service.get_credential(account.uuid) + return self.success( + data={ + 'enabled': bool(credential is not None and credential.confirmed_at is not None), + 'pending': bool(credential is not None and credential.confirmed_at is None), + 'confirmed_at': ( + credential.confirmed_at.isoformat() if credential and credential.confirmed_at else None + ), + 'last_used_at': ( + credential.last_used_at.isoformat() if credential and credential.last_used_at else None + ), + 'recovery_codes_remaining': ( + await totp_service.count_unused_recovery_codes(account.uuid) if credential else 0 + ), + } + ) + + @self.route('/totp/enroll', methods=['POST'], auth_type=group.AuthType.ACCOUNT_TOKEN) + async def _(account) -> str: + """Start TOTP enrolment; returns a server-rendered QR code only. + + The shared secret is intentionally never returned to the client so it + cannot be read out of the browser or any proxy in between. + """ + allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get( + 'allow_modify_login_info', True + ) + if not allow_modify_login_info: + return self.http_status(403, -1, 'Modifying login info is disabled') + + json_data = (await quart.request.json) or {} + # rotate=True (explicit refresh) mints a new secret; the default + # reuses any pending enrolment so duplicate requests cannot + # invalidate the QR code already displayed to the operator. + rotate = bool(json_data.get('rotate', False)) + + try: + enrollment = await self.ap.totp_service.begin_enrollment( + account.uuid, account.user, rotate=rotate + ) + except totp_module.TotpError as e: + return self.http_status(409, e.code, str(e)) + + return self.success( + data={ + 'uuid': enrollment.uuid, + 'qr_code_data_url': enrollment.qr_code_data_url, + 'algorithm': enrollment.algorithm, + 'digits': enrollment.digits, + 'period': enrollment.period, + } + ) + + @self.route('/totp/enroll/confirm', methods=['POST'], auth_type=group.AuthType.ACCOUNT_TOKEN) + async def _(account) -> str: + """Confirm enrolment with the first code; returns recovery codes once.""" + allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get( + 'allow_modify_login_info', True + ) + if not allow_modify_login_info: + return self.http_status(403, -1, 'Modifying login info is disabled') + + json_data = (await quart.request.json) or {} + code = json_data.get('code') + if not isinstance(code, str) or not code: + return self.fail(1, 'Missing code parameter') + + try: + result = await self.ap.totp_service.confirm_enrollment(account.uuid, code) + except totp_module.TotpNotEnrolledError as e: + return self.http_status(404, e.code, str(e)) + except totp_module.TotpInvalidCodeError as e: + return self.http_status(400, e.code, str(e)) + except totp_module.TotpError as e: + return self.http_status(409, e.code, str(e)) + + return self.success(data={'recovery_codes': result.codes}) + + @self.route('/totp/recovery-codes', methods=['POST'], auth_type=group.AuthType.ACCOUNT_TOKEN) + async def _(account) -> str: + """Regenerate recovery codes after a valid TOTP or recovery code.""" + allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get( + 'allow_modify_login_info', True + ) + if not allow_modify_login_info: + return self.http_status(403, -1, 'Modifying login info is disabled') + + json_data = (await quart.request.json) or {} + code = json_data.get('code') + if not isinstance(code, str) or not code: + return self.fail(1, 'Missing code parameter') + + try: + result = await self.ap.totp_service.regenerate_recovery_codes(account.uuid, code) + except totp_module.TotpNotEnrolledError as e: + return self.http_status(404, e.code, str(e)) + except totp_module.TotpInvalidCodeError as e: + return self.http_status(403, e.code, str(e)) + + return self.success(data={'recovery_codes': result.codes}) + + @self.route('/totp/disable', methods=['POST'], auth_type=group.AuthType.ACCOUNT_TOKEN) + async def _(account) -> str: + """Disable TOTP, requiring a live TOTP or recovery code.""" + allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get( + 'allow_modify_login_info', True + ) + if not allow_modify_login_info: + return self.http_status(403, -1, 'Modifying login info is disabled') + + json_data = (await quart.request.json) or {} + code = json_data.get('code') + + try: + await self.ap.totp_service.disable(account.uuid, code=code) + except totp_module.TotpNotEnrolledError as e: + return self.http_status(404, e.code, str(e)) + except totp_module.TotpInvalidCodeError as e: + return self.http_status(403, e.code, str(e)) + + return self.success() + + # ---- TOTP oversight for owners and admins ---- + + async def _require_workspace_manager(request_context: RequestContext) -> None: + """Raise unless the caller's Workspace role is owner or admin.""" + + if request_context is None: + raise PermissionError('A Workspace context is required') + access = await self.ap.workspace_collaboration_service.resolve_account_workspace( + request_context.account_uuid, + request_context.workspace_uuid, + ) + if access.membership.role not in ('owner', 'admin'): + raise PermissionError('Only Workspace owners and admins may manage other Accounts') + + @self.route('/totp/accounts', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def _(request_context: RequestContext) -> str: + """List the second-factor state of every Account for owners/admins. + + Oversight is deliberately instance-wide: managing the second factor + of any Account is an owner/admin responsibility and is not scoped to + the caller's Workspace. + """ + try: + await _require_workspace_manager(request_context) + except PermissionError as e: + return self.http_status(403, 'permission_denied', str(e)) + except Exception: + return self.http_status(403, 'permission_denied', 'Not permitted') + + accounts = await self.ap.totp_service.list_account_states() + return self.success(data={'accounts': accounts}) + + @self.route('/totp/accounts/', methods=['DELETE'], auth_type=group.AuthType.USER_TOKEN) + async def _(request_context: RequestContext, target_account_uuid: str) -> str: + """Revoke another Account's second factor when its codes are lost.""" + allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get( + 'allow_modify_login_info', True + ) + if not allow_modify_login_info: + return self.http_status(403, -1, 'Modifying login info is disabled') + + try: + await _require_workspace_manager(request_context) + except PermissionError as e: + return self.http_status(403, 'permission_denied', str(e)) + except Exception: + return self.http_status(403, 'permission_denied', 'Not permitted') + + revoked = await self.ap.totp_service.revoke_for_account(target_account_uuid) + if not revoked: + return self.http_status(404, 'totp_not_enrolled', 'TOTP is not enabled for that Account') + return self.success() + + @self.route( + '/totp/accounts//enroll', + methods=['POST'], + auth_type=group.AuthType.USER_TOKEN, + ) + async def _(request_context: RequestContext, target_account_uuid: str) -> str: + """Start a forced re-binding of another Account's second factor. + + Returns a server-rendered QR code so an owner/admin can walk the + Account through binding a new authenticator. The shared secret is + never returned to the client. + """ + allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get( + 'allow_modify_login_info', True + ) + if not allow_modify_login_info: + return self.http_status(403, -1, 'Modifying login info is disabled') + + try: + await _require_workspace_manager(request_context) + except PermissionError as e: + return self.http_status(403, 'permission_denied', str(e)) + except Exception: + return self.http_status(403, 'permission_denied', 'Not permitted') + + target = await self.ap.totp_service.get_account(target_account_uuid) + if target is None: + return self.http_status(404, 'account_not_found', 'Account not found') + + try: + enrollment = await self.ap.totp_service.begin_enrollment( + target_account_uuid, target.user, force=True + ) + except totp_module.TotpError as e: + return self.http_status(409, e.code, str(e)) + + return self.success( + data={ + 'uuid': enrollment.uuid, + 'qr_code_data_url': enrollment.qr_code_data_url, + 'algorithm': enrollment.algorithm, + 'digits': enrollment.digits, + 'period': enrollment.period, + } + ) + + @self.route( + '/totp/accounts//enroll/confirm', + methods=['POST'], + auth_type=group.AuthType.USER_TOKEN, + ) + async def _(request_context: RequestContext, target_account_uuid: str) -> str: + """Activate a forced re-binding with the first code; returns recovery codes once.""" + allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get( + 'allow_modify_login_info', True + ) + if not allow_modify_login_info: + return self.http_status(403, -1, 'Modifying login info is disabled') + + try: + await _require_workspace_manager(request_context) + except PermissionError as e: + return self.http_status(403, 'permission_denied', str(e)) + except Exception: + return self.http_status(403, 'permission_denied', 'Not permitted') + + json_data = (await quart.request.json) or {} + code = json_data.get('code') + if not isinstance(code, str) or not code: + return self.fail(1, 'Missing code parameter') + + try: + result = await self.ap.totp_service.confirm_enrollment(target_account_uuid, code) + except totp_module.TotpNotEnrolledError as e: + return self.http_status(404, e.code, str(e)) + except totp_module.TotpInvalidCodeError as e: + return self.http_status(400, e.code, str(e)) + except totp_module.TotpError as e: + return self.http_status(409, e.code, str(e)) + + return self.success(data={'recovery_codes': result.codes}) + async def _handle_space_direct_launch( self, launch_assertion: str, diff --git a/src/langbot/pkg/api/http/service/totp.py b/src/langbot/pkg/api/http/service/totp.py new file mode 100644 index 000000000..052f66835 --- /dev/null +++ b/src/langbot/pkg/api/http/service/totp.py @@ -0,0 +1,717 @@ +from __future__ import annotations + +import base64 +import dataclasses +import datetime +import hashlib +import hmac +import io +import secrets +import time +import typing +import uuid as uuid_lib +from urllib.parse import quote as url_quote + +import qrcode +import sqlalchemy +from cryptography.fernet import Fernet, InvalidToken +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.hkdf import HKDF +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from ....entity.persistence import totp as totp_entity +from ....entity.persistence import user + +if typing.TYPE_CHECKING: + from ....core.app import Application + + +# HKDF context: rotating the wrapping key means bumping the key version, never +# re-using an existing derivation context. +_HKDF_SALT = b'langbot.totp.secret.v1' +_HKDF_INFO = b'langbot-totp-secret-encryption' +_HKDF_LENGTH = 32 + +# Recovery codes: PBKDF2-HMAC-SHA256. Only the digest is ever persisted. +_RECOVERY_CODE_ALGORITHM = 'pbkdf2_hmac_sha256' +_PBKDF2_ITERATIONS = 600_000 +_PBKDF2_SALT_BYTES = 16 +_RECOVERY_CODE_COUNT = 4 +_RECOVERY_CODE_GROUPS = 4 +_RECOVERY_CODE_GROUP_LENGTH = 5 + +# Login second-factor challenge lifetime and admission bounds. +_TOTP_CHALLENGE_TTL_SECONDS = 5 * 60 +_TOTP_CHALLENGE_MAX_ENTRIES = 4096 +_TOTP_MAX_ATTEMPTS = 5 + +# Unambiguous base32 alphabet used for recovery codes (no 0/O/1/I confusion). +_RECOVERY_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' + + +class TotpError(ValueError): + """Base class for TOTP second-factor failures.""" + + code = 'totp_error' + + +class TotpNotEnrolledError(TotpError): + code = 'totp_not_enrolled' + + +class TotpAlreadyEnrolledError(TotpError): + code = 'totp_already_enrolled' + + +class TotpInvalidCodeError(TotpError): + code = 'totp_invalid_code' + + +class TotpRequiredError(TotpError): + """Raised when the password flow still requires a second factor.""" + + code = 'totp_required' + + +class TotpChallengeError(TotpError): + code = 'totp_challenge_invalid' + + +@dataclasses.dataclass(frozen=True, slots=True) +class TotpChallengeData: + account_uuid: str + user_email: str + expires_at: float + attempts: int = 0 + + +@dataclasses.dataclass(frozen=True, slots=True) +class TotpEnrollment: + """Result of starting an enrolment. + + The shared secret never leaves the backend: only a server-rendered QR code + (as a ``data:`` URL) is handed to the client so the operator can scan it into + an authenticator. The secret itself is persisted solely as a ciphertext token + and is never returned in plaintext. + """ + + uuid: str + qr_code_data_url: str + algorithm: str + digits: int + period: int + + +@dataclasses.dataclass(frozen=True, slots=True) +class TotpRecoveryCodes: + """Recovery codes returned exactly once, at confirmation or regeneration.""" + + codes: list[str] + + +def _derive_wrapping_key(jwt_secret: str, key_version: int) -> bytes: + """HKDF-SHA256 derivation of the Fernet key from the instance JWT secret.""" + + if not jwt_secret: + raise TotpError('Instance JWT secret is not configured') + hkdf = HKDF( + algorithm=hashes.SHA256(), + length=_HKDF_LENGTH, + salt=_HKDF_SALT, + info=_HKDF_INFO + b'.v' + str(int(key_version)).encode('ascii'), + ) + # Fernet requires a url-safe base64 encoded 32-byte key. + return base64.urlsafe_b64encode(hkdf.derive(jwt_secret.encode('utf-8'))) + + +def _generate_totp_secret(length: int = 32) -> str: + """Generate a base32 TOTP shared secret from a CSPRNG.""" + + return base64.b32encode(secrets.token_bytes(length)).decode('ascii').rstrip('=') + + +def _normalize_code(value: typing.Any) -> str: + return ''.join(ch for ch in str(value or '').upper() if ch.isalnum()) + + +def _hotp(secret: bytes, counter: int, digits: int) -> str: + """RFC 4226 HMAC-SHA1 dynamic truncation.""" + + digest = hmac.new(secret, counter.to_bytes(8, byteorder='big'), hashlib.sha1).digest() + offset = digest[-1] & 0x0F + truncated = ( + (digest[offset] & 0x7F) << 24 + | (digest[offset + 1] & 0xFF) << 16 + | (digest[offset + 2] & 0xFF) << 8 + | (digest[offset + 3] & 0xFF) + ) + return str(truncated % (10**digits)).zfill(digits) + + +def _decode_secret(secret: str) -> bytes: + padding = '=' * ((8 - len(secret) % 8) % 8) + try: + return base64.b32decode(secret.upper() + padding) + except Exception as exc: # noqa: BLE001 - surface as a domain error + raise TotpError('TOTP secret is not valid base32') from exc + + +def _totp_counter(period: int, *, at: float | None = None) -> int: + """RFC 6238 time step counter.""" + + moment = time.time() if at is None else at + return int(moment // max(1, period)) + + +def _pbkdf2_digest(code: str, salt: str, iterations: int = _PBKDF2_ITERATIONS) -> str: + """PBKDF2-HMAC-SHA256 digest of a recovery code, hex encoded.""" + + return hashlib.pbkdf2_hmac( + 'sha256', + _normalize_code(code).encode('utf-8'), + salt.encode('utf-8'), + iterations, + ).hex() + + +def _generate_recovery_codes() -> list[str]: + """Human-transcribable single-use recovery codes.""" + + length = _RECOVERY_CODE_GROUPS * _RECOVERY_CODE_GROUP_LENGTH + codes: list[str] = [] + for _ in range(_RECOVERY_CODE_COUNT): + raw = ''.join(secrets.choice(_RECOVERY_ALPHABET) for _ in range(length)) + codes.append( + '-'.join(raw[i : i + _RECOVERY_CODE_GROUP_LENGTH] for i in range(0, length, _RECOVERY_CODE_GROUP_LENGTH)) + ) + return codes + + +def build_totp_uri(secret: str, account: str, issuer: str = 'LangBot') -> str: + """Build an otpauth:// provisioning URI for an authenticator app.""" + + return ( + f'otpauth://totp/{url_quote(account)}?secret={secret}' + f'&issuer={url_quote(issuer)}&algorithm=SHA1&digits=6&period=30' + ) + + +def render_totp_qr_data_url(otpauth_uri: str) -> str: + """Render a provisioning URI as a PNG ``data:`` URL. + + The QR code is produced on the server so the shared secret never has to + travel to the browser as a plaintext value. + """ + + image = qrcode.QRCode(border=1, box_size=8) + image.add_data(otpauth_uri) + image.make(fit=True) + picture = image.make_image(fill_color='black', back_color='white') + buffer = io.BytesIO() + picture.save(buffer, format='PNG') + encoded = base64.b64encode(buffer.getvalue()).decode('ascii') + return f'data:image/png;base64,{encoded}' + + +class TotpService: + """TOTP enrolment, verification, disablement and recovery-code use. + + Security invariants enforced here: + * the shared secret is only ever persisted as a Fernet token whose key is + derived (HKDF-SHA256) from the instance JWT secret; + * recovery codes are only ever persisted as salted PBKDF2-HMAC-SHA256 + digests and are single-use; + * the consumed counter advances monotonically so a captured code cannot be + replayed inside the same time window. + """ + + ap: Application + + def __init__(self, ap: Application) -> None: + self.ap = ap + self._challenges: dict[str, TotpChallengeData] = {} + + # ------------------------------------------------------------------ + # configuration / storage helpers + # ------------------------------------------------------------------ + def _session_factory(self) -> async_sessionmaker[AsyncSession]: + return async_sessionmaker(self.ap.persistence_mgr.get_db_engine(), expire_on_commit=False) + + def _jwt_secret(self) -> str: + secret = self.ap.instance_config.data['system']['jwt']['secret'] + if not isinstance(secret, str) or not secret: + raise TotpError('Instance JWT secret is not configured') + return secret + + def _fernet(self, key_version: int = 1) -> Fernet: + return Fernet(_derive_wrapping_key(self._jwt_secret(), key_version)) + + async def is_enrolled(self, account_uuid: str) -> bool: + credential = await self.get_credential(account_uuid) + return credential is not None and credential.confirmed_at is not None + + async def get_credential(self, account_uuid: str) -> totp_entity.TotpCredential | None: + statement = ( + sqlalchemy.select(totp_entity.TotpCredential) + .where( + totp_entity.TotpCredential.account_uuid == account_uuid, + totp_entity.TotpCredential.disabled_at.is_(None), + ) + .order_by(totp_entity.TotpCredential.created_at.desc()) + .limit(1) + ) + async with self._session_factory()() as session: + return await session.scalar(statement) + + # ------------------------------------------------------------------ + # login second-factor challenge + # ------------------------------------------------------------------ + async def issue_login_challenge(self, account_uuid: str, user_email: str) -> str: + """Issue a single-use second-factor challenge for one Account.""" + + token = secrets.token_urlsafe(32) + self._prune_challenges() + # Bound the challenge table so unauthenticated login attempts cannot + # grow process memory without limit. + while len(self._challenges) >= _TOTP_CHALLENGE_MAX_ENTRIES: + self._challenges.pop(next(iter(self._challenges)), None) + self._challenges[token] = TotpChallengeData( + account_uuid=account_uuid, + user_email=user_email, + expires_at=time.monotonic() + _TOTP_CHALLENGE_TTL_SECONDS, + ) + return token + + def consume_login_challenge(self, token: str) -> TotpChallengeData: + data = self._challenges.get(token) + if data is None or data.expires_at < time.monotonic(): + self._challenges.pop(token, None) + raise TotpChallengeError('Invalid or expired second-factor challenge') + return data + + def complete_login_challenge(self, token: str) -> None: + self._challenges.pop(token, None) + + def record_failed_attempt(self, token: str) -> None: + """Count a failed attempt and burn the challenge once the cap is hit.""" + + data = self._challenges.get(token) + if data is None: + return + attempts = data.attempts + 1 + if attempts >= _TOTP_MAX_ATTEMPTS: + self._challenges.pop(token, None) + return + self._challenges[token] = dataclasses.replace(data, attempts=attempts) + + def _prune_challenges(self) -> None: + now = time.monotonic() + for token in [token for token, data in self._challenges.items() if data.expires_at < now]: + self._challenges.pop(token, None) + + # ------------------------------------------------------------------ + # enrolment + # ------------------------------------------------------------------ + async def begin_enrollment( + self, + account_uuid: str, + user_email: str, + *, + rotate: bool = False, + force: bool = False, + ) -> TotpEnrollment: + """Create or replace the pending TOTP credential for an Account. + + Any previous unconfirmed attempt is retired and outstanding recovery + codes are cleared, so a half-finished enrolment can never linger. + + The plaintext secret is discarded after this call: callers only ever + receive a server-rendered QR code. + + ``force`` is used by Workspace owners/admins to re-bind an Account whose + authenticator was lost: it retires an already-confirmed credential and + starts a fresh pending enrolment. + """ + + credential = await self.get_credential(account_uuid) + if credential is not None and credential.confirmed_at is not None and not force: + raise TotpAlreadyEnrolledError('TOTP is already enabled for this Account') + + # Reuse an unconfirmed (pending) enrolment instead of minting a new + # secret. Duplicate requests are normal - React StrictMode invokes + # effects twice in development and browsers/proxies may retry - and + # rotating the secret would invalidate the QR code already on screen, + # making the subsequent confirm_enrollment() call fail with + # "Invalid TOTP code". Returning the same QR keeps them in sync. + # An explicit "refresh" passes rotate=True to opt back into rotation. + if credential is not None and not rotate and not force: + pending_secret = self._decrypt_secret(credential.secret_ciphertext, credential.key_version) + return TotpEnrollment( + uuid=credential.uuid, + qr_code_data_url=render_totp_qr_data_url( + build_totp_uri(pending_secret.decode('ascii'), user_email) + ), + algorithm=credential.algorithm, + digits=credential.digits, + period=credential.period, + ) + + secret = _generate_totp_secret() + ciphertext = self._fernet().encrypt(secret.encode('utf-8')).decode('ascii') + record_uuid = str(uuid_lib.uuid4()) + + async with self._session_factory()() as session: + async with session.begin(): + await session.execute( + sqlalchemy.update(totp_entity.TotpCredential) + .where(totp_entity.TotpCredential.account_uuid == account_uuid) + .values(disabled_at=datetime.datetime.now(datetime.timezone.utc)) + ) + await session.execute( + sqlalchemy.delete(totp_entity.TotpRecoveryCode).where( + totp_entity.TotpRecoveryCode.account_uuid == account_uuid + ) + ) + session.add( + totp_entity.TotpCredential( + uuid=record_uuid, + account_uuid=account_uuid, + secret_ciphertext=ciphertext, + key_version=1, + algorithm='SHA1', + digits=6, + period=30, + ) + ) + + # Render the QR code here (and drop the plaintext secret) so the shared + # secret never crosses the API boundary towards the browser. + qr_code_data_url = render_totp_qr_data_url(build_totp_uri(secret, user_email)) + + return TotpEnrollment( + uuid=record_uuid, + qr_code_data_url=qr_code_data_url, + algorithm='SHA1', + digits=6, + period=30, + ) + + async def confirm_enrollment(self, account_uuid: str, code: str) -> TotpRecoveryCodes: + """Verify the first code, activate the credential and mint recovery codes. + + Recovery codes are returned exactly once; only their salted PBKDF2 + digests are stored. + """ + + credential = await self.get_credential(account_uuid) + if credential is None: + raise TotpNotEnrolledError('No pending TOTP enrolment for this Account') + if credential.confirmed_at is not None: + raise TotpAlreadyEnrolledError('TOTP is already enabled for this Account') + + counter = self._verify_counter(credential, code) + codes = _generate_recovery_codes() + now = datetime.datetime.now(datetime.timezone.utc) + + async with self._session_factory()() as session: + async with session.begin(): + await session.execute( + sqlalchemy.update(totp_entity.TotpCredential) + .where( + totp_entity.TotpCredential.uuid == credential.uuid, + totp_entity.TotpCredential.confirmed_at.is_(None), + ) + .values(confirmed_at=now, last_used_at=now, last_used_counter=counter) + ) + self._store_recovery_codes(session, account_uuid, credential.uuid, codes) + + return TotpRecoveryCodes(codes=codes) + + async def regenerate_recovery_codes(self, account_uuid: str, code: str) -> TotpRecoveryCodes: + """Replace all recovery codes, requiring a valid live TOTP code first.""" + + credential = await self.get_credential(account_uuid) + if credential is None or credential.confirmed_at is None: + raise TotpNotEnrolledError('TOTP is not enabled for this Account') + if not await self.verify_code(account_uuid, code, allow_recovery=True): + raise TotpInvalidCodeError('Invalid TOTP code') + + codes = _generate_recovery_codes() + async with self._session_factory()() as session: + async with session.begin(): + await session.execute( + sqlalchemy.delete(totp_entity.TotpRecoveryCode).where( + totp_entity.TotpRecoveryCode.account_uuid == account_uuid + ) + ) + self._store_recovery_codes(session, account_uuid, credential.uuid, codes) + return TotpRecoveryCodes(codes=codes) + + def _store_recovery_codes( + self, + session: AsyncSession, + account_uuid: str, + credential_uuid: str, + codes: list[str], + ) -> None: + """Persist recovery codes as salted PBKDF2-HMAC-SHA256 digests only.""" + + for index, code_value in enumerate(codes): + salt = secrets.token_hex(_PBKDF2_SALT_BYTES) + session.add( + totp_entity.TotpRecoveryCode( + uuid=str(uuid_lib.uuid4()), + account_uuid=account_uuid, + code_id=f'{credential_uuid[:8]}-{index:02d}', + salt=salt, + digest=_pbkdf2_digest(code_value, salt), + algorithm=_RECOVERY_CODE_ALGORITHM, + iterations=_PBKDF2_ITERATIONS, + ) + ) + + async def disable(self, account_uuid: str, *, code: str | None = None) -> bool: + """Disable TOTP. A live TOTP code or a recovery code is required.""" + + credential = await self.get_credential(account_uuid) + if credential is None or credential.confirmed_at is None: + raise TotpNotEnrolledError('TOTP is not enabled for this Account') + if not code: + raise TotpInvalidCodeError('A TOTP or recovery code is required to disable TOTP') + if not await self.verify_code(account_uuid, code, allow_recovery=True): + raise TotpInvalidCodeError('Invalid TOTP code') + + now = datetime.datetime.now(datetime.timezone.utc) + async with self._session_factory()() as session: + async with session.begin(): + await session.execute( + sqlalchemy.update(totp_entity.TotpCredential) + .where(totp_entity.TotpCredential.account_uuid == account_uuid) + .values(disabled_at=now) + ) + await session.execute( + sqlalchemy.delete(totp_entity.TotpRecoveryCode).where( + totp_entity.TotpRecoveryCode.account_uuid == account_uuid + ) + ) + return True + + # ------------------------------------------------------------------ + # owner/admin oversight + # ------------------------------------------------------------------ + async def list_account_states(self) -> list[dict[str, typing.Any]]: + """Second-factor state for every Account, for owner/admin oversight. + + Oversight is instance-wide by design: an owner/admin may manage the + second factor of any Account. + + Only state is returned: no secret and no recovery-code material. + """ + + credential = totp_entity.TotpCredential + unused_codes = ( + sqlalchemy.select( + totp_entity.TotpRecoveryCode.account_uuid.label('account_uuid'), + sqlalchemy.func.count().label('unused'), + ) + .where(totp_entity.TotpRecoveryCode.used_at.is_(None)) + .group_by(totp_entity.TotpRecoveryCode.account_uuid) + .subquery() + ) + statement = ( + sqlalchemy.select( + user.User.uuid, + user.User.user, + user.User.status, + credential.confirmed_at, + credential.last_used_at, + sqlalchemy.func.coalesce(unused_codes.c.unused, 0), + ) + .outerjoin( + credential, + sqlalchemy.and_( + credential.account_uuid == user.User.uuid, + credential.disabled_at.is_(None), + ), + ) + .outerjoin(unused_codes, unused_codes.c.account_uuid == user.User.uuid) + .order_by(user.User.user) + ) + async with self._session_factory()() as session: + rows = (await session.execute(statement)).all() + + return [ + { + 'account_uuid': row[0], + 'user': row[1], + 'status': row[2], + 'enabled': row[3] is not None, + 'last_used_at': row[4].isoformat() if row[4] else None, + 'recovery_codes_remaining': int(row[5] or 0), + } + for row in rows + ] + + async def revoke_for_account(self, account_uuid: str) -> bool: + """Owner/admin override: retire an Account's factor without its code. + + Used when the operator has lost the authenticator and every recovery + code. The credential is soft-disabled so the action stays auditable and + outstanding recovery codes are destroyed. + """ + + now = datetime.datetime.now(datetime.timezone.utc) + async with self._session_factory()() as session: + async with session.begin(): + result = await session.execute( + sqlalchemy.update(totp_entity.TotpCredential) + .where( + totp_entity.TotpCredential.account_uuid == account_uuid, + totp_entity.TotpCredential.disabled_at.is_(None), + ) + .values(disabled_at=now) + ) + await session.execute( + sqlalchemy.delete(totp_entity.TotpRecoveryCode).where( + totp_entity.TotpRecoveryCode.account_uuid == account_uuid + ) + ) + return bool(result.rowcount) + + # ------------------------------------------------------------------ + # verification + # ------------------------------------------------------------------ + def _decrypt_secret(self, ciphertext: str, key_version: int) -> bytes: + try: + return self._fernet(key_version).decrypt(ciphertext.encode('ascii')) + except InvalidToken as exc: + raise TotpError('Stored TOTP secret cannot be decrypted with the instance key') from exc + + def _verify_counter(self, credential: totp_entity.TotpCredential, code: str) -> int: + """Constant-time TOTP verification with a +/- one step drift window.""" + + normalized = _normalize_code(code) + if credential.algorithm.upper() != 'SHA1': + raise TotpError('Only SHA1 TOTP is supported') + if len(normalized) != credential.digits: + raise TotpInvalidCodeError('Invalid TOTP code') + + # The stored plaintext is the base32 secret; HMAC needs the raw key. + encoded_secret = self._decrypt_secret(credential.secret_ciphertext, credential.key_version) + secret_bytes = _decode_secret(encoded_secret.decode('ascii')) + current = _totp_counter(credential.period) + for candidate in (current, current - 1, current + 1): + if credential.last_used_counter is not None and candidate <= credential.last_used_counter: + # Reject replays inside an already-consumed window. + continue + if hmac.compare_digest(_hotp(secret_bytes, candidate, credential.digits), normalized): + return candidate + raise TotpInvalidCodeError('Invalid TOTP code') + + async def verify_code( + self, + account_uuid: str, + code: str, + *, + allow_recovery: bool = False, + consume_counter: bool = True, + ) -> bool: + """Verify a TOTP code, optionally falling back to a recovery code.""" + + credential = await self.get_credential(account_uuid) + if credential is None or credential.confirmed_at is None: + raise TotpNotEnrolledError('TOTP is not enabled for this Account') + + try: + counter = self._verify_counter(credential, code) + except TotpInvalidCodeError: + if not allow_recovery: + raise + if await self.use_recovery_code(account_uuid, code): + return True + raise + + if consume_counter: + now = datetime.datetime.now(datetime.timezone.utc) + async with self._session_factory()() as session: + async with session.begin(): + await session.execute( + sqlalchemy.update(totp_entity.TotpCredential) + .where( + totp_entity.TotpCredential.uuid == credential.uuid, + sqlalchemy.or_( + totp_entity.TotpCredential.last_used_counter.is_(None), + totp_entity.TotpCredential.last_used_counter < counter, + ), + ) + .values(last_used_counter=counter, last_used_at=now) + ) + return True + + async def use_recovery_code(self, account_uuid: str, code: str) -> bool: + """Consume one unused recovery code. The code is never logged.""" + + normalized = _normalize_code(code) + if len(normalized) < 16: + return False + + async with self._session_factory()() as session: + rows = list( + await session.scalars( + sqlalchemy.select(totp_entity.TotpRecoveryCode).where( + totp_entity.TotpRecoveryCode.account_uuid == account_uuid, + totp_entity.TotpRecoveryCode.used_at.is_(None), + ) + ) + ) + # Compare every stored digest in constant time and only remember + # whether a match happened: an early `break` leaks, through response + # timing, how many codes were probed before the match was found. + matched_id: int | None = None + for row in rows: + if hmac.compare_digest(_pbkdf2_digest(normalized, row.salt, row.iterations), row.digest): + matched_id = row.id + if matched_id is None: + return False + + # Single-use: the guard on used_at keeps concurrent spends from + # both succeeding. + result = await session.execute( + sqlalchemy.update(totp_entity.TotpRecoveryCode) + .where( + totp_entity.TotpRecoveryCode.id == matched_id, + totp_entity.TotpRecoveryCode.used_at.is_(None), + ) + .values(used_at=datetime.datetime.now(datetime.timezone.utc)) + ) + await session.commit() + return bool(result.rowcount) + + async def count_unused_recovery_codes(self, account_uuid: str) -> int: + statement = sqlalchemy.select(sqlalchemy.func.count()).select_from(totp_entity.TotpRecoveryCode).where( + totp_entity.TotpRecoveryCode.account_uuid == account_uuid, + totp_entity.TotpRecoveryCode.used_at.is_(None), + ) + async with self._session_factory()() as session: + return int(await session.scalar(statement) or 0) + + async def get_account(self, account_uuid: str) -> user.User | None: + statement = sqlalchemy.select(user.User).where(user.User.uuid == account_uuid) + async with self._session_factory()() as session: + return await session.scalar(statement) + + +__all__ = [ + 'TotpAlreadyEnrolledError', + 'TotpChallengeData', + 'TotpChallengeError', + 'TotpEnrollment', + 'TotpError', + 'TotpInvalidCodeError', + 'TotpNotEnrolledError', + 'TotpRecoveryCodes', + 'TotpRequiredError', + 'TotpService', + 'build_totp_uri', + 'render_totp_qr_data_url', +] diff --git a/src/langbot/pkg/api/http/service/user.py b/src/langbot/pkg/api/http/service/user.py index d0edf2e9b..11aa3ee6c 100644 --- a/src/langbot/pkg/api/http/service/user.py +++ b/src/langbot/pkg/api/http/service/user.py @@ -26,6 +26,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from ....entity.persistence import user from ....entity.persistence import passkey +from . import totp as totp_service_module from ....entity.persistence.workspace import MembershipRole, MembershipStatus, WorkspaceMembership from ....utils import constants from ....entity.errors import account as account_errors @@ -413,7 +414,13 @@ class UserService: f'space:{space_account_uuid}', ) - async def authenticate(self, user_email: str, password: str) -> str | None: + async def authenticate(self, user_email: str, password: str, totp_code: str | None = None) -> str | None: + """Verify primary credentials, then any enrolled second factor. + + When TOTP is enrolled but no code was supplied, ``TotpRequiredError`` is + raised so the caller can issue a challenge instead of a session token. + """ + user_obj = await self.get_user_by_email(user_email) if user_obj is None: raise ValueError('用户不存在') @@ -425,6 +432,14 @@ class UserService: await self._verify_password(user_obj.password, password) + totp_service = getattr(self.ap, 'totp_service', None) + if totp_service is not None and await totp_service.is_enrolled(user_obj.uuid): + if not totp_code: + raise totp_service_module.TotpRequiredError('A second factor is required') + # Recovery codes are accepted here as well, so a lost authenticator + # does not lock an Account out of its own instance. + await totp_service.verify_code(user_obj.uuid, totp_code, allow_recovery=True) + return await self.generate_jwt_token(user_obj) async def generate_jwt_token( @@ -534,6 +549,103 @@ class UserService: if isinstance(status, str) and status != user.AccountStatus.ACTIVE.value: raise AccountDisabledError('Account is disabled') + async def issue_totp_login_challenge(self, user_email: str) -> str | None: + """Issue a login second-factor challenge for a TOTP-enrolled Account. + + Returns ``None`` when the Account has no active second factor, so the + caller can proceed with the primary factors alone. + """ + + totp_service = getattr(self.ap, 'totp_service', None) + if totp_service is None: + return None + user_obj = await self.get_user_by_email(user_email) + if user_obj is None: + return None + if not await totp_service.is_enrolled(user_obj.uuid): + return None + return await totp_service.issue_login_challenge(user_obj.uuid, user_obj.user) + + async def issue_uniform_totp_login_challenge(self, user_email: str) -> str: + """Issue a challenge token even when the Account has no second factor. + + The login endpoints are unauthenticated. Returning a distinguishable + error for "no second factor enrolled" would turn them into an Account + enumeration oracle, so the caller always receives a token. Supplying a + code for a non-enrolled or unknown Account simply fails verification. + """ + + totp_service = getattr(self.ap, 'totp_service', None) + if totp_service is None: + raise ValueError('TOTP service is unavailable') + user_obj = await self.get_user_by_email(user_email) + if user_obj is None or not await totp_service.is_enrolled(user_obj.uuid): + return await totp_service.issue_login_challenge('', '') + return await totp_service.issue_login_challenge(user_obj.uuid, user_obj.user) + + async def has_totp_enrolled(self, user_email: str) -> bool: + """Whether the Account behind this email has an active second factor.""" + + totp_service = getattr(self.ap, 'totp_service', None) + if totp_service is None: + return False + user_obj = await self.get_user_by_email(user_email) + if user_obj is None: + return False + return await totp_service.is_enrolled(user_obj.uuid) + + async def verify_totp_second_factor( + self, + user_email: str, + code: str, + *, + allow_recovery: bool = True, + challenge_token: str | None = None, + ) -> bool: + """Verify a TOTP or recovery code for the Account behind this email. + + When ``challenge_token`` is supplied it must be the token issued for a + successful primary-factor check on this exact Account. This stops the + code-only path from minting a session without ever proving the password, + and it applies the per-challenge attempt cap. + """ + + totp_service = getattr(self.ap, 'totp_service', None) + if totp_service is None: + return False + user_obj = await self.get_user_by_email(user_email) + if user_obj is None: + return False + + challenge = None + if challenge_token: + try: + challenge = totp_service.consume_login_challenge(challenge_token) + except totp_service_module.TotpChallengeError: + return False + # A challenge is bound to one Account: it cannot be redeemed for + # another, nor for an Account that never had a second factor. + if challenge.account_uuid != user_obj.uuid: + totp_service.record_failed_attempt(challenge_token) + return False + + try: + verified = await totp_service.verify_code( + user_obj.uuid, code, allow_recovery=allow_recovery + ) + except totp_service_module.TotpError: + # Covers "not enrolled" and "invalid code" alike. Both are simply a + # failed verification for this caller; neither should surface as a + # server error or disclose whether the Account has a second factor. + verified = False + + if not verified and challenge_token: + totp_service.record_failed_attempt(challenge_token) + return False + if verified and challenge_token: + totp_service.complete_login_challenge(challenge_token) + return verified + async def reset_password(self, user_email: str, new_password: str) -> None: hashed_password = await self._hash_password(new_password) normalized_email = normalize_email(user_email) diff --git a/src/langbot/pkg/core/app.py b/src/langbot/pkg/core/app.py index b1951c998..21f1db6d9 100644 --- a/src/langbot/pkg/core/app.py +++ b/src/langbot/pkg/core/app.py @@ -24,6 +24,7 @@ from ..persistence import mgr as persistencemgr from ..api.http.controller import main as http_controller from ..api.http.service import user as user_service from ..api.http.service import space as space_service +from ..api.http.service import totp as totp_service from ..api.http.service import model as model_service from ..api.http.service import provider as provider_service from ..api.http.service import pipeline as pipeline_service @@ -160,6 +161,7 @@ class Application: # ========= HTTP Services ========= user_service: user_service.UserService = None + totp_service: totp_service.TotpService = None space_service: space_service.SpaceService = None diff --git a/src/langbot/pkg/core/stages/build_app.py b/src/langbot/pkg/core/stages/build_app.py index 7941a5553..ff0ab92bc 100644 --- a/src/langbot/pkg/core/stages/build_app.py +++ b/src/langbot/pkg/core/stages/build_app.py @@ -18,6 +18,7 @@ from ...persistence import mgr as persistencemgr from ...api.http.controller import main as http_controller from ...api.http.service import user as user_service from ...api.http.service import space as space_service +from ...api.http.service import totp as totp_service from ...api.http.service import model as model_service from ...api.http.service import provider as provider_service from ...api.http.service import pipeline as pipeline_service @@ -198,6 +199,8 @@ class BuildAppStage(stage.BootingStage): user_service_inst = user_service.UserService(ap) ap.user_service = user_service_inst + ap.totp_service = totp_service.TotpService(ap) + async def resolve_singleton_execution_context() -> ExecutionContext: if workspace_policy.multi_workspace_enabled: raise WorkspaceRequiredError('Cloud runtime work requires an explicit Workspace context') diff --git a/src/langbot/pkg/entity/persistence/totp.py b/src/langbot/pkg/entity/persistence/totp.py new file mode 100644 index 000000000..3c87a7315 --- /dev/null +++ b/src/langbot/pkg/entity/persistence/totp.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import uuid as uuid_lib + +import sqlalchemy + +from .base import Base + + +class TotpCredential(Base): + """Per-Account TOTP enrolment. + + ``secret_ciphertext`` stores the Fernet token produced by encrypting the + base32 TOTP shared secret with a key derived from the instance JWT secret + (HKDF-SHA256). The plaintext secret is never written to disk and never + returned by read endpoints after enrolment completes. + """ + + __tablename__ = 'totp_credentials' + + id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True) + uuid = sqlalchemy.Column( + sqlalchemy.String(36), + nullable=False, + default=lambda: str(uuid_lib.uuid4()), + ) + account_uuid = sqlalchemy.Column( + sqlalchemy.String(36), + sqlalchemy.ForeignKey('users.uuid', ondelete='CASCADE'), + nullable=False, + ) + secret_ciphertext = sqlalchemy.Column(sqlalchemy.Text, nullable=False) + # Key derivation epoch: allows rotating the wrapping key without losing the secret. + key_version = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, server_default='1') + algorithm = sqlalchemy.Column(sqlalchemy.String(16), nullable=False, server_default='SHA1') + digits = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, server_default='6') + period = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, server_default='30') + confirmed_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True) + last_used_counter = sqlalchemy.Column(sqlalchemy.BigInteger, nullable=True) + disabled_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True) + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) + last_used_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True) + + __table_args__ = ( + sqlalchemy.Index('uq_totp_credentials_uuid', 'uuid', unique=True), + sqlalchemy.Index('ix_totp_credentials_account', 'account_uuid'), + ) + + +class TotpRecoveryCode(Base): + """Single-use TOTP recovery code stored only as a salted PBKDF2 digest.""" + + __tablename__ = 'totp_recovery_codes' + + id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True) + uuid = sqlalchemy.Column( + sqlalchemy.String(36), + nullable=False, + default=lambda: str(uuid_lib.uuid4()), + ) + account_uuid = sqlalchemy.Column( + sqlalchemy.String(36), + sqlalchemy.ForeignKey('users.uuid', ondelete='CASCADE'), + nullable=False, + ) + code_id = sqlalchemy.Column(sqlalchemy.String(16), nullable=False) + salt = sqlalchemy.Column(sqlalchemy.String(64), nullable=False) + digest = sqlalchemy.Column(sqlalchemy.String(128), nullable=False) + algorithm = sqlalchemy.Column(sqlalchemy.String(32), nullable=False, server_default='pbkdf2_hmac_sha256') + iterations = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) + used_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True) + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) + + __table_args__ = ( + sqlalchemy.Index('uq_totp_recovery_codes_uuid', 'uuid', unique=True), + sqlalchemy.Index('uq_totp_recovery_codes_code_id', 'code_id', unique=True), + sqlalchemy.Index('ix_totp_recovery_codes_account', 'account_uuid'), + ) diff --git a/src/langbot/pkg/persistence/alembic/versions/0025_totp_credentials.py b/src/langbot/pkg/persistence/alembic/versions/0025_totp_credentials.py new file mode 100644 index 000000000..7171099bf --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0025_totp_credentials.py @@ -0,0 +1,138 @@ +"""add TOTP credentials and recovery codes + +Revision ID: 0025_totp_credentials +Revises: 0024_passkey_credentials +Create Date: 2026-09-22 + +The TOTP shared secret is stored only as a Fernet token keyed off the instance +JWT secret (HKDF-SHA256), and recovery codes are stored only as salted +PBKDF2-HMAC-SHA256 digests. No plaintext second-factor material is persisted. +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = '0025_totp_credentials' +down_revision = '0024_passkey_credentials' +branch_labels = None +depends_on = None + +_CREDENTIALS_TABLE = 'totp_credentials' +_RECOVERY_CODES_TABLE = 'totp_recovery_codes' +_LEGACY_CREDENTIALS_TABLE = 'totp_credentials_legacy_pre_0025' + +# Columns this migration guarantees. A pre-existing table missing any of them is +# an incompatible abandoned shape and must not be silently reused. +_REQUIRED_CREDENTIAL_COLUMNS = frozenset( + {'uuid', 'account_uuid', 'secret_ciphertext', 'key_version', 'algorithm', 'digits', 'period'} +) + + +def _retire_abandoned_credentials_table() -> None: + """Move an incompatible `totp_credentials` aside without losing its data. + + The table is retained under a clearly labelled name for forensic recovery, + but its indexes are dropped because they occupy the very names the supported + schema needs (``uq_totp_credentials_uuid`` in particular). + """ + + inspector = sa.inspect(op.get_bind()) + existing_tables = set(inspector.get_table_names()) + if _LEGACY_CREDENTIALS_TABLE in existing_tables: + op.drop_table(_LEGACY_CREDENTIALS_TABLE) + + op.rename_table(_CREDENTIALS_TABLE, _LEGACY_CREDENTIALS_TABLE) + + for index in sa.inspect(op.get_bind()).get_indexes(_LEGACY_CREDENTIALS_TABLE): + name = index.get('name') + if name: + op.drop_index(name, table_name=_LEGACY_CREDENTIALS_TABLE) + + +def _create_credentials_table() -> None: + op.create_table( + _CREDENTIALS_TABLE, + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('uuid', sa.String(36), nullable=False), + sa.Column( + 'account_uuid', + sa.String(36), + sa.ForeignKey('users.uuid', ondelete='CASCADE'), + nullable=False, + ), + sa.Column('secret_ciphertext', sa.Text(), nullable=False), + sa.Column('key_version', sa.Integer(), nullable=False, server_default='1'), + sa.Column('algorithm', sa.String(16), nullable=False, server_default='SHA1'), + sa.Column('digits', sa.Integer(), nullable=False, server_default='6'), + sa.Column('period', sa.Integer(), nullable=False, server_default='30'), + sa.Column('confirmed_at', sa.DateTime(), nullable=True), + sa.Column('last_used_counter', sa.BigInteger(), nullable=True), + sa.Column('disabled_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column('last_used_at', sa.DateTime(), nullable=True), + ) + op.create_index('uq_totp_credentials_uuid', _CREDENTIALS_TABLE, ['uuid'], unique=True) + op.create_index('ix_totp_credentials_account', _CREDENTIALS_TABLE, ['account_uuid'], unique=False) + + +def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + existing_tables = set(inspector.get_table_names()) + + if _CREDENTIALS_TABLE not in existing_tables: + _create_credentials_table() + else: + columns = {column['name'] for column in inspector.get_columns(_CREDENTIALS_TABLE)} + if not _REQUIRED_CREDENTIAL_COLUMNS.issubset(columns): + # An abandoned table from an unrelated feature occupies the name and + # cannot store a Fernet-wrapped secret. Preserve it under a clearly + # labelled name (no data loss) and install the supported schema. + _retire_abandoned_credentials_table() + _create_credentials_table() + + if _RECOVERY_CODES_TABLE not in existing_tables: + op.create_table( + _RECOVERY_CODES_TABLE, + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('uuid', sa.String(36), nullable=False), + sa.Column( + 'account_uuid', + sa.String(36), + sa.ForeignKey('users.uuid', ondelete='CASCADE'), + nullable=False, + ), + sa.Column('code_id', sa.String(16), nullable=False), + sa.Column('salt', sa.String(64), nullable=False), + sa.Column('digest', sa.String(128), nullable=False), + sa.Column( + 'algorithm', + sa.String(32), + nullable=False, + server_default='pbkdf2_hmac_sha256', + ), + sa.Column('iterations', sa.Integer(), nullable=False), + sa.Column('used_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()), + ) + op.create_index('uq_totp_recovery_codes_uuid', _RECOVERY_CODES_TABLE, ['uuid'], unique=True) + op.create_index('uq_totp_recovery_codes_code_id', _RECOVERY_CODES_TABLE, ['code_id'], unique=True) + op.create_index('ix_totp_recovery_codes_account', _RECOVERY_CODES_TABLE, ['account_uuid'], unique=False) + + +def downgrade() -> None: + inspector = sa.inspect(op.get_bind()) + existing_tables = set(inspector.get_table_names()) + + if _RECOVERY_CODES_TABLE in existing_tables: + op.drop_index('ix_totp_recovery_codes_account', table_name=_RECOVERY_CODES_TABLE) + op.drop_index('uq_totp_recovery_codes_code_id', table_name=_RECOVERY_CODES_TABLE) + op.drop_index('uq_totp_recovery_codes_uuid', table_name=_RECOVERY_CODES_TABLE) + op.drop_table(_RECOVERY_CODES_TABLE) + + if _CREDENTIALS_TABLE in existing_tables: + op.drop_index('ix_totp_credentials_account', table_name=_CREDENTIALS_TABLE) + op.drop_index('uq_totp_credentials_uuid', table_name=_CREDENTIALS_TABLE) + op.drop_table(_CREDENTIALS_TABLE) diff --git a/src/langbot/pkg/persistence/alembic/versions/0026_merge_totp_and_rag_identity.py b/src/langbot/pkg/persistence/alembic/versions/0026_merge_totp_and_rag_identity.py new file mode 100644 index 000000000..92043d8f3 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0026_merge_totp_and_rag_identity.py @@ -0,0 +1,21 @@ +"""merge the TOTP credential branch with the RAG document identity branch + +Revision ID: 0026_merge_totp_and_rag_identity +Revises: 0025_totp_credentials, 0025_rag_document_identity +Create Date: 2026-09-22 +""" + +from __future__ import annotations + +revision = '0026_merge_totp_and_rag_identity' +down_revision = ('0025_totp_credentials', '0025_rag_document_identity') +branch_labels = None +depends_on = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/uv.lock b/uv.lock index e323b1763..1e0f15af8 100644 --- a/uv.lock +++ b/uv.lock @@ -1066,7 +1066,7 @@ name = "cuda-bindings" version = "13.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-pathfinder", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "cuda-pathfinder" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/51/6b/457ca12dad3ee9bfcc9a545cfd6b64b359ba49de40f776f6e028e678f262/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474", size = 6053539, upload-time = "2026-05-29T23:11:43.19Z" }, @@ -1099,34 +1099,34 @@ wheels = [ [package.optional-dependencies] cudart = [ - { name = "nvidia-cuda-runtime", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-runtime" }, ] cufft = [ - { name = "nvidia-cufft", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cufft" }, ] cufile = [ - { name = "nvidia-cufile", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cufile" }, ] cupti = [ - { name = "nvidia-cuda-cupti", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-cupti" }, ] curand = [ - { name = "nvidia-curand", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-curand" }, ] cusolver = [ - { name = "nvidia-cusolver", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cusolver" }, ] cusparse = [ - { name = "nvidia-cusparse", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cusparse" }, ] nvjitlink = [ - { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-nvjitlink" }, ] nvrtc = [ - { name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-nvrtc" }, ] nvtx = [ - { name = "nvidia-nvtx", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-nvtx" }, ] [[package]] @@ -1747,6 +1747,9 @@ wheels = [ http2 = [ { name = "h2" }, ] +socks = [ + { name = "socksio" }, +] [[package]] name = "httpx-sse" @@ -2083,6 +2086,7 @@ dependencies = [ { name = "ebooklib" }, { name = "gewechat-client" }, { name = "html2text" }, + { name = "httpx", extra = ["socks"] }, { name = "langbot-plugin" }, { name = "langchain" }, { name = "langchain-core" }, @@ -2180,6 +2184,7 @@ requires-dist = [ { name = "ebooklib", specifier = ">=0.18" }, { name = "gewechat-client", specifier = ">=0.1.5" }, { name = "html2text", specifier = ">=2024.2.26" }, + { name = "httpx", extras = ["socks"], specifier = ">=0.28.1" }, { name = "langbot-plugin", specifier = "==0.6.0b5" }, { name = "langchain", specifier = ">=1.3.9" }, { name = "langchain-core", specifier = ">=1.3.3" }, @@ -3301,7 +3306,7 @@ name = "nvidia-cublas" version = "13.1.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-cuda-nvrtc" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, @@ -3340,7 +3345,7 @@ name = "nvidia-cudnn-cu13" version = "9.20.0.48" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-cublas" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" }, @@ -3352,7 +3357,7 @@ name = "nvidia-cufft" version = "12.0.0.61" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, @@ -3382,9 +3387,9 @@ name = "nvidia-cusolver" version = "12.0.4.66" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "nvidia-cusparse", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-cublas" }, + { name = "nvidia-cusparse" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, @@ -3396,7 +3401,7 @@ name = "nvidia-cusparse" version = "12.6.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, @@ -4489,7 +4494,7 @@ name = "pylibseekdb" version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pymysql", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "pymysql" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/ae/a8/7413d33218aff55a14ec9d20532b49243ffd0579e7a92244922c1885444e/pylibseekdb-1.4.0-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:5cb2efab9f1321cdb4b034d3a2bd92e41a402fc95e7dc9579c7473a426f96e24", size = 52173499, upload-time = "2026-08-27T13:05:09.347Z" }, @@ -5257,10 +5262,10 @@ name = "scikit-learn" version = "1.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "joblib", marker = "python_full_version >= '3.14'" }, - { name = "numpy", marker = "python_full_version >= '3.14'" }, - { name = "scipy", marker = "python_full_version >= '3.14'" }, - { name = "threadpoolctl", marker = "python_full_version >= '3.14'" }, + { name = "joblib" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "threadpoolctl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } wheels = [ @@ -5307,7 +5312,7 @@ name = "scipy" version = "1.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", marker = "python_full_version >= '3.14'" }, + { name = "numpy" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -5378,14 +5383,14 @@ name = "sentence-transformers" version = "5.2.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "huggingface-hub", marker = "python_full_version >= '3.14'" }, - { name = "numpy", marker = "python_full_version >= '3.14'" }, - { name = "scikit-learn", marker = "python_full_version >= '3.14'" }, - { name = "scipy", marker = "python_full_version >= '3.14'" }, - { name = "torch", marker = "python_full_version >= '3.14'" }, - { name = "tqdm", marker = "python_full_version >= '3.14'" }, - { name = "transformers", marker = "python_full_version >= '3.14'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.14'" }, + { name = "huggingface-hub" }, + { name = "numpy" }, + { name = "scikit-learn" }, + { name = "scipy" }, + { name = "torch" }, + { name = "tqdm" }, + { name = "transformers" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5b/30/21664028fc0776eb1ca024879480bbbab36f02923a8ff9e4cae5a150fa35/sentence_transformers-5.2.3.tar.gz", hash = "sha256:3cd3044e1f3fe859b6a1b66336aac502eaae5d3dd7d5c8fc237f37fbf58137c7", size = 381623, upload-time = "2026-02-17T14:05:20.238Z" } wheels = [ @@ -5437,6 +5442,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "socksio" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac", size = 19055, upload-time = "2020-04-17T15:50:34.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763, upload-time = "2020-04-17T15:50:31.878Z" }, +] + [[package]] name = "soupsieve" version = "2.8.3" @@ -5758,21 +5772,21 @@ name = "torch" version = "2.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-bindings", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, - { name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, - { name = "filelock", marker = "python_full_version >= '3.14'" }, - { name = "fsspec", marker = "python_full_version >= '3.14'" }, - { name = "jinja2", marker = "python_full_version >= '3.14'" }, - { name = "networkx", marker = "python_full_version >= '3.14'" }, - { name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, - { name = "nvidia-cudnn-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, - { name = "nvidia-cusparselt-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, - { name = "nvidia-nccl-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, - { name = "nvidia-nvshmem-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, - { name = "setuptools", marker = "python_full_version >= '3.14'" }, - { name = "sympy", marker = "python_full_version >= '3.14'" }, - { name = "triton", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.14'" }, + { name = "cuda-bindings", marker = "sys_platform == 'linux'" }, + { name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "networkx" }, + { name = "nvidia-cublas", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" }, + { name = "setuptools" }, + { name = "sympy" }, + { name = "triton", marker = "sys_platform == 'linux'" }, + { name = "typing-extensions" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/59/38/7028d3be540f1dcdf41660a2b01d0c51d2cb73915fe370d84e4d277a6d47/torch-2.12.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ef81f503912effea2ce3d9b12a2e3a6ed488943e91271c90c7a829f60baf6aa2", size = 87975425, upload-time = "2026-06-17T21:08:34.094Z" }, @@ -5814,15 +5828,15 @@ name = "transformers" version = "5.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "huggingface-hub", marker = "python_full_version >= '3.14'" }, - { name = "numpy", marker = "python_full_version >= '3.14'" }, - { name = "packaging", marker = "python_full_version >= '3.14'" }, - { name = "pyyaml", marker = "python_full_version >= '3.14'" }, - { name = "regex", marker = "python_full_version >= '3.14'" }, - { name = "safetensors", marker = "python_full_version >= '3.14'" }, - { name = "tokenizers", marker = "python_full_version >= '3.14'" }, - { name = "tqdm", marker = "python_full_version >= '3.14'" }, - { name = "typer", marker = "python_full_version >= '3.14'" }, + { name = "huggingface-hub" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "regex" }, + { name = "safetensors" }, + { name = "tokenizers" }, + { name = "tqdm" }, + { name = "typer" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fc/1a/70e830d53ecc96ce69cfa8de38f163712d2b43ac52fbd743f39f56025c31/transformers-5.3.0.tar.gz", hash = "sha256:009555b364029da9e2946d41f1c5de9f15e6b1df46b189b7293f33a161b9c557", size = 8830831, upload-time = "2026-03-04T17:41:46.119Z" } wheels = [ @@ -6083,9 +6097,9 @@ name = "valkey-glide" version = "2.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "sys_platform != 'win32'" }, - { name = "protobuf", marker = "sys_platform != 'win32'" }, - { name = "sniffio", marker = "sys_platform != 'win32'" }, + { name = "anyio" }, + { name = "protobuf" }, + { name = "sniffio" }, ] sdist = { url = "https://files.pythonhosted.org/packages/72/a2/582b34c6acc8dc857c537f6007459cba48dfa0dc404789a657e5c1a998c0/valkey_glide-2.4.1.tar.gz", hash = "sha256:f1155d84156d11b90488aa67e90102f0bf98a45314f5b99308ac9074c05f7241", size = 898030, upload-time = "2026-05-28T21:41:55.881Z" } wheels = [ diff --git a/web/src/app/home/components/account-settings-dialog/AccountSettingsPanel.tsx b/web/src/app/home/components/account-settings-dialog/AccountSettingsPanel.tsx index b03d5060b..b486326df 100644 --- a/web/src/app/home/components/account-settings-dialog/AccountSettingsPanel.tsx +++ b/web/src/app/home/components/account-settings-dialog/AccountSettingsPanel.tsx @@ -21,9 +21,13 @@ import { Plus, Trash2, Pencil, + ShieldCheck, + ShieldOff, } from 'lucide-react'; import { startRegistration } from '@simplewebauthn/browser'; import PasswordChangeDialog from '../password-change-dialog/PasswordChangeDialog'; +import TotpEnrollDialog, { type TotpDialogMode } from './TotpEnrollDialog'; +import TotpAdminResetDialog from './TotpAdminResetDialog'; import { PanelBody } from '../settings-dialog/panel-layout'; interface AccountSettingsPanelProps { @@ -32,6 +36,15 @@ interface AccountSettingsPanelProps { onEmailResolved?: (email: string) => void; } +interface TotpAccountRow { + account_uuid: string; + user: string; + status?: string; + enabled: boolean; + last_used_at?: string | null; + recovery_codes_remaining: number; +} + interface PasskeyItem { uuid: string; name: string; @@ -56,6 +69,23 @@ export default function AccountSettingsPanel({ const [passkeys, setPasskeys] = useState([]); const [passkeyLoading, setPasskeyLoading] = useState(false); const [registeringPasskey, setRegisteringPasskey] = useState(false); + const [totpDialogOpen, setTotpDialogOpen] = useState(false); + // Latched when the dialog opens so a status refresh cannot swap the flow. + const [totpDialogMode, setTotpDialogMode] = useState('enroll'); + // Owner/admin re-binding flow: the target Account is latched on open. + const [adminResetOpen, setAdminResetOpen] = useState(false); + const [adminResetTarget, setAdminResetTarget] = useState( + null, + ); + const [totpRows, setTotpRows] = useState([]); + const [isManager, setIsManager] = useState(false); + const [accountUuid, setAccountUuid] = useState(''); + const [totpStatus, setTotpStatus] = useState<{ + enabled: boolean; + pending: boolean; + recovery_codes_remaining: number; + last_used_at?: string | null; + } | null>(null); useEffect(() => { if (active) { @@ -64,6 +94,59 @@ export default function AccountSettingsPanel({ } }, [active]); + // Depends on `accountUuid`: the self row is keyed by it, so it must load after + // the Account is resolved rather than in the same pass. + useEffect(() => { + if (active && accountUuid) { + void loadTotpStatus(); + void loadTotpAccounts(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [active, accountUuid]); + + async function loadTotpStatus() { + try { + const own = await httpClient.getTotpStatus(); + setTotpStatus(own); + setTotpRows((prev) => { + const rest = prev.filter((row) => row.account_uuid !== accountUuid); + return [ + { + account_uuid: accountUuid, + user: userEmail, + enabled: own.enabled, + last_used_at: own.last_used_at ?? null, + recovery_codes_remaining: own.recovery_codes_remaining, + }, + ...rest, + ]; + }); + } catch { + // A disabled or unavailable second factor must not break the panel. + setTotpStatus(null); + } + } + + // Owners and admins may see and manage every Account's second factor. + async function loadTotpAccounts() { + try { + const res = await httpClient.getTotpAccounts(); + const list = res.accounts || []; + setIsManager(list.length > 1); + setTotpRows(list); + } catch { + // A non-manager receives 403 here; the panel keeps working on own state. + setIsManager(false); + } + } + + // Owners/admins re-bind the Account by walking them through a fresh QR scan + // rather than silently revoking, so the Account is never left locked out. + function handleRevokeTotp(row: TotpAccountRow) { + setAdminResetTarget(row); + setAdminResetOpen(true); + } + async function loadUserInfo() { setLoading(true); try { @@ -71,6 +154,7 @@ export default function AccountSettingsPanel({ setAccountType(info.account_type); setHasPassword(info.has_password); setUserEmail(info.user); + setAccountUuid(info.account_uuid); onEmailResolved?.(info.user); } catch { toast.error(t('common.error')); @@ -332,6 +416,129 @@ export default function AccountSettingsPanel({ )} + + {/* TOTP second factor. The card is informational: changing the factor + happens from the action on the Account's own row. */} +
+
+

+ + {t('account.totpSectionTitle')} +

+

+ {isManager + ? t('account.totpManagerSectionDesc') + : totpStatus?.enabled + ? t('account.totpEnabledDesc', { + count: totpStatus.recovery_codes_remaining, + }) + : t('account.totpSectionDesc')} +

+
+ + {totpRows.length === 0 ? ( +
+ {t('account.noAccounts')} +
+ ) : ( +
+ {totpRows.map((row) => { + // Managers re-bind someone else's factor through a dialog that + // shows a server-rendered QR code: the Account scans it and + // reads the code back, so the secret still only reaches their + // authenticator. + const isSelf = row.account_uuid === accountUuid; + return ( + + + {row.enabled ? ( + + ) : ( + + )} + + + + {row.user} + {isSelf && ( + + ({t('account.you')}) + + )} + + + {row.enabled + ? `${t('account.totpStatusEnabled')} · ${t( + 'account.totpCodesRemaining', + { count: row.recovery_codes_remaining }, + )}` + : t('account.totpStatusDisabled')} + {row.last_used_at && ( + + ·{' '} + {t('account.totpLastUsed', { + date: new Date( + row.last_used_at, + ).toLocaleDateString(), + })} + + )} + + + + {isSelf ? ( + + ) : ( + isManager && ( + // Managers can both re-bind an enabled Account and + // force-enable one that never had a second factor. + + ) + )} + + + ); + })} +
+ )} +
)} @@ -340,6 +547,27 @@ export default function AccountSettingsPanel({ onOpenChange={handlePasswordDialogClose} hasPassword={hasPassword} /> + + { + void loadTotpStatus(); + void loadTotpAccounts(); + }} + mode={totpDialogMode} + /> + + { + void loadTotpStatus(); + void loadTotpAccounts(); + }} + /> ); } diff --git a/web/src/app/home/components/account-settings-dialog/TotpAdminResetDialog.tsx b/web/src/app/home/components/account-settings-dialog/TotpAdminResetDialog.tsx new file mode 100644 index 000000000..f3e9ba046 --- /dev/null +++ b/web/src/app/home/components/account-settings-dialog/TotpAdminResetDialog.tsx @@ -0,0 +1,294 @@ +import { useEffect, useRef, useState } from 'react'; +import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { httpClient } from '@/app/infra/http/HttpClient'; +import { + Loader2, + ShieldCheck, + Copy, + Check, + Download, + AlertTriangle, + RefreshCw, +} from 'lucide-react'; + +/** + * Owner/admin driven re-binding of another Account's second factor. + * + * The manager walks the Account through a fresh enrolment: the QR code is + * rendered server-side (the shared secret never reaches the browser), the + * Account scans it and reads back the 6-digit code, and the manager enters that + * code to activate the credential. Recovery codes are then handed over. + * + * The previous authenticator stops working as soon as a new enrolment starts. + */ +type Step = 'confirm' | 'recovery'; + +interface TotpAdminResetDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** The Account whose second factor is being re-bound. */ + accountUuid: string; + /** Display name of that Account, used in the copy. */ + accountUser: string; + /** Called when a change was made so the panel can refresh its status. */ + onChanged?: () => void; +} + +export default function TotpAdminResetDialog({ + open, + onOpenChange, + accountUuid, + accountUser, + onChanged, +}: TotpAdminResetDialogProps) { + const { t } = useTranslation(); + const [step, setStep] = useState('confirm'); + const [loading, setLoading] = useState(false); + const [qrDataUrl, setQrDataUrl] = useState(''); + const [code, setCode] = useState(''); + const [recoveryCodes, setRecoveryCodes] = useState([]); + const [copiedKey, setCopiedKey] = useState(null); + const changedRef = useRef(false); + + async function startReset() { + try { + const res = await httpClient.adminBeginTotpEnroll(accountUuid); + setQrDataUrl(res.qr_code_data_url); + setCode(''); + } catch (error) { + const apiError = error as { msg?: string }; + toast.error(apiError?.msg || t('common.error')); + } + } + + // Reset the wizard each time it is opened for a (possibly new) Account. + useEffect(() => { + if (!open) { + return; + } + changedRef.current = false; + setStep('confirm'); + setQrDataUrl(''); + setCode(''); + setRecoveryCodes([]); + setCopiedKey(null); + void startReset(); + // Intentionally keyed on `open`/`accountUuid` only: `startReset` mutates state. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, accountUuid]); + + function closeDialog() { + if (changedRef.current) { + changedRef.current = false; + onChanged?.(); + } + onOpenChange(false); + } + + async function handleConfirm() { + if (!code.trim()) { + return; + } + setLoading(true); + try { + const res = await httpClient.adminConfirmTotpEnroll( + accountUuid, + code.trim(), + ); + setRecoveryCodes(res.recovery_codes || []); + changedRef.current = true; + setStep('recovery'); + toast.success(t('account.totpEnabledSuccess')); + } catch { + toast.error(t('account.totpInvalidCode')); + } finally { + setLoading(false); + } + } + + async function copyText(value: string, key: string) { + try { + await navigator.clipboard.writeText(value); + setCopiedKey(key); + setTimeout(() => setCopiedKey(null), 1500); + } catch { + toast.error(t('common.error')); + } + } + + function downloadRecoveryCodes() { + const blob = new Blob( + [ + `${t('account.totpRecoveryCodesTitle')} - ${accountUser}\n\n${recoveryCodes.join('\n')}\n`, + ], + { type: 'text/plain' }, + ); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = 'langbot-recovery-codes.txt'; + link.click(); + URL.revokeObjectURL(url); + } + + return ( + (next ? onOpenChange(true) : closeDialog())} + > + + + + + {step === 'confirm' + ? t('account.totpAdminResetTitle', { user: accountUser }) + : t('account.totpRecoveryCodesTitle')} + + + {step === 'confirm' + ? t('account.totpAdminResetDesc') + : t('account.totpRecoveryCodesDesc')} + + + + {step === 'confirm' && ( +
+ {!qrDataUrl ? ( +
+ + {t('account.totpGeneratingSecret')} +
+ ) : ( + <> +
+ +

+ {t('account.totpAdminResetWarning', { user: accountUser })} +

+
+ +
+ {t('account.totpQrAlt')} +
+ +

+ {t('account.totpAdminResetHint')} +

+ +
+ + setCode(e.target.value)} + placeholder={t('account.enterCode')} + inputMode="numeric" + spellCheck={false} + autoComplete="off" + className="tracking-widest" + /> +
+ + + + + + + )} +
+ )} + + {step === 'recovery' && ( +
+
+ +

+ {t('account.totpAdminHandOverCodes', { user: accountUser })} +

+
+ +
+ {recoveryCodes.map((value) => ( + + {value} + + ))} +
+ + +
+ + +
+ +
+
+ )} +
+
+ ); +} diff --git a/web/src/app/home/components/account-settings-dialog/TotpEnrollDialog.tsx b/web/src/app/home/components/account-settings-dialog/TotpEnrollDialog.tsx new file mode 100644 index 000000000..d8f95c654 --- /dev/null +++ b/web/src/app/home/components/account-settings-dialog/TotpEnrollDialog.tsx @@ -0,0 +1,406 @@ +import { useEffect, useRef, useState } from 'react'; +import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { httpClient } from '@/app/infra/http/HttpClient'; +import { + Loader2, + ShieldCheck, + ShieldOff, + Copy, + Check, + Download, + AlertTriangle, + RefreshCw, +} from 'lucide-react'; + +/** + * 'enroll' — the Account has no second factor: scan a server-rendered QR code, + * then confirm a code. + * 'manage' — the Account already has one: regenerate codes or disable it. + * + * The mode is chosen by the caller when the dialog opens and is intentionally + * NOT re-derived from live status, otherwise confirming an enrolment would flip + * the dialog straight into the disable view and hide the recovery codes. + */ +export type TotpDialogMode = 'enroll' | 'manage'; + +type Step = 'confirm' | 'recovery' | 'manage' | 'regenerate' | 'disable'; + +interface TotpEnrollDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Called when a change was made so the panel can refresh its status. */ + onChanged?: () => void; + mode: TotpDialogMode; +} + +export default function TotpEnrollDialog({ + open, + onOpenChange, + onChanged, + mode, +}: TotpEnrollDialogProps) { + const { t } = useTranslation(); + const [step, setStep] = useState('confirm'); + const [loading, setLoading] = useState(false); + // Server-rendered PNG data URL; the shared secret never reaches the browser. + const [qrDataUrl, setQrDataUrl] = useState(''); + const [code, setCode] = useState(''); + const [recoveryCodes, setRecoveryCodes] = useState([]); + const [copiedKey, setCopiedKey] = useState(null); + // Latched per opening so a status refresh cannot re-route an in-flight flow. + const modeRef = useRef(mode); + const changedRef = useRef(false); + + // `rotate: true` is the explicit refresh action. Leaving it false reuses the + // server-side pending enrolment, so React StrictMode's double effect and any + // request retry cannot invalidate the QR code already on screen. + async function startEnroll(rotate = false) { + try { + const res = await httpClient.beginTotpEnroll(rotate); + setQrDataUrl(res.qr_code_data_url); + setCode(''); + } catch (error) { + const apiError = error as { msg?: string }; + toast.error(apiError?.msg || t('common.error')); + } + } + + // Reset the wizard each time it is opened, then act on the latched mode. + useEffect(() => { + if (!open) { + return; + } + modeRef.current = mode; + changedRef.current = false; + setQrDataUrl(''); + setCode(''); + setRecoveryCodes([]); + setCopiedKey(null); + + if (mode === 'manage') { + setStep('manage'); + return; + } + setStep('confirm'); + void startEnroll(); + // Intentionally keyed on `open`/`mode` only: `startEnroll` mutates local state. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, mode]); + + function closeDialog() { + // Flush any change once, on the way out, so the panel refreshes. + if (changedRef.current) { + changedRef.current = false; + onChanged?.(); + } + onOpenChange(false); + } + + async function handleConfirm() { + if (!code.trim()) { + return; + } + setLoading(true); + try { + const res = await httpClient.confirmTotpEnroll(code.trim()); + setRecoveryCodes(res.recovery_codes || []); + changedRef.current = true; + // Stay on the recovery step: the codes are shown exactly once. + setStep('recovery'); + toast.success(t('account.totpEnabledSuccess')); + } catch { + toast.error(t('account.totpInvalidCode')); + } finally { + setLoading(false); + } + } + + async function handleRegenerate() { + if (!code.trim()) { + return; + } + setLoading(true); + try { + const res = await httpClient.regenerateTotpRecoveryCodes(code.trim()); + setRecoveryCodes(res.recovery_codes || []); + changedRef.current = true; + setStep('recovery'); + toast.success(t('account.totpRecoveryCodesRegenerated')); + } catch { + toast.error(t('account.totpInvalidCode')); + } finally { + setLoading(false); + } + } + + async function handleDisable() { + if (!code.trim()) { + return; + } + setLoading(true); + try { + await httpClient.disableTotp(code.trim()); + changedRef.current = true; + toast.success(t('account.totpDisabledSuccess')); + closeDialog(); + } catch { + toast.error(t('account.totpInvalidCode')); + } finally { + setLoading(false); + } + } + + async function copyText(value: string, key: string) { + try { + await navigator.clipboard.writeText(value); + setCopiedKey(key); + setTimeout(() => setCopiedKey(null), 1500); + } catch { + toast.error(t('common.error')); + } + } + + function downloadRecoveryCodes() { + const blob = new Blob( + [ + `${t('account.totpRecoveryCodesTitle')}\n\n${recoveryCodes.join('\n')}\n`, + ], + { type: 'text/plain' }, + ); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = 'langbot-recovery-codes.txt'; + link.click(); + URL.revokeObjectURL(url); + } + + const titleByStep: Record = { + confirm: t('account.totpEnrollTitle'), + recovery: t('account.totpRecoveryCodesTitle'), + manage: t('account.totpManageTitle'), + regenerate: t('account.totpRegenerateCodes'), + disable: t('account.disableTotp'), + }; + + return ( + (next ? onOpenChange(true) : closeDialog())}> + + + + + {titleByStep[step]} + + + {step === 'confirm' && t('account.totpEnrollDesc')} + {step === 'recovery' && t('account.totpRecoveryCodesDesc')} + {step === 'manage' && t('account.totpManageDesc')} + {step === 'regenerate' && t('account.totpRegenerateDesc')} + {step === 'disable' && t('account.disableTotpDesc')} + + + + {step === 'confirm' && ( +
+ {!qrDataUrl ? ( +
+ + {t('account.totpGeneratingSecret')} +
+ ) : ( + <> +
+ {t('account.totpQrAlt')} +
+ +
+ + setCode(e.target.value)} + placeholder={t('account.enterCode')} + inputMode="numeric" + spellCheck={false} + autoComplete="off" + className="tracking-widest" + /> +
+ + + + + + + )} +
+ )} + + {step === 'recovery' && ( +
+
+ +

+ {t('account.totpRecoveryCodesWarning')} +

+
+ +
+ {recoveryCodes.map((value) => ( + + {value} + + ))} +
+ + +
+ + +
+ +
+
+ )} + + {step === 'manage' && ( +
+ + + + + +
+ )} + + {(step === 'regenerate' || step === 'disable') && ( +
+
+ + setCode(e.target.value)} + placeholder={t('account.enterCode')} + spellCheck={false} + autoComplete="off" + /> +
+ + + + +
+ )} +
+
+ ); +} diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index f449ef969..5ce96a23b 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -1241,14 +1241,146 @@ export class BackendClient extends BaseHttpClient { ); } - public authUser(user: string, password: string): Promise { + public authUser( + user: string, + password: string, + totpCode?: string, + ): Promise { return this.post( '/api/v1/user/auth', - { user, password }, + { user, password, totp_code: totpCode }, { skipWorkspace: true }, ); } + // ============ TOTP second factor (login) ============ + public requestTotpChallenge( + user: string, + ): Promise<{ challenge_token: string }> { + return this.post( + '/api/v1/user/totp/challenge', + { user }, + { skipWorkspace: true }, + ); + } + + public verifyTotpLogin( + user: string, + code: string, + challengeToken: string, + ): Promise<{ token: string; user: string }> { + // The challenge token proves the password step already ran for this + // Account; without it the backend refuses to mint a session. + return this.post( + '/api/v1/user/totp/verify', + { user, code, challenge_token: challengeToken }, + { skipWorkspace: true }, + ); + } + + // ============ TOTP second factor (account settings) ============ + public getTotpStatus(): Promise<{ + enabled: boolean; + pending: boolean; + confirmed_at?: string | null; + last_used_at?: string | null; + recovery_codes_remaining: number; + }> { + return this.get('/api/v1/user/totp/status', undefined, { + skipWorkspace: true, + }); + } + + public beginTotpEnroll(rotate = false): Promise<{ + uuid: string; + // A server-rendered PNG data URL. The shared secret is never returned so it + // cannot be read out of the browser. + qr_code_data_url: string; + algorithm: string; + digits: number; + period: number; + }> { + // rotate=true is the explicit "refresh" action; the default reuses any + // pending enrolment so duplicate calls cannot invalidate the shown QR. + return this.post( + '/api/v1/user/totp/enroll', + { rotate }, + { skipWorkspace: true }, + ); + } + + public confirmTotpEnroll(code: string): Promise<{ recovery_codes: string[] }> { + return this.post( + '/api/v1/user/totp/enroll/confirm', + { code }, + { skipWorkspace: true }, + ); + } + + public regenerateTotpRecoveryCodes( + code: string, + ): Promise<{ recovery_codes: string[] }> { + return this.post( + '/api/v1/user/totp/recovery-codes', + { code }, + { skipWorkspace: true }, + ); + } + + public disableTotp(code: string): Promise { + return this.post('/api/v1/user/totp/disable', { code }, { + skipWorkspace: true, + }); + } + + // ============ TOTP oversight (Workspace owner/admin only) ============ + public getTotpAccounts(): Promise<{ + accounts: Array<{ + account_uuid: string; + user: string; + status?: string; + enabled: boolean; + last_used_at?: string | null; + recovery_codes_remaining: number; + }>; + }> { + return this.get('/api/v1/user/totp/accounts'); + } + + public revokeTotpForAccount(accountUuid: string): Promise { + return this.delete( + `/api/v1/user/totp/accounts/${encodeURIComponent(accountUuid)}`, + ); + } + + /** + * Force a re-binding of another Account's second factor (owner/admin only). + * Returns a server-rendered QR code; the shared secret is never returned. + */ + public adminBeginTotpEnroll(accountUuid: string): Promise<{ + uuid: string; + qr_code_data_url: string; + algorithm: string; + digits: number; + period: number; + }> { + return this.post( + `/api/v1/user/totp/accounts/${encodeURIComponent(accountUuid)}/enroll`, + {}, + ); + } + + /** Activate a forced re-binding; recovery codes are returned exactly once. */ + public adminConfirmTotpEnroll( + accountUuid: string, + code: string, + ): Promise<{ recovery_codes: string[] }> { + return this.post( + `/api/v1/user/totp/accounts/${encodeURIComponent(accountUuid)}/enroll/confirm`, + { code }, + ); + } + public checkUserToken(): Promise { return this.get('/api/v1/user/check-token', undefined, { skipWorkspace: true, @@ -1257,15 +1389,23 @@ export class BackendClient extends BaseHttpClient { public resetPassword( user: string, - recoveryKey: string, newPassword: string, + options: { + // 'recovery_key' is the instance-wide key; 'totp' and 'recovery_code' + // consume an Account-scoped second factor instead. + method?: 'recovery_key' | 'totp' | 'recovery_code'; + recoveryKey?: string; + totpCode?: string; + } = {}, ): Promise<{ user: string }> { return this.post( '/api/v1/user/reset-password', { user, - recovery_key: recoveryKey, new_password: newPassword, + method: options.method ?? 'recovery_key', + recovery_key: options.recoveryKey, + totp_code: options.totpCode, }, { skipWorkspace: true }, ); diff --git a/web/src/app/infra/http/BaseHttpClient.ts b/web/src/app/infra/http/BaseHttpClient.ts index ec2988ea1..9e232ed38 100644 --- a/web/src/app/infra/http/BaseHttpClient.ts +++ b/web/src/app/infra/http/BaseHttpClient.ts @@ -117,12 +117,20 @@ export abstract class BaseHttpClient { switch (status) { case 401: if (typeof window !== 'undefined') { + // Only an existing authenticated session should be torn down and + // redirected. A 401 from a sign-in attempt (bad credentials or a + // pending second factor) must not reload the page, otherwise the + // TOTP challenge step would be lost. + const hadSession = Boolean(localStorage.getItem('token')); localStorage.removeItem('token'); localStorage.removeItem('userEmail'); clearActiveWorkspaceUuid(); setCurrentWorkspaceSnapshot(null); clearWorkspaceBootstrapSnapshot(); - if (!error.request.responseURL.includes('/check-token')) { + if ( + hadSession && + !error.request.responseURL.includes('/check-token') + ) { window.location.href = '/login'; } } diff --git a/web/src/app/login/page.tsx b/web/src/app/login/page.tsx index 598569fd7..2e8116fe5 100644 --- a/web/src/app/login/page.tsx +++ b/web/src/app/login/page.tsx @@ -36,6 +36,9 @@ import { RefreshCw, Layers, Fingerprint, + ShieldCheck, + KeyRound, + ArrowLeft, } from 'lucide-react'; import { startAuthentication } from '@simplewebauthn/browser'; import langbotIcon from '@/app/assets/langbot-logo.webp'; @@ -67,6 +70,14 @@ export default function Login() { const [showSpaceLogin, setShowSpaceLogin] = useState(false); const [showPasskeyLogin, setShowPasskeyLogin] = useState(false); const [passkeyLoading, setPasskeyLoading] = useState(false); + // Second-factor step: primary credentials passed, awaiting a TOTP or recovery code. + const [totpStep, setTotpStep] = useState(false); + const [pendingEmail, setPendingEmail] = useState(''); + // Issued alongside `totp_required`; required to complete the second factor. + const [totpChallengeToken, setTotpChallengeToken] = useState(''); + const [totpCode, setTotpCode] = useState(''); + const [totpLoading, setTotpLoading] = useState(false); + const [useRecoveryCode, setUseRecoveryCode] = useState(false); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(null); const [retrying, setRetrying] = useState(false); @@ -223,11 +234,66 @@ export default function Login() { toast.success(t('common.loginSuccess')); } }) - .catch(() => { + .catch((error: { code?: string; msg?: string; data?: { challenge_token?: string } }) => { + // The backend answers `totp_required` when the password was correct but + // a second factor is still outstanding. It also hands back the + // challenge token that must accompany the code. + if (error?.code === 'totp_required') { + setPendingEmail(username); + setTotpChallengeToken(error?.data?.challenge_token || ''); + setTotpStep(true); + setUseRecoveryCode(false); + setTotpCode(''); + return; + } + if (error?.code === 'totp_invalid_code') { + toast.error(t('common.totpInvalidCode')); + return; + } toast.error(t('common.loginFailed')); }); } + async function handleTotpSubmit(event: React.FormEvent) { + event.preventDefault(); + const code = totpCode.trim(); + if (!code) { + return; + } + if (!totpChallengeToken) { + toast.error(t('common.totpVerifyFailed')); + return; + } + setTotpLoading(true); + try { + // The same endpoint accepts both authenticator codes and recovery codes. + const res = await httpClient.verifyTotpLogin( + pendingEmail, + code, + totpChallengeToken, + ); + if (await finishLogin(res.token, res.user || pendingEmail)) { + toast.success(t('common.loginSuccess')); + } + } catch (error) { + const apiError = error as { code?: string }; + toast.error( + apiError?.code === 'totp_invalid_code' + ? t('common.totpInvalidCode') + : t('common.totpVerifyFailed'), + ); + } finally { + setTotpLoading(false); + } + } + + function handleBackToPassword() { + setTotpStep(false); + setTotpChallengeToken(''); + setTotpCode(''); + setUseRecoveryCode(false); + } + const handleSpaceLoginClick = useCallback(async () => { setSpaceLoading(true); try { @@ -336,6 +402,81 @@ export default function Login() { + {/* Second-factor challenge: shown once the password has been accepted. */} + {totpStep ? ( +
+
+ +
+

+ {t('common.totpChallengeTitle')} +

+

+ {useRecoveryCode + ? t('common.totpUseRecoveryCode') + : t('common.totpChallengeDesc')} +

+
+
+ +
+ {useRecoveryCode ? ( + + ) : ( + + )} + setTotpCode(e.target.value)} + /> +
+ + + +
+ + +
+
+ ) : ( + <> {/* Space and password login are per-account capabilities. */} {showSpaceLogin && (
@@ -487,6 +628,8 @@ export default function Login() { {t('common.dataCollectionPolicy')}

+ + )}
diff --git a/web/src/app/reset-password/page.tsx b/web/src/app/reset-password/page.tsx index 104a76da5..7301a350a 100644 --- a/web/src/app/reset-password/page.tsx +++ b/web/src/app/reset-password/page.tsx @@ -22,16 +22,30 @@ import { import { useState } from 'react'; import { httpClient } from '@/app/infra/http/HttpClient'; import { useNavigate } from 'react-router-dom'; -import { Mail, Lock, ArrowLeft, KeyRound } from 'lucide-react'; +import { + Mail, + Lock, + ArrowLeft, + KeyRound, + ShieldCheck, + LifeBuoy, +} from 'lucide-react'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { ThemeToggle } from '@/components/ui/theme-toggle'; +// The reset flow accepts three second-factor methods: +// * recovery_key — the instance-wide key from data/config.yaml (default); +// * totp — a code from the Account's authenticator app; +// * recovery_code — one of the Account's single-use recovery codes. +type ResetMethod = 'recovery_key' | 'totp' | 'recovery_code'; + const formSchema = (t: (key: string) => string) => z.object({ email: z.string().email(t('common.invalidEmail')), - recoveryKey: z.string().min(1, t('resetPassword.recoveryKeyRequired')), + recoveryKey: z.string().optional(), + totpCode: z.string().optional(), newPassword: z.string().min(1, t('resetPassword.newPasswordRequired')), }); @@ -39,40 +53,92 @@ export default function ResetPassword() { const navigate = useNavigate(); const { t } = useTranslation(); const [isResetting, setIsResetting] = useState(false); + const [method, setMethod] = useState('recovery_key'); const form = useForm>>({ resolver: zodResolver(formSchema(t)), defaultValues: { email: '', recoveryKey: '', + totpCode: '', newPassword: '', }, }); function onSubmit(values: z.infer>) { - handleResetPassword(values.email, values.recoveryKey, values.newPassword); + // Validate the second factor for the selected method before calling out. + if (method === 'recovery_key' && !values.recoveryKey?.trim()) { + form.setError('recoveryKey', { + message: t('resetPassword.recoveryKeyRequired'), + }); + return; + } + if (method !== 'recovery_key' && !values.totpCode?.trim()) { + form.setError('totpCode', { + message: + method === 'totp' + ? t('resetPassword.totpCodeRequired') + : t('resetPassword.recoveryCodeRequired'), + }); + return; + } + handleResetPassword( + values.email, + values.newPassword, + method, + values.recoveryKey, + values.totpCode, + ); } function handleResetPassword( email: string, - recoveryKey: string, newPassword: string, + selectedMethod: ResetMethod, + recoveryKey?: string, + totpCode?: string, ) { setIsResetting(true); httpClient - .resetPassword(email, recoveryKey, newPassword) + .resetPassword(email, newPassword, { + method: selectedMethod, + recoveryKey, + totpCode, + }) .then(() => { toast.success(t('resetPassword.resetSuccess')); navigate('/login'); }) .catch(() => { - toast.error(t('resetPassword.resetFailed')); + toast.error( + selectedMethod === 'recovery_key' + ? t('resetPassword.resetFailed') + : t('resetPassword.secondFactorFailed'), + ); }) .finally(() => { setIsResetting(false); }); } + const methodButton = (value: ResetMethod, label: string, Icon: typeof KeyRound) => ( + + ); + return (
@@ -118,32 +184,99 @@ export default function ResetPassword() { )} /> - ( - - {t('resetPassword.recoveryKey')} - - {t('resetPassword.recoveryKeyDescription')} - - - {/* Recovery keys are case-sensitive base64url strings; send them verbatim */} -
- - -
-
- -
- )} - /> + {/* Second-factor method selector */} +
+ {t('resetPassword.verifyWith')} +
+ {methodButton( + 'recovery_key', + t('resetPassword.methodRecoveryKey'), + KeyRound, + )} + {methodButton( + 'totp', + t('resetPassword.methodTotp'), + ShieldCheck, + )} + {methodButton( + 'recovery_code', + t('resetPassword.methodRecoveryCode'), + LifeBuoy, + )} +
+
+ + {method === 'recovery_key' ? ( + ( + + {t('resetPassword.recoveryKey')} + + {t('resetPassword.recoveryKeyDescription')} + + + {/* Recovery keys are case-sensitive base64url strings; send them verbatim */} +
+ + +
+
+ +
+ )} + /> + ) : ( + ( + + + {method === 'totp' + ? t('resetPassword.totpCode') + : t('resetPassword.recoveryCode')} + + + {method === 'totp' + ? t('resetPassword.totpCodeDescription') + : t('resetPassword.recoveryCodeDescription')} + + +
+ {method === 'totp' ? ( + + ) : ( + + )} + +
+
+ +
+ )} + /> + )}