diff --git a/alembic/versions/0025_user_twofa_recovery_codes.py b/alembic/versions/0025_user_twofa_recovery_codes.py new file mode 100644 index 0000000..ea567b9 --- /dev/null +++ b/alembic/versions/0025_user_twofa_recovery_codes.py @@ -0,0 +1,26 @@ +"""add recovery codes storage for 2fa + +Revision ID: 0025_user_twofa_recovery_codes +Revises: 0024_chat_bans +Create Date: 2026-03-09 16:55:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "0025_user_twofa_recovery_codes" +down_revision: Union[str, Sequence[str], None] = "0024_chat_bans" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("users", sa.Column("twofa_recovery_codes_hashes", sa.String(length=4096), nullable=True)) + + +def downgrade() -> None: + op.drop_column("users", "twofa_recovery_codes_hashes") + diff --git a/app/auth/router.py b/app/auth/router.py index f201db3..356114c 100644 --- a/app/auth/router.py +++ b/app/auth/router.py @@ -16,6 +16,8 @@ from app.auth.schemas import ( TokenResponse, SessionRead, TwoFactorCodeRequest, + TwoFactorRecoveryCodesRead, + TwoFactorRecoveryStatusRead, TwoFactorSetupRead, VerifyEmailRequest, ) @@ -37,6 +39,8 @@ from app.auth.service import ( resend_verification_email, reset_password, setup_twofa, + regenerate_twofa_recovery_codes, + get_twofa_recovery_codes_remaining, verify_email, oauth2_scheme, ) @@ -224,3 +228,20 @@ async def disable_2fa( ) -> MessageResponse: await disable_twofa(db, current_user, code=payload.code) return MessageResponse(message="2FA disabled") + + +@router.post("/2fa/recovery-codes/regenerate", response_model=TwoFactorRecoveryCodesRead) +async def regenerate_2fa_recovery_codes( + payload: TwoFactorCodeRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> TwoFactorRecoveryCodesRead: + codes = await regenerate_twofa_recovery_codes(db, current_user, code=payload.code) + return TwoFactorRecoveryCodesRead(codes=codes) + + +@router.get("/2fa/recovery-codes/status", response_model=TwoFactorRecoveryStatusRead) +async def get_2fa_recovery_codes_status( + current_user: User = Depends(get_current_user), +) -> TwoFactorRecoveryStatusRead: + return TwoFactorRecoveryStatusRead(remaining_codes=get_twofa_recovery_codes_remaining(current_user)) diff --git a/app/auth/schemas.py b/app/auth/schemas.py index 86b2864..37f5ded 100644 --- a/app/auth/schemas.py +++ b/app/auth/schemas.py @@ -15,6 +15,7 @@ class LoginRequest(BaseModel): email: EmailStr password: str = Field(min_length=8, max_length=128) otp_code: str | None = Field(default=None, min_length=6, max_length=8) + recovery_code: str | None = Field(default=None, min_length=6, max_length=32) class RefreshTokenRequest(BaseModel): @@ -86,6 +87,14 @@ class TwoFactorCodeRequest(BaseModel): code: str = Field(min_length=6, max_length=8) +class TwoFactorRecoveryCodesRead(BaseModel): + codes: list[str] + + +class TwoFactorRecoveryStatusRead(BaseModel): + remaining_codes: int + + class EmailStatusResponse(BaseModel): email: EmailStr registered: bool diff --git a/app/auth/service.py b/app/auth/service.py index 0b461f3..b50d631 100644 --- a/app/auth/service.py +++ b/app/auth/service.py @@ -1,4 +1,6 @@ from datetime import datetime, timedelta, timezone +import json +import secrets from uuid import uuid4 from fastapi import Depends, HTTPException, status @@ -135,10 +137,15 @@ async def login_user( if not user.email_verified: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Email not verified") if user.twofa_enabled: - if not payload.otp_code: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="2FA code required") - if not user.twofa_secret or not verify_totp_code(user.twofa_secret, payload.otp_code): - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid 2FA code") + if payload.recovery_code: + recovery_used = await _consume_twofa_recovery_code(db, user=user, recovery_code=payload.recovery_code) + if not recovery_used: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid recovery code") + else: + if not payload.otp_code: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="2FA code required") + if not user.twofa_secret or not verify_totp_code(user.twofa_secret, payload.otp_code): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid 2FA code") refresh_jti = str(uuid4()) refresh_token = create_refresh_token(str(user.id), jti=refresh_jti) @@ -286,6 +293,7 @@ async def disable_twofa(db: AsyncSession, user: User, *, code: str) -> None: if not user.twofa_enabled or not user.twofa_secret: user.twofa_enabled = False user.twofa_secret = None + user.twofa_recovery_codes_hashes = None await db.commit() await db.refresh(user) return @@ -293,10 +301,78 @@ async def disable_twofa(db: AsyncSession, user: User, *, code: str) -> None: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid 2FA code") user.twofa_enabled = False user.twofa_secret = None + user.twofa_recovery_codes_hashes = None await db.commit() await db.refresh(user) +def _normalize_recovery_code(code: str) -> str: + return "".join(ch for ch in code.upper() if ch.isalnum()) + + +def _generate_recovery_codes(count: int = 8) -> list[str]: + alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + codes: list[str] = [] + for _ in range(max(1, count)): + raw = "".join(secrets.choice(alphabet) for _ in range(10)) + codes.append(f"{raw[:5]}-{raw[5:]}") + return codes + + +def _load_twofa_recovery_hashes(user: User) -> list[str]: + raw = user.twofa_recovery_codes_hashes + if not raw: + return [] + try: + parsed = json.loads(raw) + except Exception: + return [] + if not isinstance(parsed, list): + return [] + hashes: list[str] = [] + for item in parsed: + if isinstance(item, str) and item: + hashes.append(item) + return hashes + + +async def regenerate_twofa_recovery_codes(db: AsyncSession, user: User, *, code: str) -> list[str]: + if not user.twofa_enabled or not user.twofa_secret: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="2FA is not enabled") + if not verify_totp_code(user.twofa_secret, code): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid 2FA code") + codes = _generate_recovery_codes() + normalized = [_normalize_recovery_code(item) for item in codes] + user.twofa_recovery_codes_hashes = json.dumps([hash_password(item) for item in normalized], ensure_ascii=True) + await db.commit() + await db.refresh(user) + return codes + + +def get_twofa_recovery_codes_remaining(user: User) -> int: + return len(_load_twofa_recovery_hashes(user)) + + +async def _consume_twofa_recovery_code(db: AsyncSession, *, user: User, recovery_code: str) -> bool: + prepared = _normalize_recovery_code(recovery_code) + if not prepared: + return False + hashes = _load_twofa_recovery_hashes(user) + if not hashes: + return False + match_index = -1 + for idx, code_hash in enumerate(hashes): + if verify_password(prepared, code_hash): + match_index = idx + break + if match_index < 0: + return False + del hashes[match_index] + user.twofa_recovery_codes_hashes = json.dumps(hashes, ensure_ascii=True) if hashes else None + await db.commit() + return True + + async def request_password_reset( db: AsyncSession, payload: RequestPasswordResetRequest, diff --git a/app/users/models.py b/app/users/models.py index 2f353c6..1b37d68 100644 --- a/app/users/models.py +++ b/app/users/models.py @@ -30,6 +30,7 @@ class User(Base): privacy_group_invites: Mapped[str] = mapped_column(String(16), nullable=False, default="everyone", server_default="everyone") twofa_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, server_default="false") twofa_secret: Mapped[str | None] = mapped_column(String(64), nullable=True) + twofa_recovery_codes_hashes: Mapped[str | None] = mapped_column(String(4096), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), diff --git a/docs/api-reference.md b/docs/api-reference.md index 90d2cc1..66b83e0 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -93,11 +93,13 @@ For `/health/ready` failure: { "email": "user@example.com", "password": "strongpassword", - "otp_code": "123456" + "otp_code": "123456", + "recovery_code": "ABCDE-12345" } ``` -`otp_code` is optional and used only when 2FA is enabled. +`otp_code` is optional and used when 2FA is enabled. +`recovery_code` is optional one-time fallback when 2FA is enabled. ### TokenResponse @@ -577,6 +579,36 @@ Body: Response: `200` + `MessageResponse` +### POST `/api/v1/auth/2fa/recovery-codes/regenerate` + +Auth required. +Body: + +```json +{ "code": "123456" } +``` + +Response: + +```json +{ + "codes": ["ABCDE-12345", "FGHIJ-67890"] +} +``` + +Codes are one-time and shown only at generation time. + +### GET `/api/v1/auth/2fa/recovery-codes/status` + +Auth required. +Response: + +```json +{ + "remaining_codes": 8 +} +``` + ## 6. Users endpoints ### GET `/api/v1/users/me` diff --git a/docs/core-checklist-status.md b/docs/core-checklist-status.md index 5e08dc7..68cbdd1 100644 --- a/docs/core-checklist-status.md +++ b/docs/core-checklist-status.md @@ -38,7 +38,7 @@ Legend: 29. Archive - `DONE` 30. Blacklist - `DONE` 31. Privacy - `PARTIAL` (avatar/last-seen/group-invites + PM policy `everyone|contacts|nobody`; policy behavior covered by integration tests, remaining UX/matrix hardening) -32. Security - `PARTIAL` (sessions + revoke + 2FA base + access-session visibility; revoke-all now force-disconnects active realtime sessions; 2FA setup now blocked after enable to prevent secret re-issuance; UX/TOTP recovery flow ongoing) +32. Security - `PARTIAL` (sessions + revoke + 2FA base + access-session visibility; revoke-all now force-disconnects active realtime sessions; 2FA setup now blocked after enable to prevent secret re-issuance; one-time recovery codes added; UX polish ongoing) 33. Realtime Events - `DONE` (connect/disconnect/send/receive/typing/read/delivered/online/offline + chat/message updates) 34. Sync - `PARTIAL` (cross-device via backend state + realtime; reconciliation improved for loaded chats/messages, chat-info panel now hot-refreshes on `chat_updated`) 35. Additional - `PARTIAL` (drafts/link preview partial/autoload media basic) diff --git a/tests/test_auth_flow.py b/tests/test_auth_flow.py index f88c6f6..26050ff 100644 --- a/tests/test_auth_flow.py +++ b/tests/test_auth_flow.py @@ -148,3 +148,52 @@ async def test_twofa_setup_is_blocked_when_already_enabled(client, db_session): setup_again_response = await client.post("/api/v1/auth/2fa/setup", headers=headers) assert setup_again_response.status_code == 400 + + +async def test_twofa_recovery_codes_can_login_once(client, db_session): + payload = { + "email": "erin@example.com", + "name": "Erin", + "username": "erin", + "password": "strongpass123", + } + await client.post("/api/v1/auth/register", json=payload) + token_row = await db_session.execute(select(EmailVerificationToken).order_by(EmailVerificationToken.id.desc())) + verify_token = token_row.scalar_one().token + await client.post("/api/v1/auth/verify-email", json={"token": verify_token}) + + login_response = await client.post( + "/api/v1/auth/login", + json={"email": payload["email"], "password": payload["password"]}, + ) + assert login_response.status_code == 200 + access_token = login_response.json()["access_token"] + headers = {"Authorization": f"Bearer {access_token}"} + + setup_response = await client.post("/api/v1/auth/2fa/setup", headers=headers) + assert setup_response.status_code == 200 + secret = setup_response.json()["secret"] + enable_response = await client.post("/api/v1/auth/2fa/enable", headers=headers, json={"code": _totp_code(secret)}) + assert enable_response.status_code == 200 + + regen_response = await client.post( + "/api/v1/auth/2fa/recovery-codes/regenerate", + headers=headers, + json={"code": _totp_code(secret)}, + ) + assert regen_response.status_code == 200 + codes = regen_response.json()["codes"] + assert len(codes) >= 1 + first_code = codes[0] + + login_with_recovery = await client.post( + "/api/v1/auth/login", + json={"email": payload["email"], "password": payload["password"], "recovery_code": first_code}, + ) + assert login_with_recovery.status_code == 200 + + reuse_recovery_code = await client.post( + "/api/v1/auth/login", + json={"email": payload["email"], "password": payload["password"], "recovery_code": first_code}, + ) + assert reuse_recovery_code.status_code == 401 diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts index e75fe74..0ee5d2e 100644 --- a/web/src/api/auth.ts +++ b/web/src/api/auth.ts @@ -5,8 +5,13 @@ export async function registerRequest(email: string, name: string, username: str await http.post("/auth/register", { email, name, username, password }); } -export async function loginRequest(email: string, password: string, otpCode?: string): Promise { - const { data } = await http.post("/auth/login", { email, password, otp_code: otpCode || undefined }); +export async function loginRequest(email: string, password: string, otpCode?: string, recoveryCode?: string): Promise { + const { data } = await http.post("/auth/login", { + email, + password, + otp_code: otpCode || undefined, + recovery_code: recoveryCode || undefined, + }); return data; } @@ -62,3 +67,21 @@ export async function enableTwoFactor(code: string): Promise { export async function disableTwoFactor(code: string): Promise { await http.post("/auth/2fa/disable", { code }); } + +export interface TwoFactorRecoveryStatusResponse { + remaining_codes: number; +} + +export interface TwoFactorRecoveryCodesResponse { + codes: string[]; +} + +export async function getTwoFactorRecoveryStatus(): Promise { + const { data } = await http.get("/auth/2fa/recovery-codes/status"); + return data; +} + +export async function regenerateTwoFactorRecoveryCodes(code: string): Promise { + const { data } = await http.post("/auth/2fa/recovery-codes/regenerate", { code }); + return data; +} diff --git a/web/src/components/SettingsPanel.tsx b/web/src/components/SettingsPanel.tsx index 32d456f..dfc2178 100644 --- a/web/src/components/SettingsPanel.tsx +++ b/web/src/components/SettingsPanel.tsx @@ -3,7 +3,16 @@ import { createPortal } from "react-dom"; import QRCode from "qrcode"; import { listBlockedUsers, updateMyProfile } from "../api/users"; import { requestUploadUrl, uploadToPresignedUrl } from "../api/chats"; -import { disableTwoFactor, enableTwoFactor, listSessions, revokeAllSessions, revokeSession, setupTwoFactor } from "../api/auth"; +import { + disableTwoFactor, + enableTwoFactor, + getTwoFactorRecoveryStatus, + listSessions, + regenerateTwoFactorRecoveryCodes, + revokeAllSessions, + revokeSession, + setupTwoFactor +} from "../api/auth"; import { getNotifications, type NotificationItem } from "../api/notifications"; import type { AuthSession, AuthUser } from "../chat/types"; import { useAuthStore } from "../store/authStore"; @@ -34,6 +43,8 @@ export function SettingsPanel({ open, onClose }: Props) { const [twofaUrl, setTwofaUrl] = useState(null); const [twofaQrUrl, setTwofaQrUrl] = useState(null); const [twofaError, setTwofaError] = useState(null); + const [recoveryRemaining, setRecoveryRemaining] = useState(0); + const [recoveryCodes, setRecoveryCodes] = useState([]); const [privacyLastSeen, setPrivacyLastSeen] = useState<"everyone" | "contacts" | "nobody">("everyone"); const [privacyAvatar, setPrivacyAvatar] = useState<"everyone" | "contacts" | "nobody">("everyone"); const [privacyGroupInvites, setPrivacyGroupInvites] = useState<"everyone" | "contacts">("everyone"); @@ -116,6 +127,29 @@ export function SettingsPanel({ open, onClose }: Props) { }; }, [open, page]); + useEffect(() => { + if (!open || page !== "privacy" || !me?.twofa_enabled) { + setRecoveryRemaining(0); + return; + } + let cancelled = false; + void (async () => { + try { + const status = await getTwoFactorRecoveryStatus(); + if (!cancelled) { + setRecoveryRemaining(status.remaining_codes); + } + } catch { + if (!cancelled) { + setRecoveryRemaining(0); + } + } + })(); + return () => { + cancelled = true; + }; + }, [open, page, me?.twofa_enabled]); + useEffect(() => { if (!open || page !== "notifications") { return; @@ -572,6 +606,8 @@ export function SettingsPanel({ open, onClose }: Props) { setTwofaSecret(null); setTwofaUrl(null); setTwofaQrUrl(null); + setRecoveryCodes([]); + setRecoveryRemaining(0); } catch { setTwofaError("Invalid 2FA code"); } @@ -583,6 +619,43 @@ export function SettingsPanel({ open, onClose }: Props) { )} {twofaError ?

{twofaError}

: null} + {me.twofa_enabled ? ( +
+
+

Recovery codes remaining: {recoveryRemaining}

+ +
+ {recoveryCodes.length > 0 ? ( +
+

Save these codes now. They are shown only once.

+
+ {recoveryCodes.map((item) => ( + + {item} + + ))} +
+
+ ) : null} +
+ ) : null}