feat: integrate Logto OIDC for user authentication
- Add Logto OIDC integration (app/core/logto.py): CookieStorage adapter, create/decode session token helpers, sync_logto_user upsert - New auth endpoints (/api/v1/auth): sign-in, callback, sign-out, me - AuthRedirectMiddleware: protects HTML pages, redirects to /setup when Logto is unconfigured, to /login otherwise - Update require_admin_auth: accepts dmarq_session cookie JWT first, then API key, then Bearer JWT (fully backward compatible) - Update User model: add logto_id, username, picture, created_at, updated_at; make hashed_password nullable for Logto-only users; is_superuser default=True - New Alembic migration d4e5f6a7b8c9 for the above schema changes - Add LOGTO_ENDPOINT / LOGTO_APP_ID / LOGTO_APP_SECRET / LOGTO_REDIRECT_URI settings with logto_configured property - Create login.html (Sign in with Logto button) and setup.html (step-by-step configuration guide) - Update base.html: user menu with avatar/name and sign-out via Alpine.js fetch to /api/v1/auth/me - Update settings.html: remove localStorage adminApiKey; session cookie is sent automatically by browser; add 401 → /login redirect - Update requirements.txt: replace fastapi-users additions with logto + aiohttp - Add test_auth.py: 18 new tests covering session tokens, CookieStorage, sync_logto_user, /me, /sign-in (503), /sign-out cookie clearing - Fix test_security_extra.py: pass Request mock to require_admin_auth; add new test_valid_session_cookie_returns_auth_context Agent-Logs-Url: https://github.com/christianlouis/dmarq/sessions/b448f585-7646-40f8-ae2d-9986c361e3fd Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.api_v1.endpoints import (
|
||||
auth,
|
||||
domains,
|
||||
health,
|
||||
imap,
|
||||
@@ -14,6 +15,7 @@ from app.api.api_v1.endpoints import (
|
||||
api_router = APIRouter()
|
||||
|
||||
# Include all endpoint routers
|
||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||
api_router.include_router(health.router, tags=["health"])
|
||||
api_router.include_router(domains.router, prefix="/domains", tags=["domains"])
|
||||
api_router.include_router(reports.router, prefix="/reports", tags=["reports"])
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
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 = await 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.
|
||||
|
||||
Clears the app session cookie and redirects to Logto's end-session
|
||||
endpoint (if available) so that the Logto session is terminated too.
|
||||
"""
|
||||
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("/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.
|
||||
|
||||
Reads the ``dmarq_session`` cookie (issued at callback time) and looks up
|
||||
the corresponding local ``User`` record.
|
||||
"""
|
||||
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,
|
||||
}
|
||||
Reference in New Issue
Block a user