Security: gate sessions on verified email (opt-in)
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) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user