auth(2fa): add one-time recovery codes with regenerate/status APIs
All checks were successful
CI / test (push) Successful in 40s

This commit is contained in:
2026-03-08 19:16:15 +03:00
parent f91a6493ff
commit fb812c9a39
10 changed files with 320 additions and 10 deletions

View File

@@ -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")

View File

@@ -16,6 +16,8 @@ from app.auth.schemas import (
TokenResponse, TokenResponse,
SessionRead, SessionRead,
TwoFactorCodeRequest, TwoFactorCodeRequest,
TwoFactorRecoveryCodesRead,
TwoFactorRecoveryStatusRead,
TwoFactorSetupRead, TwoFactorSetupRead,
VerifyEmailRequest, VerifyEmailRequest,
) )
@@ -37,6 +39,8 @@ from app.auth.service import (
resend_verification_email, resend_verification_email,
reset_password, reset_password,
setup_twofa, setup_twofa,
regenerate_twofa_recovery_codes,
get_twofa_recovery_codes_remaining,
verify_email, verify_email,
oauth2_scheme, oauth2_scheme,
) )
@@ -224,3 +228,20 @@ async def disable_2fa(
) -> MessageResponse: ) -> MessageResponse:
await disable_twofa(db, current_user, code=payload.code) await disable_twofa(db, current_user, code=payload.code)
return MessageResponse(message="2FA disabled") 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))

View File

@@ -15,6 +15,7 @@ class LoginRequest(BaseModel):
email: EmailStr email: EmailStr
password: str = Field(min_length=8, max_length=128) password: str = Field(min_length=8, max_length=128)
otp_code: str | None = Field(default=None, min_length=6, max_length=8) 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): class RefreshTokenRequest(BaseModel):
@@ -86,6 +87,14 @@ class TwoFactorCodeRequest(BaseModel):
code: str = Field(min_length=6, max_length=8) code: str = Field(min_length=6, max_length=8)
class TwoFactorRecoveryCodesRead(BaseModel):
codes: list[str]
class TwoFactorRecoveryStatusRead(BaseModel):
remaining_codes: int
class EmailStatusResponse(BaseModel): class EmailStatusResponse(BaseModel):
email: EmailStr email: EmailStr
registered: bool registered: bool

View File

@@ -1,4 +1,6 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import json
import secrets
from uuid import uuid4 from uuid import uuid4
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
@@ -135,6 +137,11 @@ async def login_user(
if not user.email_verified: if not user.email_verified:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Email not verified") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Email not verified")
if user.twofa_enabled: if user.twofa_enabled:
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: if not payload.otp_code:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="2FA code required") 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): if not user.twofa_secret or not verify_totp_code(user.twofa_secret, payload.otp_code):
@@ -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: if not user.twofa_enabled or not user.twofa_secret:
user.twofa_enabled = False user.twofa_enabled = False
user.twofa_secret = None user.twofa_secret = None
user.twofa_recovery_codes_hashes = None
await db.commit() await db.commit()
await db.refresh(user) await db.refresh(user)
return 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") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid 2FA code")
user.twofa_enabled = False user.twofa_enabled = False
user.twofa_secret = None user.twofa_secret = None
user.twofa_recovery_codes_hashes = None
await db.commit() await db.commit()
await db.refresh(user) 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( async def request_password_reset(
db: AsyncSession, db: AsyncSession,
payload: RequestPasswordResetRequest, payload: RequestPasswordResetRequest,

View File

@@ -30,6 +30,7 @@ class User(Base):
privacy_group_invites: Mapped[str] = mapped_column(String(16), nullable=False, default="everyone", server_default="everyone") 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_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_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) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True),

View File

@@ -93,11 +93,13 @@ For `/health/ready` failure:
{ {
"email": "user@example.com", "email": "user@example.com",
"password": "strongpassword", "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 ### TokenResponse
@@ -577,6 +579,36 @@ Body:
Response: `200` + `MessageResponse` 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 ## 6. Users endpoints
### GET `/api/v1/users/me` ### GET `/api/v1/users/me`

View File

@@ -38,7 +38,7 @@ Legend:
29. Archive - `DONE` 29. Archive - `DONE`
30. Blacklist - `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) 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) 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`) 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) 35. Additional - `PARTIAL` (drafts/link preview partial/autoload media basic)

View File

@@ -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) setup_again_response = await client.post("/api/v1/auth/2fa/setup", headers=headers)
assert setup_again_response.status_code == 400 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

View File

