feat: magic-link auth with access requests and RBAC
This commit is contained in:
parent
c2125f063b
commit
c8280f7078
13 changed files with 1058 additions and 2 deletions
173
backend/app/auth/api.py
Normal file
173
backend/app/auth/api.py
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
"""Access + auth HTTP API (magic-link login, session cookie)."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.models import User
|
||||
|
||||
from . import mail, service
|
||||
from .deps import (
|
||||
SESSION_COOKIE,
|
||||
SESSION_MAX_AGE,
|
||||
get_current_user,
|
||||
get_db,
|
||||
require_admin,
|
||||
)
|
||||
from .schemas import (
|
||||
AccessDecisionRead,
|
||||
AccessRequestCreate,
|
||||
AccessRequestRead,
|
||||
AdminBootstrapRequest,
|
||||
MagicConsumeRequest,
|
||||
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(
|
||||
id=r.id,
|
||||
name=r.name,
|
||||
email=r.email,
|
||||
reason=r.reason,
|
||||
status=r.status,
|
||||
created_at=r.created_at,
|
||||
decided_at=r.decided_at,
|
||||
decided_by=r.decided_by,
|
||||
)
|
||||
|
||||
|
||||
def _read_me(user: User) -> MeRead:
|
||||
return MeRead(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
name=user.name,
|
||||
role=user.role,
|
||||
tenant_id=user.tenant_id,
|
||||
)
|
||||
|
||||
|
||||
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: 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(
|
||||
status: str | None = Query(default=None),
|
||||
session: Session = Depends(get_db),
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
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: Session = Depends(get_db),
|
||||
admin: 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: Session = Depends(get_db),
|
||||
admin: 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/consume", response_model=MeRead)
|
||||
def consume_magic(
|
||||
data: MagicConsumeRequest,
|
||||
response: Response,
|
||||
session: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
user_session = 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
|
||||
user = service.get_user(session, user_session.user_id)
|
||||
if user is None: # pragma: no cover - consume already validated the user
|
||||
raise HTTPException(status_code=400, detail="unknown token")
|
||||
_set_session_cookie(response, user_session.id)
|
||||
return _read_me(user)
|
||||
|
||||
|
||||
@auth_router.get("/me", response_model=MeRead)
|
||||
def read_me(user: User = Depends(get_current_user)):
|
||||
return _read_me(user)
|
||||
|
||||
|
||||
@auth_router.post("/logout", response_model=MessageRead)
|
||||
def logout(
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: Session = Depends(get_db),
|
||||
):
|
||||
# Revoke the server-side row when the cookie maps to a live session;
|
||||
# always clear the client cookie.
|
||||
session_id = request.cookies.get(SESSION_COOKIE)
|
||||
if session_id:
|
||||
service.revoke_session(session, session_id)
|
||||
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: 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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue