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:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user