Initial commit: webmail client (M0/M1)
Three-pane webmail client. - Backend: FastAPI + async SQLAlchemy + arq worker (Postgres/Redis), IMAP/SMTP sync, threading, search, drafts/outbox with undo, attachments, 2FA. - Frontend: React 18 + Vite + TypeScript + TanStack Query + Tailwind; three-pane UI with rich-text composer, light/dark themes, resizable panels. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Identity: app accounts, auth, sessions, handles (SPEC §6.1)."""
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Session and CSRF cookie handling (SPEC §6.1, §10.3).
|
||||
|
||||
Over HTTPS we emit the spec-mandated ``__Host-session`` prefix with ``Secure``.
|
||||
Over plain HTTP (dev only) we fall back to a plain ``session`` name without
|
||||
``Secure`` so login works behind the Vite HTTP proxy. See docs/open-questions.md
|
||||
OQ-3. ``SameSite=Lax``, ``HttpOnly`` (session), ``Path=/`` are always applied.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
|
||||
from fastapi import Request, Response
|
||||
|
||||
SESSION_COOKIE_SECURE = "__Host-session"
|
||||
SESSION_COOKIE_INSECURE = "session"
|
||||
CSRF_COOKIE_SECURE = "__Host-csrf"
|
||||
CSRF_COOKIE_INSECURE = "csrf"
|
||||
CSRF_HEADER = "X-CSRF"
|
||||
|
||||
|
||||
def _is_secure(request: Request) -> bool:
|
||||
return request.url.scheme == "https"
|
||||
|
||||
|
||||
def session_cookie_name(request: Request) -> str:
|
||||
"""Return the session cookie name appropriate to the request scheme."""
|
||||
return SESSION_COOKIE_SECURE if _is_secure(request) else SESSION_COOKIE_INSECURE
|
||||
|
||||
|
||||
def csrf_cookie_name(request: Request) -> str:
|
||||
"""Return the CSRF cookie name appropriate to the request scheme."""
|
||||
return CSRF_COOKIE_SECURE if _is_secure(request) else CSRF_COOKIE_INSECURE
|
||||
|
||||
|
||||
def read_session_token(request: Request) -> str:
|
||||
"""Read the session token from either cookie name (whichever is present)."""
|
||||
return request.cookies.get(SESSION_COOKIE_SECURE) or request.cookies.get(
|
||||
SESSION_COOKIE_INSECURE, ""
|
||||
)
|
||||
|
||||
|
||||
def new_csrf_token() -> str:
|
||||
"""Return a fresh CSRF token for the double-submit pattern."""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def set_auth_cookies(
|
||||
request: Request, response: Response, session_token: str, csrf_token: str, max_age: int
|
||||
) -> None:
|
||||
"""Set the session (HttpOnly) and CSRF (JS-readable) cookies."""
|
||||
secure = _is_secure(request)
|
||||
response.set_cookie(
|
||||
session_cookie_name(request),
|
||||
session_token,
|
||||
max_age=max_age,
|
||||
httponly=True,
|
||||
secure=secure,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
)
|
||||
response.set_cookie(
|
||||
csrf_cookie_name(request),
|
||||
csrf_token,
|
||||
max_age=max_age,
|
||||
httponly=False, # double-submit: client JS echoes it in the X-CSRF header
|
||||
secure=secure,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def clear_auth_cookies(request: Request, response: Response) -> None:
|
||||
"""Clear both auth cookies (logout)."""
|
||||
for name in (session_cookie_name(request), csrf_cookie_name(request)):
|
||||
response.delete_cookie(name, path="/")
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Auth dependencies: current user and CSRF enforcement (SPEC §6.1, §10.3)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.errors import raise_api_error
|
||||
from app.db import get_session
|
||||
from app.identity.cookies import CSRF_HEADER, csrf_cookie_name, read_session_token
|
||||
from app.identity.sessions import Session, resolve_session
|
||||
from app.models import User
|
||||
|
||||
|
||||
async def current_session(request: Request) -> Session:
|
||||
"""Resolve the caller's session or raise 401."""
|
||||
token = read_session_token(request)
|
||||
session = await resolve_session(token)
|
||||
if session is None:
|
||||
raise_api_error(401, "not_authenticated", "authentication required")
|
||||
return session
|
||||
|
||||
|
||||
async def current_user(
|
||||
session: Annotated[Session, Depends(current_session)],
|
||||
db: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> User:
|
||||
"""Load the authenticated :class:`User` (I-7: scoped by the session's user_id)."""
|
||||
user = await db.scalar(select(User).where(User.id == session.user_id))
|
||||
if user is None:
|
||||
# Session references a deleted user; treat as unauthenticated.
|
||||
raise_api_error(401, "not_authenticated", "authentication required")
|
||||
return user
|
||||
|
||||
|
||||
async def require_csrf(request: Request) -> None:
|
||||
"""Enforce the double-submit CSRF token on mutating requests (SPEC §10.3)."""
|
||||
if request.method in ("GET", "HEAD", "OPTIONS"):
|
||||
return
|
||||
header = request.headers.get(CSRF_HEADER, "")
|
||||
cookie = request.cookies.get(csrf_cookie_name(request), "")
|
||||
if not header or not cookie or header != cookie:
|
||||
raise_api_error(403, "csrf_failed", "missing or invalid CSRF token")
|
||||
|
||||
|
||||
CurrentUser = Annotated[User, Depends(current_user)]
|
||||
CurrentSession = Annotated[Session, Depends(current_session)]
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Handle (localpart) validation per SPEC §4.
|
||||
|
||||
Rules (enforced in code, mail-grade from day one):
|
||||
- localpart: 3-30 chars, characters ``[a-z0-9._-]``
|
||||
- no leading/trailing dot, no double dot
|
||||
- case-insensitive (normalize to lowercase)
|
||||
- not in the reserved list (:mod:`app.identity.reserved`)
|
||||
|
||||
The stored handle is the full form ``localpart@{APP_DOMAIN}``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from app.config import get_settings
|
||||
from app.identity.reserved import is_reserved
|
||||
|
||||
_LOCALPART_RE = re.compile(r"^[a-z0-9._-]+$")
|
||||
_MIN_LEN = 3
|
||||
_MAX_LEN = 30
|
||||
|
||||
|
||||
class InvalidHandle(ValueError):
|
||||
"""Raised when a proposed localpart violates the SPEC §4 rules."""
|
||||
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
|
||||
def normalize_localpart(localpart: str) -> str:
|
||||
"""Lowercase and strip surrounding whitespace (case-insensitive handles)."""
|
||||
return localpart.strip().lower()
|
||||
|
||||
|
||||
def validate_localpart(localpart: str) -> str:
|
||||
"""Validate and normalize ``localpart``; return it or raise :class:`InvalidHandle`."""
|
||||
lp = normalize_localpart(localpart)
|
||||
if not (_MIN_LEN <= len(lp) <= _MAX_LEN):
|
||||
raise InvalidHandle("length", f"handle must be {_MIN_LEN}-{_MAX_LEN} characters")
|
||||
if not _LOCALPART_RE.match(lp):
|
||||
raise InvalidHandle("charset", "handle may contain only a-z, 0-9, '.', '_', '-'")
|
||||
if lp.startswith(".") or lp.endswith("."):
|
||||
raise InvalidHandle("dot_edge", "handle may not start or end with a dot")
|
||||
if ".." in lp:
|
||||
raise InvalidHandle("double_dot", "handle may not contain consecutive dots")
|
||||
if is_reserved(lp):
|
||||
raise InvalidHandle("reserved", "that handle is reserved")
|
||||
return lp
|
||||
|
||||
|
||||
def full_handle(localpart: str) -> str:
|
||||
"""Return the full handle ``localpart@{APP_DOMAIN}`` for a validated localpart."""
|
||||
return f"{validate_localpart(localpart)}@{get_settings().app_domain}"
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Argon2id password hashing (SPEC §6.1, §10.1).
|
||||
|
||||
Params are fixed: ``time_cost=3``, ``memory_cost=64 MiB``, ``parallelism=4``.
|
||||
No other password hashing is permitted anywhere (SPEC §10.1).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerificationError, VerifyMismatchError
|
||||
|
||||
_hasher = PasswordHasher(
|
||||
time_cost=3,
|
||||
memory_cost=64 * 1024, # KiB -> 64 MiB
|
||||
parallelism=4,
|
||||
)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Return an argon2id hash for ``password``."""
|
||||
return _hasher.hash(password)
|
||||
|
||||
|
||||
def verify_password(password_hash: str, password: str) -> bool:
|
||||
"""Constant-time verify ``password`` against ``password_hash``."""
|
||||
try:
|
||||
return _hasher.verify(password_hash, password)
|
||||
except (VerifyMismatchError, VerificationError):
|
||||
return False
|
||||
|
||||
|
||||
def needs_rehash(password_hash: str) -> bool:
|
||||
"""Return True if ``password_hash`` was made with outdated parameters."""
|
||||
return _hasher.check_needs_rehash(password_hash)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Reserved localparts that may not be registered (SPEC §4/§6.1).
|
||||
|
||||
Mail-grade from day one: role addresses (RFC 2142) plus common admin/system
|
||||
handles. Comparison is case-insensitive; keep entries lowercase.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
RESERVED_LOCALPARTS: frozenset[str] = frozenset(
|
||||
{
|
||||
# RFC 2142 role addresses
|
||||
"postmaster",
|
||||
"abuse",
|
||||
"hostmaster",
|
||||
"webmaster",
|
||||
"usenet",
|
||||
"news",
|
||||
"www",
|
||||
"uucp",
|
||||
"ftp",
|
||||
"noc",
|
||||
"security",
|
||||
"mailer-daemon",
|
||||
# Common system/admin handles
|
||||
"admin",
|
||||
"administrator",
|
||||
"root",
|
||||
"noreply",
|
||||
"no-reply",
|
||||
"donotreply",
|
||||
"do-not-reply",
|
||||
"support",
|
||||
"help",
|
||||
"info",
|
||||
"contact",
|
||||
"sales",
|
||||
"billing",
|
||||
"system",
|
||||
"daemon",
|
||||
"nobody",
|
||||
"null",
|
||||
"void",
|
||||
"test",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def is_reserved(localpart: str) -> bool:
|
||||
"""Return True if ``localpart`` is reserved (case-insensitive)."""
|
||||
return localpart.lower() in RESERVED_LOCALPARTS
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Password reset tokens (SPEC §6.1).
|
||||
|
||||
Single-use, 30-minute, Redis-backed tokens. Only issued when the account has a
|
||||
recovery email on file. Actual delivery of the token to the recovery address
|
||||
requires an app-level mailer (not part of M0/M1); until then the token is logged
|
||||
server-side. See docs/open-questions.md.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import uuid
|
||||
|
||||
from app.redis_client import aw, get_redis
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PREFIX = "pwreset:"
|
||||
_TTL = 30 * 60 # 30 minutes
|
||||
|
||||
|
||||
async def issue_token(user_id: uuid.UUID, recovery_email: str) -> str:
|
||||
"""Create a single-use reset token bound to ``user_id`` and return it."""
|
||||
token = secrets.token_urlsafe(32)
|
||||
await get_redis().set(_PREFIX + token, str(user_id), ex=_TTL)
|
||||
# TODO(spec): deliver via the recovery address once an app mailer exists.
|
||||
logger.info("password reset token issued for user=%s recovery=%s", user_id, recovery_email)
|
||||
return token
|
||||
|
||||
|
||||
async def consume_token(token: str) -> uuid.UUID | None:
|
||||
"""Atomically consume a reset token, returning the user_id (or None)."""
|
||||
if not token:
|
||||
return None
|
||||
redis = get_redis()
|
||||
raw = await aw(redis.getdel(_PREFIX + token)) # single-use: fetch and delete atomically
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return uuid.UUID(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Auth API routes (SPEC §9.1): register, login (2FA-aware), logout, me,
|
||||
TOTP setup/enable, session list/revoke, password reset."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.errors import raise_api_error
|
||||
from app.config import get_settings
|
||||
from app.db import get_session
|
||||
from app.identity import totp as totp_mod
|
||||
from app.identity.cookies import (
|
||||
clear_auth_cookies,
|
||||
new_csrf_token,
|
||||
set_auth_cookies,
|
||||
)
|
||||
from app.identity.deps import CurrentSession, CurrentUser, require_csrf
|
||||
from app.identity.passwords import hash_password, verify_password
|
||||
from app.identity.reset import consume_token, issue_token
|
||||
from app.identity.schemas import (
|
||||
LoginRequest,
|
||||
MessageOut,
|
||||
PasswordResetIn,
|
||||
PasswordResetRequestIn,
|
||||
RegisterRequest,
|
||||
SessionOut,
|
||||
TotpDisableRequest,
|
||||
TotpEnableRequest,
|
||||
TotpSetupResponse,
|
||||
UserOut,
|
||||
)
|
||||
from app.identity.service import (
|
||||
RateLimited,
|
||||
RegistrationError,
|
||||
TotpInvalid,
|
||||
TotpRequired,
|
||||
authenticate,
|
||||
register_user,
|
||||
resolve_handle,
|
||||
)
|
||||
from app.identity.sessions import (
|
||||
create_session,
|
||||
destroy_all_sessions,
|
||||
destroy_session,
|
||||
list_sessions,
|
||||
revoke_session,
|
||||
)
|
||||
from app.models import User
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
async def _establish_session(request: Request, response: Response, user: User) -> None:
|
||||
token = await create_session(user.id, request.headers.get("user-agent", ""))
|
||||
csrf = new_csrf_token()
|
||||
max_age = get_settings().session_ttl_days * 24 * 3600
|
||||
set_auth_cookies(request, response, token, csrf, max_age)
|
||||
|
||||
|
||||
def _user_out(user: User) -> UserOut:
|
||||
return UserOut(
|
||||
id=user.id,
|
||||
handle=user.handle,
|
||||
recovery_email=user.recovery_email,
|
||||
settings=user.settings,
|
||||
totp_enabled=user.totp_secret is not None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserOut)
|
||||
async def register(
|
||||
body: RegisterRequest, request: Request, response: Response, db: DbSession
|
||||
) -> UserOut:
|
||||
try:
|
||||
user = await register_user(
|
||||
db,
|
||||
localpart=body.localpart,
|
||||
password=body.password,
|
||||
recovery_email=body.recovery_email,
|
||||
)
|
||||
except RegistrationError as exc:
|
||||
status = 409 if exc.code == "handle_taken" else 400
|
||||
raise_api_error(status, exc.code, exc.message)
|
||||
await db.commit()
|
||||
await _establish_session(request, response, user)
|
||||
return _user_out(user)
|
||||
|
||||
|
||||
@router.post("/login", response_model=UserOut)
|
||||
async def login(body: LoginRequest, request: Request, response: Response, db: DbSession) -> UserOut:
|
||||
try:
|
||||
user = await authenticate(
|
||||
db,
|
||||
handle_input=body.handle,
|
||||
password=body.password,
|
||||
ip=_client_ip(request),
|
||||
totp=body.totp,
|
||||
)
|
||||
except RateLimited as exc:
|
||||
response.headers["Retry-After"] = str(exc.retry_after)
|
||||
raise_api_error(429, "rate_limited", "too many attempts; try again later")
|
||||
except TotpRequired:
|
||||
raise_api_error(401, "totp_required", "a 2FA code is required")
|
||||
except TotpInvalid:
|
||||
raise_api_error(401, "totp_invalid", "incorrect 2FA code")
|
||||
if user is None:
|
||||
raise_api_error(401, "invalid_credentials", "incorrect handle or password")
|
||||
await _establish_session(request, response, user)
|
||||
return _user_out(user)
|
||||
|
||||
|
||||
@router.post("/logout", response_model=MessageOut, dependencies=[Depends(require_csrf)])
|
||||
async def logout(request: Request, response: Response, session: CurrentSession) -> MessageOut:
|
||||
await destroy_session(session.token)
|
||||
clear_auth_cookies(request, response)
|
||||
return MessageOut()
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
async def me(user: CurrentUser) -> UserOut:
|
||||
return _user_out(user)
|
||||
|
||||
|
||||
# --- Two-factor authentication (SPEC §6.1) ----------------------------------
|
||||
|
||||
|
||||
@router.post("/totp/setup", response_model=TotpSetupResponse, dependencies=[Depends(require_csrf)])
|
||||
async def totp_setup(user: CurrentUser) -> TotpSetupResponse:
|
||||
if user.totp_secret is not None:
|
||||
raise_api_error(409, "totp_already_enabled", "2FA is already enabled")
|
||||
secret = await totp_mod.start_setup(user.id)
|
||||
return TotpSetupResponse(
|
||||
secret=secret, provisioning_uri=totp_mod.provisioning_uri(secret, user.handle)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/totp/enable", response_model=MessageOut, dependencies=[Depends(require_csrf)])
|
||||
async def totp_enable(body: TotpEnableRequest, user: CurrentUser, db: DbSession) -> MessageOut:
|
||||
secret = await totp_mod.get_pending(user.id)
|
||||
if secret is None:
|
||||
raise_api_error(400, "totp_no_setup", "start TOTP setup first")
|
||||
if not totp_mod.verify(secret, body.code):
|
||||
raise_api_error(400, "totp_invalid", "incorrect 2FA code")
|
||||
user.totp_secret = totp_mod.encrypt_totp_secret(secret)
|
||||
await db.commit()
|
||||
await totp_mod.clear_pending(user.id)
|
||||
return MessageOut()
|
||||
|
||||
|
||||
@router.post("/totp/disable", response_model=MessageOut, dependencies=[Depends(require_csrf)])
|
||||
async def totp_disable(body: TotpDisableRequest, user: CurrentUser, db: DbSession) -> MessageOut:
|
||||
# Re-authenticate with the password before turning off 2FA.
|
||||
if not verify_password(user.password_hash, body.password):
|
||||
raise_api_error(401, "invalid_credentials", "incorrect password")
|
||||
user.totp_secret = None
|
||||
await db.commit()
|
||||
return MessageOut()
|
||||
|
||||
|
||||
# --- Session management (SPEC §9.1) -----------------------------------------
|
||||
|
||||
|
||||
@router.get("/sessions", response_model=list[SessionOut])
|
||||
async def sessions(user: CurrentUser, session: CurrentSession) -> list[SessionOut]:
|
||||
infos = await list_sessions(user.id, session.token)
|
||||
return [
|
||||
SessionOut(sid=i.sid, created=i.created.isoformat(), ua=i.ua, current=i.current)
|
||||
for i in infos
|
||||
]
|
||||
|
||||
|
||||
@router.delete("/sessions/{sid}", response_model=MessageOut, dependencies=[Depends(require_csrf)])
|
||||
async def revoke(sid: str, user: CurrentUser) -> MessageOut:
|
||||
if not await revoke_session(user.id, sid):
|
||||
raise_api_error(404, "not_found", "session not found")
|
||||
return MessageOut()
|
||||
|
||||
|
||||
# --- Password reset (SPEC §6.1) ---------------------------------------------
|
||||
|
||||
|
||||
@router.post("/password/reset-request", response_model=MessageOut)
|
||||
async def password_reset_request(body: PasswordResetRequestIn, db: DbSession) -> MessageOut:
|
||||
# Always return ok (do not leak whether the handle exists / has recovery mail).
|
||||
handle = resolve_handle(body.handle)
|
||||
user = await db.scalar(select(User).where(User.handle == handle))
|
||||
if user is not None and user.recovery_email:
|
||||
await issue_token(user.id, user.recovery_email)
|
||||
return MessageOut()
|
||||
|
||||
|
||||
@router.post("/password/reset", response_model=MessageOut)
|
||||
async def password_reset(body: PasswordResetIn, db: DbSession) -> MessageOut:
|
||||
user_id = await consume_token(body.token)
|
||||
if user_id is None:
|
||||
raise_api_error(400, "invalid_token", "reset token is invalid or expired")
|
||||
user = await db.get(User, user_id)
|
||||
if user is None:
|
||||
raise_api_error(400, "invalid_token", "reset token is invalid or expired")
|
||||
user.password_hash = hash_password(body.password)
|
||||
await db.commit()
|
||||
await destroy_all_sessions(user_id) # force re-login everywhere after reset
|
||||
return MessageOut()
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Request/response models for the auth API (SPEC §9.1)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
localpart: str = Field(min_length=3, max_length=30)
|
||||
password: str = Field(min_length=8, max_length=1024)
|
||||
recovery_email: str | None = None
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
# Accepts either a bare localpart or the full ``localpart@domain`` handle.
|
||||
handle: str = Field(min_length=1, max_length=320)
|
||||
password: str = Field(min_length=1, max_length=1024)
|
||||
totp: str | None = None # required when the account has 2FA enabled
|
||||
|
||||
|
||||
class TotpSetupResponse(BaseModel):
|
||||
secret: str # base32, for manual entry
|
||||
provisioning_uri: str # otpauth:// URI to render as a QR code
|
||||
|
||||
|
||||
class TotpEnableRequest(BaseModel):
|
||||
code: str = Field(min_length=6, max_length=10)
|
||||
|
||||
|
||||
class TotpDisableRequest(BaseModel):
|
||||
password: str = Field(min_length=1, max_length=1024)
|
||||
|
||||
|
||||
class SessionOut(BaseModel):
|
||||
sid: str
|
||||
created: str
|
||||
ua: str
|
||||
current: bool
|
||||
|
||||
|
||||
class PasswordResetRequestIn(BaseModel):
|
||||
handle: str = Field(min_length=1, max_length=320)
|
||||
|
||||
|
||||
class PasswordResetIn(BaseModel):
|
||||
token: str = Field(min_length=1)
|
||||
password: str = Field(min_length=8, max_length=1024)
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
handle: str
|
||||
recovery_email: str | None
|
||||
settings: dict[str, object]
|
||||
totp_enabled: bool = False
|
||||
|
||||
|
||||
class MessageOut(BaseModel):
|
||||
status: str = "ok"
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Registration, authentication, and login rate limiting (SPEC §6.1)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
from app.identity.handles import InvalidHandle, full_handle, normalize_localpart
|
||||
from app.identity.passwords import hash_password, verify_password
|
||||
from app.models import User
|
||||
from app.redis_client import get_redis
|
||||
|
||||
# SPEC §6.1: 10 attempts / 15 min per handle AND per IP.
|
||||
_RL_MAX = 10
|
||||
_RL_WINDOW = 15 * 60
|
||||
|
||||
|
||||
class RegistrationError(Exception):
|
||||
"""Registration failed for a user-actionable reason."""
|
||||
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RateLimited(Exception):
|
||||
"""Raised when the login rate limit is exceeded."""
|
||||
|
||||
retry_after: int
|
||||
|
||||
|
||||
class TotpRequired(Exception):
|
||||
"""Password verified but the account has 2FA enabled and no code was given."""
|
||||
|
||||
|
||||
class TotpInvalid(Exception):
|
||||
"""Password verified but the supplied TOTP code was wrong."""
|
||||
|
||||
|
||||
def resolve_handle(handle_input: str) -> str:
|
||||
"""Normalize a login identifier to the full ``localpart@APP_DOMAIN`` form."""
|
||||
text = handle_input.strip().lower()
|
||||
if "@" in text:
|
||||
return text
|
||||
return f"{normalize_localpart(text)}@{get_settings().app_domain}"
|
||||
|
||||
|
||||
async def register_user(
|
||||
db: AsyncSession, *, localpart: str, password: str, recovery_email: str | None
|
||||
) -> User:
|
||||
"""Create a new user. Raises :class:`RegistrationError` on validation/uniqueness."""
|
||||
try:
|
||||
handle = full_handle(localpart)
|
||||
except InvalidHandle as exc:
|
||||
raise RegistrationError(exc.code, exc.message) from exc
|
||||
|
||||
user = User(
|
||||
handle=handle,
|
||||
password_hash=hash_password(password),
|
||||
recovery_email=(recovery_email.strip().lower() if recovery_email else None),
|
||||
)
|
||||
db.add(user)
|
||||
try:
|
||||
await db.flush()
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
raise RegistrationError("handle_taken", "that handle is already registered") from exc
|
||||
return user
|
||||
|
||||
|
||||
async def _bump_rate_limit(key: str) -> int:
|
||||
redis = get_redis()
|
||||
count = await redis.incr(key)
|
||||
if count == 1:
|
||||
await redis.expire(key, _RL_WINDOW)
|
||||
return int(count)
|
||||
|
||||
|
||||
async def _check_rate_limit(handle: str, ip: str) -> None:
|
||||
for key in (f"rl:login:handle:{handle}", f"rl:login:ip:{ip}"):
|
||||
count = await _bump_rate_limit(key)
|
||||
if count > _RL_MAX:
|
||||
ttl = await get_redis().ttl(key)
|
||||
raise RateLimited(retry_after=max(ttl, 1))
|
||||
|
||||
|
||||
async def authenticate(
|
||||
db: AsyncSession, *, handle_input: str, password: str, ip: str, totp: str | None = None
|
||||
) -> User | None:
|
||||
"""Verify credentials (constant-time). Returns the user or None; may rate-limit.
|
||||
|
||||
When the account has TOTP enabled, raises :class:`TotpRequired` (no code given)
|
||||
or :class:`TotpInvalid` (wrong code) after the password check passes.
|
||||
"""
|
||||
handle = resolve_handle(handle_input)
|
||||
await _check_rate_limit(handle, ip)
|
||||
|
||||
user = await db.scalar(select(User).where(User.handle == handle))
|
||||
# Always run a verify to keep timing uniform whether or not the user exists.
|
||||
stored_hash = user.password_hash if user else _DUMMY_HASH
|
||||
ok = verify_password(stored_hash, password)
|
||||
if user is None or not ok:
|
||||
return None
|
||||
|
||||
if user.totp_secret is not None:
|
||||
from app.identity.totp import decrypt_totp_secret
|
||||
from app.identity.totp import verify as verify_totp
|
||||
|
||||
if not totp:
|
||||
raise TotpRequired
|
||||
if not verify_totp(decrypt_totp_secret(user.totp_secret), totp):
|
||||
raise TotpInvalid
|
||||
return user
|
||||
|
||||
|
||||
# A precomputed hash so authentication of a non-existent handle still spends
|
||||
# argon2 time (mitigates user-enumeration via timing, SPEC §6.1 constant-time).
|
||||
_DUMMY_HASH = hash_password("x" * 24)
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Server-side sessions in Redis (SPEC §4 "Sessions", §9.1 session management).
|
||||
|
||||
``sess:{token}`` -> JSON ``{user_id, sid, created, ua}`` with a sliding TTL of
|
||||
``SESSION_TTL_DAYS`` days. A per-user index ``usess:{user_id}`` (hash sid->token)
|
||||
lets us list and revoke a user's sessions without exposing the raw token, which is
|
||||
the session credential itself. Tokens are opaque and high-entropy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.config import get_settings
|
||||
from app.redis_client import aw, get_redis
|
||||
|
||||
_PREFIX = "sess:"
|
||||
_INDEX = "usess:"
|
||||
_TOKEN_BYTES = 32
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Session:
|
||||
token: str
|
||||
user_id: uuid.UUID
|
||||
sid: str # public session id (safe to expose; not the token)
|
||||
created: datetime
|
||||
ua: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionInfo:
|
||||
sid: str
|
||||
created: datetime
|
||||
ua: str
|
||||
current: bool
|
||||
|
||||
|
||||
def _ttl_seconds() -> int:
|
||||
return get_settings().session_ttl_days * 24 * 3600
|
||||
|
||||
|
||||
def _index_key(user_id: uuid.UUID | str) -> str:
|
||||
return f"{_INDEX}{user_id}"
|
||||
|
||||
|
||||
async def create_session(user_id: uuid.UUID, user_agent: str) -> str:
|
||||
"""Create a session for ``user_id`` and return its opaque token."""
|
||||
token = secrets.token_urlsafe(_TOKEN_BYTES)
|
||||
sid = secrets.token_hex(8)
|
||||
payload = {
|
||||
"user_id": str(user_id),
|
||||
"sid": sid,
|
||||
"created": datetime.now(UTC).isoformat(),
|
||||
"ua": user_agent[:256],
|
||||
}
|
||||
redis = get_redis()
|
||||
ttl = _ttl_seconds()
|
||||
await redis.set(_PREFIX + token, json.dumps(payload), ex=ttl)
|
||||
await aw(redis.hset(_index_key(user_id), sid, token))
|
||||
await redis.expire(_index_key(user_id), ttl)
|
||||
return token
|
||||
|
||||
|
||||
async def resolve_session(token: str) -> Session | None:
|
||||
"""Resolve ``token`` to a :class:`Session`, refreshing its sliding TTL."""
|
||||
if not token:
|
||||
return None
|
||||
redis = get_redis()
|
||||
raw = await redis.get(_PREFIX + token)
|
||||
if raw is None:
|
||||
return None
|
||||
ttl = _ttl_seconds()
|
||||
data = json.loads(raw)
|
||||
await redis.expire(_PREFIX + token, ttl) # sliding
|
||||
await redis.expire(_index_key(data["user_id"]), ttl)
|
||||
return Session(
|
||||
token=token,
|
||||
user_id=uuid.UUID(data["user_id"]),
|
||||
sid=data.get("sid", ""),
|
||||
created=datetime.fromisoformat(data["created"]),
|
||||
ua=data.get("ua", ""),
|
||||
)
|
||||
|
||||
|
||||
async def destroy_session(token: str) -> None:
|
||||
"""Delete a session (logout)."""
|
||||
if not token:
|
||||
return
|
||||
redis = get_redis()
|
||||
raw = await redis.get(_PREFIX + token)
|
||||
await redis.delete(_PREFIX + token)
|
||||
if raw is not None:
|
||||
data = json.loads(raw)
|
||||
await aw(redis.hdel(_index_key(data["user_id"]), data.get("sid", "")))
|
||||
|
||||
|
||||
async def list_sessions(user_id: uuid.UUID, current_token: str) -> list[SessionInfo]:
|
||||
"""List a user's active sessions, pruning any whose token has expired."""
|
||||
redis = get_redis()
|
||||
mapping: dict[str, str] = await aw(redis.hgetall(_index_key(user_id)))
|
||||
out: list[SessionInfo] = []
|
||||
for sid, token in mapping.items():
|
||||
raw = await redis.get(_PREFIX + token)
|
||||
if raw is None:
|
||||
await aw(redis.hdel(_index_key(user_id), sid)) # prune stale index entry
|
||||
continue
|
||||
data = json.loads(raw)
|
||||
out.append(
|
||||
SessionInfo(
|
||||
sid=sid,
|
||||
created=datetime.fromisoformat(data["created"]),
|
||||
ua=data.get("ua", ""),
|
||||
current=(token == current_token),
|
||||
)
|
||||
)
|
||||
out.sort(key=lambda s: s.created, reverse=True)
|
||||
return out
|
||||
|
||||
|
||||
async def revoke_session(user_id: uuid.UUID, sid: str) -> bool:
|
||||
"""Revoke a specific session by its public sid. Returns False if not found."""
|
||||
redis = get_redis()
|
||||
token = await aw(redis.hget(_index_key(user_id), sid))
|
||||
if token is None:
|
||||
return False
|
||||
await redis.delete(_PREFIX + token)
|
||||
await aw(redis.hdel(_index_key(user_id), sid))
|
||||
return True
|
||||
|
||||
|
||||
async def destroy_all_sessions(user_id: uuid.UUID) -> None:
|
||||
"""Revoke every session for a user (e.g. after a password reset)."""
|
||||
redis = get_redis()
|
||||
mapping: dict[str, str] = await aw(redis.hgetall(_index_key(user_id)))
|
||||
for token in mapping.values():
|
||||
await redis.delete(_PREFIX + token)
|
||||
await redis.delete(_index_key(user_id))
|
||||
@@ -0,0 +1,59 @@
|
||||
"""TOTP two-factor authentication (SPEC §6.1).
|
||||
|
||||
The base32 secret is stored envelope-encrypted in ``users.totp_secret``. A pending
|
||||
(not-yet-enabled) secret lives in Redis until the user confirms one valid code.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pyotp
|
||||
|
||||
from app.config import get_settings
|
||||
from app.redis_client import get_redis
|
||||
from app.security.crypto import decrypt_secret, encrypt_secret
|
||||
|
||||
_PENDING_PREFIX = "totp_pending:"
|
||||
_PENDING_TTL = 600 # 10 minutes to complete enrolment
|
||||
_VALID_WINDOW = 1 # ±1 step tolerance (SPEC §6.1)
|
||||
|
||||
|
||||
def generate_secret() -> str:
|
||||
"""Return a fresh base32 TOTP secret."""
|
||||
return pyotp.random_base32()
|
||||
|
||||
|
||||
def provisioning_uri(secret: str, handle: str) -> str:
|
||||
"""Return the otpauth:// URI for QR provisioning."""
|
||||
return pyotp.TOTP(secret).provisioning_uri(name=handle, issuer_name=get_settings().app_domain)
|
||||
|
||||
|
||||
def verify(secret: str, code: str) -> bool:
|
||||
"""Verify a TOTP ``code`` against ``secret`` (±1 window)."""
|
||||
return bool(pyotp.TOTP(secret).verify(code.strip(), valid_window=_VALID_WINDOW))
|
||||
|
||||
|
||||
async def start_setup(user_id: uuid.UUID) -> str:
|
||||
"""Generate a pending secret (stored in Redis) and return it."""
|
||||
secret = generate_secret()
|
||||
await get_redis().set(_PENDING_PREFIX + str(user_id), secret, ex=_PENDING_TTL)
|
||||
return secret
|
||||
|
||||
|
||||
async def get_pending(user_id: uuid.UUID) -> str | None:
|
||||
"""Return the pending (un-enabled) secret for a user, if any."""
|
||||
secret: str | None = await get_redis().get(_PENDING_PREFIX + str(user_id))
|
||||
return secret
|
||||
|
||||
|
||||
async def clear_pending(user_id: uuid.UUID) -> None:
|
||||
await get_redis().delete(_PENDING_PREFIX + str(user_id))
|
||||
|
||||
|
||||
def encrypt_totp_secret(secret: str) -> bytes:
|
||||
return encrypt_secret(secret.encode("utf-8"))
|
||||
|
||||
|
||||
def decrypt_totp_secret(blob: bytes) -> str:
|
||||
return decrypt_secret(blob).decode("utf-8")
|
||||
Reference in New Issue
Block a user