335 lines
11 KiB
Python
335 lines
11 KiB
Python
"""Access requests, magic-link tokens, and stateless JWT sessions.
|
|
|
|
Keep API handlers thin: all persistence lives here. Tokens and session
|
|
JWTs are bearer secrets — log user/request ids only, never secret values.
|
|
|
|
Sessions are stateless HS256 JWTs (sub=user.id, email, role, tenant_id,
|
|
iat, exp=7d, jti) in the HttpOnly session cookie. No session table and no
|
|
revocation list: logout clears the cookie client-side, and a stolen token
|
|
is valid until its short expiry. Role/tenant changes apply at next login
|
|
(deleted users fail closed — the user row is reloaded per request).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import logging
|
|
import secrets
|
|
import uuid
|
|
from datetime import UTC, datetime, timedelta
|
|
from uuid import UUID
|
|
|
|
import jwt
|
|
from sqlmodel import Session, func, select
|
|
|
|
from app.models import AccessRequest, MagicLinkToken, User
|
|
from app.models.base import utcnow
|
|
from app.settings import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
MAGIC_LINK_TTL = timedelta(minutes=15)
|
|
# Session window is deliberately short: sessions are stateless JWTs with
|
|
# no server-side revocation (logout only clears the cookie), so a stolen
|
|
# token stays valid until expiry. 7 days bounds that window.
|
|
SESSION_TTL = timedelta(days=7)
|
|
|
|
# Public request endpoint: at most this many requests per email per hour.
|
|
REQUESTS_PER_EMAIL_PER_HOUR = 5
|
|
# Same for magic-link requests (email bombing / token spam protection).
|
|
MAGIC_LINKS_PER_EMAIL_PER_HOUR = 5
|
|
|
|
|
|
class AuthError(Exception):
|
|
pass
|
|
|
|
|
|
class NotFoundError(AuthError):
|
|
pass
|
|
|
|
|
|
class ConflictError(AuthError):
|
|
pass
|
|
|
|
|
|
class ForbiddenError(AuthError):
|
|
pass
|
|
|
|
|
|
class InvalidTokenError(AuthError):
|
|
pass
|
|
|
|
|
|
class ExpiredTokenError(AuthError):
|
|
pass
|
|
|
|
|
|
class UsedTokenError(AuthError):
|
|
pass
|
|
|
|
|
|
class RateLimitedError(AuthError):
|
|
pass
|
|
|
|
|
|
def normalize_email(email: str) -> str:
|
|
return email.strip().lower()
|
|
|
|
|
|
def hash_token(token: str) -> str:
|
|
return hashlib.sha256(token.encode()).hexdigest()
|
|
|
|
|
|
def is_admin_email(email: str) -> bool:
|
|
return normalize_email(email) in settings.admin_email_list
|
|
|
|
|
|
def role_for(email: str) -> str:
|
|
return "admin" if is_admin_email(email) else "member"
|
|
|
|
|
|
def get_user(session: Session, user_id: UUID) -> User | None:
|
|
return session.get(User, user_id)
|
|
|
|
|
|
def get_user_by_email(session: Session, email: str) -> User | None:
|
|
return session.exec(select(User).where(User.email == normalize_email(email))).first()
|
|
|
|
|
|
def user_count(session: Session) -> int:
|
|
return session.exec(select(func.count()).select_from(User)).one()
|
|
|
|
|
|
def _recent_count(session: Session, model, *filters, hours: int = 1) -> int:
|
|
since = utcnow() - timedelta(hours=hours)
|
|
return session.exec(
|
|
select(func.count()).select_from(model).where(*filters, model.created_at >= since)
|
|
).one()
|
|
|
|
|
|
def recent_request_count(session: Session, email: str, hours: int = 1) -> int:
|
|
return _recent_count(
|
|
session,
|
|
AccessRequest,
|
|
AccessRequest.email == normalize_email(email),
|
|
hours=hours,
|
|
)
|
|
|
|
|
|
def create_access_request(
|
|
session: Session, name: str, email: str, reason: str = ""
|
|
) -> AccessRequest:
|
|
email = normalize_email(email)
|
|
if recent_request_count(session, email) >= REQUESTS_PER_EMAIL_PER_HOUR:
|
|
raise RateLimitedError("too many requests from this email, try again later")
|
|
request = AccessRequest(name=name.strip(), email=email, reason=reason.strip())
|
|
session.add(request)
|
|
session.commit()
|
|
session.refresh(request)
|
|
logger.info("access request %s from %s", request.id, email)
|
|
return request
|
|
|
|
|
|
def get_access_request(session: Session, request_id: UUID) -> AccessRequest:
|
|
request = session.get(AccessRequest, request_id)
|
|
if request is None:
|
|
raise NotFoundError(f"access request {request_id} not found")
|
|
return request
|
|
|
|
|
|
def list_access_requests(session: Session, status: str | None = None) -> list[AccessRequest]:
|
|
query = select(AccessRequest).order_by(AccessRequest.created_at.desc())
|
|
if status is not None:
|
|
query = query.where(AccessRequest.status == status)
|
|
return list(session.exec(query).all())
|
|
|
|
|
|
def _get_or_create_user(session: Session, name: str, email: str) -> User:
|
|
user = get_user_by_email(session, email)
|
|
if user is None:
|
|
user = User(name=name.strip(), email=email, role=role_for(email))
|
|
session.add(user)
|
|
session.flush()
|
|
logger.info("user %s created for %s", user.id, email)
|
|
else:
|
|
if not user.name and name.strip():
|
|
user.name = name.strip()
|
|
new_role = role_for(email)
|
|
if user.role != new_role:
|
|
user.role = new_role
|
|
session.add(user)
|
|
session.flush()
|
|
return user
|
|
|
|
|
|
def issue_magic_token(session: Session, user: User) -> str:
|
|
token = secrets.token_urlsafe(32)
|
|
session.add(
|
|
MagicLinkToken(
|
|
user_id=user.id,
|
|
token_hash=hash_token(token),
|
|
expires_at=utcnow() + MAGIC_LINK_TTL,
|
|
)
|
|
)
|
|
session.commit()
|
|
logger.info("magic token issued for user %s", user.id)
|
|
return token
|
|
|
|
|
|
def recent_token_count(session: Session, user: User, hours: int = 1) -> int:
|
|
return _recent_count(session, MagicLinkToken, MagicLinkToken.user_id == user.id, hours=hours)
|
|
|
|
|
|
def request_magic_link(session: Session, email: str) -> tuple[User, str]:
|
|
"""Self-service login link for an allowed email.
|
|
|
|
Allowed = approved before (has a User row) or listed in ADMIN_EMAILS
|
|
(admin user is created on first request). Anything else raises
|
|
ForbiddenError — the frontend forwards those to the access form.
|
|
Note: 403-vs-200 distinguishes registered emails by design here.
|
|
"""
|
|
email = normalize_email(email)
|
|
user = get_user_by_email(session, email)
|
|
if user is None:
|
|
if not is_admin_email(email):
|
|
raise ForbiddenError("email is not allowed to log in")
|
|
user = _get_or_create_user(session, "", email)
|
|
session.commit()
|
|
session.refresh(user)
|
|
if recent_token_count(session, user) >= MAGIC_LINKS_PER_EMAIL_PER_HOUR:
|
|
raise RateLimitedError("too many login links, try again later")
|
|
return user, issue_magic_token(session, user)
|
|
|
|
|
|
def approve_access_request(
|
|
session: Session, request_id: UUID, decided_by: str
|
|
) -> tuple[AccessRequest, User, str]:
|
|
"""Approve → upsert User + fresh magic-link token (plaintext, mail it)."""
|
|
request = get_access_request(session, request_id)
|
|
if request.status != "pending":
|
|
raise ConflictError(f"access request {request_id} already {request.status}")
|
|
user = _get_or_create_user(session, request.name, request.email)
|
|
request.status = "approved"
|
|
request.decided_at = utcnow()
|
|
request.decided_by = normalize_email(decided_by)
|
|
session.add(request)
|
|
session.commit()
|
|
session.refresh(request)
|
|
token = issue_magic_token(session, user)
|
|
logger.info(
|
|
"access request %s approved by %s; user %s",
|
|
request.id,
|
|
request.decided_by,
|
|
user.id,
|
|
)
|
|
return request, user, token
|
|
|
|
|
|
def reject_access_request(session: Session, request_id: UUID, decided_by: str) -> AccessRequest:
|
|
request = get_access_request(session, request_id)
|
|
if request.status != "pending":
|
|
raise ConflictError(f"access request {request_id} already {request.status}")
|
|
request.status = "rejected"
|
|
request.decided_at = utcnow()
|
|
request.decided_by = normalize_email(decided_by)
|
|
session.add(request)
|
|
session.commit()
|
|
session.refresh(request)
|
|
logger.info("access request %s rejected by %s", request.id, request.decided_by)
|
|
return request
|
|
|
|
|
|
def _aware(value: datetime) -> datetime:
|
|
# SQLite returns naive datetimes; stored values are UTC.
|
|
if value.tzinfo is None:
|
|
return value.replace(tzinfo=UTC)
|
|
return value
|
|
|
|
|
|
def _jwt_secret() -> str:
|
|
secret = settings.jwt_secret
|
|
if not secret:
|
|
raise RuntimeError("JWT_SECRET is empty — set it in .env")
|
|
return secret
|
|
|
|
|
|
def mint_session_token(user: User) -> str:
|
|
"""Issue a stateless session JWT for a user (30-day expiry)."""
|
|
now = utcnow()
|
|
return jwt.encode(
|
|
{
|
|
"sub": str(user.id),
|
|
"email": user.email,
|
|
"role": user.role,
|
|
"tenant_id": user.tenant_id,
|
|
"iat": int(now.timestamp()),
|
|
"exp": int((now + SESSION_TTL).timestamp()),
|
|
"jti": uuid.uuid4().hex,
|
|
},
|
|
_jwt_secret(),
|
|
algorithm="HS256",
|
|
)
|
|
|
|
|
|
def decode_session_token(token: str) -> dict:
|
|
"""Verify a session JWT → claims. Expired/forged → token errors."""
|
|
try:
|
|
return jwt.decode(token.strip(), _jwt_secret(), algorithms=["HS256"])
|
|
except jwt.ExpiredSignatureError as exc:
|
|
raise ExpiredTokenError("session expired") from exc
|
|
except jwt.InvalidTokenError as exc:
|
|
raise InvalidTokenError("invalid session") from exc
|
|
|
|
|
|
def consume_magic_token(session: Session, token: str) -> tuple[User, str]:
|
|
"""Single-use consume → (user, session JWT). used_at is set first."""
|
|
record = session.exec(
|
|
select(MagicLinkToken).where(MagicLinkToken.token_hash == hash_token(token.strip()))
|
|
).first()
|
|
if record is None:
|
|
raise InvalidTokenError("unknown token")
|
|
if record.used_at is not None:
|
|
raise UsedTokenError("token already used")
|
|
if _aware(record.expires_at) <= utcnow():
|
|
raise ExpiredTokenError("token expired")
|
|
user = session.get(User, record.user_id)
|
|
if user is None:
|
|
raise InvalidTokenError("unknown token")
|
|
# Mark used first: a reused token can never mint a second session.
|
|
record.used_at = utcnow()
|
|
session.add(record)
|
|
session.commit()
|
|
session.refresh(user)
|
|
logger.info("magic token consumed for user %s", user.id)
|
|
return user, mint_session_token(user)
|
|
|
|
|
|
def get_token_user(session: Session, token: str) -> User | None:
|
|
"""User for a session JWT, or None (expired/forged/deleted user)."""
|
|
try:
|
|
claims = decode_session_token(token)
|
|
except (InvalidTokenError, ExpiredTokenError):
|
|
return None
|
|
try:
|
|
user_id = UUID(str(claims.get("sub", "")))
|
|
except ValueError:
|
|
return None
|
|
return session.get(User, user_id)
|
|
|
|
|
|
def bootstrap_admin(session: Session, email: str) -> tuple[User, str]:
|
|
"""First-admin setup: only when the user table is empty AND the email
|
|
is in ADMIN_EMAILS. Issues a magic-link token like approve does."""
|
|
email = normalize_email(email)
|
|
if user_count(session) > 0:
|
|
raise ConflictError("users already exist, bootstrap is closed")
|
|
if not is_admin_email(email):
|
|
raise ForbiddenError("email is not an admin email")
|
|
user = User(name="", email=email, role="admin")
|
|
session.add(user)
|
|
session.flush()
|
|
session.commit()
|
|
session.refresh(user)
|
|
token = issue_magic_token(session, user)
|
|
logger.info("admin bootstrapped for user %s", user.id)
|
|
return user, token
|