OOB-Dashboard/app/routers/auth.py

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}