diff --git a/src/langbot/pkg/api/http/controller/groups/user.py b/src/langbot/pkg/api/http/controller/groups/user.py index 31e6d7daf..20846a3ba 100644 --- a/src/langbot/pkg/api/http/controller/groups/user.py +++ b/src/langbot/pkg/api/http/controller/groups/user.py @@ -1,3 +1,10 @@ +"""Account, authentication, passkey and TOTP HTTP routes. + +Exposes the unauthenticated login/recovery surface as well as the authenticated +account-management, passkey (WebAuthn) and TOTP second-factor endpoints under +``/api/v1/user``. +""" + from __future__ import annotations import quart @@ -15,6 +22,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.totp import TotpAlreadyEnabledError, TotpInvalidCodeError, TotpNotEnabledError # 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 @@ -46,7 +54,10 @@ def _admit_reset_attempt(now: float) -> bool: @group.group_class('user', '/api/v1/user') class UserRouterGroup(group.RouterGroup): + """``/api/v1/user`` routes for accounts, auth, passkeys and TOTP.""" + def _validate_space_redirect_uri(self, redirect_uri: str, *, bind: bool) -> str: + """Validate a Space OAuth redirect URI against the expected callback shape.""" parsed = urlsplit(redirect_uri) if ( parsed.scheme not in {'http', 'https'} @@ -67,7 +78,11 @@ class UserRouterGroup(group.RouterGroup): return redirect_uri - def _extract_origin_and_rp_id(self, json_data: dict[str, typing.Any] | None = None) -> tuple[str, str]: + def _extract_origin_and_rp_id( + self, + json_data: dict[str, typing.Any] | None = None, + ) -> tuple[str, str]: + """Resolve the WebAuthn origin and relying-party ID for a request.""" origin = '' if json_data and isinstance(json_data, dict): origin = json_data.get('origin', '') @@ -80,14 +95,21 @@ class UserRouterGroup(group.RouterGroup): parsed = urlsplit(origin) rp_id = parsed.hostname or 'localhost' - clean_origin = f'{parsed.scheme}://{parsed.netloc}' if parsed.scheme and parsed.netloc else origin.rstrip('/') + if parsed.scheme and parsed.netloc: + clean_origin = f'{parsed.scheme}://{parsed.netloc}' + else: + clean_origin = origin.rstrip('/') return clean_origin, rp_id async def initialize(self) -> None: + """Register every ``/api/v1/user`` route on this router group.""" + @self.route('/init', methods=['GET', 'POST'], auth_type=group.AuthType.NONE) async def _() -> str: + """Report initialization state, or create the first account (POST).""" if quart.request.method == 'GET': - return self.success(data={'initialized': await self.ap.user_service.is_initialized()}) + initialized = await self.ap.user_service.is_initialized() + return self.success(data={'initialized': initialized}) if await self.ap.user_service.is_initialized(): return self.fail(1, 'System already initialized') @@ -108,27 +130,56 @@ class UserRouterGroup(group.RouterGroup): @self.route('/auth', methods=['POST'], auth_type=group.AuthType.NONE) async def _() -> str: - if getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud': - return self.http_status(403, 'password_login_disabled', 'Password login is disabled on LangBot Cloud') + """Authenticate a local Account, requiring a TOTP factor when enabled.""" + deployment = getattr(self.ap, 'deployment', None) + if getattr(deployment, 'mode', 'oss') == 'cloud': + 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'] try: - token = await self.ap.user_service.authenticate(json_data['user'], json_data['password']) + token = await self.ap.user_service.authenticate(user_email, json_data['password']) except argon2.exceptions.VerifyMismatchError: return self.fail(1, 'Invalid username or password') except ValueError as e: return self.fail(1, str(e)) + # Second factor: an enabled TOTP credential makes the password alone + # insufficient. The client retries the same request with a code. + user_obj = await self.ap.user_service.get_user_by_email(user_email) + if user_obj is not None and await self.ap.totp_service.is_enabled(user_obj.uuid): + totp_code = json_data.get('totp_code') + recovery_code = json_data.get('recovery_code') + verified = False + if totp_code: + verified = await self.ap.totp_service.verify_for_account( + user_obj.uuid, + str(totp_code), + ) + elif recovery_code: + verified = await self.ap.totp_service.redeem_recovery_code( + user_obj.uuid, + str(recovery_code), + ) + if not verified: + return self.http_status(401, 'totp_required', 'TOTP verification required') + return self.success(data={'token': token}) @self.route('/check-token', methods=['GET'], auth_type=group.AuthType.ACCOUNT_TOKEN) async def _(account) -> str: + """Issue a fresh user token for an already-authenticated Account.""" token = await self.ap.user_service.generate_jwt_token(account) return self.success(data={'token': token}) @self.route('/reset-password', methods=['POST'], auth_type=group.AuthType.NONE) async def _() -> str: + """Reset a password using the recovery key, TOTP, or a recovery code.""" # Admit (or reject) BEFORE touching the body or any service call (#2392): # rejecting requests never reach the slow path, and quota accounting happens # synchronously at entry, closing the post-await race of burst requests. @@ -138,7 +189,11 @@ class UserRouterGroup(group.RouterGroup): json_data = await quart.request.json user_email = json_data['user'] - recovery_key = json_data['recovery_key'] + # Recovery accepts either the instance recovery key, or (for accounts + # that enrolled one) a TOTP code or a one-time TOTP recovery code. + recovery_key = json_data.get('recovery_key') + totp_code = json_data.get('totp_code') + recovery_code = json_data.get('recovery_code') new_password = json_data['new_password'] # hard sleep 3s for security @@ -152,19 +207,39 @@ 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 totp_code or recovery_code: + if not await self.ap.totp_service.is_enabled(user_obj.uuid): + return self.http_status( + 403, + 'totp_not_enabled', + 'TOTP is not enabled for this account', + ) + if totp_code: + authorized = await self.ap.totp_service.verify_for_account( + user_obj.uuid, + str(totp_code), + ) + else: + authorized = await self.ap.totp_service.redeem_recovery_code( + user_obj.uuid, + str(recovery_code), + ) + if not authorized: + return self.http_status(403, 'totp_invalid_code', 'Invalid TOTP code') + else: + 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') + if not key_matches: + return self.http_status(403, -1, 'Invalid recovery key') await self.ap.user_service.reset_password(user_email, new_password) @@ -172,6 +247,7 @@ class UserRouterGroup(group.RouterGroup): @self.route('/change-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) async def _(user_email: str) -> str: + """Change the current Account password after verifying the old one.""" # Check if password change is allowed allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get( 'allow_modify_login_info', True @@ -185,7 +261,11 @@ class UserRouterGroup(group.RouterGroup): new_password = json_data['new_password'] try: - await self.ap.user_service.change_password(user_email, current_password, new_password) + await self.ap.user_service.change_password( + user_email, + current_password, + new_password, + ) except argon2.exceptions.VerifyMismatchError: return self.http_status(400, -1, 'Current password is incorrect') except ValueError as e: @@ -209,7 +289,8 @@ class UserRouterGroup(group.RouterGroup): redirect_uri = self._validate_space_redirect_uri(redirect_uri, bind=False) launch_workspace_uuid = quart.request.args.get('launch_workspace_uuid') if launch_workspace_uuid: - if not getattr(getattr(self.ap, 'deployment', None), 'multi_workspace_enabled', False): + deployment = getattr(self.ap, 'deployment', None) + if not getattr(deployment, 'multi_workspace_enabled', False): return self.fail(1, 'Space launch requires Cloud mode') try: uuid.UUID(launch_workspace_uuid) @@ -226,7 +307,11 @@ class UserRouterGroup(group.RouterGroup): except ValueError as e: return self.fail(1, str(e)) - @self.route('/space/bind-authorize-url', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + @self.route( + '/space/bind-authorize-url', + methods=['GET'], + auth_type=group.AuthType.USER_TOKEN, + ) async def _(request_context: RequestContext) -> str: """Issue an account-bound, one-time Space OAuth redirect.""" redirect_uri = quart.request.args.get('redirect_uri', '') @@ -272,19 +357,24 @@ class UserRouterGroup(group.RouterGroup): try: redirect_uri = self._validate_space_redirect_uri(str(redirect_uri), bind=False) - consumed_state = await self.ap.user_service.consume_space_oauth_state_details(state, 'login') + consumed_state = await self.ap.user_service.consume_space_oauth_state_details( + state, + 'login', + ) # Exchange code for tokens launch_workspace_uuid = consumed_state.launch_workspace_uuid workspace_uuids = [launch_workspace_uuid] if launch_workspace_uuid else [] workspace_created_ats: dict[str, int] = {} - if not workspace_uuids and getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') != 'cloud': + deployment = getattr(self.ap, 'deployment', None) + if not workspace_uuids and getattr(deployment, 'mode', 'oss') != 'cloud': binding = await self.ap.workspace_service.get_execution_binding() workspace_uuids = [binding.workspace_uuid] workspace_created_at = binding.workspace_created_at if workspace_created_at is not None: if workspace_created_at.tzinfo is None: workspace_created_at = workspace_created_at.replace(tzinfo=datetime.UTC) - workspace_created_ats[binding.workspace_uuid] = int(workspace_created_at.timestamp()) + created_at_epoch = int(workspace_created_at.timestamp()) + workspace_created_ats[binding.workspace_uuid] = created_at_epoch token_data = await self.ap.space_service.exchange_oauth_code( code, workspace_uuids, @@ -299,14 +389,20 @@ class UserRouterGroup(group.RouterGroup): if not access_token: return self.fail(1, 'Failed to get access token from Space') - cloud_mode = getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud' - if cloud_mode and launch_workspace_uuid and launch_workspace_uuid != cloud_workspace_uuid: + deployment = getattr(self.ap, 'deployment', None) + cloud_mode = getattr(deployment, 'mode', 'oss') == 'cloud' + launch_mismatch = launch_workspace_uuid != cloud_workspace_uuid + if cloud_mode and launch_workspace_uuid and launch_mismatch: return self.fail(1, 'Space OAuth Workspace binding mismatch') target_workspace_uuid = launch_workspace_uuid or cloud_workspace_uuid if cloud_mode: if not target_workspace_uuid: - return self.fail(1, 'Space OAuth response is missing the Cloud Workspace binding') - await self.ap.directory_projection_service.reconcile_workspaces((target_workspace_uuid,)) + return self.fail( + 1, + 'Space OAuth response is missing the Cloud Workspace binding', + ) + projection_service = self.ap.directory_projection_service + await projection_service.reconcile_workspaces((target_workspace_uuid,)) # Authenticate only after the signed, exact Workspace delta has # established the Account and membership runtime shadow rows. @@ -316,12 +412,15 @@ class UserRouterGroup(group.RouterGroup): if target_workspace_uuid: try: - access = await self.ap.workspace_collaboration_service.resolve_account_workspace( + collab_service = self.ap.workspace_collaboration_service + access = await collab_service.resolve_account_workspace( user_obj.uuid, target_workspace_uuid, ) except Exception: - self.ap.logger.warning('Rejected Space OAuth launch for unauthorized Workspace') + self.ap.logger.warning( + 'Rejected Space OAuth launch for unauthorized Workspace', + ) return self.fail(1, 'Space OAuth failed') return self.success( data={ @@ -356,6 +455,7 @@ class UserRouterGroup(group.RouterGroup): 'user': account.user, 'account_type': account.account_type, 'has_password': bool(account.password and account.password.strip()), + 'totp_enabled': await self.ap.totp_service.is_enabled(account.uuid), } ) @@ -408,6 +508,7 @@ class UserRouterGroup(group.RouterGroup): capabilities['invitation_registration_enabled'] = not cloud_mode capabilities['passkey_login_enabled'] = True capabilities['passkey_supported'] = True + capabilities['totp_supported'] = True return self.success(data={'initialized': True, **capabilities}) @self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) @@ -498,7 +599,11 @@ class UserRouterGroup(group.RouterGroup): except Exception: raise - @self.route('/passkey/register/options', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) + @self.route( + '/passkey/register/options', + methods=['POST'], + auth_type=group.AuthType.USER_TOKEN, + ) async def _(user_email: str) -> str: """Generate WebAuthn registration options for current account.""" allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get( @@ -515,7 +620,9 @@ class UserRouterGroup(group.RouterGroup): origin, rp_id = self._extract_origin_and_rp_id(json_data) try: - options, challenge_token = await self.ap.user_service.generate_passkey_registration_options( + user_service = self.ap.user_service + reg_options = user_service.generate_passkey_registration_options + options, challenge_token = await reg_options( account_uuid=user_obj.uuid, rp_id=rp_id, origin=origin, @@ -525,7 +632,11 @@ class UserRouterGroup(group.RouterGroup): except Exception as e: return self.fail(1, str(e)) - @self.route('/passkey/register/verify', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) + @self.route( + '/passkey/register/verify', + methods=['POST'], + auth_type=group.AuthType.USER_TOKEN, + ) async def _(user_email: str) -> str: """Verify WebAuthn registration response and save credential.""" allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get( @@ -570,7 +681,9 @@ class UserRouterGroup(group.RouterGroup): origin, rp_id = self._extract_origin_and_rp_id(json_data) try: - options, challenge_token = await self.ap.user_service.generate_passkey_authentication_options( + user_service = self.ap.user_service + auth_options = user_service.generate_passkey_authentication_options + options, challenge_token = await auth_options( rp_id=rp_id, origin=origin, email=email, @@ -626,7 +739,11 @@ class UserRouterGroup(group.RouterGroup): ] ) - @self.route('/passkey/', methods=['PATCH'], auth_type=group.AuthType.USER_TOKEN) + @self.route( + '/passkey/', + methods=['PATCH'], + auth_type=group.AuthType.USER_TOKEN, + ) async def _(user_email: str, passkey_uuid: str) -> str: """Rename a registered passkey.""" allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get( @@ -653,7 +770,11 @@ class UserRouterGroup(group.RouterGroup): return self.http_status(404, -1, 'Passkey not found') return self.success(data={'uuid': updated.uuid, 'name': updated.name}) - @self.route('/passkey/', methods=['DELETE'], auth_type=group.AuthType.USER_TOKEN) + @self.route( + '/passkey/', + methods=['DELETE'], + auth_type=group.AuthType.USER_TOKEN, + ) async def _(user_email: str, passkey_uuid: str) -> str: """Delete/revoke a registered passkey.""" allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get( @@ -674,6 +795,160 @@ class UserRouterGroup(group.RouterGroup): return self.http_status(404, -1, 'Passkey not found') return self.success() + @self.route('/totp/check', methods=['POST'], auth_type=group.AuthType.NONE) + async def _() -> str: + """Report whether TOTP is enabled for a given Account (unauthenticated). + + Used by the password-recovery page to decide whether the TOTP and + recovery-code verification methods are selectable. Only the boolean + capability is disclosed; no account details leak. + """ + if not await self.ap.user_service.is_initialized(): + return self.http_status(400, -1, 'System not initialized') + + json_data = await quart.request.json + user_email = json_data.get('user') + if not isinstance(user_email, str) or not user_email: + return self.fail(1, 'User is required') + + user_obj = await self.ap.user_service.get_user_by_email(user_email) + enabled = user_obj is not None and await self.ap.totp_service.is_enabled(user_obj.uuid) + + return self.success(data={'totp_enabled': enabled}) + + @self.route('/totp/status', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def _(user_email: str) -> str: + """Report whether the current Account has TOTP enabled.""" + user_obj = await self.ap.user_service.get_user_by_email(user_email) + if user_obj is None: + return self.http_status(404, -1, 'User not found') + + return self.success( + data={ + 'enabled': await self.ap.totp_service.is_enabled(user_obj.uuid), + 'remaining_recovery_codes': await self.ap.totp_service.remaining_recovery_codes( + user_obj.uuid, + ), + } + ) + + @self.route('/totp/enroll', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) + async def _(user_email: str) -> str: + """Start TOTP enrolment and return the QR payload plus recovery codes. + + The secret is not enforced until ``/totp/enroll/verify`` confirms the + authenticator app can produce a valid 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') + + user_obj = await self.ap.user_service.get_user_by_email(user_email) + if user_obj is None: + return self.http_status(404, -1, 'User not found') + + try: + enrollment, recovery_codes = await self.ap.totp_service.begin_enrollment(user_obj) + except TotpAlreadyEnabledError as e: + return self.http_status(409, e.code, str(e)) + + return self.success( + data={ + 'secret': enrollment.secret, + 'otpauth_uri': enrollment.otpauth_uri, + 'qr_svg': self.ap.totp_service.build_qr_svg(enrollment.otpauth_uri), + 'recovery_codes': recovery_codes, + } + ) + + @self.route('/totp/enroll/verify', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) + async def _(user_email: str) -> str: + """Confirm enrolment with the first code from the authenticator app.""" + 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') + + user_obj = await self.ap.user_service.get_user_by_email(user_email) + if user_obj is None: + return self.http_status(404, -1, 'User not found') + + json_data = await quart.request.json + code = json_data.get('code') + if not code: + return self.fail(1, 'Verification code is required') + + try: + await self.ap.totp_service.confirm_enrollment(user_obj.uuid, str(code)) + except TotpNotEnabledError as e: + return self.http_status(400, e.code, str(e)) + except TotpAlreadyEnabledError as e: + return self.http_status(409, e.code, str(e)) + except TotpInvalidCodeError as e: + return self.http_status(400, e.code, str(e)) + + return self.success(data={'enabled': True}) + + @self.route('/totp/recovery-codes', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) + async def _(user_email: str) -> str: + """Regenerate one-time recovery codes after proving a valid TOTP 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') + + user_obj = await self.ap.user_service.get_user_by_email(user_email) + if user_obj is None: + return self.http_status(404, -1, 'User not found') + + json_data = await quart.request.json + code = json_data.get('code') + if not code: + return self.fail(1, 'Verification code is required') + + if not await self.ap.totp_service.verify_for_account(user_obj.uuid, str(code)): + return self.http_status(400, TotpInvalidCodeError.code, 'Invalid verification code') + + try: + _, recovery_codes = await self.ap.totp_service.regenerate_recovery_codes( + user_obj.uuid, + ) + except TotpNotEnabledError as e: + return self.http_status(400, e.code, str(e)) + + return self.success(data={'recovery_codes': recovery_codes}) + + @self.route('/totp/disable', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) + async def _(user_email: str) -> str: + """Disable TOTP for the current Account after a valid code check.""" + 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') + + user_obj = await self.ap.user_service.get_user_by_email(user_email) + if user_obj is None: + return self.http_status(404, -1, 'User not found') + + json_data = await quart.request.json + code = json_data.get('code') + if not code: + return self.fail(1, 'Verification code is required') + + try: + await self.ap.totp_service.disable(user_obj.uuid, str(code)) + except TotpNotEnabledError as e: + return self.http_status(400, e.code, str(e)) + except TotpInvalidCodeError as e: + return self.http_status(400, e.code, str(e)) + + return self.success(data={'enabled': False}) + 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..fb5ba77bc --- /dev/null +++ b/src/langbot/pkg/api/http/service/totp.py @@ -0,0 +1,478 @@ +"""Second-factor TOTP (RFC 6238) enrolment, verification and recovery. + +This service backs the optional TOTP second factor for LangBot Accounts: + +* the shared secret is encrypted at rest with a Fernet key derived from the + instance JWT secret via HKDF, and is never persisted in plaintext; +* recovery codes are stored only as salted PBKDF2-HMAC-SHA256 digests; +* the plaintext secret and recovery codes leave the server exactly once, in the + enrolment response. +""" + +from __future__ import annotations + +import asyncio +import base64 +import dataclasses +import datetime +import hashlib +import hmac +import json +import logging +import secrets +import struct +import time +import typing +import uuid + +import sqlalchemy +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from ....entity.persistence import totp +from ....entity.persistence import user + +if typing.TYPE_CHECKING: + from ....core.app import Application + +_logger = logging.getLogger(__name__) + +# RFC 6238 parameters. Six digits and a 30 second step are what every common +# authenticator app (Google Authenticator, Authy, 1Password, ...) defaults to. +_TOTP_DIGITS = 6 +_TOTP_STEP_SECONDS = 30 +# Accept one step of clock skew in either direction, which tolerates small +# device clock drift without materially widening the brute-force window. +_TOTP_WINDOW_STEPS = 1 +_RECOVERY_CODE_COUNT = 10 +# 10 groups drawn from 32 symbols provide 50 bits of entropy per recovery code. +_RECOVERY_CODE_ALPHABET = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ' +_RECOVERY_CODE_LENGTH = 10 +# Recovery codes are stored only as salted PBKDF2-HMAC-SHA256 digests. The work +# factor is intentionally high: guessing is already infeasible against 50 bits of +# entropy, and the slow KDF keeps a dumped database from being attacked cheaply. +# Hashing runs off the event loop, so this is a latency cost paid only at +# enrolment / regeneration / redemption. +_RECOVERY_CODE_KDF_ITERATIONS = 300_000 + + +class TotpAlreadyEnabledError(ValueError): + """Raised when enrolling an Account that already has TOTP enabled.""" + + code = 'totp_already_enabled' + + +class TotpNotEnabledError(ValueError): + """Raised when an operation requires an enabled TOTP credential.""" + + code = 'totp_not_enabled' + + +class TotpInvalidCodeError(ValueError): + """Raised when a supplied TOTP or recovery code fails verification.""" + + code = 'totp_invalid_code' + + +@dataclasses.dataclass(frozen=True, slots=True) +class TotpEnrollment: + """Result of starting (or restarting) TOTP enrolment for an Account.""" + + secret: str + otpauth_uri: str + + +class TotpService: + """Second-factor TOTP enrolment, verification and recovery for Accounts. + + Nothing usable is persisted in plaintext: + + * The shared TOTP secret is encrypted at rest with a Fernet key derived from + the instance JWT secret via HKDF, so a leaked database file alone does not + expose live secrets (the attacker additionally needs ``config.yaml``). + * Recovery codes are stored only as salted PBKDF2-HMAC-SHA256 digests and are + consumed one at a time. + * The plaintext secret / recovery codes leave the server exactly once, in the + enrolment response, and are never stored or logged server-side. + """ + + ap: Application + + def __init__(self, ap: Application) -> None: + self.ap = ap + + # -- storage helpers ------------------------------------------------- + + def _session_factory(self) -> async_sessionmaker[AsyncSession]: + return async_sessionmaker(self.ap.persistence_mgr.get_db_engine(), expire_on_commit=False) + + def _encryption_key(self) -> bytes: + """Derive a stable 32-byte Fernet key from the instance JWT secret. + + HKDF-SHA256 with a fixed domain-separation salt keeps the key stable + across restarts and distinct from the JWT signing secret. The key + material is NOT stored in the database, so a leaked ``langbot.db`` alone + cannot decrypt the TOTP secrets. + """ + secret = '' + try: + secret = self.ap.instance_config.data['system']['jwt']['secret'] or '' + except (KeyError, TypeError): + secret = '' + if not secret: + # Defence in depth: a missing JWT secret must not silently produce a + # well-known encryption key. This should never happen because + # GenKeysStage seeds it, but failing closed is safer than encrypting + # with a predictable key. The caller maps this to an invalid-code + # failure, so no plaintext is ever persisted. + raise TotpInvalidCodeError('Instance JWT secret unavailable') + + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.kdf.hkdf import HKDF + + # HKDF enforces the label internally, so include it as `info`. + derived = HKDF( + algorithm=hashes.SHA256(), + length=32, + salt=b'langbot-totp-v1', + info=b'langbot-totp-secret-encryption', + ).derive(secret.encode('utf-8')) + return base64.urlsafe_b64encode(derived) + + def _encrypt_secret(self, secret: str) -> str: + from cryptography.fernet import Fernet + + return Fernet(self._encryption_key()).encrypt(secret.encode('utf-8')).decode('ascii') + + def _decrypt_secret(self, token: str) -> str: + from cryptography.fernet import Fernet, InvalidToken + + try: + return Fernet(self._encryption_key()).decrypt(token.encode('ascii')).decode('utf-8') + except (InvalidToken, ValueError) as exc: + raise TotpInvalidCodeError('Stored TOTP secret cannot be decrypted') from exc + + # -- RFC 6238 primitives --------------------------------------------- + + @staticmethod + def generate_secret() -> str: + """Return a fresh base32 secret (160 bits, the RFC 4226 recommendation).""" + return base64.b32encode(secrets.token_bytes(20)).decode('ascii').rstrip('=') + + @staticmethod + def _hotp(secret: str, counter: int) -> str: + padding = '=' * (-len(secret) % 8) + key = base64.b32decode(secret.upper() + padding) + msg = struct.pack('>Q', counter) + digest = hmac.new(key, msg, hashlib.sha1).digest() + offset = digest[-1] & 0x0F + binary = struct.unpack('>I', digest[offset : offset + 4])[0] & 0x7FFFFFFF + return str(binary % (10**_TOTP_DIGITS)).zfill(_TOTP_DIGITS) + + @classmethod + def generate_code(cls, secret: str, at: float | None = None) -> str: + """Return the TOTP code for ``secret`` at the given (or current) time.""" + counter = int((at if at is not None else time.time()) // _TOTP_STEP_SECONDS) + return cls._hotp(secret, counter) + + @classmethod + def verify_code(cls, secret: str, code: str, at: float | None = None) -> bool: + """Constant-time check of a user-supplied code within the skew window.""" + candidate = (code or '').strip().replace(' ', '') + if not candidate.isdigit() or len(candidate) != _TOTP_DIGITS: + return False + now = at if at is not None else time.time() + counter = int(now // _TOTP_STEP_SECONDS) + for offset in range(-_TOTP_WINDOW_STEPS, _TOTP_WINDOW_STEPS + 1): + expected = cls._hotp(secret, counter + offset) + if hmac.compare_digest(expected, candidate): + return True + return False + + @staticmethod + def build_otpauth_uri(secret: str, account_name: str, issuer: str = 'LangBot') -> str: + """Build the otpauth:// URI an authenticator app scans from the QR code.""" + from urllib.parse import quote, urlencode + + label = quote(f'{issuer}:{account_name}') + params = urlencode( + { + 'secret': secret, + 'issuer': issuer, + 'algorithm': 'SHA1', + 'digits': _TOTP_DIGITS, + 'period': _TOTP_STEP_SECONDS, + } + ) + return f'otpauth://totp/{label}?{params}' + + @staticmethod + def build_qr_svg(otpauth_uri: str) -> str: + """Render the otpauth URI to an inline SVG QR code. + + SVG keeps the response text-only so the frontend can drop it straight + into a dialog without byte-encoding a PNG data URL. + """ + import qrcode + import qrcode.image.svg + + qr = qrcode.QRCode( + version=None, + error_correction=qrcode.constants.ERROR_CORRECT_M, + box_size=10, + border=2, + image_factory=qrcode.image.svg.SvgPathImage, + ) + qr.add_data(otpauth_uri) + qr.make(fit=True) + image = qr.make_image() + import io + + buffer = io.BytesIO() + image.save(buffer) + return buffer.getvalue().decode('utf-8') + + # -- recovery codes --------------------------------------------------- + + @staticmethod + def _normalise_recovery_code(code: str) -> str: + return (code or '').strip().upper().replace('-', '').replace(' ', '') + + @classmethod + def _hash_recovery_code(cls, code: str, *, salt: bytes | None = None) -> str: + """Return a self-describing PBKDF2-HMAC-SHA256 digest of a recovery code. + + The format is ``pbkdf2_sha256$$$`` so the + work factor is stored alongside the digest and can be raised later + without invalidating existing codes. Salted and slow, so a database dump + does not allow offline brute-forcing of recovery codes. + """ + if salt is None: + salt = secrets.token_bytes(16) + digest = hashlib.pbkdf2_hmac( + 'sha256', + cls._normalise_recovery_code(code).encode('utf-8'), + salt, + _RECOVERY_CODE_KDF_ITERATIONS, + ) + return f'pbkdf2_sha256${_RECOVERY_CODE_KDF_ITERATIONS}${salt.hex()}${digest.hex()}' + + @staticmethod + def _split_recovery_digest(stored: str) -> tuple[int, bytes, bytes] | None: + parts = (stored or '').split('$') + if len(parts) != 4 or parts[0] != 'pbkdf2_sha256': + return None + try: + iterations = int(parts[1]) + salt = bytes.fromhex(parts[2]) + digest = bytes.fromhex(parts[3]) + except ValueError: + return None + return iterations, salt, digest + + @staticmethod + def _random_recovery_code() -> str: + """Return one random recovery code from the unambiguous alphabet.""" + alphabet = _RECOVERY_CODE_ALPHABET + return ''.join(secrets.choice(alphabet) for _ in range(_RECOVERY_CODE_LENGTH)) + + @classmethod + async def generate_recovery_codes(cls) -> tuple[list[str], list[str]]: + """Return ``(plaintext_codes, hashed_codes)`` for one enrolment.""" + plaintext: list[str] = [] + hashed: list[str] = [] + for _ in range(_RECOVERY_CODE_COUNT): + code = cls._random_recovery_code() + plaintext.append(code) + # Offload the expensive KDF so 10 codes do not stall the event loop. + hashed.append(await asyncio.to_thread(cls._hash_recovery_code, code)) + return plaintext, hashed + + # -- persistence ------------------------------------------------------ + + @staticmethod + def _credential_statement(account_uuid: str) -> typing.Any: + """Build the SELECT that loads an Account's TOTP credential row.""" + entity = totp.TotpCredential + return sqlalchemy.select(entity).where(entity.account_uuid == account_uuid) + + async def get_credential(self, account_uuid: str) -> totp.TotpCredential | None: + """Load the (single) TOTP credential row for an Account, if any.""" + statement = self._credential_statement(account_uuid) + async with self._session_factory()() as session: + return await session.scalar(statement) + + async def is_enabled(self, account_uuid: str) -> bool: + """Return whether the Account has a confirmed, enabled TOTP credential.""" + credential = await self.get_credential(account_uuid) + return bool(credential and credential.enabled) + + async def begin_enrollment(self, account: user.User) -> tuple[TotpEnrollment, list[str]]: + """Create or replace a pending TOTP secret and return recovery codes. + + A previous *enabled* credential is left untouched until the new secret + is confirmed, so a failed re-enrolment cannot lock the account out. + """ + secret = self.generate_secret() + uri = self.build_otpauth_uri(secret, account_name=account.user) + plaintext_codes, hashed_codes = await self.generate_recovery_codes() + + async with self._session_factory()() as session: + async with session.begin(): + credential = await session.scalar(self._credential_statement(account.uuid)) + if credential is None: + credential = totp.TotpCredential( + uuid=str(uuid.uuid4()), + account_uuid=account.uuid, + secret_encrypted=self._encrypt_secret(secret), + account_name=account.user, + enabled=False, + recovery_codes=json.dumps(hashed_codes), + ) + session.add(credential) + elif not credential.enabled: + credential.secret_encrypted = self._encrypt_secret(secret) + credential.account_name = account.user + credential.recovery_codes = json.dumps(hashed_codes) + else: + raise TotpAlreadyEnabledError('TOTP is already enabled for this account') + await session.flush() + + return TotpEnrollment(secret=secret, otpauth_uri=uri), plaintext_codes + + async def confirm_enrollment(self, account_uuid: str, code: str) -> None: + """Verify the first code and flip the credential to enabled.""" + credential = await self.get_credential(account_uuid) + if credential is None: + raise TotpNotEnabledError('No pending TOTP enrolment found') + if credential.enabled: + raise TotpAlreadyEnabledError('TOTP is already enabled for this account') + + try: + code_matches = self.verify_code(self._decrypt_secret(credential.secret_encrypted), code) + except TotpInvalidCodeError: + code_matches = False + if not code_matches: + raise TotpInvalidCodeError('Invalid verification code') + + async with self._session_factory()() as session: + async with session.begin(): + record = await session.scalar(self._credential_statement(account_uuid)) + if record is None: + raise TotpNotEnabledError('No pending TOTP enrolment found') + record.enabled = True + record.last_used_at = datetime.datetime.now() + + async def verify_for_account(self, account_uuid: str, code: str) -> bool: + """Validate a live TOTP code for an enabled credential.""" + credential = await self.get_credential(account_uuid) + if credential is None or not credential.enabled: + return False + try: + secret = self._decrypt_secret(credential.secret_encrypted) + except TotpInvalidCodeError: + return False + if not self.verify_code(secret, code): + return False + async with self._session_factory()() as session: + async with session.begin(): + record = await session.scalar(self._credential_statement(account_uuid)) + if record is not None: + record.last_used_at = datetime.datetime.now() + return True + + @classmethod + def _match_recovery_code(cls, code: str, hashed_codes: list[str]) -> int: + """Return the index of the matching digest, or -1. Constant-time per entry.""" + candidate = cls._normalise_recovery_code(code) + for index, stored in enumerate(hashed_codes): + parsed = cls._split_recovery_digest(stored) + if parsed is None: + continue + iterations, salt, expected = parsed + digest = hashlib.pbkdf2_hmac('sha256', candidate.encode('utf-8'), salt, iterations) + if hmac.compare_digest(digest, expected): + return index + return -1 + + async def redeem_recovery_code(self, account_uuid: str, code: str) -> bool: + """Consume a one-time recovery code for password reset fallback.""" + credential = await self.get_credential(account_uuid) + if credential is None: + return False + + hashed_codes: list[str] = [] + if credential.recovery_codes: + try: + parsed = json.loads(credential.recovery_codes) + if isinstance(parsed, list): + hashed_codes = [str(item) for item in parsed] + except (ValueError, TypeError): + hashed_codes = [] + + # Recomputing PBKDF2 for up to 10 salted digests is CPU-bound; keep it + # off the event loop so a recovery attempt cannot stall other requests. + matched_index = await asyncio.to_thread(self._match_recovery_code, code or '', hashed_codes) + if matched_index < 0: + return False + + remaining = hashed_codes[:matched_index] + hashed_codes[matched_index + 1 :] + async with self._session_factory()() as session: + async with session.begin(): + record = await session.scalar(self._credential_statement(account_uuid)) + if record is not None: + record.recovery_codes = json.dumps(remaining) + record.last_used_at = datetime.datetime.now() + return True + + async def regenerate_recovery_codes(self, account_uuid: str) -> tuple[None, list[str]]: + """Replace the recovery codes for an enabled credential. + + The caller is responsible for proving possession of a valid TOTP code + first; this method only swaps the stored digests for a fresh set and + returns the plaintext codes for one-time display. + """ + credential = await self.get_credential(account_uuid) + if credential is None or not credential.enabled: + raise TotpNotEnabledError('TOTP is not enabled for this account') + + plaintext_codes, hashed_codes = await self.generate_recovery_codes() + async with self._session_factory()() as session: + async with session.begin(): + record = await session.scalar(self._credential_statement(account_uuid)) + if record is None: + raise TotpNotEnabledError('TOTP is not enabled for this account') + record.recovery_codes = json.dumps(hashed_codes) + record.updated_at = datetime.datetime.now() + return None, plaintext_codes + + async def disable(self, account_uuid: str, code: str) -> bool: + """Remove TOTP after the caller proves possession of a valid factor.""" + credential = await self.get_credential(account_uuid) + if credential is None or not credential.enabled: + raise TotpNotEnabledError('TOTP is not enabled for this account') + + try: + secret = self._decrypt_secret(credential.secret_encrypted) + code_matches = self.verify_code(secret, code) + except TotpInvalidCodeError: + code_matches = False + if not code_matches: + raise TotpInvalidCodeError('Invalid verification code') + + async with self._session_factory()() as session: + async with session.begin(): + record = await session.scalar(self._credential_statement(account_uuid)) + if record is not None: + await session.delete(record) + return True + + async def remaining_recovery_codes(self, account_uuid: str) -> int: + """Return how many unused recovery codes remain for the Account.""" + credential = await self.get_credential(account_uuid) + if credential is None or not credential.recovery_codes: + return 0 + try: + parsed = json.loads(credential.recovery_codes) + except (ValueError, TypeError): + return 0 + return len(parsed) if isinstance(parsed, list) else 0 diff --git a/src/langbot/pkg/core/app.py b/src/langbot/pkg/core/app.py index b1951c998..0b47f2f6f 100644 --- a/src/langbot/pkg/core/app.py +++ b/src/langbot/pkg/core/app.py @@ -34,6 +34,7 @@ from ..api.http.service import apikey as apikey_service from ..api.http.service import webhook as webhook_service from ..api.http.service import monitoring as monitoring_service from ..api.http.service import skill as skill_service +from ..api.http.service import totp as totp_service from ..api.http.service import maintenance as maintenance_service from ..discover import engine as discover_engine from ..storage import mgr as storagemgr @@ -161,6 +162,8 @@ class Application: user_service: user_service.UserService = None + totp_service: totp_service.TotpService = None + space_service: space_service.SpaceService = None llm_model_service: model_service.LLMModelsService = None diff --git a/src/langbot/pkg/core/stages/build_app.py b/src/langbot/pkg/core/stages/build_app.py index 7941a5553..4736c60d0 100644 --- a/src/langbot/pkg/core/stages/build_app.py +++ b/src/langbot/pkg/core/stages/build_app.py @@ -28,6 +28,7 @@ from ...api.http.service import apikey as apikey_service from ...api.http.service import webhook as webhook_service from ...api.http.service import monitoring as monitoring_service from ...api.http.service import skill as skill_service +from ...api.http.service import totp as totp_service from ...skill import manager as skill_mgr from ...api.http.service import maintenance as maintenance_service from ...discover import engine as discover_engine @@ -198,6 +199,9 @@ class BuildAppStage(stage.BootingStage): user_service_inst = user_service.UserService(ap) ap.user_service = user_service_inst + totp_service_inst = totp_service.TotpService(ap) + ap.totp_service = totp_service_inst + 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..c443702dd --- /dev/null +++ b/src/langbot/pkg/entity/persistence/totp.py @@ -0,0 +1,60 @@ +"""Persistence entity for per-Account TOTP (RFC 6238) second factors.""" + +from __future__ import annotations + +import uuid as uuid_lib + +import sqlalchemy + +from .base import Base + + +class TotpCredential(Base): + """Per-Account TOTP (RFC 6238) second factor and its recovery codes. + + A single row is kept per Account. The shared secret is stored encrypted + (``secret_encrypted``, Fernet keyed off the instance JWT secret via HKDF) + rather than in plaintext, and remains unenforced until the owner confirms + possession by submitting a valid code (``enabled``). Recovery codes are + stored only as salted PBKDF2-HMAC-SHA256 digests, so a database leak does + not hand out account recovery. No plaintext secret or recovery code is ever + persisted; both leave the server exactly once, in the enrolment response. + """ + + __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, + ) + # Fernet-encrypted base32 secret; never exposed to the client after enrol. + secret_encrypted = sqlalchemy.Column(sqlalchemy.Text, nullable=False) + # Issuer label shown inside the authenticator app (e.g. the account email). + account_name = sqlalchemy.Column(sqlalchemy.String(320), nullable=False) + enabled = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, server_default='0') + # JSON-encoded list of salted PBKDF2 hashes for the one-time recovery codes. + recovery_codes = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + last_used_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True) + created_at = sqlalchemy.Column( + sqlalchemy.DateTime, + nullable=False, + server_default=sqlalchemy.func.now(), + ) + updated_at = sqlalchemy.Column( + sqlalchemy.DateTime, + nullable=False, + server_default=sqlalchemy.func.now(), + onupdate=sqlalchemy.func.now(), + ) + + __table_args__ = ( + sqlalchemy.Index('uq_totp_credentials_uuid', 'uuid', unique=True), + sqlalchemy.Index('uq_totp_credentials_account', 'account_uuid', unique=True), + ) 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..2b28cce68 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0025_totp_credentials.py @@ -0,0 +1,50 @@ +"""add totp credentials table + +Revision ID: 0025_totp_credentials +Revises: 0024_passkey_credentials +Create Date: 2026-09-12 +""" + +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 + +_TABLE_NAME = 'totp_credentials' + + +def upgrade() -> None: + conn = op.get_bind() + existing_tables = set(sa.inspect(conn).get_table_names()) + if _TABLE_NAME not in existing_tables: + op.create_table( + _TABLE_NAME, + 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_encrypted', sa.Text(), nullable=False), + sa.Column('account_name', sa.String(320), nullable=False), + sa.Column('enabled', sa.Boolean(), nullable=False, server_default='0'), + sa.Column('recovery_codes', sa.Text(), nullable=True), + sa.Column('last_used_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()), + ) + op.create_index('uq_totp_credentials_uuid', _TABLE_NAME, ['uuid'], unique=True) + op.create_index('uq_totp_credentials_account', _TABLE_NAME, ['account_uuid'], unique=True) + + +def downgrade() -> None: + op.drop_index('uq_totp_credentials_account', table_name=_TABLE_NAME) + op.drop_index('uq_totp_credentials_uuid', table_name=_TABLE_NAME) + op.drop_table(_TABLE_NAME) 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..be347d2a9 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,11 @@ import { Plus, Trash2, Pencil, + ShieldCheck, } from 'lucide-react'; import { startRegistration } from '@simplewebauthn/browser'; import PasswordChangeDialog from '../password-change-dialog/PasswordChangeDialog'; +import TotpEnrollDialog from './TotpEnrollDialog'; import { PanelBody } from '../settings-dialog/panel-layout'; interface AccountSettingsPanelProps { @@ -56,11 +58,16 @@ export default function AccountSettingsPanel({ const [passkeys, setPasskeys] = useState([]); const [passkeyLoading, setPasskeyLoading] = useState(false); const [registeringPasskey, setRegisteringPasskey] = useState(false); + const [totpEnabled, setTotpEnabled] = useState(false); + const [remainingRecoveryCodes, setRemainingRecoveryCodes] = useState(0); + const [totpLoading, setTotpLoading] = useState(false); + const [totpDialogOpen, setTotpDialogOpen] = useState(false); useEffect(() => { if (active) { loadUserInfo(); loadPasskeys(); + loadTotpStatus(); } }, [active]); @@ -91,6 +98,19 @@ export default function AccountSettingsPanel({ } } + async function loadTotpStatus() { + setTotpLoading(true); + try { + const status = await httpClient.getTotpStatus(); + setTotpEnabled(status.enabled); + setRemainingRecoveryCodes(status.remaining_recovery_codes); + } catch { + // ignore + } finally { + setTotpLoading(false); + } + } + const handleAddPasskey = async () => { setRegisteringPasskey(true); try { @@ -332,6 +352,56 @@ export default function AccountSettingsPanel({ )} + + {/* TOTP (2FA) Section */} +
+
+
+

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

