feat(auth): add TOTP two-factor authentication

Add a per-Account time-based one-time password (TOTP) second sign-in
factor, plus the owner/admin tooling needed to operate it.

Backend
- TotpService: enrolment, constant-time verification with a +/- one step
  drift window, single-use recovery codes and disablement.
  * shared secret is only ever persisted as a Fernet token whose key is
    derived (HKDF-SHA256) from the instance JWT secret and gated by a
    key_version epoch;
  * recovery codes are only ever persisted as salted
    PBKDF2-HMAC-SHA256 digests (600k iterations) and are single-use;
  * the consumed counter advances monotonically so a captured code cannot
    be replayed inside the same time window.
- New persistence entities and alembic migrations for credentials and
  recovery codes.
- Login second-factor challenge, bound to the Account that passed the
  password step.
- Owner/admin oversight endpoints to inspect and re-bind the second
  factor of any Account.

Frontend
- Account settings panel for enrolling, managing, re-binding and
  disabling the second factor.
- Login second-factor step and the matching client methods.
- Strings for all eight locales.

The shared secret never crosses the API boundary: enrolment returns a
server-rendered QR code (as a data: URL) and the plaintext secret is
discarded as soon as the image is produced.
This commit is contained in:
TyperBody
2026-09-24 01:31:54 +08:00
parent 6b83089535
commit 8633567ce7
25 changed files with 3493 additions and 112 deletions
@@ -15,6 +15,7 @@ from .....entity.errors import account as account_errors
from ...context import RequestContext
from .....cloud.launch import SpaceLaunchError
from ...service.user import ControlPlaneDirectoryRequiredError, PublicRegistrationClosedError
from ...service import totp as totp_module
# Fixed-window admission quota for the unauthenticated reset-password endpoint (#2392).
# The admission check and slot bump share ONE synchronous critical section with no await
@@ -112,15 +113,93 @@ class UserRouterGroup(group.RouterGroup):
return self.http_status(403, 'password_login_disabled', 'Password login is disabled on LangBot Cloud')
json_data = await quart.request.json
user_email = json_data['user']
password = json_data['password']
totp_code = json_data.get('totp_code') or json_data.get('code')
try:
token = await self.ap.user_service.authenticate(json_data['user'], json_data['password'])
token = await self.ap.user_service.authenticate(user_email, password, totp_code)
except argon2.exceptions.VerifyMismatchError:
return self.fail(1, 'Invalid username or password')
except totp_module.TotpRequiredError:
# Primary factors passed, but the second factor is still missing.
# Issue a challenge instead of a session token.
challenge_token = await self.ap.user_service.issue_totp_login_challenge(user_email)
if challenge_token is None:
return self.fail(1, 'A second factor is required')
return (
quart.jsonify(
{
'code': 'totp_required',
'msg': 'A TOTP second factor is required',
'data': {'challenge_token': challenge_token},
}
),
401,
)
except totp_module.TotpInvalidCodeError:
return self.http_status(401, 'totp_invalid_code', 'Invalid TOTP or recovery code')
except ValueError as e:
return self.fail(1, str(e))
return self.success(data={'token': token})
# ---- TOTP second factor (login challenge) ----
@self.route('/totp/challenge', methods=['POST'], auth_type=group.AuthType.NONE)
async def _() -> str:
"""Start a login second-factor challenge.
Always answers with a token, even for unknown or non-enrolled
Accounts: a distinguishable reply would let an unauthenticated caller
enumerate which emails have a second factor.
"""
json_data = (await quart.request.json) or {}
user_email = json_data.get('user')
if not isinstance(user_email, str) or not user_email:
return self.fail(1, 'Missing user parameter')
challenge_token = await self.ap.user_service.issue_uniform_totp_login_challenge(user_email)
return self.success(data={'challenge_token': challenge_token})
@self.route('/totp/verify', methods=['POST'], auth_type=group.AuthType.NONE)
async def _() -> str:
"""Complete the login second factor and return a session token.
A ``challenge_token`` is mandatory: it proves the password step ran
for this Account, so a bare code can never mint a session on its own.
"""
json_data = (await quart.request.json) or {}
user_email = json_data.get('user')
code = json_data.get('code')
challenge_token = json_data.get('challenge_token')
if (
not isinstance(user_email, str)
or not user_email
or not isinstance(code, str)
or not code
or not isinstance(challenge_token, str)
or not challenge_token
):
return self.fail(1, 'Missing user, code or challenge_token parameter')
verified = await self.ap.user_service.verify_totp_second_factor(
user_email,
code,
challenge_token=challenge_token,
)
if not verified:
return self.http_status(401, 'totp_invalid_code', 'Invalid TOTP or recovery code')
user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is None:
return self.http_status(401, 'totp_invalid_code', 'Invalid TOTP or recovery code')
token = await self.ap.user_service.generate_jwt_token(user_obj)
return self.success(data={'token': token, 'user': user_obj.user})
@self.route('/check-token', methods=['GET'], auth_type=group.AuthType.ACCOUNT_TOKEN)
async def _(account) -> str:
token = await self.ap.user_service.generate_jwt_token(account)
@@ -138,8 +217,10 @@ class UserRouterGroup(group.RouterGroup):
json_data = await quart.request.json
user_email = json_data['user']
recovery_key = json_data['recovery_key']
new_password = json_data['new_password']
# Second-factor method: 'recovery_key' (instance-wide, default),
# 'totp' (an authenticator code) or 'recovery_code' (a one-time code).
method = json_data.get('method') or 'recovery_key'
# hard sleep 3s for security
await asyncio.sleep(3)
@@ -152,19 +233,35 @@ class UserRouterGroup(group.RouterGroup):
if user_obj is None:
return self.http_status(400, -1, 'User not found')
stored_key = self.ap.instance_config.data['system']['recovery_key']
try:
key_matches = (
isinstance(recovery_key, str)
and isinstance(stored_key, str)
and hmac.compare_digest(recovery_key.encode(), stored_key.encode())
)
except UnicodeEncodeError:
# JSON can contain lone surrogates, which are not valid UTF-8.
key_matches = False
if method in ('totp', 'recovery_code'):
# Account-scoped second factor: a live TOTP code or, for the
# recovery_code method, a one-time recovery code.
allow_recovery = method == 'recovery_code'
second_factor = json_data.get('totp_code') or json_data.get('code')
if not isinstance(second_factor, str) or not second_factor:
return self.http_status(400, -1, 'Missing TOTP or recovery code')
if not key_matches:
return self.http_status(403, -1, 'Invalid recovery key')
if not await self.ap.user_service.verify_totp_second_factor(
user_email,
second_factor,
allow_recovery=allow_recovery,
):
return self.http_status(403, -1, 'Invalid TOTP or recovery code')
else:
recovery_key = json_data.get('recovery_key')
stored_key = self.ap.instance_config.data['system']['recovery_key']
try:
key_matches = (
isinstance(recovery_key, str)
and isinstance(stored_key, str)
and hmac.compare_digest(recovery_key.encode(), stored_key.encode())
)
except UnicodeEncodeError:
# JSON can contain lone surrogates, which are not valid UTF-8.
key_matches = False
if not key_matches:
return self.http_status(403, -1, 'Invalid recovery key')
await self.ap.user_service.reset_password(user_email, new_password)
@@ -674,6 +771,272 @@ class UserRouterGroup(group.RouterGroup):
return self.http_status(404, -1, 'Passkey not found')
return self.success()
# ---- TOTP second factor (account settings) ----
# The second factor belongs to the Account, so these routes are
# ACCOUNT_TOKEN scoped. Requiring a Workspace would lock out an Account
# that has not been added to one yet.
@self.route('/totp/status', methods=['GET'], auth_type=group.AuthType.ACCOUNT_TOKEN)
async def _(account) -> str:
"""Report second-factor state without ever returning the secret."""
totp_service = self.ap.totp_service
credential = await totp_service.get_credential(account.uuid)
return self.success(
data={
'enabled': bool(credential is not None and credential.confirmed_at is not None),
'pending': bool(credential is not None and credential.confirmed_at is None),
'confirmed_at': (
credential.confirmed_at.isoformat() if credential and credential.confirmed_at else None
),
'last_used_at': (
credential.last_used_at.isoformat() if credential and credential.last_used_at else None
),
'recovery_codes_remaining': (
await totp_service.count_unused_recovery_codes(account.uuid) if credential else 0
),
}
)
@self.route('/totp/enroll', methods=['POST'], auth_type=group.AuthType.ACCOUNT_TOKEN)
async def _(account) -> str:
"""Start TOTP enrolment; returns a server-rendered QR code only.
The shared secret is intentionally never returned to the client so it
cannot be read out of the browser or any proxy in between.
"""
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
)
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
json_data = (await quart.request.json) or {}
# rotate=True (explicit refresh) mints a new secret; the default
# reuses any pending enrolment so duplicate requests cannot
# invalidate the QR code already displayed to the operator.
rotate = bool(json_data.get('rotate', False))
try:
enrollment = await self.ap.totp_service.begin_enrollment(
account.uuid, account.user, rotate=rotate
)
except totp_module.TotpError as e:
return self.http_status(409, e.code, str(e))
return self.success(
data={
'uuid': enrollment.uuid,
'qr_code_data_url': enrollment.qr_code_data_url,
'algorithm': enrollment.algorithm,
'digits': enrollment.digits,
'period': enrollment.period,
}
)
@self.route('/totp/enroll/confirm', methods=['POST'], auth_type=group.AuthType.ACCOUNT_TOKEN)
async def _(account) -> str:
"""Confirm enrolment with the first code; returns recovery codes once."""
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
)
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
json_data = (await quart.request.json) or {}
code = json_data.get('code')
if not isinstance(code, str) or not code:
return self.fail(1, 'Missing code parameter')
try:
result = await self.ap.totp_service.confirm_enrollment(account.uuid, code)
except totp_module.TotpNotEnrolledError as e:
return self.http_status(404, e.code, str(e))
except totp_module.TotpInvalidCodeError as e:
return self.http_status(400, e.code, str(e))
except totp_module.TotpError as e:
return self.http_status(409, e.code, str(e))
return self.success(data={'recovery_codes': result.codes})
@self.route('/totp/recovery-codes', methods=['POST'], auth_type=group.AuthType.ACCOUNT_TOKEN)
async def _(account) -> str:
"""Regenerate recovery codes after a valid TOTP or recovery code."""
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
)
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
json_data = (await quart.request.json) or {}
code = json_data.get('code')
if not isinstance(code, str) or not code:
return self.fail(1, 'Missing code parameter')
try:
result = await self.ap.totp_service.regenerate_recovery_codes(account.uuid, code)
except totp_module.TotpNotEnrolledError as e:
return self.http_status(404, e.code, str(e))
except totp_module.TotpInvalidCodeError as e:
return self.http_status(403, e.code, str(e))
return self.success(data={'recovery_codes': result.codes})
@self.route('/totp/disable', methods=['POST'], auth_type=group.AuthType.ACCOUNT_TOKEN)
async def _(account) -> str:
"""Disable TOTP, requiring a live TOTP or recovery code."""
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
)
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
json_data = (await quart.request.json) or {}
code = json_data.get('code')
try:
await self.ap.totp_service.disable(account.uuid, code=code)
except totp_module.TotpNotEnrolledError as e:
return self.http_status(404, e.code, str(e))
except totp_module.TotpInvalidCodeError as e:
return self.http_status(403, e.code, str(e))
return self.success()
# ---- TOTP oversight for owners and admins ----
async def _require_workspace_manager(request_context: RequestContext) -> None:
"""Raise unless the caller's Workspace role is owner or admin."""
if request_context is None:
raise PermissionError('A Workspace context is required')
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
request_context.account_uuid,
request_context.workspace_uuid,
)
if access.membership.role not in ('owner', 'admin'):
raise PermissionError('Only Workspace owners and admins may manage other Accounts')
@self.route('/totp/accounts', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(request_context: RequestContext) -> str:
"""List the second-factor state of every Account for owners/admins.
Oversight is deliberately instance-wide: managing the second factor
of any Account is an owner/admin responsibility and is not scoped to
the caller's Workspace.
"""
try:
await _require_workspace_manager(request_context)
except PermissionError as e:
return self.http_status(403, 'permission_denied', str(e))
except Exception:
return self.http_status(403, 'permission_denied', 'Not permitted')
accounts = await self.ap.totp_service.list_account_states()
return self.success(data={'accounts': accounts})
@self.route('/totp/accounts/<target_account_uuid>', methods=['DELETE'], auth_type=group.AuthType.USER_TOKEN)
async def _(request_context: RequestContext, target_account_uuid: str) -> str:
"""Revoke another Account's second factor when its codes are lost."""
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
)
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
try:
await _require_workspace_manager(request_context)
except PermissionError as e:
return self.http_status(403, 'permission_denied', str(e))
except Exception:
return self.http_status(403, 'permission_denied', 'Not permitted')
revoked = await self.ap.totp_service.revoke_for_account(target_account_uuid)
if not revoked:
return self.http_status(404, 'totp_not_enrolled', 'TOTP is not enabled for that Account')
return self.success()
@self.route(
'/totp/accounts/<target_account_uuid>/enroll',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN,
)
async def _(request_context: RequestContext, target_account_uuid: str) -> str:
"""Start a forced re-binding of another Account's second factor.
Returns a server-rendered QR code so an owner/admin can walk the
Account through binding a new authenticator. The shared secret is
never returned to the client.
"""
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
)
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
try:
await _require_workspace_manager(request_context)
except PermissionError as e:
return self.http_status(403, 'permission_denied', str(e))
except Exception:
return self.http_status(403, 'permission_denied', 'Not permitted')
target = await self.ap.totp_service.get_account(target_account_uuid)
if target is None:
return self.http_status(404, 'account_not_found', 'Account not found')
try:
enrollment = await self.ap.totp_service.begin_enrollment(
target_account_uuid, target.user, force=True
)
except totp_module.TotpError as e:
return self.http_status(409, e.code, str(e))
return self.success(
data={
'uuid': enrollment.uuid,
'qr_code_data_url': enrollment.qr_code_data_url,
'algorithm': enrollment.algorithm,
'digits': enrollment.digits,
'period': enrollment.period,
}
)
@self.route(
'/totp/accounts/<target_account_uuid>/enroll/confirm',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN,
)
async def _(request_context: RequestContext, target_account_uuid: str) -> str:
"""Activate a forced re-binding with the first code; returns recovery codes once."""
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
)
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
try:
await _require_workspace_manager(request_context)
except PermissionError as e:
return self.http_status(403, 'permission_denied', str(e))
except Exception:
return self.http_status(403, 'permission_denied', 'Not permitted')
json_data = (await quart.request.json) or {}
code = json_data.get('code')
if not isinstance(code, str) or not code:
return self.fail(1, 'Missing code parameter')
try:
result = await self.ap.totp_service.confirm_enrollment(target_account_uuid, code)
except totp_module.TotpNotEnrolledError as e:
return self.http_status(404, e.code, str(e))
except totp_module.TotpInvalidCodeError as e:
return self.http_status(400, e.code, str(e))
except totp_module.TotpError as e:
return self.http_status(409, e.code, str(e))
return self.success(data={'recovery_codes': result.codes})
async def _handle_space_direct_launch(
self,
launch_assertion: str,
+717
View File
@@ -0,0 +1,717 @@
from __future__ import annotations
import base64
import dataclasses
import datetime
import hashlib
import hmac
import io
import secrets
import time
import typing
import uuid as uuid_lib
from urllib.parse import quote as url_quote
import qrcode
import sqlalchemy
from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from ....entity.persistence import totp as totp_entity
from ....entity.persistence import user
if typing.TYPE_CHECKING:
from ....core.app import Application
# HKDF context: rotating the wrapping key means bumping the key version, never
# re-using an existing derivation context.
_HKDF_SALT = b'langbot.totp.secret.v1'
_HKDF_INFO = b'langbot-totp-secret-encryption'
_HKDF_LENGTH = 32
# Recovery codes: PBKDF2-HMAC-SHA256. Only the digest is ever persisted.
_RECOVERY_CODE_ALGORITHM = 'pbkdf2_hmac_sha256'
_PBKDF2_ITERATIONS = 600_000
_PBKDF2_SALT_BYTES = 16
_RECOVERY_CODE_COUNT = 4
_RECOVERY_CODE_GROUPS = 4
_RECOVERY_CODE_GROUP_LENGTH = 5
# Login second-factor challenge lifetime and admission bounds.
_TOTP_CHALLENGE_TTL_SECONDS = 5 * 60
_TOTP_CHALLENGE_MAX_ENTRIES = 4096
_TOTP_MAX_ATTEMPTS = 5
# Unambiguous base32 alphabet used for recovery codes (no 0/O/1/I confusion).
_RECOVERY_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
class TotpError(ValueError):
"""Base class for TOTP second-factor failures."""
code = 'totp_error'
class TotpNotEnrolledError(TotpError):
code = 'totp_not_enrolled'
class TotpAlreadyEnrolledError(TotpError):
code = 'totp_already_enrolled'
class TotpInvalidCodeError(TotpError):
code = 'totp_invalid_code'
class TotpRequiredError(TotpError):
"""Raised when the password flow still requires a second factor."""
code = 'totp_required'
class TotpChallengeError(TotpError):
code = 'totp_challenge_invalid'
@dataclasses.dataclass(frozen=True, slots=True)
class TotpChallengeData:
account_uuid: str
user_email: str
expires_at: float
attempts: int = 0
@dataclasses.dataclass(frozen=True, slots=True)
class TotpEnrollment:
"""Result of starting an enrolment.
The shared secret never leaves the backend: only a server-rendered QR code
(as a ``data:`` URL) is handed to the client so the operator can scan it into
an authenticator. The secret itself is persisted solely as a ciphertext token
and is never returned in plaintext.
"""
uuid: str
qr_code_data_url: str
algorithm: str
digits: int
period: int
@dataclasses.dataclass(frozen=True, slots=True)
class TotpRecoveryCodes:
"""Recovery codes returned exactly once, at confirmation or regeneration."""
codes: list[str]
def _derive_wrapping_key(jwt_secret: str, key_version: int) -> bytes:
"""HKDF-SHA256 derivation of the Fernet key from the instance JWT secret."""
if not jwt_secret:
raise TotpError('Instance JWT secret is not configured')
hkdf = HKDF(
algorithm=hashes.SHA256(),
length=_HKDF_LENGTH,
salt=_HKDF_SALT,
info=_HKDF_INFO + b'.v' + str(int(key_version)).encode('ascii'),
)
# Fernet requires a url-safe base64 encoded 32-byte key.
return base64.urlsafe_b64encode(hkdf.derive(jwt_secret.encode('utf-8')))
def _generate_totp_secret(length: int = 32) -> str:
"""Generate a base32 TOTP shared secret from a CSPRNG."""
return base64.b32encode(secrets.token_bytes(length)).decode('ascii').rstrip('=')
def _normalize_code(value: typing.Any) -> str:
return ''.join(ch for ch in str(value or '').upper() if ch.isalnum())
def _hotp(secret: bytes, counter: int, digits: int) -> str:
"""RFC 4226 HMAC-SHA1 dynamic truncation."""
digest = hmac.new(secret, counter.to_bytes(8, byteorder='big'), hashlib.sha1).digest()
offset = digest[-1] & 0x0F
truncated = (
(digest[offset] & 0x7F) << 24
| (digest[offset + 1] & 0xFF) << 16
| (digest[offset + 2] & 0xFF) << 8
| (digest[offset + 3] & 0xFF)
)
return str(truncated % (10**digits)).zfill(digits)
def _decode_secret(secret: str) -> bytes:
padding = '=' * ((8 - len(secret) % 8) % 8)
try:
return base64.b32decode(secret.upper() + padding)
except Exception as exc: # noqa: BLE001 - surface as a domain error
raise TotpError('TOTP secret is not valid base32') from exc
def _totp_counter(period: int, *, at: float | None = None) -> int:
"""RFC 6238 time step counter."""
moment = time.time() if at is None else at
return int(moment // max(1, period))
def _pbkdf2_digest(code: str, salt: str, iterations: int = _PBKDF2_ITERATIONS) -> str:
"""PBKDF2-HMAC-SHA256 digest of a recovery code, hex encoded."""
return hashlib.pbkdf2_hmac(
'sha256',
_normalize_code(code).encode('utf-8'),
salt.encode('utf-8'),
iterations,
).hex()
def _generate_recovery_codes() -> list[str]:
"""Human-transcribable single-use recovery codes."""
length = _RECOVERY_CODE_GROUPS * _RECOVERY_CODE_GROUP_LENGTH
codes: list[str] = []
for _ in range(_RECOVERY_CODE_COUNT):
raw = ''.join(secrets.choice(_RECOVERY_ALPHABET) for _ in range(length))
codes.append(
'-'.join(raw[i : i + _RECOVERY_CODE_GROUP_LENGTH] for i in range(0, length, _RECOVERY_CODE_GROUP_LENGTH))
)
return codes
def build_totp_uri(secret: str, account: str, issuer: str = 'LangBot') -> str:
"""Build an otpauth:// provisioning URI for an authenticator app."""
return (
f'otpauth://totp/{url_quote(account)}?secret={secret}'
f'&issuer={url_quote(issuer)}&algorithm=SHA1&digits=6&period=30'
)
def render_totp_qr_data_url(otpauth_uri: str) -> str:
"""Render a provisioning URI as a PNG ``data:`` URL.
The QR code is produced on the server so the shared secret never has to
travel to the browser as a plaintext value.
"""
image = qrcode.QRCode(border=1, box_size=8)
image.add_data(otpauth_uri)
image.make(fit=True)
picture = image.make_image(fill_color='black', back_color='white')
buffer = io.BytesIO()
picture.save(buffer, format='PNG')
encoded = base64.b64encode(buffer.getvalue()).decode('ascii')
return f'data:image/png;base64,{encoded}'
class TotpService:
"""TOTP enrolment, verification, disablement and recovery-code use.
Security invariants enforced here:
* the shared secret is only ever persisted as a Fernet token whose key is
derived (HKDF-SHA256) from the instance JWT secret;
* recovery codes are only ever persisted as salted PBKDF2-HMAC-SHA256
digests and are single-use;
* the consumed counter advances monotonically so a captured code cannot be
replayed inside the same time window.
"""
ap: Application
def __init__(self, ap: Application) -> None:
self.ap = ap
self._challenges: dict[str, TotpChallengeData] = {}
# ------------------------------------------------------------------
# configuration / storage helpers
# ------------------------------------------------------------------
def _session_factory(self) -> async_sessionmaker[AsyncSession]:
return async_sessionmaker(self.ap.persistence_mgr.get_db_engine(), expire_on_commit=False)
def _jwt_secret(self) -> str:
secret = self.ap.instance_config.data['system']['jwt']['secret']
if not isinstance(secret, str) or not secret:
raise TotpError('Instance JWT secret is not configured')
return secret
def _fernet(self, key_version: int = 1) -> Fernet:
return Fernet(_derive_wrapping_key(self._jwt_secret(), key_version))
async def is_enrolled(self, account_uuid: str) -> bool:
credential = await self.get_credential(account_uuid)
return credential is not None and credential.confirmed_at is not None
async def get_credential(self, account_uuid: str) -> totp_entity.TotpCredential | None:
statement = (
sqlalchemy.select(totp_entity.TotpCredential)
.where(
totp_entity.TotpCredential.account_uuid == account_uuid,
totp_entity.TotpCredential.disabled_at.is_(None),
)
.order_by(totp_entity.TotpCredential.created_at.desc())
.limit(1)
)
async with self._session_factory()() as session:
return await session.scalar(statement)
# ------------------------------------------------------------------
# login second-factor challenge
# ------------------------------------------------------------------
async def issue_login_challenge(self, account_uuid: str, user_email: str) -> str:
"""Issue a single-use second-factor challenge for one Account."""
token = secrets.token_urlsafe(32)
self._prune_challenges()
# Bound the challenge table so unauthenticated login attempts cannot
# grow process memory without limit.
while len(self._challenges) >= _TOTP_CHALLENGE_MAX_ENTRIES:
self._challenges.pop(next(iter(self._challenges)), None)
self._challenges[token] = TotpChallengeData(
account_uuid=account_uuid,
user_email=user_email,
expires_at=time.monotonic() + _TOTP_CHALLENGE_TTL_SECONDS,
)
return token
def consume_login_challenge(self, token: str) -> TotpChallengeData:
data = self._challenges.get(token)
if data is None or data.expires_at < time.monotonic():
self._challenges.pop(token, None)
raise TotpChallengeError('Invalid or expired second-factor challenge')
return data
def complete_login_challenge(self, token: str) -> None:
self._challenges.pop(token, None)
def record_failed_attempt(self, token: str) -> None:
"""Count a failed attempt and burn the challenge once the cap is hit."""
data = self._challenges.get(token)
if data is None:
return
attempts = data.attempts + 1
if attempts >= _TOTP_MAX_ATTEMPTS:
self._challenges.pop(token, None)
return
self._challenges[token] = dataclasses.replace(data, attempts=attempts)
def _prune_challenges(self) -> None:
now = time.monotonic()
for token in [token for token, data in self._challenges.items() if data.expires_at < now]:
self._challenges.pop(token, None)
# ------------------------------------------------------------------
# enrolment
# ------------------------------------------------------------------
async def begin_enrollment(
self,
account_uuid: str,
user_email: str,
*,
rotate: bool = False,
force: bool = False,
) -> TotpEnrollment:
"""Create or replace the pending TOTP credential for an Account.
Any previous unconfirmed attempt is retired and outstanding recovery
codes are cleared, so a half-finished enrolment can never linger.
The plaintext secret is discarded after this call: callers only ever
receive a server-rendered QR code.
``force`` is used by Workspace owners/admins to re-bind an Account whose
authenticator was lost: it retires an already-confirmed credential and
starts a fresh pending enrolment.
"""
credential = await self.get_credential(account_uuid)
if credential is not None and credential.confirmed_at is not None and not force:
raise TotpAlreadyEnrolledError('TOTP is already enabled for this Account')
# Reuse an unconfirmed (pending) enrolment instead of minting a new
# secret. Duplicate requests are normal - React StrictMode invokes
# effects twice in development and browsers/proxies may retry - and
# rotating the secret would invalidate the QR code already on screen,
# making the subsequent confirm_enrollment() call fail with
# "Invalid TOTP code". Returning the same QR keeps them in sync.
# An explicit "refresh" passes rotate=True to opt back into rotation.
if credential is not None and not rotate and not force:
pending_secret = self._decrypt_secret(credential.secret_ciphertext, credential.key_version)
return TotpEnrollment(
uuid=credential.uuid,
qr_code_data_url=render_totp_qr_data_url(
build_totp_uri(pending_secret.decode('ascii'), user_email)
),
algorithm=credential.algorithm,
digits=credential.digits,
period=credential.period,
)
secret = _generate_totp_secret()
ciphertext = self._fernet().encrypt(secret.encode('utf-8')).decode('ascii')
record_uuid = str(uuid_lib.uuid4())
async with self._session_factory()() as session:
async with session.begin():
await session.execute(
sqlalchemy.update(totp_entity.TotpCredential)
.where(totp_entity.TotpCredential.account_uuid == account_uuid)
.values(disabled_at=datetime.datetime.now(datetime.timezone.utc))
)
await session.execute(
sqlalchemy.delete(totp_entity.TotpRecoveryCode).where(
totp_entity.TotpRecoveryCode.account_uuid == account_uuid
)
)
session.add(
totp_entity.TotpCredential(
uuid=record_uuid,
account_uuid=account_uuid,
secret_ciphertext=ciphertext,
key_version=1,
algorithm='SHA1',
digits=6,
period=30,
)
)
# Render the QR code here (and drop the plaintext secret) so the shared
# secret never crosses the API boundary towards the browser.
qr_code_data_url = render_totp_qr_data_url(build_totp_uri(secret, user_email))
return TotpEnrollment(
uuid=record_uuid,
qr_code_data_url=qr_code_data_url,
algorithm='SHA1',
digits=6,
period=30,
)
async def confirm_enrollment(self, account_uuid: str, code: str) -> TotpRecoveryCodes:
"""Verify the first code, activate the credential and mint recovery codes.
Recovery codes are returned exactly once; only their salted PBKDF2
digests are stored.
"""
credential = await self.get_credential(account_uuid)
if credential is None:
raise TotpNotEnrolledError('No pending TOTP enrolment for this Account')
if credential.confirmed_at is not None:
raise TotpAlreadyEnrolledError('TOTP is already enabled for this Account')
counter = self._verify_counter(credential, code)
codes = _generate_recovery_codes()
now = datetime.datetime.now(datetime.timezone.utc)
async with self._session_factory()() as session:
async with session.begin():
await session.execute(
sqlalchemy.update(totp_entity.TotpCredential)
.where(
totp_entity.TotpCredential.uuid == credential.uuid,
totp_entity.TotpCredential.confirmed_at.is_(None),
)
.values(confirmed_at=now, last_used_at=now, last_used_counter=counter)
)
self._store_recovery_codes(session, account_uuid, credential.uuid, codes)
return TotpRecoveryCodes(codes=codes)
async def regenerate_recovery_codes(self, account_uuid: str, code: str) -> TotpRecoveryCodes:
"""Replace all recovery codes, requiring a valid live TOTP code first."""
credential = await self.get_credential(account_uuid)
if credential is None or credential.confirmed_at is None:
raise TotpNotEnrolledError('TOTP is not enabled for this Account')
if not await self.verify_code(account_uuid, code, allow_recovery=True):
raise TotpInvalidCodeError('Invalid TOTP code')
codes = _generate_recovery_codes()
async with self._session_factory()() as session:
async with session.begin():
await session.execute(
sqlalchemy.delete(totp_entity.TotpRecoveryCode).where(
totp_entity.TotpRecoveryCode.account_uuid == account_uuid
)
)
self._store_recovery_codes(session, account_uuid, credential.uuid, codes)
return TotpRecoveryCodes(codes=codes)
def _store_recovery_codes(
self,
session: AsyncSession,
account_uuid: str,
credential_uuid: str,
codes: list[str],
) -> None:
"""Persist recovery codes as salted PBKDF2-HMAC-SHA256 digests only."""
for index, code_value in enumerate(codes):
salt = secrets.token_hex(_PBKDF2_SALT_BYTES)
session.add(
totp_entity.TotpRecoveryCode(
uuid=str(uuid_lib.uuid4()),
account_uuid=account_uuid,
code_id=f'{credential_uuid[:8]}-{index:02d}',
salt=salt,
digest=_pbkdf2_digest(code_value, salt),
algorithm=_RECOVERY_CODE_ALGORITHM,
iterations=_PBKDF2_ITERATIONS,
)
)
async def disable(self, account_uuid: str, *, code: str | None = None) -> bool:
"""Disable TOTP. A live TOTP code or a recovery code is required."""
credential = await self.get_credential(account_uuid)
if credential is None or credential.confirmed_at is None:
raise TotpNotEnrolledError('TOTP is not enabled for this Account')
if not code:
raise TotpInvalidCodeError('A TOTP or recovery code is required to disable TOTP')
if not await self.verify_code(account_uuid, code, allow_recovery=True):
raise TotpInvalidCodeError('Invalid TOTP code')
now = datetime.datetime.now(datetime.timezone.utc)
async with self._session_factory()() as session:
async with session.begin():
await session.execute(
sqlalchemy.update(totp_entity.TotpCredential)
.where(totp_entity.TotpCredential.account_uuid == account_uuid)
.values(disabled_at=now)
)
await session.execute(
sqlalchemy.delete(totp_entity.TotpRecoveryCode).where(
totp_entity.TotpRecoveryCode.account_uuid == account_uuid
)
)
return True
# ------------------------------------------------------------------
# owner/admin oversight
# ------------------------------------------------------------------
async def list_account_states(self) -> list[dict[str, typing.Any]]:
"""Second-factor state for every Account, for owner/admin oversight.
Oversight is instance-wide by design: an owner/admin may manage the
second factor of any Account.
Only state is returned: no secret and no recovery-code material.
"""
credential = totp_entity.TotpCredential
unused_codes = (
sqlalchemy.select(
totp_entity.TotpRecoveryCode.account_uuid.label('account_uuid'),
sqlalchemy.func.count().label('unused'),
)
.where(totp_entity.TotpRecoveryCode.used_at.is_(None))
.group_by(totp_entity.TotpRecoveryCode.account_uuid)
.subquery()
)
statement = (
sqlalchemy.select(
user.User.uuid,
user.User.user,
user.User.status,
credential.confirmed_at,
credential.last_used_at,
sqlalchemy.func.coalesce(unused_codes.c.unused, 0),
)
.outerjoin(
credential,
sqlalchemy.and_(
credential.account_uuid == user.User.uuid,
credential.disabled_at.is_(None),
),
)
.outerjoin(unused_codes, unused_codes.c.account_uuid == user.User.uuid)
.order_by(user.User.user)
)
async with self._session_factory()() as session:
rows = (await session.execute(statement)).all()
return [
{
'account_uuid': row[0],
'user': row[1],
'status': row[2],
'enabled': row[3] is not None,
'last_used_at': row[4].isoformat() if row[4] else None,
'recovery_codes_remaining': int(row[5] or 0),
}
for row in rows
]
async def revoke_for_account(self, account_uuid: str) -> bool:
"""Owner/admin override: retire an Account's factor without its code.
Used when the operator has lost the authenticator and every recovery
code. The credential is soft-disabled so the action stays auditable and
outstanding recovery codes are destroyed.
"""
now = datetime.datetime.now(datetime.timezone.utc)
async with self._session_factory()() as session:
async with session.begin():
result = await session.execute(
sqlalchemy.update(totp_entity.TotpCredential)
.where(
totp_entity.TotpCredential.account_uuid == account_uuid,
totp_entity.TotpCredential.disabled_at.is_(None),
)
.values(disabled_at=now)
)
await session.execute(
sqlalchemy.delete(totp_entity.TotpRecoveryCode).where(
totp_entity.TotpRecoveryCode.account_uuid == account_uuid
)
)
return bool(result.rowcount)
# ------------------------------------------------------------------
# verification
# ------------------------------------------------------------------
def _decrypt_secret(self, ciphertext: str, key_version: int) -> bytes:
try:
return self._fernet(key_version).decrypt(ciphertext.encode('ascii'))
except InvalidToken as exc:
raise TotpError('Stored TOTP secret cannot be decrypted with the instance key') from exc
def _verify_counter(self, credential: totp_entity.TotpCredential, code: str) -> int:
"""Constant-time TOTP verification with a +/- one step drift window."""
normalized = _normalize_code(code)
if credential.algorithm.upper() != 'SHA1':
raise TotpError('Only SHA1 TOTP is supported')
if len(normalized) != credential.digits:
raise TotpInvalidCodeError('Invalid TOTP code')
# The stored plaintext is the base32 secret; HMAC needs the raw key.
encoded_secret = self._decrypt_secret(credential.secret_ciphertext, credential.key_version)
secret_bytes = _decode_secret(encoded_secret.decode('ascii'))
current = _totp_counter(credential.period)
for candidate in (current, current - 1, current + 1):
if credential.last_used_counter is not None and candidate <= credential.last_used_counter:
# Reject replays inside an already-consumed window.
continue
if hmac.compare_digest(_hotp(secret_bytes, candidate, credential.digits), normalized):
return candidate
raise TotpInvalidCodeError('Invalid TOTP code')
async def verify_code(
self,
account_uuid: str,
code: str,
*,
allow_recovery: bool = False,
consume_counter: bool = True,
) -> bool:
"""Verify a TOTP code, optionally falling back to a recovery code."""
credential = await self.get_credential(account_uuid)
if credential is None or credential.confirmed_at is None:
raise TotpNotEnrolledError('TOTP is not enabled for this Account')
try:
counter = self._verify_counter(credential, code)
except TotpInvalidCodeError:
if not allow_recovery:
raise
if await self.use_recovery_code(account_uuid, code):
return True
raise
if consume_counter:
now = datetime.datetime.now(datetime.timezone.utc)
async with self._session_factory()() as session:
async with session.begin():
await session.execute(
sqlalchemy.update(totp_entity.TotpCredential)
.where(
totp_entity.TotpCredential.uuid == credential.uuid,
sqlalchemy.or_(
totp_entity.TotpCredential.last_used_counter.is_(None),
totp_entity.TotpCredential.last_used_counter < counter,
),
)
.values(last_used_counter=counter, last_used_at=now)
)
return True
async def use_recovery_code(self, account_uuid: str, code: str) -> bool:
"""Consume one unused recovery code. The code is never logged."""
normalized = _normalize_code(code)
if len(normalized) < 16:
return False
async with self._session_factory()() as session:
rows = list(
await session.scalars(
sqlalchemy.select(totp_entity.TotpRecoveryCode).where(
totp_entity.TotpRecoveryCode.account_uuid == account_uuid,
totp_entity.TotpRecoveryCode.used_at.is_(None),
)
)
)
# Compare every stored digest in constant time and only remember
# whether a match happened: an early `break` leaks, through response
# timing, how many codes were probed before the match was found.
matched_id: int | None = None
for row in rows:
if hmac.compare_digest(_pbkdf2_digest(normalized, row.salt, row.iterations), row.digest):
matched_id = row.id
if matched_id is None:
return False
# Single-use: the guard on used_at keeps concurrent spends from
# both succeeding.
result = await session.execute(
sqlalchemy.update(totp_entity.TotpRecoveryCode)
.where(
totp_entity.TotpRecoveryCode.id == matched_id,
totp_entity.TotpRecoveryCode.used_at.is_(None),
)
.values(used_at=datetime.datetime.now(datetime.timezone.utc))
)
await session.commit()
return bool(result.rowcount)
async def count_unused_recovery_codes(self, account_uuid: str) -> int:
statement = sqlalchemy.select(sqlalchemy.func.count()).select_from(totp_entity.TotpRecoveryCode).where(
totp_entity.TotpRecoveryCode.account_uuid == account_uuid,
totp_entity.TotpRecoveryCode.used_at.is_(None),
)
async with self._session_factory()() as session:
return int(await session.scalar(statement) or 0)
async def get_account(self, account_uuid: str) -> user.User | None:
statement = sqlalchemy.select(user.User).where(user.User.uuid == account_uuid)
async with self._session_factory()() as session:
return await session.scalar(statement)
__all__ = [
'TotpAlreadyEnrolledError',
'TotpChallengeData',
'TotpChallengeError',
'TotpEnrollment',
'TotpError',
'TotpInvalidCodeError',
'TotpNotEnrolledError',
'TotpRecoveryCodes',
'TotpRequiredError',
'TotpService',
'build_totp_uri',
'render_totp_qr_data_url',
]
+113 -1
View File
@@ -26,6 +26,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from ....entity.persistence import user
from ....entity.persistence import passkey
from . import totp as totp_service_module
from ....entity.persistence.workspace import MembershipRole, MembershipStatus, WorkspaceMembership
from ....utils import constants
from ....entity.errors import account as account_errors
@@ -413,7 +414,13 @@ class UserService:
f'space:{space_account_uuid}',
)
async def authenticate(self, user_email: str, password: str) -> str | None:
async def authenticate(self, user_email: str, password: str, totp_code: str | None = None) -> str | None:
"""Verify primary credentials, then any enrolled second factor.
When TOTP is enrolled but no code was supplied, ``TotpRequiredError`` is
raised so the caller can issue a challenge instead of a session token.
"""
user_obj = await self.get_user_by_email(user_email)
if user_obj is None:
raise ValueError('用户不存在')
@@ -425,6 +432,14 @@ class UserService:
await self._verify_password(user_obj.password, password)
totp_service = getattr(self.ap, 'totp_service', None)
if totp_service is not None and await totp_service.is_enrolled(user_obj.uuid):
if not totp_code:
raise totp_service_module.TotpRequiredError('A second factor is required')
# Recovery codes are accepted here as well, so a lost authenticator
# does not lock an Account out of its own instance.
await totp_service.verify_code(user_obj.uuid, totp_code, allow_recovery=True)
return await self.generate_jwt_token(user_obj)
async def generate_jwt_token(
@@ -534,6 +549,103 @@ class UserService:
if isinstance(status, str) and status != user.AccountStatus.ACTIVE.value:
raise AccountDisabledError('Account is disabled')
async def issue_totp_login_challenge(self, user_email: str) -> str | None:
"""Issue a login second-factor challenge for a TOTP-enrolled Account.
Returns ``None`` when the Account has no active second factor, so the
caller can proceed with the primary factors alone.
"""
totp_service = getattr(self.ap, 'totp_service', None)
if totp_service is None:
return None
user_obj = await self.get_user_by_email(user_email)
if user_obj is None:
return None
if not await totp_service.is_enrolled(user_obj.uuid):
return None
return await totp_service.issue_login_challenge(user_obj.uuid, user_obj.user)
async def issue_uniform_totp_login_challenge(self, user_email: str) -> str:
"""Issue a challenge token even when the Account has no second factor.
The login endpoints are unauthenticated. Returning a distinguishable
error for "no second factor enrolled" would turn them into an Account
enumeration oracle, so the caller always receives a token. Supplying a
code for a non-enrolled or unknown Account simply fails verification.
"""
totp_service = getattr(self.ap, 'totp_service', None)
if totp_service is None:
raise ValueError('TOTP service is unavailable')
user_obj = await self.get_user_by_email(user_email)
if user_obj is None or not await totp_service.is_enrolled(user_obj.uuid):
return await totp_service.issue_login_challenge('', '')
return await totp_service.issue_login_challenge(user_obj.uuid, user_obj.user)
async def has_totp_enrolled(self, user_email: str) -> bool:
"""Whether the Account behind this email has an active second factor."""
totp_service = getattr(self.ap, 'totp_service', None)
if totp_service is None:
return False
user_obj = await self.get_user_by_email(user_email)
if user_obj is None:
return False
return await totp_service.is_enrolled(user_obj.uuid)
async def verify_totp_second_factor(
self,
user_email: str,
code: str,
*,
allow_recovery: bool = True,
challenge_token: str | None = None,
) -> bool:
"""Verify a TOTP or recovery code for the Account behind this email.
When ``challenge_token`` is supplied it must be the token issued for a
successful primary-factor check on this exact Account. This stops the
code-only path from minting a session without ever proving the password,
and it applies the per-challenge attempt cap.
"""
totp_service = getattr(self.ap, 'totp_service', None)
if totp_service is None:
return False
user_obj = await self.get_user_by_email(user_email)
if user_obj is None:
return False
challenge = None
if challenge_token:
try:
challenge = totp_service.consume_login_challenge(challenge_token)
except totp_service_module.TotpChallengeError:
return False
# A challenge is bound to one Account: it cannot be redeemed for
# another, nor for an Account that never had a second factor.
if challenge.account_uuid != user_obj.uuid:
totp_service.record_failed_attempt(challenge_token)
return False
try:
verified = await totp_service.verify_code(
user_obj.uuid, code, allow_recovery=allow_recovery
)
except totp_service_module.TotpError:
# Covers "not enrolled" and "invalid code" alike. Both are simply a
# failed verification for this caller; neither should surface as a
# server error or disclose whether the Account has a second factor.
verified = False
if not verified and challenge_token:
totp_service.record_failed_attempt(challenge_token)
return False
if verified and challenge_token:
totp_service.complete_login_challenge(challenge_token)
return verified
async def reset_password(self, user_email: str, new_password: str) -> None:
hashed_password = await self._hash_password(new_password)
normalized_email = normalize_email(user_email)
+2
View File
@@ -24,6 +24,7 @@ from ..persistence import mgr as persistencemgr
from ..api.http.controller import main as http_controller
from ..api.http.service import user as user_service
from ..api.http.service import space as space_service
from ..api.http.service import totp as totp_service
from ..api.http.service import model as model_service
from ..api.http.service import provider as provider_service
from ..api.http.service import pipeline as pipeline_service
@@ -160,6 +161,7 @@ class Application:
# ========= HTTP Services =========
user_service: user_service.UserService = None
totp_service: totp_service.TotpService = None
space_service: space_service.SpaceService = None
+3
View File
@@ -18,6 +18,7 @@ from ...persistence import mgr as persistencemgr
from ...api.http.controller import main as http_controller
from ...api.http.service import user as user_service
from ...api.http.service import space as space_service
from ...api.http.service import totp as totp_service
from ...api.http.service import model as model_service
from ...api.http.service import provider as provider_service
from ...api.http.service import pipeline as pipeline_service
@@ -198,6 +199,8 @@ class BuildAppStage(stage.BootingStage):
user_service_inst = user_service.UserService(ap)
ap.user_service = user_service_inst
ap.totp_service = totp_service.TotpService(ap)
async def resolve_singleton_execution_context() -> ExecutionContext:
if workspace_policy.multi_workspace_enabled:
raise WorkspaceRequiredError('Cloud runtime work requires an explicit Workspace context')
@@ -0,0 +1,78 @@
from __future__ import annotations
import uuid as uuid_lib
import sqlalchemy
from .base import Base
class TotpCredential(Base):
"""Per-Account TOTP enrolment.
``secret_ciphertext`` stores the Fernet token produced by encrypting the
base32 TOTP shared secret with a key derived from the instance JWT secret
(HKDF-SHA256). The plaintext secret is never written to disk and never
returned by read endpoints after enrolment completes.
"""
__tablename__ = 'totp_credentials'
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
uuid = sqlalchemy.Column(
sqlalchemy.String(36),
nullable=False,
default=lambda: str(uuid_lib.uuid4()),
)
account_uuid = sqlalchemy.Column(
sqlalchemy.String(36),
sqlalchemy.ForeignKey('users.uuid', ondelete='CASCADE'),
nullable=False,
)
secret_ciphertext = sqlalchemy.Column(sqlalchemy.Text, nullable=False)
# Key derivation epoch: allows rotating the wrapping key without losing the secret.
key_version = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, server_default='1')
algorithm = sqlalchemy.Column(sqlalchemy.String(16), nullable=False, server_default='SHA1')
digits = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, server_default='6')
period = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, server_default='30')
confirmed_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
last_used_counter = sqlalchemy.Column(sqlalchemy.BigInteger, nullable=True)
disabled_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
last_used_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
__table_args__ = (
sqlalchemy.Index('uq_totp_credentials_uuid', 'uuid', unique=True),
sqlalchemy.Index('ix_totp_credentials_account', 'account_uuid'),
)
class TotpRecoveryCode(Base):
"""Single-use TOTP recovery code stored only as a salted PBKDF2 digest."""
__tablename__ = 'totp_recovery_codes'
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
uuid = sqlalchemy.Column(
sqlalchemy.String(36),
nullable=False,
default=lambda: str(uuid_lib.uuid4()),
)
account_uuid = sqlalchemy.Column(
sqlalchemy.String(36),
sqlalchemy.ForeignKey('users.uuid', ondelete='CASCADE'),
nullable=False,
)
code_id = sqlalchemy.Column(sqlalchemy.String(16), nullable=False)
salt = sqlalchemy.Column(sqlalchemy.String(64), nullable=False)
digest = sqlalchemy.Column(sqlalchemy.String(128), nullable=False)
algorithm = sqlalchemy.Column(sqlalchemy.String(32), nullable=False, server_default='pbkdf2_hmac_sha256')
iterations = sqlalchemy.Column(sqlalchemy.Integer, nullable=False)
used_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
__table_args__ = (
sqlalchemy.Index('uq_totp_recovery_codes_uuid', 'uuid', unique=True),
sqlalchemy.Index('uq_totp_recovery_codes_code_id', 'code_id', unique=True),
sqlalchemy.Index('ix_totp_recovery_codes_account', 'account_uuid'),
)
@@ -0,0 +1,138 @@
"""add TOTP credentials and recovery codes
Revision ID: 0025_totp_credentials
Revises: 0024_passkey_credentials
Create Date: 2026-09-22
The TOTP shared secret is stored only as a Fernet token keyed off the instance
JWT secret (HKDF-SHA256), and recovery codes are stored only as salted
PBKDF2-HMAC-SHA256 digests. No plaintext second-factor material is persisted.
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = '0025_totp_credentials'
down_revision = '0024_passkey_credentials'
branch_labels = None
depends_on = None
_CREDENTIALS_TABLE = 'totp_credentials'
_RECOVERY_CODES_TABLE = 'totp_recovery_codes'
_LEGACY_CREDENTIALS_TABLE = 'totp_credentials_legacy_pre_0025'
# Columns this migration guarantees. A pre-existing table missing any of them is
# an incompatible abandoned shape and must not be silently reused.
_REQUIRED_CREDENTIAL_COLUMNS = frozenset(
{'uuid', 'account_uuid', 'secret_ciphertext', 'key_version', 'algorithm', 'digits', 'period'}
)
def _retire_abandoned_credentials_table() -> None:
"""Move an incompatible `totp_credentials` aside without losing its data.
The table is retained under a clearly labelled name for forensic recovery,
but its indexes are dropped because they occupy the very names the supported
schema needs (``uq_totp_credentials_uuid`` in particular).
"""
inspector = sa.inspect(op.get_bind())
existing_tables = set(inspector.get_table_names())
if _LEGACY_CREDENTIALS_TABLE in existing_tables:
op.drop_table(_LEGACY_CREDENTIALS_TABLE)
op.rename_table(_CREDENTIALS_TABLE, _LEGACY_CREDENTIALS_TABLE)
for index in sa.inspect(op.get_bind()).get_indexes(_LEGACY_CREDENTIALS_TABLE):
name = index.get('name')
if name:
op.drop_index(name, table_name=_LEGACY_CREDENTIALS_TABLE)
def _create_credentials_table() -> None:
op.create_table(
_CREDENTIALS_TABLE,
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('uuid', sa.String(36), nullable=False),
sa.Column(
'account_uuid',
sa.String(36),
sa.ForeignKey('users.uuid', ondelete='CASCADE'),
nullable=False,
),
sa.Column('secret_ciphertext', sa.Text(), nullable=False),
sa.Column('key_version', sa.Integer(), nullable=False, server_default='1'),
sa.Column('algorithm', sa.String(16), nullable=False, server_default='SHA1'),
sa.Column('digits', sa.Integer(), nullable=False, server_default='6'),
sa.Column('period', sa.Integer(), nullable=False, server_default='30'),
sa.Column('confirmed_at', sa.DateTime(), nullable=True),
sa.Column('last_used_counter', sa.BigInteger(), nullable=True),
sa.Column('disabled_at', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column('last_used_at', sa.DateTime(), nullable=True),
)
op.create_index('uq_totp_credentials_uuid', _CREDENTIALS_TABLE, ['uuid'], unique=True)
op.create_index('ix_totp_credentials_account', _CREDENTIALS_TABLE, ['account_uuid'], unique=False)
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
if _CREDENTIALS_TABLE not in existing_tables:
_create_credentials_table()
else:
columns = {column['name'] for column in inspector.get_columns(_CREDENTIALS_TABLE)}
if not _REQUIRED_CREDENTIAL_COLUMNS.issubset(columns):
# An abandoned table from an unrelated feature occupies the name and
# cannot store a Fernet-wrapped secret. Preserve it under a clearly
# labelled name (no data loss) and install the supported schema.
_retire_abandoned_credentials_table()
_create_credentials_table()
if _RECOVERY_CODES_TABLE not in existing_tables:
op.create_table(
_RECOVERY_CODES_TABLE,
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('uuid', sa.String(36), nullable=False),
sa.Column(
'account_uuid',
sa.String(36),
sa.ForeignKey('users.uuid', ondelete='CASCADE'),
nullable=False,
),
sa.Column('code_id', sa.String(16), nullable=False),
sa.Column('salt', sa.String(64), nullable=False),
sa.Column('digest', sa.String(128), nullable=False),
sa.Column(
'algorithm',
sa.String(32),
nullable=False,
server_default='pbkdf2_hmac_sha256',
),
sa.Column('iterations', sa.Integer(), nullable=False),
sa.Column('used_at', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
)
op.create_index('uq_totp_recovery_codes_uuid', _RECOVERY_CODES_TABLE, ['uuid'], unique=True)
op.create_index('uq_totp_recovery_codes_code_id', _RECOVERY_CODES_TABLE, ['code_id'], unique=True)
op.create_index('ix_totp_recovery_codes_account', _RECOVERY_CODES_TABLE, ['account_uuid'], unique=False)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
existing_tables = set(inspector.get_table_names())
if _RECOVERY_CODES_TABLE in existing_tables:
op.drop_index('ix_totp_recovery_codes_account', table_name=_RECOVERY_CODES_TABLE)
op.drop_index('uq_totp_recovery_codes_code_id', table_name=_RECOVERY_CODES_TABLE)
op.drop_index('uq_totp_recovery_codes_uuid', table_name=_RECOVERY_CODES_TABLE)
op.drop_table(_RECOVERY_CODES_TABLE)
if _CREDENTIALS_TABLE in existing_tables:
op.drop_index('ix_totp_credentials_account', table_name=_CREDENTIALS_TABLE)
op.drop_index('uq_totp_credentials_uuid', table_name=_CREDENTIALS_TABLE)
op.drop_table(_CREDENTIALS_TABLE)
@@ -0,0 +1,21 @@
"""merge the TOTP credential branch with the RAG document identity branch
Revision ID: 0026_merge_totp_and_rag_identity
Revises: 0025_totp_credentials, 0025_rag_document_identity
Create Date: 2026-09-22
"""
from __future__ import annotations
revision = '0026_merge_totp_and_rag_identity'
down_revision = ('0025_totp_credentials', '0025_rag_document_identity')
branch_labels = None
depends_on = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass