kirche-einfach/backend/app/auth/service.py

331 lines
10 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=30d, jti) in the HttpOnly session cookie. No session table:
logout clears the cookie; role/tenant changes apply at next login (or
immediately for deleted users — 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_TTL = timedelta(days=30)
# 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