@@ -5,8 +5,13 @@ export async function registerRequest(email: string, name: string, username: str
await http.post("/auth/register", { email, name, username, password }); await http.post("/auth/register", { email, name, username, password });
} }
export async function loginRequest(email: string, password: string, otpCode?: string): Promise<TokenPair> { export async function loginRequest(email: string, password: string, otpCode?: string, recoveryCode?: string): Promise<TokenPair> {
const { data } = await http.post<TokenPair>("/auth/login", { email, password, otp_code: otpCode || undefined }); const { data } = await http.post<TokenPair>("/auth/login", {
email,
password,
otp_code: otpCode || undefined,
recovery_code: recoveryCode || undefined,
});
return data; return data;
} }
@@ -62,3 +67,21 @@ export async function enableTwoFactor(code: string): Promise<void> {
export async function disableTwoFactor(code: string): Promise<void> { export async function disableTwoFactor(code: string): Promise<void> {
await http.post("/auth/2fa/disable", { code }); await http.post("/auth/2fa/disable", { code });
} }
export interface TwoFactorRecoveryStatusResponse {
remaining_codes: number;
}
export interface TwoFactorRecoveryCodesResponse {
codes: string[];
}
export async function getTwoFactorRecoveryStatus(): Promise<TwoFactorRecoveryStatusResponse> {
const { data } = await http.get<TwoFactorRecoveryStatusResponse>("/auth/2fa/recovery-codes/status");
return data;
}
export async function regenerateTwoFactorRecoveryCodes(code: string): Promise<TwoFactorRecoveryCodesResponse> {
const { data } = await http.post<TwoFactorRecoveryCodesResponse>("/auth/2fa/recovery-codes/regenerate", { code });
return data;
}

View File

@@ -3,7 +3,16 @@ import { createPortal } from "react-dom";
import QRCode from "qrcode"; import QRCode from "qrcode";
import { listBlockedUsers, updateMyProfile } from "../api/users"; import { listBlockedUsers, updateMyProfile } from "../api/users";
import { requestUploadUrl, uploadToPresignedUrl } from "../api/chats"; 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 { getNotifications, type NotificationItem } from "../api/notifications";
import type { AuthSession, AuthUser } from "../chat/types"; import type { AuthSession, AuthUser } from "../chat/types";
import { useAuthStore } from "../store/authStore"; import { useAuthStore } from "../store/authStore";
@@ -34,6 +43,8 @@ export function SettingsPanel({ open, onClose }: Props) {
const [twofaUrl, setTwofaUrl] = useState<string | null>(null); const [twofaUrl, setTwofaUrl] = useState<string | null>(null);
const [twofaQrUrl, setTwofaQrUrl] = useState<string | null>(null); const [twofaQrUrl, setTwofaQrUrl] = useState<string | null>(null);
const [twofaError, setTwofaError] = useState<string | null>(null); const [twofaError, setTwofaError] = useState<string | null>(null);
const [recoveryRemaining, setRecoveryRemaining] = useState<number>(0);
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([]);
const [privacyLastSeen, setPrivacyLastSeen] = useState<"everyone" | "contacts" | "nobody">("everyone"); const [privacyLastSeen, setPrivacyLastSeen] = useState<"everyone" | "contacts" | "nobody">("everyone");
const [privacyAvatar, setPrivacyAvatar] = useState<"everyone" | "contacts" | "nobody">("everyone"); const [privacyAvatar, setPrivacyAvatar] = useState<"everyone" | "contacts" | "nobody">("everyone");
const [privacyGroupInvites, setPrivacyGroupInvites] = useState<"everyone" | "contacts">("everyone"); const [privacyGroupInvites, setPrivacyGroupInvites] = useState<"everyone" | "contacts">("everyone");
@@ -116,6 +127,29 @@ export function SettingsPanel({ open, onClose }: Props) {
}; };
}, [open, page]); }, [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(() => { useEffect(() => {
if (!open || page !== "notifications") { if (!open || page !== "notifications") {
return; return;
@@ -572,6 +606,8 @@ export function SettingsPanel({ open, onClose }: Props) {
setTwofaSecret(null); setTwofaSecret(null);
setTwofaUrl(null); setTwofaUrl(null);
setTwofaQrUrl(null); setTwofaQrUrl(null);
setRecoveryCodes([]);
setRecoveryRemaining(0);
} catch { } catch {
setTwofaError("Invalid 2FA code"); setTwofaError("Invalid 2FA code");
} }
@@ -583,6 +619,43 @@ export function SettingsPanel({ open, onClose }: Props) {
</div> </div>
)} )}
{twofaError ? <p className="mt-2 text-xs text-red-400">{twofaError}</p> : null} {twofaError ? <p className="mt-2 text-xs text-red-400">{twofaError}</p> : null}
{me.twofa_enabled ? (
<div className="mt-3 rounded bg-slate-900/50 px-2 py-2">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="text-xs text-slate-300">Recovery codes remaining: {recoveryRemaining}</p>
<button
className="rounded bg-slate-700 px-2 py-1 text-[11px]"
onClick={async () => {
setTwofaError(null);
try {
const result = await regenerateTwoFactorRecoveryCodes(twofaCode);
setRecoveryCodes(result.codes);
setRecoveryRemaining(result.codes.length);
setTwofaCode("");
showToast("New recovery codes generated");
} catch {
setTwofaError("Invalid 2FA code for recovery codes");
}
}}
type="button"
>
Generate recovery codes
</button>
</div>
{recoveryCodes.length > 0 ? (
<div className="rounded border border-amber-500/40 bg-amber-500/10 p-2">
<p className="mb-1 text-[11px] text-amber-200">Save these codes now. They are shown only once.</p>
<div className="grid grid-cols-2 gap-1">
{recoveryCodes.map((item) => (
<code className="rounded bg-slate-900 px-1.5 py-1 text-[11px]" key={item}>
{item}
</code>
))}
</div>
</div>
) : null}
</div>
) : null}
</section> </section>
<section className="rounded border border-slate-700/70 bg-slate-800/50 p-3"> <section className="rounded border border-slate-700/70 bg-slate-800/50 p-3">
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">