+

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

+
+ +
+ + + + + + + + {totpEnabled + ? t('account.totpEnabled') + : t('account.totpDisabled')} + + + {totpEnabled + ? t('account.totpRecoveryCodesRemaining', { + count: remainingRecoveryCodes, + }) + : t('account.totpSectionDesc')} + + + +
)} @@ -340,6 +410,13 @@ export default function AccountSettingsPanel({ onOpenChange={handlePasswordDialogClose} hasPassword={hasPassword} /> + + ); } diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index f449ef969..b870a4692 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -1241,10 +1241,21 @@ export class BackendClient extends BaseHttpClient { ); } - public authUser(user: string, password: string): Promise { + public authUser( + user: string, + password: string, + secondFactor?: { totpCode?: string; recoveryCode?: string }, + ): Promise { return this.post( '/api/v1/user/auth', - { user, password }, + { + user, + password, + ...(secondFactor?.totpCode ? { totp_code: secondFactor.totpCode } : {}), + ...(secondFactor?.recoveryCode + ? { recovery_code: secondFactor.recoveryCode } + : {}), + }, { skipWorkspace: true }, ); } @@ -1257,15 +1268,25 @@ export class BackendClient extends BaseHttpClient { public resetPassword( user: string, - recoveryKey: string, newPassword: string, + factor: + | { recoveryKey: string } + | { totpCode: string } + | { recoveryCode: string }, ): Promise<{ user: string }> { return this.post( '/api/v1/user/reset-password', { user, - recovery_key: recoveryKey, new_password: newPassword, + // Exactly one proof-of-ownership factor is accepted by the backend. + ...('recoveryKey' in factor + ? { recovery_key: factor.recoveryKey } + : {}), + ...('totpCode' in factor ? { totp_code: factor.totpCode } : {}), + ...('recoveryCode' in factor + ? { recovery_code: factor.recoveryCode } + : {}), }, { skipWorkspace: true }, ); @@ -1290,6 +1311,7 @@ export class BackendClient extends BaseHttpClient { user: string; account_type: 'local' | 'space'; has_password: boolean; + totp_enabled?: boolean; }> { return this.get('/api/v1/user/info', undefined, { skipWorkspace: true }); } @@ -1306,12 +1328,28 @@ export class BackendClient extends BaseHttpClient { space_login_enabled?: boolean; passkey_login_enabled?: boolean; passkey_supported?: boolean; + totp_supported?: boolean; }> { return this.get('/api/v1/user/account-info', undefined, { skipWorkspace: true, }); } + /** + * Whether the account identified by the given email has TOTP enabled. + * + * This endpoint is unauthenticated so the password-recovery page can decide + * whether to offer the TOTP / recovery-code verification methods. The + * response only exposes the boolean capability. + */ + public checkTotpForEmail(user: string): Promise<{ totp_enabled: boolean }> { + return this.post( + '/api/v1/user/totp/check', + { user }, + { skipWorkspace: true }, + ); + } + // ============ Passkey (WebAuthn) API ============ public getPasskeyAuthOptions( email?: string, @@ -1390,6 +1428,51 @@ export class BackendClient extends BaseHttpClient { }); } + // ============ TOTP (2FA) API ============ + public getTotpStatus(): Promise<{ + enabled: boolean; + remaining_recovery_codes: number; + }> { + return this.get('/api/v1/user/totp/status', undefined, { + skipWorkspace: true, + }); + } + + public beginTotpEnrollment(): Promise<{ + secret: string; + otpauth_uri: string; + qr_svg: string; + recovery_codes: string[]; + }> { + return this.post('/api/v1/user/totp/enroll', {}, { skipWorkspace: true }); + } + + public verifyTotpEnrollment(code: string): Promise<{ enabled: boolean }> { + return this.post( + '/api/v1/user/totp/enroll/verify', + { 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<{ success: boolean }> { + return this.post( + '/api/v1/user/totp/disable', + { code }, + { skipWorkspace: true }, + ); + } + // ============ Workspace API ============ public getWorkspaceBootstrap(): Promise { return this.get('/api/v1/workspaces/bootstrap', undefined, { diff --git a/web/src/app/login/page.tsx b/web/src/app/login/page.tsx index 598569fd7..c9f11d835 100644 --- a/web/src/app/login/page.tsx +++ b/web/src/app/login/page.tsx @@ -36,6 +36,7 @@ import { RefreshCw, Layers, Fingerprint, + ShieldCheck, } from 'lucide-react'; import { startAuthentication } from '@simplewebauthn/browser'; import langbotIcon from '@/app/assets/langbot-logo.webp'; @@ -71,6 +72,15 @@ export default function Login() { const [loadError, setLoadError] = useState(null); const [retrying, setRetrying] = useState(false); const autoSpaceLoginStarted = useRef(false); + // Second-factor state: when /auth replies with totp_required we keep the + // credentials and ask for a TOTP or recovery code instead of a password. + const [totpRequired, setTotpRequired] = useState(false); + const [totpCode, setTotpCode] = useState(''); + const [totpSubmitting, setTotpSubmitting] = useState(false); + const [pendingCredentials, setPendingCredentials] = useState<{ + username: string; + password: string; + } | null>(null); const form = useForm>>({ resolver: zodResolver(formSchema(t)), @@ -223,11 +233,49 @@ export default function Login() { toast.success(t('common.loginSuccess')); } }) - .catch(() => { + .catch((error: unknown) => { + const apiError = error as { code?: string }; + if (apiError?.code === 'totp_required') { + // Password was accepted; the account additionally requires TOTP. + setPendingCredentials({ username, password }); + setTotpCode(''); + setTotpRequired(true); + return; + } toast.error(t('common.loginFailed')); }); } + async function handleTotpSubmit() { + if (!pendingCredentials || !totpCode.trim()) { + return; + } + setTotpSubmitting(true); + try { + const code = totpCode.trim(); + // A recovery code is longer than six digits; treat it as such so users + // can sign in even when the authenticator is unavailable. + const isRecoveryCode = code.replace(/\s/g, '').length !== 6; + const res = await httpClient.authUser( + pendingCredentials.username, + pendingCredentials.password, + isRecoveryCode ? { recoveryCode: code } : { totpCode: code }, + ); + setTotpRequired(false); + setPendingCredentials(null); + if (await finishLogin(res.token, pendingCredentials.username)) { + toast.success(t('common.loginSuccess')); + } + } catch (error: unknown) { + const apiError = error as { code?: string; message?: string }; + // Keep the second-factor step open so the user can retry; surface the + // server message when available. + toast.error(apiError?.message || t('common.loginTotpInvalid')); + } finally { + setTotpSubmitting(false); + } + } + const handleSpaceLoginClick = useCallback(async () => { setSpaceLoading(true); try { @@ -336,8 +384,67 @@ export default function Login() { + {/* TOTP second-factor step: shown after the password is accepted. */} + {totpRequired && ( +
+
+ +

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

+

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

+
+
+ + setTotpCode(e.target.value)} + placeholder={t('common.loginTotpPlaceholder')} + className="pl-10 font-mono tracking-widest" + inputMode="text" + autoComplete="one-time-code" + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter') { + void handleTotpSubmit(); + } + }} + /> +
+ + +
+ )} + {/* Space and password login are per-account capabilities. */} - {showSpaceLogin && ( + {!totpRequired && showSpaceLogin && (
+ {/* Recommend enabling TOTP once the account exists */} +

+ + {t('register.totpHint')} +

diff --git a/web/src/app/reset-password/page.tsx b/web/src/app/reset-password/page.tsx index 104a76da5..e733cb6f2 100644 --- a/web/src/app/reset-password/page.tsx +++ b/web/src/app/reset-password/page.tsx @@ -19,19 +19,24 @@ import { FormMessage, FormDescription, } from '@/components/ui/form'; -import { useState } from 'react'; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { useEffect, 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 } 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'; +type RecoveryMethod = 'recoveryKey' | 'totp' | 'recoveryCode'; + 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(), + recoveryCode: z.string().optional(), newPassword: z.string().min(1, t('resetPassword.newPasswordRequired')), }); @@ -39,34 +44,129 @@ export default function ResetPassword() { const navigate = useNavigate(); const { t } = useTranslation(); const [isResetting, setIsResetting] = useState(false); + const [method, setMethod] = useState('recoveryKey'); + // Whether TOTP is enabled for the email currently entered. `null` means we have + // not yet resolved it (empty/invalid email), so the TOTP methods stay disabled + // until we can confirm the account actually enrolled one. + const [totpEnabledForEmail, setTotpEnabledForEmail] = useState< + boolean | null + >(null); const form = useForm>>({ resolver: zodResolver(formSchema(t)), defaultValues: { email: '', recoveryKey: '', + totpCode: '', + recoveryCode: '', newPassword: '', }, }); + // Watch the email so we can resolve, per account, whether TOTP is enabled. + const email = form.watch('email'); + + // Resolve whether the entered email has TOTP enabled; only then may the user + // pick the TOTP / recovery-code verification methods. While unresolved (empty + // or invalid email) both TOTP methods stay disabled, so an account without + // TOTP can never select them. + useEffect(() => { + if (!email || !z.string().email().safeParse(email).success) { + setTotpEnabledForEmail(null); + setMethod('recoveryKey'); + return; + } + let cancelled = false; + // Debounce so we only query once the user pauses typing. + const timer = setTimeout(() => { + httpClient + .checkTotpForEmail(email) + .then((res) => { + if (cancelled) { + return; + } + setTotpEnabledForEmail(res.totp_enabled); + if (!res.totp_enabled) { + setMethod('recoveryKey'); + } + }) + .catch(() => { + if (!cancelled) { + // Fail closed: if we cannot confirm TOTP, only the recovery key is + // offered rather than letting an unverified TOTP path through. + setTotpEnabledForEmail(null); + setMethod('recoveryKey'); + } + }); + }, 400); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [email]); + + const totpMethodsDisabled = totpEnabledForEmail !== true; + function onSubmit(values: z.infer>) { - handleResetPassword(values.email, values.recoveryKey, values.newPassword); + if (method === 'recoveryKey') { + if (!values.recoveryKey || !values.recoveryKey.trim()) { + toast.error(t('resetPassword.recoveryKeyRequired')); + return; + } + handleResetPassword( + values.email, + { recoveryKey: values.recoveryKey.trim() }, + values.newPassword, + ); + return; + } + if (method === 'totp') { + if (!values.totpCode || !values.totpCode.trim()) { + toast.error(t('resetPassword.totpCodeRequired')); + return; + } + handleResetPassword( + values.email, + { totpCode: values.totpCode.trim() }, + values.newPassword, + ); + return; + } + if (!values.recoveryCode || !values.recoveryCode.trim()) { + toast.error(t('resetPassword.recoveryCodeRequired')); + return; + } + handleResetPassword( + values.email, + { recoveryCode: values.recoveryCode.trim() }, + values.newPassword, + ); } function handleResetPassword( email: string, - recoveryKey: string, + factor: + | { recoveryKey: string } + | { totpCode: string } + | { recoveryCode: string }, newPassword: string, ) { setIsResetting(true); httpClient - .resetPassword(email, recoveryKey, newPassword) + .resetPassword(email, newPassword, factor) .then(() => { toast.success(t('resetPassword.resetSuccess')); navigate('/login'); }) - .catch(() => { - toast.error(t('resetPassword.resetFailed')); + .catch((error: unknown) => { + const apiError = error as { code?: string }; + if (apiError?.code === 'totp_not_enabled') { + toast.error(t('resetPassword.totpNotEnabled')); + } else if (apiError?.code === 'totp_invalid_code') { + toast.error(t('resetPassword.invalidTotpCode')); + } else { + toast.error(t('resetPassword.resetFailed')); + } }) .finally(() => { setIsResetting(false); @@ -118,32 +218,124 @@ export default function ResetPassword() { )} /> - ( - - {t('resetPassword.recoveryKey')} - - {t('resetPassword.recoveryKeyDescription')} - - - {/* Recovery keys are case-sensitive base64url strings; send them verbatim */} -
- - -
-
- -
+ {/* Recovery method selector: recovery key, TOTP, or recovery code. + The TOTP-based methods are only selectable once we have + confirmed the entered account actually enrolled TOTP. */} +
+ {t('resetPassword.verifyMethod')} + setMethod(v as RecoveryMethod)} + > + + + {t('resetPassword.recoveryKey')} + + + {t('resetPassword.totpMethod')} + + + {t('resetPassword.recoveryCodeMethod')} + + + + {totpMethodsDisabled && ( +

+ {t('resetPassword.totpMethodsUnavailable')} +

)} - /> +
+ + {method === 'recoveryKey' && ( + ( + + {t('resetPassword.recoveryKey')} + + {t('resetPassword.recoveryKeyDescription')} + + + {/* Recovery keys are case-sensitive base64url strings; send them verbatim */} +
+ + +
+
+ +
+ )} + /> + )} + + {method === 'totp' && ( + ( + + {t('resetPassword.totpCode')} + + {t('resetPassword.totpMethodDescription')} + + +
+ + +
+
+ +
+ )} + /> + )} + + {method === 'recoveryCode' && ( + ( + + {t('resetPassword.recoveryCode')} + +
+ + +
+
+ +
+ )} + /> + )}