311 lines
12 KiB
Python
311 lines
12 KiB
Python
"""Sign up, sign in, verify an address, reset a password.
|
|
|
|
Two rules run through all of it.
|
|
|
|
Nothing here tells a caller whether an address has an account. Signup and
|
|
forgot-password always answer the same way; login answers with one generic
|
|
message whether the address is unknown, the password wrong, the account locked,
|
|
or the account disabled.
|
|
|
|
Nothing here consumes a token on GET. Mail scanners -- Outlook Safe Links,
|
|
Defender, Proofpoint -- fetch every URL in an inbound message, so a link that
|
|
acts on GET is spent before the recipient ever clicks it. The pages are static;
|
|
they read the token from the URL and POST it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import timedelta
|
|
|
|
from fastapi import (APIRouter, BackgroundTasks, Depends, HTTPException, Request,
|
|
Response, status)
|
|
from sqlalchemy import delete, select, update
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.config import Settings, get_settings
|
|
from app.db import get_db
|
|
from app.deps import client_ip, current_user, require_user
|
|
from app.models import (PASSWORD_RESET, VERIFY_EMAIL, AuthSession, EmailToken,
|
|
User, utcnow)
|
|
from app.schemas import EmailIn, LoginIn, Ok, ResetIn, SignupIn, TokenIn, UserOut
|
|
from app.security import (burn_cpu, clear_session_cookie, fingerprint,
|
|
hash_password, new_token, session_expiry,
|
|
set_session_cookie, verify_password)
|
|
from app.services import email_service
|
|
from app.services.ratelimit import limiter
|
|
from app.services.store import store_of
|
|
|
|
log = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
|
|
|
BAD_CREDENTIALS = "Email or password is incorrect."
|
|
CHECK_INBOX = "Check your email to finish setting up your account."
|
|
RESET_SENT = "If that address has an account, a reset link is on its way."
|
|
BAD_LINK = "That link has expired or has already been used."
|
|
|
|
|
|
# --------------------------------------------------------------------- helpers
|
|
|
|
def _limit(bucket: str, subject: str) -> None:
|
|
wait = limiter.check(bucket, subject)
|
|
if wait:
|
|
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS,
|
|
"Too many attempts. Try again in a few minutes.",
|
|
headers={"Retry-After": str(int(wait))})
|
|
|
|
|
|
def _mint(db: Session, user: User, purpose: str, ttl: timedelta,
|
|
ip: str | None) -> str:
|
|
"""Issue a one-shot token, retiring any earlier unused one of this purpose
|
|
so a mailbox cannot accumulate a stack of live links."""
|
|
db.execute(
|
|
update(EmailToken)
|
|
.where(EmailToken.user_id == user.id, EmailToken.purpose == purpose,
|
|
EmailToken.used_at.is_(None))
|
|
.values(used_at=utcnow())
|
|
)
|
|
raw = new_token()
|
|
db.add(EmailToken(user_id=user.id, purpose=purpose, token_hash=fingerprint(raw),
|
|
expires_at=utcnow() + ttl, requested_ip=ip))
|
|
db.commit()
|
|
return raw
|
|
|
|
|
|
def _consume(db: Session, raw: str, purpose: str) -> User:
|
|
"""Spend a token, or refuse. One UPDATE, so two simultaneous clicks cannot
|
|
both win: whichever loses sees rowcount 0."""
|
|
now = utcnow()
|
|
result = db.execute(
|
|
update(EmailToken)
|
|
.where(EmailToken.token_hash == fingerprint(raw),
|
|
EmailToken.purpose == purpose,
|
|
EmailToken.used_at.is_(None),
|
|
EmailToken.expires_at > now)
|
|
.values(used_at=now)
|
|
)
|
|
if result.rowcount != 1:
|
|
db.rollback()
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, BAD_LINK)
|
|
|
|
token = db.scalar(select(EmailToken).where(EmailToken.token_hash == fingerprint(raw)))
|
|
user = db.get(User, token.user_id) if token else None
|
|
if user is None:
|
|
db.rollback()
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, BAD_LINK)
|
|
db.commit()
|
|
return user
|
|
|
|
|
|
def _start_session(db: Session, user: User, request: Request,
|
|
response: Response, s: Settings) -> None:
|
|
now = utcnow()
|
|
raw = new_token()
|
|
db.add(AuthSession(
|
|
user_id=user.id, token_hash=fingerprint(raw, s),
|
|
created_at=now, last_seen_at=now, expires_at=session_expiry(s, now),
|
|
ip=client_ip(request)[:45],
|
|
user_agent=(request.headers.get("user-agent") or "")[:255] or None,
|
|
))
|
|
user.last_login_at = now
|
|
user.failed_login_count = 0
|
|
user.locked_until = None
|
|
db.commit()
|
|
set_session_cookie(response, raw, s)
|
|
|
|
|
|
def _send_verification(user: User, raw: str, s: Settings) -> None:
|
|
email_service.send_verification_email(
|
|
user.email, user.name, f"{s.base_url}/verify?token={raw}")
|
|
|
|
|
|
def _domain_allowed(email: str, s: Settings) -> bool:
|
|
allowed = s.allowed_signup_domains
|
|
return not allowed or email.rsplit("@", 1)[-1].lower() in allowed
|
|
|
|
|
|
# ---------------------------------------------------------------------- routes
|
|
|
|
@router.post("/signup", response_model=Ok)
|
|
def signup(body: SignupIn, request: Request, bg: BackgroundTasks,
|
|
db: Session = Depends(get_db), s: Settings = Depends(get_settings)) -> Ok:
|
|
if not s.SIGNUP_ENABLED:
|
|
raise HTTPException(status.HTTP_403_FORBIDDEN,
|
|
"New accounts are not being created right now.")
|
|
ip = client_ip(request)
|
|
_limit("signup:ip", ip)
|
|
|
|
email = body.email.strip().lower()
|
|
if not _domain_allowed(email, s):
|
|
allowed = ", ".join(sorted(s.allowed_signup_domains))
|
|
raise HTTPException(status.HTTP_403_FORBIDDEN,
|
|
f"Accounts are limited to these domains: {allowed}.")
|
|
|
|
existing = db.scalar(select(User).where(User.email == email))
|
|
if existing is not None:
|
|
# Same answer as a fresh signup. The real owner is told what happened;
|
|
# whoever submitted the form learns nothing.
|
|
if existing.is_verified:
|
|
bg.add_task(email_service.send_account_exists_email, existing.email,
|
|
existing.name, f"{s.base_url}/login", f"{s.base_url}/forgot")
|
|
else:
|
|
raw = _mint(db, existing, VERIFY_EMAIL,
|
|
timedelta(hours=s.VERIFY_TOKEN_TTL_HOURS), ip)
|
|
bg.add_task(_send_verification, existing, raw, s)
|
|
return Ok(message=CHECK_INBOX)
|
|
|
|
user = User(email=email, name=body.name, password_hash=hash_password(body.password))
|
|
db.add(user)
|
|
try:
|
|
db.commit()
|
|
except IntegrityError:
|
|
# Lost a race against a simultaneous signup for the same address.
|
|
db.rollback()
|
|
return Ok(message=CHECK_INBOX)
|
|
|
|
raw = _mint(db, user, VERIFY_EMAIL, timedelta(hours=s.VERIFY_TOKEN_TTL_HOURS), ip)
|
|
bg.add_task(_send_verification, user, raw, s)
|
|
log.info("account created: %s", email)
|
|
return Ok(message=CHECK_INBOX)
|
|
|
|
|
|
@router.post("/login")
|
|
def login(body: LoginIn, request: Request, response: Response,
|
|
db: Session = Depends(get_db), s: Settings = Depends(get_settings)) -> dict:
|
|
email = body.email.strip().lower()
|
|
_limit("login:ip", client_ip(request))
|
|
_limit("login:email", email)
|
|
|
|
user = db.scalar(select(User).where(User.email == email))
|
|
if user is None:
|
|
burn_cpu() # match the timing of a real verify
|
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, BAD_CREDENTIALS)
|
|
|
|
now = utcnow()
|
|
# Locked accounts get the same message as a wrong password. Saying "locked"
|
|
# would confirm the address exists and hand out a way to grief a colleague.
|
|
if user.locked_until and user.locked_until > now:
|
|
burn_cpu()
|
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, BAD_CREDENTIALS)
|
|
|
|
ok, rehashed = verify_password(user.password_hash, body.password)
|
|
if not ok:
|
|
user.failed_login_count += 1
|
|
if user.failed_login_count >= s.MAX_FAILED_LOGINS:
|
|
over = user.failed_login_count - s.MAX_FAILED_LOGINS
|
|
minutes = min(60, s.LOCKOUT_MINUTES * (2 ** over))
|
|
user.locked_until = now + timedelta(minutes=minutes)
|
|
log.warning("account %s locked for %d minutes", email, minutes)
|
|
db.commit()
|
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, BAD_CREDENTIALS)
|
|
|
|
if not user.is_active:
|
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, BAD_CREDENTIALS)
|
|
|
|
if not user.is_verified:
|
|
# The one place the answer is specific. It only reaches someone who
|
|
# already has valid credentials, and without it an unverified user has
|
|
# no way forward.
|
|
user.failed_login_count = 0
|
|
db.commit()
|
|
raise HTTPException(
|
|
status.HTTP_403_FORBIDDEN,
|
|
{"error": "Confirm your email address before signing in.",
|
|
"code": "email_not_verified"})
|
|
|
|
if rehashed:
|
|
user.password_hash = rehashed
|
|
_start_session(db, user, request, response, s)
|
|
limiter.reset("login:email", email)
|
|
return {"ok": True, "user": UserOut.of(user).model_dump(mode="json")}
|
|
|
|
|
|
@router.post("/logout", response_model=Ok)
|
|
def logout(request: Request, response: Response,
|
|
db: Session = Depends(get_db), s: Settings = Depends(get_settings)) -> Ok:
|
|
raw = request.cookies.get(s.SESSION_COOKIE_NAME)
|
|
if raw:
|
|
db.execute(delete(AuthSession).where(AuthSession.token_hash == fingerprint(raw, s)))
|
|
db.commit()
|
|
clear_session_cookie(response, s)
|
|
return Ok(message="Signed out.")
|
|
|
|
|
|
@router.get("/me")
|
|
def me(user: User = Depends(require_user)) -> dict:
|
|
return {"user": UserOut.of(user).model_dump(mode="json")}
|
|
|
|
|
|
@router.post("/verify", response_model=Ok)
|
|
def verify(body: TokenIn, db: Session = Depends(get_db)) -> Ok:
|
|
user = _consume(db, body.token, VERIFY_EMAIL)
|
|
if not user.is_verified:
|
|
user.email_verified_at = utcnow()
|
|
db.commit()
|
|
log.info("email verified: %s", user.email)
|
|
return Ok(message="Your email address is confirmed. You can sign in now.")
|
|
|
|
|
|
@router.post("/resend-verification", response_model=Ok)
|
|
def resend_verification(body: EmailIn, request: Request, bg: BackgroundTasks,
|
|
db: Session = Depends(get_db),
|
|
s: Settings = Depends(get_settings)) -> Ok:
|
|
email = body.email.strip().lower()
|
|
_limit("resend:email", email)
|
|
user = db.scalar(select(User).where(User.email == email))
|
|
if user is not None and not user.is_verified and user.is_active:
|
|
raw = _mint(db, user, VERIFY_EMAIL, timedelta(hours=s.VERIFY_TOKEN_TTL_HOURS),
|
|
client_ip(request))
|
|
bg.add_task(_send_verification, user, raw, s)
|
|
return Ok(message=CHECK_INBOX)
|
|
|
|
|
|
@router.post("/forgot", response_model=Ok)
|
|
def forgot(body: EmailIn, request: Request, bg: BackgroundTasks,
|
|
db: Session = Depends(get_db), s: Settings = Depends(get_settings)) -> Ok:
|
|
email = body.email.strip().lower()
|
|
_limit("forgot:ip", client_ip(request))
|
|
_limit("forgot:email", email)
|
|
|
|
user = db.scalar(select(User).where(User.email == email))
|
|
if user is None or not user.is_active:
|
|
burn_cpu() # keep the timing indistinguishable
|
|
return Ok(message=RESET_SENT)
|
|
|
|
raw = _mint(db, user, PASSWORD_RESET,
|
|
timedelta(minutes=s.RESET_TOKEN_TTL_MINUTES), client_ip(request))
|
|
bg.add_task(email_service.send_password_reset_email, user.email, user.name,
|
|
f"{s.base_url}/reset?token={raw}")
|
|
return Ok(message=RESET_SENT)
|
|
|
|
|
|
@router.post("/reset", response_model=Ok)
|
|
def reset(body: ResetIn, bg: BackgroundTasks, db: Session = Depends(get_db)) -> Ok:
|
|
user = _consume(db, body.token, PASSWORD_RESET)
|
|
|
|
user.password_hash = hash_password(body.password)
|
|
# Controlling the mailbox proves the address, so an unverified account
|
|
# becomes verified here rather than stranding the user.
|
|
if not user.is_verified:
|
|
user.email_verified_at = utcnow()
|
|
user.failed_login_count = 0
|
|
user.locked_until = None
|
|
# Every other browser is signed out. This is the point of server-side
|
|
# sessions: a stolen cookie dies with the password that leaked it.
|
|
db.execute(delete(AuthSession).where(AuthSession.user_id == user.id))
|
|
db.commit()
|
|
|
|
store_of().drop(user.id)
|
|
bg.add_task(email_service.send_password_changed_email, user.email, user.name)
|
|
log.info("password reset completed: %s", user.email)
|
|
return Ok(message="Your password has been changed. Sign in with it now.")
|
|
|
|
|
|
@router.get("/session")
|
|
def session_state(user: User | None = Depends(current_user)) -> dict:
|
|
"""Whether this browser is signed in. Never 401s -- the sign-in page itself
|
|
uses it to bounce an already-authenticated visitor onward."""
|
|
return {"authenticated": user is not None,
|
|
"user": UserOut.of(user).model_dump(mode="json") if user else None}
|