bank-fraud / app /auth.py
root
init
942b115
Raw
History Blame Contribute Delete
6.59 kB
"""Authentication: password hashing, server-side sessions, and the FastAPI
dependencies that enforce role boundaries on every protected route.
Sessions, not JWT (per user decision): a session is an opaque random token
(`secrets.token_urlsafe`) stored in the `sessions` table and in an httpOnly
cookie. Every authenticated request does one DB lookup (token -> user, with
an `expires_at` check) -- logout, or any future "disable this account"
action, is an instant, unconditional revocation (delete the row), which a
JWT can't give you without an extra denylist table.
Password hashing uses the `bcrypt` library directly (not passlib -- passlib
is unmaintained and breaks against modern bcrypt releases; bcrypt is what
passlib wraps internally anyway).
"""
from __future__ import annotations
import os
import secrets
from datetime import datetime, timedelta, timezone
import bcrypt
from fastapi import Cookie, Depends, HTTPException, Response, status
from sqlalchemy import select
from sqlalchemy.orm import Session as DBSession
from app.config import settings
from app.db.models import Client, Session as SessionModel, User, UserRole
from app.db.session import get_session
SESSION_COOKIE_NAME = "session_token"
# Secure-by-default: the cookie is only ever sent back by a browser over
# HTTPS, which is correct once this app sits behind the reverse proxy that
# terminates TLS externally. Set COOKIE_SECURE=false only for local dev
# when hitting the app directly over http:// (e.g. http://127.0.0.1:8811),
# bypassing the proxy -- never in a deployed environment.
COOKIE_SECURE = os.environ.get("COOKIE_SECURE", "true").strip().lower() != "false"
BCRYPT_MAX_PASSWORD_BYTES = 72 # bcrypt's own hard limit
def hash_password(password: str) -> str:
if len(password.encode("utf-8")) > BCRYPT_MAX_PASSWORD_BYTES:
raise ValueError(f"Password must be at most {BCRYPT_MAX_PASSWORD_BYTES} bytes.")
hashed = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt())
return hashed.decode("utf-8")
def verify_password(password: str, password_hash: str) -> bool:
try:
return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
except ValueError:
# Malformed/foreign hash -- never let this raise into a 500 that
# might hint at *why* verification failed.
return False
def create_session(db: DBSession, user: User) -> tuple[str, datetime]:
"""Create a session row for `user`, returning (token, expires_at).
Also opportunistically clears this user's already-expired sessions --
a lightweight tidy-up rather than a background job, adequate at this
scale.
"""
now = datetime.now(timezone.utc)
db.query(SessionModel).filter(
SessionModel.user_id == user.id, SessionModel.expires_at < now
).delete()
token = secrets.token_urlsafe(32)
expires_at = now + timedelta(minutes=settings.SESSION_EXPIRE_MINUTES)
db.add(SessionModel(token=token, user_id=user.id, expires_at=expires_at))
db.flush()
return token, expires_at
def revoke_session(db: DBSession, token: str) -> None:
db.query(SessionModel).filter(SessionModel.token == token).delete()
def set_session_cookie(response: Response, token: str, expires_at: datetime) -> None:
max_age = max(0, int((expires_at - datetime.now(timezone.utc)).total_seconds()))
response.set_cookie(
key=SESSION_COOKIE_NAME,
value=token,
httponly=True,
secure=COOKIE_SECURE,
samesite="lax",
max_age=max_age,
path="/",
)
def clear_session_cookie(response: Response) -> None:
response.delete_cookie(key=SESSION_COOKIE_NAME, path="/")
def get_current_user(
session_token: str | None = Cookie(default=None, alias=SESSION_COOKIE_NAME),
db: DBSession = Depends(get_session),
) -> User:
"""FastAPI dependency: resolve the logged-in user from the session
cookie. Raises 401 if there's no valid, unexpired session -- the same
error regardless of *why* (missing cookie, unknown token, expired
token) so nothing about the failure reason leaks.
"""
unauthorized = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated.",
headers={"WWW-Authenticate": "Cookie"},
)
if not session_token:
raise unauthorized
now = datetime.now(timezone.utc)
stmt = select(SessionModel).where(
SessionModel.token == session_token, SessionModel.expires_at > now
)
db_session = db.execute(stmt).scalar_one_or_none()
if db_session is None:
raise unauthorized
user = db.get(User, db_session.user_id)
if user is None:
raise unauthorized
return user
def require_officer(user: User = Depends(get_current_user)) -> User:
if user.role != UserRole.OFFICER:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="This action requires a banking officer account.",
)
return user
def require_client(user: User = Depends(get_current_user)) -> User:
if user.role != UserRole.CLIENT:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="This action requires a client account.",
)
return user
def get_current_client_profile(
user: User = Depends(require_client), db: DBSession = Depends(get_session)
) -> Client:
"""Resolves the logged-in client's own `clients` row. A client user
without a linked profile is a data-integrity bug, not a client mistake
-- 500s loudly rather than pretending they're unauthenticated.
"""
stmt = select(Client).where(Client.user_id == user.id)
client = db.execute(stmt).scalar_one_or_none()
if client is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Client account has no linked profile.",
)
return client
def seed_officer_if_missing(db: DBSession) -> None:
"""Idempotent startup step (EXPL-04-style: runs once, at lifespan
startup): ensures `SEED_OFFICER_EMAIL` exists as an officer account, so
there's always a way in on first launch.
"""
stmt = select(User).where(User.email == settings.SEED_OFFICER_EMAIL)
existing = db.execute(stmt).scalar_one_or_none()
if existing is not None:
return
officer = User(
name="Seed Officer",
email=settings.SEED_OFFICER_EMAIL,
password_hash=hash_password(settings.SEED_OFFICER_PASSWORD),
role=UserRole.OFFICER,
)
db.add(officer)
db.commit()