From d069ff1121978f2202e5a418549143f4dce74b34 Mon Sep 17 00:00:00 2001 From: benya Date: Sun, 8 Mar 2026 19:07:20 +0300 Subject: [PATCH] auth(2fa): block setup after enable to avoid secret reissue --- app/auth/service.py | 2 ++ docs/api-reference.md | 2 ++ docs/core-checklist-status.md | 2 +- tests/test_auth_flow.py | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 38 insertions(+), 1 deletion(-) diff --git a/app/auth/service.py b/app/auth/service.py index 901ee5b..0b461f3 100644 --- a/app/auth/service.py +++ b/app/auth/service.py @@ -260,6 +260,8 @@ def get_access_session_info(token: str) -> tuple[str, datetime] | None: async def setup_twofa(db: AsyncSession, user: User) -> tuple[str, str]: if user.twofa_enabled and user.twofa_secret: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="2FA is already enabled") + if user.twofa_secret: secret = user.twofa_secret else: secret = generate_totp_secret() diff --git a/docs/api-reference.md b/docs/api-reference.md index dd377e5..90d2cc1 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -553,6 +553,8 @@ Response: } ``` +If 2FA is already enabled for the account, returns `400` (`"2FA is already enabled"`). + ### POST `/api/v1/auth/2fa/enable` Auth required. diff --git a/docs/core-checklist-status.md b/docs/core-checklist-status.md index 31ad6cd..5e08dc7 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; 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; UX/TOTP recovery flow 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 2bb0e8e..f88c6f6 100644 --- a/tests/test_auth_flow.py +++ b/tests/test_auth_flow.py @@ -1,6 +1,7 @@ from sqlalchemy import select from app.auth.models import EmailVerificationToken +from app.utils.totp import _totp_code async def test_register_verify_login_and_me(client, db_session): @@ -115,3 +116,35 @@ async def test_revoke_all_sessions_invalidates_access_and_refresh(client, db_ses refresh_response = await client.post("/api/v1/auth/refresh", json={"refresh_token": refresh_token}) assert refresh_response.status_code == 401 + + +async def test_twofa_setup_is_blocked_when_already_enabled(client, db_session): + payload = { + "email": "dana@example.com", + "name": "Dana", + "username": "dana", + "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"] + code = _totp_code(secret) + + enable_response = await client.post("/api/v1/auth/2fa/enable", headers=headers, json={"code": code}) + assert enable_response.status_code == 200 + + setup_again_response = await client.post("/api/v1/auth/2fa/setup", headers=headers) + assert setup_again_response.status_code == 400