523b0529f4
Agent-Logs-Url: https://github.com/christianlouis/dmarq/sessions/d566f8d7-4560-4945-b9e5-388259f626a3 Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
355 lines
12 KiB
Python
355 lines
12 KiB
Python
"""
|
||
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,
|
||
}
|