Compare commits

...

1 Commits

Author SHA1 Message Date
TyperBody a40051daf1 feat(auth): add TOTP (RFC 6238) second factor with recovery codes
- store the per-Account shared secret encrypted at rest (Fernet keyed off
  the instance JWT secret via HKDF); never persist it in plaintext
- store recovery codes only as salted PBKDF2-HMAC-SHA256 digests
- add TotpService covering enrol / verify / disable and recovery-code use
- expose the login second-factor challenge (code `totp_required`) and the
  recovery-code path in the auth / reset flows
- add the `totp_credentials` migration (0025, revises 0024_passkey_credentials)
- web: TOTP challenge step on login, TOTP / recovery-code methods on
  reset-password, TotpEnrollDialog in account settings, i18n for all locales
2026-09-13 00:01:54 +08:00
19 changed files with 1597 additions and 90 deletions
@@ -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/<passkey_uuid>', methods=['PATCH'], auth_type=group.AuthType.USER_TOKEN)
@self.route(
'/passkey/<passkey_uuid>',
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/<passkey_uuid>', methods=['DELETE'], auth_type=group.AuthType.USER_TOKEN)
@self.route(
'/passkey/<passkey_uuid>',
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,
+478
View File
@@ -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$<iterations>$<salt_hex>$<digest_hex>`` 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
+3
View File
@@ -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
+4
View File
@@ -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')
@@ -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),
)
@@ -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)
@@ -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<PasskeyItem[]>([]);
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({
</div>
)}
</div>
{/* TOTP (2FA) Section */}
<div className="pt-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<h4 className="text-sm font-medium">
{t('account.totpSectionTitle')}
</h4>
<p className="text-xs text-muted-foreground">
{t('account.totpSectionDesc')}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setTotpDialogOpen(true)}
disabled={totpLoading || !systemInfo.allow_modify_login_info}
className="cursor-pointer"
>
{totpLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<ShieldCheck className="mr-2 h-4 w-4" />
)}
{totpEnabled
? t('account.disableTotp')
: t('account.enableTotp')}
</Button>
</div>
<Item size="sm" variant="muted" className="rounded-lg">
<ItemMedia variant="icon">
<ShieldCheck className="h-4 w-4" />
</ItemMedia>
<ItemContent>
<ItemTitle>
{totpEnabled
? t('account.totpEnabled')
: t('account.totpDisabled')}
</ItemTitle>
<ItemDescription>
{totpEnabled
? t('account.totpRecoveryCodesRemaining', {
count: remainingRecoveryCodes,
})
: t('account.totpSectionDesc')}
</ItemDescription>
</ItemContent>
</Item>
</div>
</div>
)}
@@ -340,6 +410,13 @@ export default function AccountSettingsPanel({
onOpenChange={handlePasswordDialogClose}
hasPassword={hasPassword}
/>
<TotpEnrollDialog
open={totpDialogOpen}
onOpenChange={setTotpDialogOpen}
enabled={totpEnabled}
onChanged={loadTotpStatus}
/>
</PanelBody>
);
}
+87 -4
View File
@@ -1241,10 +1241,21 @@ export class BackendClient extends BaseHttpClient {
);
}
public authUser(user: string, password: string): Promise<ApiRespUserToken> {
public authUser(
user: string,
password: string,
secondFactor?: { totpCode?: string; recoveryCode?: string },
): Promise<ApiRespUserToken> {
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<WorkspaceBootstrapResponse> {
return this.get('/api/v1/workspaces/bootstrap', undefined, {
+124 -15
View File
@@ -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<string | null>(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<z.infer<ReturnType<typeof formSchema>>>({
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() {
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* TOTP second-factor step: shown after the password is accepted. */}
{totpRequired && (
<div className="space-y-4">
<div className="flex flex-col items-center gap-1 text-center">
<ShieldCheck className="h-8 w-8 text-primary" />
<p className="text-sm font-medium">
{t('common.loginTotpTitle')}
</p>
<p className="text-xs text-muted-foreground">
{t('common.loginTotpDesc')}
</p>
</div>
<div className="relative">
<ShieldCheck className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input
value={totpCode}
onChange={(e) => 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();
}
}}
/>
</div>
<Button
type="button"
className="w-full cursor-pointer"
onClick={handleTotpSubmit}
disabled={totpSubmitting || !totpCode.trim()}
>
{totpSubmitting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<ShieldCheck className="mr-2 h-4 w-4" />
)}
{totpSubmitting
? t('common.loginTotpVerifying')
: t('common.loginTotpVerify')}
</Button>
<Button
type="button"
variant="ghost"
className="w-full cursor-pointer"
onClick={() => {
setTotpRequired(false);
setPendingCredentials(null);
setTotpCode('');
}}
>
{t('common.backToLogin')}
</Button>
</div>
)}
{/* Space and password login are per-account capabilities. */}
{showSpaceLogin && (
{!totpRequired && showSpaceLogin && (
<div className="space-y-3">
<Button
type="button"
@@ -355,7 +462,7 @@ export default function Login() {
</div>
)}
{showPasskeyLogin && (
{!totpRequired && showPasskeyLogin && (
<div className="space-y-3">
<Button
type="button"
@@ -375,21 +482,23 @@ export default function Login() {
)}
{/* Divider - only show if both login methods are available */}
{(showSpaceLogin || showPasskeyLogin) && showLocalLogin && (
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
{!totpRequired &&
(showSpaceLogin || showPasskeyLogin) &&
showLocalLogin && (
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white dark:bg-card px-2 text-muted-foreground">
{t('common.or')}
</span>
</div>
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white dark:bg-card px-2 text-muted-foreground">
{t('common.or')}
</span>
</div>
</div>
)}
)}
{/* Password login remains available to every account with a password. */}
{showLocalLogin && (
{!totpRequired && showLocalLogin && (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
+6 -1
View File
@@ -22,7 +22,7 @@ import {
import { useEffect, useState } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { useNavigate } from 'react-router-dom';
import { Mail, Lock, Loader2, Info, Layers } from 'lucide-react';
import { Mail, Lock, Loader2, Info, Layers, ShieldCheck } from 'lucide-react';
import {
Popover,
PopoverContent,
@@ -236,6 +236,11 @@ export default function Register() {
>
{t('register.registerWithPassword')}
</Button>
{/* Recommend enabling TOTP once the account exists */}
<p className="flex items-start gap-1.5 text-xs text-muted-foreground">
<ShieldCheck className="mt-0.5 h-3.5 w-3.5 shrink-0 text-primary" />
<span>{t('register.totpHint')}</span>
</p>
</form>
</Form>
</>
+225 -33
View File
@@ -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<RecoveryMethod>('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<z.infer<ReturnType<typeof formSchema>>>({
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<ReturnType<typeof formSchema>>) {
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() {
)}
/>
<FormField
control={form.control}
name="recoveryKey"
render={({ field }) => (
<FormItem>
<FormLabel>{t('resetPassword.recoveryKey')}</FormLabel>
<FormDescription>
{t('resetPassword.recoveryKeyDescription')}
</FormDescription>
<FormControl>
{/* Recovery keys are case-sensitive base64url strings; send them verbatim */}
<div className="relative">
<KeyRound className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input
placeholder={t('resetPassword.enterRecoveryKey')}
className="pl-10 font-mono"
autoComplete="off"
spellCheck={false}
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
{/* 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. */}
<div className="space-y-3">
<FormLabel>{t('resetPassword.verifyMethod')}</FormLabel>
<Tabs
value={method}
onValueChange={(v) => setMethod(v as RecoveryMethod)}
>
<TabsList className="w-full">
<TabsTrigger value="recoveryKey" className="flex-1">
{t('resetPassword.recoveryKey')}
</TabsTrigger>
<TabsTrigger
value="totp"
className="flex-1"
disabled={totpMethodsDisabled}
>
{t('resetPassword.totpMethod')}
</TabsTrigger>
<TabsTrigger
value="recoveryCode"
className="flex-1"
disabled={totpMethodsDisabled}
>
{t('resetPassword.recoveryCodeMethod')}
</TabsTrigger>
</TabsList>
</Tabs>
{totpMethodsDisabled && (
<p className="text-xs text-muted-foreground">
{t('resetPassword.totpMethodsUnavailable')}
</p>
)}
/>
</div>
{method === 'recoveryKey' && (
<FormField
control={form.control}
name="recoveryKey"
render={({ field }) => (
<FormItem>
<FormLabel>{t('resetPassword.recoveryKey')}</FormLabel>
<FormDescription>
{t('resetPassword.recoveryKeyDescription')}
</FormDescription>
<FormControl>
{/* Recovery keys are case-sensitive base64url strings; send them verbatim */}
<div className="relative">
<KeyRound className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input
placeholder={t('resetPassword.enterRecoveryKey')}
className="pl-10 font-mono"
autoComplete="off"
spellCheck={false}
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{method === 'totp' && (
<FormField
control={form.control}
name="totpCode"
render={({ field }) => (
<FormItem>
<FormLabel>{t('resetPassword.totpCode')}</FormLabel>
<FormDescription>
{t('resetPassword.totpMethodDescription')}
</FormDescription>
<FormControl>
<div className="relative">
<ShieldCheck className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input
placeholder={t('resetPassword.enterTotpCode')}
className="pl-10 font-mono tracking-widest"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{method === 'recoveryCode' && (
<FormField
control={form.control}
name="recoveryCode"
render={({ field }) => (
<FormItem>
<FormLabel>{t('resetPassword.recoveryCode')}</FormLabel>
<FormControl>
<div className="relative">
<ShieldCheck className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input
placeholder={t('resetPassword.enterRecoveryCode')}
className="pl-10 font-mono"
autoComplete="off"
spellCheck={false}
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
+57
View File
@@ -90,6 +90,13 @@ const enUS = {
passkeyLoginSuccess: 'Passkey verified successfully, signing in...',
passkeyLoginFailed: 'Failed to sign in with Passkey',
passkeyNotSupported: 'Passkey is not supported on this browser or device',
loginTotpTitle: 'Two-Factor Verification',
loginTotpDesc:
'Enter the 6-digit code from your authenticator app, or a recovery code',
loginTotpPlaceholder: 'Authenticator or recovery code',
loginTotpVerify: 'Verify',
loginTotpVerifying: 'Verifying...',
loginTotpInvalid: 'Invalid code, please try again',
spaceLoginTitle: 'Login with LangBot Account',
spaceLoginDescription:
'Scan the QR code or visit the link below to authorize',
@@ -1279,6 +1286,8 @@ const enUS = {
registerWithPassword: 'Register with email and password',
initSuccess: 'Initialization successful, please login',
initFailed: 'Initialization failed: ',
totpHint:
'Recommended: enable two-factor authentication (TOTP) after signing in to secure your account.',
},
resetPassword: {
title: 'Reset Password 🔐',
@@ -1298,6 +1307,22 @@ const enUS = {
resetFailed:
'Password reset failed, please check your email and recovery key',
backToLogin: 'Back to Login',
totpMethod: 'TOTP Authenticator',
recoveryCodeMethod: 'Recovery Code',
verifyMethod: 'Verification Method',
totpMethodsUnavailable:
'TOTP is not enabled for this account; only the recovery key can be used.',
totpCode: 'Authenticator Code',
enterTotpCode: 'Enter the 6-digit code from your authenticator app',
recoveryCode: 'Recovery Code',
enterRecoveryCode: 'Enter one of your recovery codes',
totpCodeRequired: 'Authenticator code cannot be empty',
recoveryCodeRequired: 'Recovery code cannot be empty',
totpNotEnabled:
'TOTP is not enabled for this account, use the recovery key instead',
invalidTotpCode: 'Invalid verification code, please try again',
totpMethodDescription:
'Verify with a TOTP authenticator app or one of your recovery codes',
},
embedding: {
description: 'Manage Embedding models for text vectorization',
@@ -1357,6 +1382,38 @@ const enUS = {
passkeyAddedSuccess: 'Passkey added successfully',
passkeyDeleteSuccess: 'Passkey deleted',
passkeyRenameSuccess: 'Passkey renamed successfully',
totpSectionTitle: 'Two-Factor Authentication (TOTP)',
totpSectionDesc:
'Scan a QR code to add a TOTP authenticator for extra login security',
totpEnabled: 'Enabled',
totpDisabled: 'Disabled',
enableTotp: 'Enable TOTP',
disableTotp: 'Disable TOTP',
totpEnabledSuccess: 'Two-factor authentication enabled',
totpDisabledSuccess: 'Two-factor authentication disabled',
totpEnrollTitle: 'Add TOTP Authenticator',
totpEnrollDesc:
'Scan the QR code with your authenticator app, then enter the 6-digit code to confirm',
totpScanHint: 'Scan this QR code with your authenticator app',
totpManualSecret: 'Or enter this key manually',
totpCodeLabel: 'Authenticator Code',
totpCodePlaceholder: '6-digit code',
totpVerify: 'Verify and Enable',
totpVerifying: 'Verifying...',
totpRecoveryCodesTitle: 'Recovery Codes',
totpRecoveryCodesDesc:
'Store these codes somewhere safe. Each code can be used once if you lose access to your authenticator.',
totpRecoveryCodesRemaining: '{{count}} recovery codes remaining',
totpRegenerateRecoveryCodes: 'Regenerate Recovery Codes',
totpRecoveryCodesRegenerated: 'Recovery codes regenerated',
totpDisableTitle: 'Disable Two-Factor Authentication',
totpDisableDesc:
'Enter a valid authenticator code to disable two-factor authentication',
totpConfirmDisable: 'Disable',
totpInvalidCode: 'Invalid code, please try again',
totpLoadFailed: 'Failed to load two-factor authentication status',
totpCopySecret: 'Copy key',
totpCopied: 'Copied to clipboard',
bindSpaceFailed: 'Failed to bind LangBot Account',
bindSpaceInvalidState:
'Invalid bind request. Please try again from account settings.',
+2
View File
@@ -1330,6 +1330,8 @@ const esES = {
newPasswordRequired: 'La nueva contraseña no puede estar vacía',
resetPassword: 'Restablecer contraseña',
resetting: 'Restableciendo...',
totpMethodsUnavailable:
'TOTP no está habilitado para esta cuenta; solo se puede usar la clave de recuperación.',
resetSuccess:
'Contraseña restablecida correctamente, por favor inicia sesión',
resetFailed:
+55
View File
@@ -92,6 +92,13 @@ const jaJP = {
passkeyLoginFailed: 'パスキーでのログインに失敗しました',
passkeyNotSupported:
'お使いのブラウザまたはデバイスはパスキーをサポートしていません',
loginTotpTitle: '二要素認証',
loginTotpDesc:
'認証アプリの6桁のコード、またはリカバリーコードを入力してください',
loginTotpPlaceholder: '認証コードまたはリカバリーコード',
loginTotpVerify: '確認',
loginTotpVerifying: '確認中...',
loginTotpInvalid: 'コードが無効です。もう一度お試しください',
spaceLoginTitle: 'LangBot アカウントでログイン',
spaceLoginDescription:
'QRコードをスキャンするか、下のリンクにアクセスして認証してください',
@@ -1286,6 +1293,8 @@ const jaJP = {
registerWithPassword: 'メールアドレスとパスワードで登録',
initSuccess: '初期化に成功しました。ログインしてください',
initFailed: '初期化に失敗しました:',
totpHint:
'推奨:ログイン後、アカウント設定で二要素認証(TOTP)を有効にしてアカウントを保護してください。',
},
resetPassword: {
title: 'パスワードをリセット 🔐',
@@ -1305,6 +1314,21 @@ const jaJP = {
resetFailed:
'パスワードのリセットに失敗しました。メールアドレスと復旧キーを確認してください',
backToLogin: 'ログインに戻る',
totpMethod: 'TOTP 認証アプリ',
recoveryCodeMethod: 'リカバリーコード',
verifyMethod: '確認方法',
totpMethodsUnavailable:
'このアカウントでは TOTP が有効になっていません。リカバリーキーのみ使用できます。',
totpCode: '認証コード',
enterTotpCode: '認証アプリに表示される6桁のコードを入力',
recoveryCode: 'リカバリーコード',
enterRecoveryCode: 'リカバリーコードのいずれかを入力',
totpCodeRequired: '認証コードは必須です',
recoveryCodeRequired: 'リカバリーコードは必須です',
totpNotEnabled:
'このアカウントでは TOTP が有効になっていません。復旧キーを使用してください',
invalidTotpCode: '認証コードが無効です。もう一度お試しください',
totpMethodDescription: 'TOTP 認証アプリまたはリカバリーコードで確認します',
},
embedding: {
description: 'テキストのベクトル化に使用する埋め込みモデルを管理します',
@@ -1364,6 +1388,37 @@ const jaJP = {
passkeyAddedSuccess: 'パスキーが正常に追加されました',
passkeyDeleteSuccess: 'パスキーを削除しました',
passkeyRenameSuccess: 'パスキー名を変更しました',
totpSectionTitle: '二要素認証 (TOTP)',
totpSectionDesc:
'QR コードをスキャンして TOTP 認証アプリを追加し、ログインの安全性を高めます',
totpEnabled: '有効',
totpDisabled: '無効',
enableTotp: 'TOTP を有効化',
disableTotp: 'TOTP を無効化',
totpEnabledSuccess: '二要素認証を有効にしました',
totpDisabledSuccess: '二要素認証を無効にしました',
totpEnrollTitle: 'TOTP 認証アプリを追加',
totpEnrollDesc:
'認証アプリで QR コードをスキャンし、6桁のコードを入力して確認します',
totpScanHint: '認証アプリでこの QR コードをスキャンしてください',
totpManualSecret: 'またはこのキーを手動で入力',
totpCodeLabel: '認証コード',
totpCodePlaceholder: '6桁のコード',
totpVerify: '確認して有効化',
totpVerifying: '確認中...',
totpRecoveryCodesTitle: 'リカバリーコード',
totpRecoveryCodesDesc:
'これらのコードは安全な場所に保管してください。認証アプリが使えない場合、各コードは一度だけ使用できます。',
totpRecoveryCodesRemaining: '残り {{count}} 個のリカバリーコード',
totpRegenerateRecoveryCodes: 'リカバリーコードを再生成',
totpRecoveryCodesRegenerated: 'リカバリーコードを再生成しました',
totpDisableTitle: '二要素認証を無効化',
totpDisableDesc: '有効な認証コードを入力して二要素認証を無効化します',
totpConfirmDisable: '無効化',
totpInvalidCode: 'コードが無効です。もう一度お試しください',
totpLoadFailed: '二要素認証の状態の読み込みに失敗しました',
totpCopySecret: 'キーをコピー',
totpCopied: 'クリップボードにコピーしました',
bindSpaceFailed: 'LangBot アカウントの連携に失敗しました',
bindSpaceInvalidState:
'無効な連携リクエストです。アカウント設定から再度お試しください。',
+2
View File
@@ -1306,6 +1306,8 @@ const ruRU = {
newPasswordRequired: 'Новый пароль не может быть пустым',
resetPassword: 'Сбросить пароль',
resetting: 'Сброс...',
totpMethodsUnavailable:
'TOTP не включён для этой учётной записи; доступен только ключ восстановления.',
resetSuccess: 'Пароль успешно сброшен, пожалуйста, войдите',
resetFailed: 'Ошибка сброса пароля, проверьте email и ключ восстановления',
backToLogin: 'Вернуться к входу',
+2
View File
@@ -1277,6 +1277,8 @@ const thTH = {
newPasswordRequired: 'รหัสผ่านใหม่ต้องไม่ว่างเปล่า',
resetPassword: 'รีเซ็ตรหัสผ่าน',
resetting: 'กำลังรีเซ็ต...',
totpMethodsUnavailable:
'บัญชีนี้ยังไม่ได้เปิดใช้ TOTP ใช้ได้เฉพาะคีย์กู้คืนเท่านั้น',
resetSuccess: 'รีเซ็ตรหัสผ่านสำเร็จ กรุณาเข้าสู่ระบบ',
resetFailed: 'รีเซ็ตรหัสผ่านล้มเหลว กรุณาตรวจสอบอีเมลและคีย์กู้คืน',
backToLogin: 'กลับไปหน้าเข้าสู่ระบบ',
+2
View File
@@ -1298,6 +1298,8 @@ const viVN = {
newPasswordRequired: 'Mật khẩu mới không được để trống',
resetPassword: 'Đặt lại mật khẩu',
resetting: 'Đang đặt lại...',
totpMethodsUnavailable:
'TOTP chưa được bật cho tài khoản này; chỉ có thể dùng khóa khôi phục.',
resetSuccess: 'Đặt lại mật khẩu thành công, vui lòng đăng nhập',
resetFailed:
'Đặt lại mật khẩu thất bại, vui lòng kiểm tra email và khóa khôi phục',
+50
View File
@@ -88,6 +88,12 @@ const zhHans = {
passkeyLoginSuccess: 'Passkey 验证成功,正在登录...',
passkeyLoginFailed: 'Passkey 登录失败',
passkeyNotSupported: '当前浏览器或设备不支持 Passkey',
loginTotpTitle: '两步验证',
loginTotpDesc: '请输入验证器应用中的 6 位验证码,或使用恢复码',
loginTotpPlaceholder: '验证码或恢复码',
loginTotpVerify: '验证',
loginTotpVerifying: '验证中...',
loginTotpInvalid: '验证码无效,请重试',
spaceLoginTitle: '通过 LangBot 账号登录',
spaceLoginDescription: '扫描二维码或访问下方链接进行授权',
spaceLoginUserCode: '您的验证码',
@@ -1219,6 +1225,8 @@ const zhHans = {
registerWithPassword: '通过邮箱密码组合注册',
initSuccess: '初始化成功 请登录',
initFailed: '初始化失败:',
totpHint:
'推荐:登录后在账户设置中开启两步验证(TOTP)以保护您的账户安全。',
},
resetPassword: {
title: '重置密码 🔐',
@@ -1236,6 +1244,19 @@ const zhHans = {
resetSuccess: '密码重置成功,请登录',
resetFailed: '密码重置失败,请检查邮箱和恢复密钥是否正确',
backToLogin: '返回登录',
totpMethod: 'TOTP 验证器',
recoveryCodeMethod: '恢复码',
verifyMethod: '验证方式',
totpCode: '验证器验证码',
enterTotpCode: '输入验证器应用中的 6 位验证码',
recoveryCode: '恢复码',
enterRecoveryCode: '输入您的其中一个恢复码',
totpCodeRequired: '验证码不能为空',
recoveryCodeRequired: '恢复码不能为空',
totpNotEnabled: '该账户未开启 TOTP,请改用恢复密钥',
invalidTotpCode: '验证码无效,请重试',
totpMethodDescription: '使用 TOTP 验证器应用或恢复码进行验证',
totpMethodsUnavailable: '该账户未开启 TOTP 验证,仅可使用恢复密钥重置密码',
},
embedding: {
description: '管理嵌入模型,用于向量化文本',
@@ -1291,6 +1312,35 @@ const zhHans = {
passkeyAddedSuccess: '通行密钥添加成功',
passkeyDeleteSuccess: '通行密钥已删除',
passkeyRenameSuccess: '通行密钥重命名成功',
totpSectionTitle: '两步验证 (TOTP)',
totpSectionDesc: '扫描二维码添加 TOTP 验证器,提升登录安全性',
totpEnabled: '已开启',
totpDisabled: '未开启',
enableTotp: '开启 TOTP',
disableTotp: '关闭 TOTP',
totpEnabledSuccess: '两步验证已开启',
totpDisabledSuccess: '两步验证已关闭',
totpEnrollTitle: '添加 TOTP 验证器',
totpEnrollDesc: '使用验证器应用扫描二维码,然后输入 6 位验证码完成确认',
totpScanHint: '使用验证器应用扫描此二维码',
totpManualSecret: '或手动输入此密钥',
totpCodeLabel: '验证码',
totpCodePlaceholder: '6 位验证码',
totpVerify: '验证并开启',
totpVerifying: '验证中...',
totpRecoveryCodesTitle: '恢复码',
totpRecoveryCodesDesc:
'请妥善保存这些恢复码。当您无法使用验证器时,每个恢复码可使用一次。',
totpRecoveryCodesRemaining: '剩余 {{count}} 个恢复码',
totpRegenerateRecoveryCodes: '重新生成恢复码',
totpRecoveryCodesRegenerated: '恢复码已重新生成',
totpDisableTitle: '关闭两步验证',
totpDisableDesc: '输入有效的验证器验证码以关闭两步验证',
totpConfirmDisable: '关闭',
totpInvalidCode: '验证码无效,请重试',
totpLoadFailed: '加载两步验证状态失败',
totpCopySecret: '复制密钥',
totpCopied: '已复制到剪贴板',
bindSpaceFailed: '绑定 LangBot 账号失败',
bindSpaceInvalidState: '无效的绑定请求,请从账户设置重新发起',
setPasswordHint: '设置密码后可使用邮箱密码登录',
+1
View File
@@ -1234,6 +1234,7 @@ const zhHant = {
newPasswordRequired: '新密碼不能為空',
resetPassword: '重設密碼',
resetting: '重設中...',
totpMethodsUnavailable: '此帳戶未開啟 TOTP 驗證,僅可使用恢復金鑰重設密碼',
resetSuccess: '密碼重設成功,請登入',
resetFailed: '密碼重設失敗,請檢查電子郵件和恢復金鑰是否正確',
backToLogin: '返回登入',