From 660fe7b37f627b7d7414d872142fde8ad883df93 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Tue, 9 Jun 2026 11:22:54 -0400 Subject: [PATCH] Security: gate sessions on verified email (opt-in) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backlog §2.10: registration issued a live session and email_verified_at was written but never read, so an unverified user had full access and there was no switch to require verification. Add REQUIRE_EMAIL_VERIFICATION (default false). When true: - resolve_session_user returns None for a user whose email_verified_at is null — the single read-side gate covering every authenticated request, incl. the session minted at registration. - login raises 403 ("email not verified") instead of issuing a useless token. Default false on purpose: self-hosts without SMTP, and accounts created before this gate existed (email_verified_at null), must not be locked out. Operators enable it once mail works and accounts are verified. Documented in .env.example. Tests: default-off keeps unverified accounts working; on → register's session won't resolve (401), login is 403, and after verify-email both work. 75 passed. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Justin Paul --- backend/app/core/config.py | 5 ++++ backend/app/services/auth_service.py | 9 +++++- backend/tests/test_auth.py | 41 ++++++++++++++++++++++++++++ deploy/.env.example | 3 ++ 4 files changed, 57 insertions(+), 1 deletion(-) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index edf232d..74ea306 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -48,6 +48,11 @@ class Settings(BaseSettings): purge_after_days: int = 30 # soft-deleted rows older than this are purged # --- Email (SMTP) --- + # When true, a user with no verified email gets no active session (login is + # refused and existing sessions stop resolving). Default false so self-hosts + # without SMTP — and accounts created before this gate existed — aren't + # locked out; operators turn it on once mail works and accounts are verified. + require_email_verification: bool = False mailer: str = Field(default="console", description="console | smtp") smtp_host: str | None = None smtp_port: int = 587 diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index 43dcc22..a029906 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -113,6 +113,8 @@ async def login( user = await _local_provider.authenticate(session, identifier=email, secret=password) if user is None: return None + if get_settings().require_email_verification and user.email_verified_at is None: + raise Forbidden("email not verified — check your inbox for the verification link") raw_token, record = _issue_session(session, user) record_audit( session, action="login", entity_type="User", entity_id=user.id, actor_user_id=user.id @@ -141,11 +143,16 @@ async def resolve_session_user(session: AsyncSession, *, raw_token: str) -> User ).scalar_one_or_none() if record is None or record.revoked_at is not None or record.expires_at <= _now(): return None - return ( + user = ( await session.execute( select(User).where(User.id == record.user_id, User.deleted_at.is_(None)) ) ).scalar_one_or_none() + # The single read-side enforcement: an unverified user has no active session + # when verification is required. Gates every authenticated request at once. + if user is not None and get_settings().require_email_verification and user.email_verified_at is None: + return None + return user async def verify_email(session: AsyncSession, *, raw_token: str) -> None: diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index ee09cb3..6d98f96 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -104,3 +104,44 @@ async def test_logout_revokes_session(client): assert (await client.get("/api/v1/users/me", headers=auth(token))).status_code == 200 assert (await client.post("/api/v1/auth/logout", headers=auth(token))).status_code == 204 assert (await client.get("/api/v1/users/me", headers=auth(token))).status_code == 401 + + +async def test_unverified_user_works_by_default(client): + # Default (require_email_verification off): unverified accounts work as before. + token = await register(client, "open@example.com") + assert (await client.get("/api/v1/users/me", headers=auth(token))).status_code == 200 + + +async def test_verification_gate_blocks_until_verified(client, mailer, monkeypatch): + from app.core.config import get_settings + + monkeypatch.setattr(get_settings(), "require_email_verification", True) + + reg = await client.post( + "/api/v1/auth/register", json={"email": "gate@example.com", "password": "password123"} + ) + assert reg.status_code == 201 + token = reg.json()["token"] + + # The session issued at registration does not resolve while unverified... + assert (await client.get("/api/v1/users/me", headers=auth(token))).status_code == 401 + # ...and login is refused with 403 (not 401 — credentials are valid). + blocked = await client.post( + "/api/v1/auth/login", json={"email": "gate@example.com", "password": "password123"} + ) + assert blocked.status_code == 403 + + # Verify via the emailed link. + link = mailer.verifications[-1][1] + assert ( + await client.post("/api/v1/auth/verify-email", json={"token": token_from_link(link)}) + ).status_code == 204 + + # Now login works and the session resolves. + ok = await client.post( + "/api/v1/auth/login", json={"email": "gate@example.com", "password": "password123"} + ) + assert ok.status_code == 200 + assert ( + await client.get("/api/v1/users/me", headers=auth(ok.json()["token"])) + ).status_code == 200 diff --git a/deploy/.env.example b/deploy/.env.example index 63a373f..86af32f 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -46,6 +46,9 @@ COOKIE_SECURE=false APP_BASE_URL=http://localhost # Mailer: 'console' logs links to stdout (dev); 'smtp' uses the SMTP settings below. MAILER=console +# Require a verified email before an account has an active session. Leave false +# until SMTP works and existing accounts are verified, or you will lock users out. +REQUIRE_EMAIL_VERIFICATION=false # --- Email (SMTP) — wired in a later phase --- SMTP_HOST= -- 2.52.0