diff --git a/backend/alembic/versions/d4e5f6a7b8c9_add_logto_user_fields.py b/backend/alembic/versions/d4e5f6a7b8c9_add_logto_user_fields.py new file mode 100644 index 0000000..6438154 --- /dev/null +++ b/backend/alembic/versions/d4e5f6a7b8c9_add_logto_user_fields.py @@ -0,0 +1,87 @@ +"""Add Logto fields to users table. + +Adds the columns required for Logto OIDC integration and general user-profile +enhancements: + +- ``logto_id`` – the Logto subject claim (``sub``); acts as the stable + external identity reference. +- ``username`` – optional display username synced from Logto. +- ``picture`` – profile-picture URL synced from Logto. +- ``created_at`` – row-creation timestamp. +- ``updated_at`` – last-update timestamp. + +``hashed_password`` is made nullable because Logto-authenticated users +authenticate externally and have no local password. + +Revision ID: d4e5f6a7b8c9 +Revises: c3d4e5f6a7b8 +Create Date: 2026-03-30 10:00:00.000000 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "d4e5f6a7b8c9" +down_revision: Union[str, Sequence[str], None] = "c3d4e5f6a7b8" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Apply schema changes.""" + with op.batch_alter_table("users") as batch_op: + # New columns + batch_op.add_column( + sa.Column("logto_id", sa.String(), nullable=True) + ) + batch_op.add_column( + sa.Column("username", sa.String(), nullable=True) + ) + batch_op.add_column( + sa.Column("picture", sa.String(), nullable=True) + ) + batch_op.add_column( + sa.Column( + "created_at", + sa.DateTime(), + nullable=True, + server_default=sa.func.now(), + ) + ) + batch_op.add_column( + sa.Column( + "updated_at", + sa.DateTime(), + nullable=True, + server_default=sa.func.now(), + ) + ) + # Make hashed_password nullable (Logto users have no local password) + batch_op.alter_column("hashed_password", nullable=True) + # Set is_superuser default to True (all users are admins for now) + batch_op.alter_column("is_superuser", server_default=sa.true()) + + # Add unique index on logto_id + op.create_index( + op.f("ix_users_logto_id"), + "users", + ["logto_id"], + unique=True, + ) + + +def downgrade() -> None: + """Revert schema changes.""" + op.drop_index(op.f("ix_users_logto_id"), table_name="users") + + with op.batch_alter_table("users") as batch_op: + batch_op.drop_column("updated_at") + batch_op.drop_column("created_at") + batch_op.drop_column("picture") + batch_op.drop_column("username") + batch_op.drop_column("logto_id") + batch_op.alter_column("hashed_password", nullable=False) + batch_op.alter_column("is_superuser", server_default=sa.false()) diff --git a/backend/app/api/api_v1/api.py b/backend/app/api/api_v1/api.py index 4a8978e..51512d3 100644 --- a/backend/app/api/api_v1/api.py +++ b/backend/app/api/api_v1/api.py @@ -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"]) diff --git a/backend/app/api/api_v1/endpoints/auth.py b/backend/app/api/api_v1/endpoints/auth.py new file mode 100644 index 0000000..2bc2a1b --- /dev/null +++ b/backend/app/api/api_v1/endpoints/auth.py @@ -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, + } diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 73341d3..b6439ba 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -54,9 +54,29 @@ class Settings(BaseSettings): # Use: openssl rand -hex 32 ADMIN_API_KEY: Optional[str] = None + # ── Logto OIDC ──────────────────────────────────────────────────────────── + # Set these to enable Logto-based authentication. + # LOGTO_ENDPOINT: the base URL of your Logto instance, + # e.g. "https://your-tenant.logto.app" or a self-hosted URL. + # LOGTO_APP_ID: the Client ID of the "Traditional Web" application in Logto. + # LOGTO_APP_SECRET: the Client Secret of the same application. + # LOGTO_REDIRECT_URI (optional): override the default callback URL. + # Defaults to /api/v1/auth/callback. + LOGTO_ENDPOINT: Optional[str] = None + LOGTO_APP_ID: Optional[str] = None + LOGTO_APP_SECRET: Optional[str] = None + LOGTO_REDIRECT_URI: Optional[str] = None + + @property + def logto_configured(self) -> bool: + """Return True when the minimum Logto settings are present.""" + return bool(self.LOGTO_ENDPOINT and self.LOGTO_APP_ID and self.LOGTO_APP_SECRET) + @validator("ADMIN_API_KEY", pre=True, always=True) @classmethod - def validate_admin_api_key(cls, v: Optional[str]) -> Optional[str]: # pylint: disable=no-self-argument + def validate_admin_api_key( + cls, v: Optional[str] + ) -> Optional[str]: # pylint: disable=no-self-argument """Warn if ADMIN_API_KEY is set but too short.""" if v is not None and len(v) < 32: logger.warning( diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 1fd824e..9be825a 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -1,10 +1,9 @@ import os -from typing import AsyncGenerator, Generator +from typing import Generator from urllib.parse import urlparse, urlunparse from sqlalchemy import create_engine from sqlalchemy.engine import make_url -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker diff --git a/backend/app/core/logto.py b/backend/app/core/logto.py new file mode 100644 index 0000000..3112116 --- /dev/null +++ b/backend/app/core/logto.py @@ -0,0 +1,218 @@ +""" +Logto OIDC integration helpers. + +Provides: +- ``CookieStorage`` – Logto SDK Storage adapter backed by HTTP cookies. +- ``make_logto_client`` – Factory that builds a per-request LogtoClient. +- ``create_session_token``/``decode_session_token`` – thin JWT helpers for the + app-level session cookie (independent of Logto after the initial callback). +- ``sync_logto_user`` – Upserts the local User shadow record from Logto claims. +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timedelta +from typing import Optional + +from fastapi import Request, Response +from jose import JWTError, jwt +from logto import IdTokenClaims, LogtoClient, LogtoConfig, PersistKey, Scope, Storage, UserInfoScope +from sqlalchemy.orm import Session + +from app.core.config import get_settings +from app.models.user import User + +logger = logging.getLogger(__name__) +settings = get_settings() + +# ── Constants ──────────────────────────────────────────────────────────────── + +SESSION_COOKIE = "dmarq_session" + +# Short-lived: only needed while the browser is being redirected to Logto and back. +_SIGN_IN_SESSION_MAX_AGE = 600 # 10 minutes +# The app-level session lasts 24 hours by default; the Logto ID-token has its own +# expiry but we don't keep it in the browser beyond the callback request. +_SESSION_MAX_AGE = 86_400 # 24 hours + + +# ── Cookie-backed Logto Storage ─────────────────────────────────────────────── + + +class CookieStorage(Storage): + """ + Storage adapter for the Logto SDK that persists the OIDC session data + (sign-in session, tokens) in HTTP-only cookies. + + Usage:: + + storage = CookieStorage(request) + client = make_logto_client(storage) + url = await client.signIn(redirect_uri=…) + # build a response, then: + storage.apply_to_response(response) + return response + """ + + _COOKIE_PREFIX = "logto_" + + def __init__(self, request: Request) -> None: + self._request = request + # Pending writes/deletes – applied to the Response via apply_to_response(). + self._writes: dict[str, Optional[str]] = {} + self._deletes: set[str] = set() + + # ── Storage protocol ────────────────────────────────────────────────────── + + def get(self, key: PersistKey) -> Optional[str]: # type: ignore[override] + if key in self._writes: + return self._writes[key] + if key in self._deletes: + return None + return self._request.cookies.get(self._COOKIE_PREFIX + key) + + def set(self, key: PersistKey, value: Optional[str]) -> None: # type: ignore[override] + self._writes[key] = value + self._deletes.discard(key) + + def delete(self, key: PersistKey) -> None: # type: ignore[override] + self._deletes.add(key) + self._writes.pop(key, None) + + # ── Response helper ─────────────────────────────────────────────────────── + + def apply_to_response(self, response: Response) -> None: + """Flush pending cookie mutations onto *response*.""" + for key, value in self._writes.items(): + if value is None: + continue + max_age = _SIGN_IN_SESSION_MAX_AGE if key == "signInSession" else _SESSION_MAX_AGE + response.set_cookie( + key=self._COOKIE_PREFIX + key, + value=value, + httponly=True, + samesite="lax", + max_age=max_age, + ) + for key in self._deletes: + response.delete_cookie( + key=self._COOKIE_PREFIX + key, + httponly=True, + samesite="lax", + ) + + def clear_all_logto_cookies(self, response: Response) -> None: + """Remove every Logto cookie (called after we've issued our own session).""" + for key in ("signInSession", "idToken", "accessTokenMap", "refreshToken"): + response.delete_cookie( + key=self._COOKIE_PREFIX + key, + httponly=True, + samesite="lax", + ) + + +# ── LogtoClient factory ─────────────────────────────────────────────────────── + + +def make_logto_client(storage: CookieStorage) -> LogtoClient: + """Return a per-request ``LogtoClient`` bound to *storage*.""" + return LogtoClient( + LogtoConfig( + endpoint=settings.LOGTO_ENDPOINT or "", + appId=settings.LOGTO_APP_ID or "", + appSecret=settings.LOGTO_APP_SECRET, + scopes=[ + UserInfoScope.email, + UserInfoScope.profile, + Scope.offlineAccess, + ], + ), + storage=storage, + ) + + +# ── App-level session JWT (independent of Logto after first login) ──────────── + + +def create_session_token(user_id: int) -> str: + """Mint a signed HS256 JWT for *user_id* with a 24-hour lifetime.""" + payload = { + "sub": str(user_id), + "type": "dmarq_session", + "exp": datetime.utcnow() + timedelta(seconds=_SESSION_MAX_AGE), + } + return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + + +def decode_session_token(token: str) -> Optional[int]: + """ + Validate *token* and return the user's local DB id. + + Returns ``None`` on any error (expired, wrong type, bad signature, …). + """ + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + if payload.get("type") != "dmarq_session": + return None + return int(payload["sub"]) + except (JWTError, ValueError, TypeError): + return None + + +# ── Local user sync ─────────────────────────────────────────────────────────── + + +def sync_logto_user(claims: IdTokenClaims, db: Session) -> User: + """ + Upsert the local ``User`` shadow record from Logto ID-token claims. + + Lookup order: + 1. Match on ``logto_id`` (``sub`` claim) – fastest, stable. + 2. Fall back to matching on email if the user was created before Logto + integration and doesn't have a ``logto_id`` yet. + 3. Create a brand-new record if neither match. + + All users are treated as admins (``is_superuser=True``) until RBAC is + added in a future milestone. + """ + logto_id: str = claims.sub + email: str = claims.email or f"{logto_id}@logto.local" + + # 1. Try existing Logto-linked user + user: Optional[User] = db.query(User).filter(User.logto_id == logto_id).first() + + if user is None: + # 2. Try to link a legacy user by email + user = db.query(User).filter(User.email == email).first() + if user is not None: + user.logto_id = logto_id + logger.info( + "Linked existing user id=%d (%s) to Logto sub=%s", + user.id, + email, + logto_id, + ) + + if user is None: + # 3. Create new user + user = User( + logto_id=logto_id, + email=email, + is_active=True, + is_superuser=True, + is_verified=bool(getattr(claims, "email_verified", False)), + ) + db.add(user) + db.flush() # populate user.id before commit + logger.info("Created new user id=%d from Logto sub=%s (%s)", user.id, logto_id, email) + + # Always refresh profile from latest claims + user.full_name = getattr(claims, "name", None) or user.full_name + user.username = getattr(claims, "username", None) or user.username + user.picture = getattr(claims, "picture", None) or user.picture + user.updated_at = datetime.utcnow() + + db.commit() + db.refresh(user) + return user diff --git a/backend/app/core/security.py b/backend/app/core/security.py index c016dc3..0c55ad9 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -4,7 +4,7 @@ import secrets from datetime import datetime, timedelta from typing import Any, Optional, Union -from fastapi import HTTPException, Security, status +from fastapi import HTTPException, Request, Security, status from fastapi.security import APIKeyHeader, HTTPAuthorizationCredentials, HTTPBearer from jose import JWTError, jwt from passlib.context import CryptContext @@ -162,42 +162,55 @@ async def verify_token( async def require_admin_auth( + request: Request, api_key: Optional[str] = Security(api_key_header), bearer: Optional[HTTPAuthorizationCredentials] = Security(security_bearer), ) -> dict: """ - Dependency to require either API key or JWT token authentication for admin endpoints. + Dependency to require authentication for admin/API endpoints. - Checks API key first, then falls back to JWT token. + Accepts (in priority order): + 1. ``dmarq_session`` cookie – set after a successful Logto login. + 2. ``X-API-Key`` header – static admin key for programmatic access. + 3. ``Authorization: Bearer `` header – app-issued JWT. - Args: - api_key: Optional API key from X-API-Key header - bearer: Optional JWT token from Authorization header - - Returns: - Authentication context (api_key or token payload) - - Raises: - HTTPException: If no valid authentication is provided + Returns an authentication context dict describing how the request was + authenticated. Raises ``HTTP 401`` when no valid credential is present. """ - # Try API key first + # 1. Session cookie (Logto-backed app session) + from app.core.logto import SESSION_COOKIE, decode_session_token # local import + + session_token = request.cookies.get(SESSION_COOKIE) + if session_token: + user_id = decode_session_token(session_token) + if user_id is not None: + return {"auth_type": "session", "user_id": user_id} + + # 2. Static admin API key if api_key and verify_api_key(api_key): return {"auth_type": "api_key", "api_key": api_key} - # Try JWT token + # 3. Bearer JWT (app-issued; also covers Bearer tokens set by older clients) if bearer: + from app.core.logto import decode_session_token as _dec # local import + + user_id = _dec(bearer.credentials) + if user_id is not None: + return {"auth_type": "bearer", "user_id": user_id} + + # Fallback: legacy python-jose JWT (pre-Logto API keys / CI tokens) try: payload = jwt.decode( bearer.credentials, settings.SECRET_KEY, algorithms=[settings.ALGORITHM] ) return {"auth_type": "jwt", "payload": payload} except JWTError as e: - logger.warning("Invalid JWT token: %s", str(e)) + logger.warning("Invalid Bearer JWT: %s", str(e)) - # No valid authentication provided + # No valid authentication raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Authentication required. Provide either X-API-Key header or Bearer token.", + detail="Authentication required. Provide a session cookie, X-API-Key header, or Bearer token.", headers={"WWW-Authenticate": "ApiKey, Bearer"}, ) diff --git a/backend/app/main.py b/backend/app/main.py index 2b6ac92..f52fc6e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -17,10 +17,11 @@ from app.api.api_v1.api import api_router from app.core.config import get_settings from app.core.database import Base, SessionLocal, engine from app.core.security import add_api_key, generate_api_key, require_admin_auth +from app.middleware.auth import AuthRedirectMiddleware from app.middleware.security import SecurityHeadersMiddleware from app.models.mail_source import MailSource # noqa: F401 – ensure table is registered -from app.services.gmail_client import GmailClient from app.models.user import User # noqa: F401 – ensure User mapper is registered +from app.services.gmail_client import GmailClient from app.services.imap_client import IMAPClient from app.services.report_store import ReportStore @@ -255,6 +256,9 @@ def create_app() -> FastAPI: environment = os.getenv("ENVIRONMENT", "development") application.add_middleware(SecurityHeadersMiddleware, environment=environment) + # Auth redirect middleware – protects HTML pages; must sit outside CORS + application.add_middleware(AuthRedirectMiddleware) + # Improved CORS configuration - restrict to specific methods and headers if settings.BACKEND_CORS_ORIGINS: application.add_middleware( @@ -362,13 +366,28 @@ async def dashboard(request: Request): @app.get("/login", response_class=HTMLResponse) -async def login(request: Request): - return templates.TemplateResponse(request, "login.html", {"app_name": settings.PROJECT_NAME}) +async def login(request: Request, next: str = "/"): + return templates.TemplateResponse( + request, + "login.html", + { + "app_name": settings.PROJECT_NAME, + "logto_configured": settings.logto_configured, + "next": next, + }, + ) @app.get("/setup", response_class=HTMLResponse) async def setup(request: Request): - return templates.TemplateResponse(request, "setup.html", {"app_name": settings.PROJECT_NAME}) + return templates.TemplateResponse( + request, + "setup.html", + { + "app_name": settings.PROJECT_NAME, + "logto_configured": settings.logto_configured, + }, + ) @app.get("/domains", response_class=HTMLResponse) @@ -418,9 +437,7 @@ async def reports(request: Request): @app.get("/reports/{report_id}", response_class=HTMLResponse) async def report_detail(request: Request, report_id: str): """View detailed information for a specific DMARC report""" - return templates.TemplateResponse( - request, "report_detail.html", {"report_id": report_id} - ) + return templates.TemplateResponse(request, "report_detail.html", {"report_id": report_id}) @app.get("/settings", response_class=HTMLResponse) diff --git a/backend/app/middleware/auth.py b/backend/app/middleware/auth.py new file mode 100644 index 0000000..a87f608 --- /dev/null +++ b/backend/app/middleware/auth.py @@ -0,0 +1,80 @@ +""" +Authentication redirect middleware. + +Intercepts browser requests for protected HTML pages and redirects +unauthenticated visitors to ``/login`` (or ``/setup`` if Logto is not yet +configured). + +API routes (``/api/…``) are intentionally left to handle their own 401 +responses so that programmatic clients are not broken. +""" + +from __future__ import annotations + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import RedirectResponse, Response +from starlette.types import ASGIApp + +from app.core.logto import SESSION_COOKIE, decode_session_token + +# Paths that are always publicly accessible +_PUBLIC_PATHS: frozenset[str] = frozenset( + { + "/login", + "/setup", + "/health", + "/healthz", + } +) + +# Request path prefixes that bypass auth checks +_PUBLIC_PREFIXES: tuple[str, ...] = ( + "/api/", + "/static/", + "/docs", + "/redoc", + "/openapi", +) + + +class AuthRedirectMiddleware(BaseHTTPMiddleware): + """ + Redirect unauthenticated browser requests to the appropriate page. + + Decision tree + ------------- + 1. Path is public → pass through. + 2. Session cookie present and valid → pass through. + 3. Logto not configured → redirect to ``/setup``. + 4. Otherwise → redirect to ``/login?next=``. + """ + + def __init__(self, app: ASGIApp) -> None: + super().__init__(app) + + async def dispatch(self, request: Request, call_next) -> Response: # type: ignore[override] + path = request.url.path + + # ── 1. Public paths & prefixes ──────────────────────────────────────── + if path in _PUBLIC_PATHS: + return await call_next(request) + if any(path.startswith(p) for p in _PUBLIC_PREFIXES): + return await call_next(request) + + # ── 2. Valid session cookie ─────────────────────────────────────────── + token = request.cookies.get(SESSION_COOKIE) + if token and decode_session_token(token) is not None: + return await call_next(request) + + # ── 3. Logto not configured ─────────────────────────────────────────── + from app.core.config import get_settings # local import avoids circular dep + + if not get_settings().logto_configured: + return RedirectResponse(url="/setup", status_code=302) + + # ── 4. Redirect to login ────────────────────────────────────────────── + next_path = request.url.path + if request.url.query: + next_path = f"{next_path}?{request.url.query}" + return RedirectResponse(url=f"/login?next={next_path}", status_code=302) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 1370a83..cc60640 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,24 +1,44 @@ -from sqlalchemy import Boolean, Column, Integer, String +from datetime import datetime + +from sqlalchemy import Boolean, Column, DateTime, Integer, String from sqlalchemy.orm import relationship from app.core.database import Base class User(Base): - """User model""" + """User model – local shadow of the identity managed by Logto.""" __tablename__ = "users" id = Column(Integer, primary_key=True, index=True) email = Column(String, unique=True, index=True, nullable=False) - hashed_password = Column(String, nullable=False) + # Logto subject claim (the user's stable ID inside Logto). + # Null for users that pre-date Logto integration or for + # programmatic/service accounts created directly in the DB. + logto_id = Column(String, unique=True, index=True, nullable=True) + # hashed_password kept for possible future local-auth fallback; nullable + # because Logto users authenticate externally and have no local password. + hashed_password = Column(String, nullable=True) is_active = Column(Boolean, default=True) - is_superuser = Column(Boolean, default=False) + # For now all users are treated as admin. RBAC tiers are planned. + is_superuser = Column(Boolean, default=True) is_verified = Column(Boolean, default=False) - # Additional fields + # Profile – synced from Logto claims on every login full_name = Column(String, nullable=True) + username = Column(String, nullable=True) organization = Column(String, nullable=True) + picture = Column(String, nullable=True) + + # Timestamps + created_at = Column(DateTime, default=datetime.utcnow, nullable=True) + updated_at = Column( + DateTime, + default=datetime.utcnow, + onupdate=datetime.utcnow, + nullable=True, + ) # Relationships user_domains = relationship("UserDomain", back_populates="user", cascade="all, delete-orphan") diff --git a/backend/app/templates/layouts/base.html b/backend/app/templates/layouts/base.html index 25dda64..f3e5b98 100644 --- a/backend/app/templates/layouts/base.html +++ b/backend/app/templates/layouts/base.html @@ -19,7 +19,8 @@ -