Files
gh-christianlouis-dmarq/backend/app/api/api_v1/endpoints/auth.py
T

355 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Authentication endpoints (Logto OIDC).
Routes
------
GET /sign-in Initiate the Logto sign-in flow.
GET /callback Handle the Logto authorization-code callback.
GET /sign-out Sign the user out (clears session + redirects to Logto).
GET /me Return the currently authenticated user's profile.
GET /forgot-password Redirect to Logto's forgot-password screen (unauthenticated).
GET /change-password Redirect to Logto Account Center password page (authenticated).
GET /manage-mfa Redirect to Logto Account Center MFA page (authenticated).
GET /account-portal Redirect to the Logto Account Center root.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.database import get_db
from app.core.logto import (
SESSION_COOKIE,
CookieStorage,
create_session_token,
decode_session_token,
make_logto_client,
sync_logto_user,
)
from app.models.user import User
router = APIRouter()
logger = logging.getLogger(__name__)
settings = get_settings()
# ── Helpers ───────────────────────────────────────────────────────────────────
_SAFE_NEXT_PREFIXES = ("/",) # only allow relative redirects after login
def _safe_next(next_url: Optional[str]) -> str:
"""Validate and return a safe post-login redirect path."""
if next_url and next_url.startswith("/") and not next_url.startswith("//"):
return next_url
return "/"
def _logto_not_configured() -> HTTPException:
return HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=(
"Logto is not configured. "
"Set LOGTO_ENDPOINT, LOGTO_APP_ID, and LOGTO_APP_SECRET "
"in your environment."
),
)
def _get_redirect_uri(request: Request) -> str:
"""Build the callback redirect URI, preferring the configured override."""
if settings.LOGTO_REDIRECT_URI:
return settings.LOGTO_REDIRECT_URI
base = str(request.base_url).rstrip("/")
return f"{base}/api/v1/auth/callback"
# ── Endpoints ─────────────────────────────────────────────────────────────────
@router.get("/sign-in")
async def sign_in(
request: Request,
next: Optional[str] = None,
) -> RedirectResponse:
"""
Initiate the Logto OIDC sign-in flow.
Stores the PKCE sign-in session in a short-lived cookie and redirects the
browser to Logto's authorization endpoint. The optional ``next`` query
parameter is persisted in a separate cookie and used to redirect the user
to their original page after a successful login.
"""
if not settings.logto_configured:
raise _logto_not_configured()
storage = CookieStorage(request)
client = make_logto_client(storage)
sign_in_url: str = await client.signIn(redirectUri=_get_redirect_uri(request))
response = RedirectResponse(url=sign_in_url, status_code=302)
storage.apply_to_response(response)
# Persist the post-login destination so the callback can redirect there.
safe = _safe_next(next)
if safe != "/":
response.set_cookie(
key="logto_next",
value=safe,
httponly=True,
samesite="lax",
max_age=600, # 10 minutes must survive the Logto redirect round-trip
)
return response
@router.get("/callback")
async def callback(
request: Request,
db: Session = Depends(get_db),
) -> RedirectResponse:
"""
Handle the Logto authorization-code callback.
Exchanges the code for tokens, validates the ID token, upserts the local
user shadow record, issues the app-level session cookie, and clears the
temporary Logto cookies.
"""
if not settings.logto_configured:
raise _logto_not_configured()
storage = CookieStorage(request)
client = make_logto_client(storage)
try:
await client.handleSignInCallback(str(request.url))
except Exception as exc: # pylint: disable=broad-exception-caught
logger.warning("Logto callback error: %s", exc)
return RedirectResponse(url="/login?error=callback_failed", status_code=302)
try:
claims = client.getIdTokenClaims()
except Exception as exc: # pylint: disable=broad-exception-caught
logger.warning("Failed to extract ID-token claims: %s", exc)
return RedirectResponse(url="/login?error=token_error", status_code=302)
user = sync_logto_user(claims, db)
# Where to go after login
next_url = _safe_next(request.cookies.get("logto_next"))
response = RedirectResponse(url=next_url, status_code=302)
# Issue our own session cookie (independent of Logto from here on)
session_token = create_session_token(user.id)
response.set_cookie(
key=SESSION_COOKIE,
value=session_token,
httponly=True,
samesite="lax",
max_age=86_400, # 24 hours
)
# Clean up all temporary Logto & next cookies
storage.clear_all_logto_cookies(response)
response.delete_cookie(key="logto_next", httponly=True, samesite="lax")
logger.info("User id=%d logged in via Logto.", user.id)
return response
@router.get("/sign-out")
async def sign_out(request: Request) -> RedirectResponse:
"""
Sign the user out.
When ``AUTH_DISABLED=true`` there is nothing to sign out of; redirects to ``/``.
Otherwise clears the app session cookie and redirects to Logto's end-session
endpoint (if available) so that the Logto session is terminated too.
"""
if settings.AUTH_DISABLED:
return RedirectResponse(url="/", status_code=302)
post_logout_url = str(request.base_url).rstrip("/")
# Best-effort: obtain Logto's end-session URL from OIDC metadata.
end_session_url: Optional[str] = None
if settings.logto_configured:
try:
storage = CookieStorage(request)
client = make_logto_client(storage)
core = await client.getOidcCore()
end_session_url = getattr(core.metadata, "end_session_endpoint", None)
except Exception: # pylint: disable=broad-exception-caught
pass
if end_session_url:
redirect_to = f"{end_session_url}?post_logout_redirect_uri={post_logout_url}"
else:
redirect_to = "/login"
response = RedirectResponse(url=redirect_to, status_code=302)
response.delete_cookie(key=SESSION_COOKIE, httponly=True, samesite="lax")
return response
@router.get("/change-password")
async def change_password(request: Request) -> RedirectResponse:
"""
Redirect an authenticated user to the Logto Account Center password page.
Uses Logto's prebuilt Account Center flow at ``{LOGTO_ENDPOINT}/account/password``
so the user can change their existing password directly. A ``redirect``
query parameter is appended so that Logto returns the user to the Profile &
Security page after a successful update.
"""
if not settings.logto_configured:
raise _logto_not_configured()
base = str(request.base_url).rstrip("/")
password_url = f"{settings.LOGTO_ENDPOINT.rstrip('/')}/account/password?redirect={base}/profile"
return RedirectResponse(url=password_url, status_code=302)
@router.get("/forgot-password")
async def forgot_password(request: Request) -> RedirectResponse:
"""
Redirect the user to Logto's forgot-password screen.
Builds a standard Logto authorization URL and appends the
``first_screen=forgot_password`` parameter so that Logto shows the
password-reset form immediately instead of the normal sign-in form.
After the user resets their password they are returned via the normal
callback flow and land on the app dashboard.
This endpoint is kept for unauthenticated / "I forgot my password" use
cases. Authenticated users should use ``/change-password`` instead.
"""
if not settings.logto_configured:
raise _logto_not_configured()
storage = CookieStorage(request)
client = make_logto_client(storage)
sign_in_url: str = await client.signIn(redirectUri=_get_redirect_uri(request))
# Append the Logto-specific first_screen parameter so the password-reset
# form is shown directly. The sign-in URL normally already contains a "?"
# but we defensively detect the right separator in case the structure varies.
separator = "&" if "?" in sign_in_url else "?"
forgot_url = f"{sign_in_url}{separator}first_screen=forgot_password"
response = RedirectResponse(url=forgot_url, status_code=302)
storage.apply_to_response(response)
return response
@router.get("/manage-mfa")
async def manage_mfa(request: Request) -> RedirectResponse:
"""
Redirect an authenticated user to the Logto Account Center MFA page.
Uses Logto's prebuilt Account Center flow at
``{LOGTO_ENDPOINT}/account/authenticator-app`` so the user can enable,
configure, or remove TOTP authenticator-app MFA directly. A ``redirect``
query parameter is appended so that Logto returns the user to the Profile &
Security page after a successful update.
"""
if not settings.logto_configured:
raise _logto_not_configured()
base = str(request.base_url).rstrip("/")
mfa_url = (
f"{settings.LOGTO_ENDPOINT.rstrip('/')}/account/authenticator-app"
f"?redirect={base}/profile"
)
return RedirectResponse(url=mfa_url, status_code=302)
@router.get("/account-portal")
async def account_portal(request: Request) -> RedirectResponse:
"""
Redirect an authenticated user to the Logto Account Center.
The Logto account portal (``{LOGTO_ENDPOINT}/account``) lets users manage
their profile, linked identities, and multi-factor authentication settings
without leaving the Logto-hosted UI. A ``redirect`` query parameter is
appended so that Logto returns the user to the Profile & Security page
after a successful update.
"""
if not settings.logto_configured:
raise _logto_not_configured()
base = str(request.base_url).rstrip("/")
portal_url = f"{settings.LOGTO_ENDPOINT.rstrip('/')}/account?redirect={base}/profile"
return RedirectResponse(url=portal_url, status_code=302)
@router.get("/me", response_model=None)
async def get_current_user(
request: Request,
db: Session = Depends(get_db),
) -> Dict[str, Any]:
"""
Return the profile of the currently authenticated user.
When ``AUTH_DISABLED=true`` a synthetic anonymous-admin profile is returned
so that UI components (e.g. the navbar user menu) work without a real session.
Otherwise reads the ``dmarq_session`` cookie (issued at callback time) and
looks up the corresponding local ``User`` record.
"""
# Auth-disabled: return a synthetic profile so the UI renders correctly.
if settings.AUTH_DISABLED:
return {
"id": 0,
"email": "admin@localhost",
"full_name": "Local Admin",
"username": "admin",
"picture": None,
"is_superuser": True,
"logto_id": None,
"auth_disabled": True,
}
token = request.cookies.get(SESSION_COOKIE)
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
)
user_id = decode_session_token(token)
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired session",
)
user: Optional[User] = (
db.query(User).filter(User.id == user_id, User.is_active == True).first() # noqa: E712
)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or inactive",
)
return {
"id": user.id,
"email": user.email,
"full_name": user.full_name,
"username": user.username,
"picture": user.picture,
"is_superuser": user.is_superuser,
"logto_id": user.logto_id,
}