166 lines
5.9 KiB
Python
166 lines
5.9 KiB
Python
"""Access + auth HTTP API (magic-link login, session cookie)."""
|
|
|
|
from typing import Annotated
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
|
from sqlmodel import Session
|
|
|
|
from app.db import get_db
|
|
from app.models import User
|
|
|
|
from . import mail, service
|
|
from .deps import SESSION_COOKIE, SESSION_MAX_AGE, get_current_user, require_admin
|
|
from .schemas import (
|
|
AccessDecisionRead,
|
|
AccessRequestCreate,
|
|
AccessRequestRead,
|
|
AdminBootstrapRequest,
|
|
MagicConsumeRequest,
|
|
MagicRequestRead,
|
|
MagicRequestRequest,
|
|
MeRead,
|
|
MessageRead,
|
|
)
|
|
|
|
access_router = APIRouter(prefix="/api/access", tags=["access"])
|
|
auth_router = APIRouter(prefix="/api/auth", tags=["auth"])
|
|
|
|
|
|
def _read_request(r) -> AccessRequestRead:
|
|
return AccessRequestRead.model_validate(r, from_attributes=True)
|
|
|
|
|
|
def _read_me(user: User) -> MeRead:
|
|
return MeRead.model_validate(user, from_attributes=True)
|
|
|
|
|
|
def _set_session_cookie(response: Response, session_id: str) -> None:
|
|
# localhost is a secure context, so Secure is safe for local dev too.
|
|
response.set_cookie(
|
|
SESSION_COOKIE,
|
|
session_id,
|
|
max_age=SESSION_MAX_AGE,
|
|
path="/",
|
|
httponly=True,
|
|
secure=True,
|
|
samesite="lax",
|
|
)
|
|
|
|
|
|
@access_router.post("/requests", response_model=AccessRequestRead, status_code=201)
|
|
def create_request(data: AccessRequestCreate, session: Annotated[Session, Depends(get_db)]):
|
|
try:
|
|
request = service.create_access_request(session, data.name, data.email, data.reason)
|
|
except service.RateLimitedError as exc:
|
|
raise HTTPException(status_code=429, detail=str(exc)) from exc
|
|
mail.send_access_notification(str(request.id), request.name, request.email)
|
|
return _read_request(request)
|
|
|
|
|
|
@access_router.get("/requests", response_model=list[AccessRequestRead])
|
|
def list_requests(
|
|
session: Annotated[Session, Depends(get_db)],
|
|
admin: Annotated[User, Depends(require_admin)],
|
|
status: str | None = Query(default=None),
|
|
):
|
|
return [_read_request(r) for r in service.list_access_requests(session, status)]
|
|
|
|
|
|
@access_router.post("/requests/{request_id}/approve", response_model=AccessDecisionRead)
|
|
def approve_request(
|
|
request_id: UUID,
|
|
session: Annotated[Session, Depends(get_db)],
|
|
admin: Annotated[User, Depends(require_admin)],
|
|
):
|
|
try:
|
|
request, _user, token = service.approve_access_request(session, request_id, admin.email)
|
|
except service.NotFoundError as exc:
|
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
except service.ConflictError as exc:
|
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
# The token travels by mail only — never in an API response.
|
|
email_sent = mail.send_magic_link(request.email, mail.magic_link(token))
|
|
return AccessDecisionRead(request=_read_request(request), email_sent=email_sent)
|
|
|
|
|
|
@access_router.post("/requests/{request_id}/reject", response_model=AccessRequestRead)
|
|
def reject_request(
|
|
request_id: UUID,
|
|
session: Annotated[Session, Depends(get_db)],
|
|
admin: Annotated[User, Depends(require_admin)],
|
|
):
|
|
try:
|
|
request = service.reject_access_request(session, request_id, admin.email)
|
|
except service.NotFoundError as exc:
|
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
except service.ConflictError as exc:
|
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
return _read_request(request)
|
|
|
|
|
|
@auth_router.post("/magic/request", response_model=MagicRequestRead)
|
|
def request_magic(
|
|
data: MagicRequestRequest,
|
|
session: Annotated[Session, Depends(get_db)],
|
|
):
|
|
"""Email-first login: allowed emails get a magic link mailed.
|
|
|
|
403 means "not allowed" — the frontend forwards those to /access.
|
|
The token travels by mail only, never in the response.
|
|
"""
|
|
try:
|
|
user, token = service.request_magic_link(session, data.email)
|
|
except service.ForbiddenError as exc:
|
|
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
except service.RateLimitedError as exc:
|
|
raise HTTPException(status_code=429, detail=str(exc)) from exc
|
|
email_sent = mail.send_magic_link(user.email, mail.magic_link(token))
|
|
return MagicRequestRead(message="login link sent", email_sent=email_sent)
|
|
|
|
|
|
@auth_router.post("/magic/consume", response_model=MeRead)
|
|
def consume_magic(
|
|
data: MagicConsumeRequest,
|
|
response: Response,
|
|
session: Annotated[Session, Depends(get_db)],
|
|
):
|
|
try:
|
|
user, session_token = service.consume_magic_token(session, data.token)
|
|
except (
|
|
service.InvalidTokenError,
|
|
service.ExpiredTokenError,
|
|
service.UsedTokenError,
|
|
) as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
_set_session_cookie(response, session_token)
|
|
return _read_me(user)
|
|
|
|
|
|
@auth_router.get("/me", response_model=MeRead)
|
|
def read_me(user: Annotated[User, Depends(get_current_user)]):
|
|
return _read_me(user)
|
|
|
|
|
|
@auth_router.post("/logout", response_model=MessageRead)
|
|
def logout(
|
|
request: Request,
|
|
response: Response,
|
|
session: Annotated[Session, Depends(get_db)],
|
|
):
|
|
# Stateless sessions: nothing server-side to revoke — always clear
|
|
# the client cookie.
|
|
response.delete_cookie(SESSION_COOKIE, path="/")
|
|
return MessageRead(message="logged out")
|
|
|
|
|
|
@auth_router.post("/admin-bootstrap", response_model=MessageRead, status_code=201)
|
|
def admin_bootstrap(data: AdminBootstrapRequest, session: Annotated[Session, Depends(get_db)]):
|
|
try:
|
|
_user, token = service.bootstrap_admin(session, data.email)
|
|
except service.ConflictError as exc:
|
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
except service.ForbiddenError as exc:
|
|
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
mail.send_magic_link(service.normalize_email(data.email), mail.magic_link(token))
|
|
return MessageRead(message="admin created, magic link sent")
|