Merge pull request #83 from christianlouis/copilot/fix-unauthorized-access-settings-api
feat: Logto OIDC authentication + AUTH_DISABLED no-auth fallback
This commit is contained in:
@@ -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())
|
||||
@@ -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,258 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
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("/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,
|
||||
}
|
||||
@@ -54,9 +54,38 @@ class Settings(BaseSettings):
|
||||
# Use: openssl rand -hex 32
|
||||
ADMIN_API_KEY: Optional[str] = None
|
||||
|
||||
# ── Authentication mode ───────────────────────────────────────────────────
|
||||
# Set AUTH_DISABLED=true to run without any authentication.
|
||||
# Every request is treated as an anonymous admin.
|
||||
#
|
||||
# ⚠️ Only use this for local development or deployments that are protected
|
||||
# by an external auth proxy (e.g. Authelia, OAuth2 Proxy, Traefik Forward Auth).
|
||||
# Never expose an AUTH_DISABLED instance directly to the internet.
|
||||
AUTH_DISABLED: bool = False
|
||||
|
||||
# ── 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 <base_url>/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(
|
||||
|
||||
@@ -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
|
||||
@@ -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,60 @@ 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. ``AUTH_DISABLED=true`` env var – passes through with a synthetic context.
|
||||
2. ``dmarq_session`` cookie – set after a successful Logto login.
|
||||
3. ``X-API-Key`` header – static admin key for programmatic access.
|
||||
4. ``Authorization: Bearer <token>`` 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
|
||||
# 0. Auth globally disabled
|
||||
if settings.AUTH_DISABLED:
|
||||
return {"auth_type": "disabled"}
|
||||
|
||||
# 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"},
|
||||
)
|
||||
|
||||
|
||||
+37
-7
@@ -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(
|
||||
@@ -296,6 +300,18 @@ def create_app() -> FastAPI:
|
||||
# Ensure all tables exist (no-op if already present)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# Warn loudly when authentication is completely disabled
|
||||
if settings.AUTH_DISABLED:
|
||||
logger.warning(
|
||||
"%s\n"
|
||||
"⚠️ AUTH_DISABLED=true — authentication is turned OFF.\n"
|
||||
"All requests have unrestricted admin access.\n"
|
||||
"Do NOT expose this instance directly to the internet.\n"
|
||||
"%s",
|
||||
"=" * 80,
|
||||
"=" * 80,
|
||||
)
|
||||
|
||||
# Load or generate the admin API key
|
||||
if settings.ADMIN_API_KEY:
|
||||
api_key = settings.ADMIN_API_KEY
|
||||
@@ -362,13 +378,29 @@ 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,
|
||||
"auth_disabled": settings.AUTH_DISABLED,
|
||||
"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 +450,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)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
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=<original_path>``.
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
# ── 0. Auth disabled globally ─────────────────────────────────────────
|
||||
from app.core.config import get_settings # local import avoids circular dep
|
||||
|
||||
cfg = get_settings()
|
||||
if cfg.AUTH_DISABLED:
|
||||
return await call_next(request)
|
||||
|
||||
# ── 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 ───────────────────────────────────────────
|
||||
if not cfg.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)
|
||||
@@ -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")
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
</head>
|
||||
<body class="min-h-screen bg-base-100 font-body antialiased">
|
||||
<!-- Updated Menu Bar -->
|
||||
<header class="navbar bg-primary text-primary-content">
|
||||
<header class="navbar bg-primary text-primary-content"
|
||||
x-data="userMenu()" x-init="loadUser()">
|
||||
<div class="flex-1">
|
||||
<a href="/" class="btn btn-ghost normal-case text-xl">
|
||||
<img src="/static/img/monogram_light.png" alt="DMARQ Logo" class="w-8 h-8 mr-2">
|
||||
@@ -35,6 +36,41 @@
|
||||
<li><a href="/mail-sources">Mail Sources</a></li>
|
||||
<li><a href="/settings">Settings</a></li>
|
||||
</ul>
|
||||
<!-- User menu -->
|
||||
<div class="ml-2">
|
||||
<template x-if="user">
|
||||
<div class="dropdown dropdown-end">
|
||||
<label tabindex="0" class="btn btn-ghost btn-circle avatar placeholder">
|
||||
<div class="bg-primary-content text-primary rounded-full w-8">
|
||||
<template x-if="user.picture">
|
||||
<img :src="user.picture" :alt="user.full_name || user.email" class="rounded-full w-8 h-8 object-cover">
|
||||
</template>
|
||||
<template x-if="!user.picture">
|
||||
<span class="text-sm font-semibold" x-text="(user.full_name || user.email || '?')[0].toUpperCase()"></span>
|
||||
</template>
|
||||
</div>
|
||||
</label>
|
||||
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 text-base-content rounded-box w-56">
|
||||
<li class="menu-title px-2 py-1">
|
||||
<span class="text-xs font-semibold truncate" x-text="user.full_name || user.email"></span>
|
||||
<span class="text-xs text-base-content/50 truncate" x-text="user.email" x-show="user.full_name"></span>
|
||||
</li>
|
||||
<li><a href="/settings">Settings</a></li>
|
||||
<li>
|
||||
<a href="/api/v1/auth/sign-out" class="text-error">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
|
||||
</svg>
|
||||
Sign out
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!user">
|
||||
<a href="/login" class="btn btn-ghost btn-sm">Sign in</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -45,7 +81,26 @@
|
||||
|
||||
<!-- Scripts -->
|
||||
{% block scripts %}{% endblock %}
|
||||
|
||||
|
||||
<!-- User-menu Alpine component -->
|
||||
<script>
|
||||
function userMenu() {
|
||||
return {
|
||||
user: null,
|
||||
async loadUser() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/auth/me');
|
||||
if (res.ok) {
|
||||
this.user = await res.json();
|
||||
}
|
||||
} catch (_) {
|
||||
// silently ignore – user is simply not shown
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Initialize theme from localStorage -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dmarqlight">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sign In – {{ app_name }}</title>
|
||||
|
||||
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&family=Open+Sans:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.24/dist/full.css" rel="stylesheet" type="text/css"/>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-base-200 flex items-center justify-center font-sans antialiased">
|
||||
|
||||
<div class="w-full max-w-md px-4">
|
||||
<!-- Logo / brand -->
|
||||
<div class="text-center mb-8">
|
||||
<a href="/" class="inline-flex items-center gap-3">
|
||||
<img src="/static/img/monogram_light.png" alt="{{ app_name }} logo" class="w-12 h-12">
|
||||
<span class="text-3xl font-bold text-primary font-heading">{{ app_name }}</span>
|
||||
</a>
|
||||
<p class="mt-2 text-base-content/60 text-sm">DMARC Monitoring & Analysis</p>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body gap-6">
|
||||
<h1 class="card-title text-xl justify-center">Sign in to your account</h1>
|
||||
|
||||
{% if auth_disabled %}
|
||||
<!-- Auth disabled mode -->
|
||||
<div role="alert" class="alert alert-info">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M12 2a10 10 0 100 20A10 10 0 0012 2z"/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="font-semibold">Authentication is disabled</p>
|
||||
<p class="text-sm">
|
||||
<code class="font-mono bg-base-200 px-1 rounded">AUTH_DISABLED=true</code>
|
||||
is set. All requests have full access — no sign-in required.
|
||||
<a href="/" class="link link-info font-medium">Go to dashboard →</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% elif not logto_configured %}
|
||||
<!-- Logto not yet configured -->
|
||||
<div role="alert" class="alert alert-warning">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="font-semibold">Authentication not configured</p>
|
||||
<p class="text-sm">Logto is not set up yet. Please visit the
|
||||
<a href="/setup" class="link link-warning font-medium">setup page</a>
|
||||
for configuration instructions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
<!-- Error banner (shown when ?error= is present) -->
|
||||
<div x-data="{ error: new URLSearchParams(window.location.search).get('error') }"
|
||||
x-show="error" x-cloak>
|
||||
<div role="alert" class="alert alert-error">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="font-semibold">Sign-in failed</p>
|
||||
<p class="text-sm" x-text="error === 'callback_failed'
|
||||
? 'The authentication callback failed. Please try again.'
|
||||
: error === 'token_error'
|
||||
? 'Could not read authentication token. Please try again.'
|
||||
: 'An unexpected error occurred. Please try again.'">
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Primary sign-in button -->
|
||||
<div class="space-y-3">
|
||||
<a href="/api/v1/auth/sign-in?next={{ next | urlencode }}"
|
||||
class="btn btn-primary btn-lg w-full gap-2">
|
||||
<!-- Logto "shield" icon approximation -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 2L4 5v6c0 5.25 3.5 9.74 8 11 4.5-1.26 8-5.75 8-11V5L12 2z"/>
|
||||
</svg>
|
||||
Sign in with Logto
|
||||
</a>
|
||||
|
||||
<p class="text-center text-xs text-base-content/50">
|
||||
Logto securely handles authentication.
|
||||
Your credentials are never sent to {{ app_name }}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="divider text-xs text-base-content/40">What is Logto?</div>
|
||||
|
||||
<div class="text-sm text-base-content/60 space-y-1">
|
||||
<p>
|
||||
<a href="https://logto.io" target="_blank" rel="noopener" class="link link-primary">Logto</a>
|
||||
is an open-source identity platform that supports email/password login,
|
||||
social providers (Google, GitHub, …), passkeys, and multi-factor authentication.
|
||||
</p>
|
||||
<p>
|
||||
It can be self-hosted alongside {{ app_name }} or used via the
|
||||
<a href="https://cloud.logto.io" target="_blank" rel="noopener" class="link link-primary">Logto Cloud</a>
|
||||
free tier.
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-center mt-6 text-xs text-base-content/40">
|
||||
© {{ app_name }} – DMARC Monitoring Platform
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Restore dark-mode preference from localStorage (mirrors base.html logic)
|
||||
(function () {
|
||||
if (localStorage.getItem('darkMode') === 'true') {
|
||||
document.documentElement.setAttribute('data-theme', 'dmarqdark');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -342,14 +342,19 @@ function settingsApp() {
|
||||
showCfToken: false,
|
||||
showSmtpPw: false,
|
||||
|
||||
// Session cookie is sent automatically by the browser (httpOnly, same-origin).
|
||||
// No manual auth header needed for API calls from the UI.
|
||||
apiHeaders() {
|
||||
const key = localStorage.getItem('adminApiKey') || '';
|
||||
return { 'Content-Type': 'application/json', 'X-API-Key': key };
|
||||
return { 'Content-Type': 'application/json' };
|
||||
},
|
||||
|
||||
async loadSettings() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/settings', { headers: this.apiHeaders() });
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
window.location.href = '/login?next=/settings';
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
this.showFlash('Failed to load settings: ' + res.statusText, false);
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dmarqlight">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Setup – {{ app_name }}</title>
|
||||
|
||||
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&family=Open+Sans:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.24/dist/full.css" rel="stylesheet" type="text/css"/>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-base-200 font-sans antialiased">
|
||||
|
||||
<div class="max-w-3xl mx-auto px-4 py-12">
|
||||
|
||||
<!-- Logo / brand -->
|
||||
<div class="text-center mb-10">
|
||||
<a href="/" class="inline-flex items-center gap-3">
|
||||
<img src="/static/img/monogram_light.png" alt="{{ app_name }} logo" class="w-12 h-12">
|
||||
<span class="text-3xl font-bold text-primary font-heading">{{ app_name }}</span>
|
||||
</a>
|
||||
<h1 class="mt-3 text-2xl font-semibold">First-run Setup</h1>
|
||||
<p class="mt-1 text-base-content/60 text-sm">Configure Logto to enable user authentication.</p>
|
||||
</div>
|
||||
|
||||
{% if logto_configured %}
|
||||
<!-- Already configured -->
|
||||
<div class="card bg-base-100 shadow-xl mb-8">
|
||||
<div class="card-body">
|
||||
<div role="alert" class="alert alert-success">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="font-semibold">Logto is configured!</p>
|
||||
<p class="text-sm">Authentication is ready. You can now
|
||||
<a href="/login" class="link link-success font-medium">sign in</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Step-by-step guide -->
|
||||
<div class="space-y-6">
|
||||
|
||||
<!-- Step 1 -->
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-lg">
|
||||
<span class="badge badge-primary badge-lg mr-2">1</span>
|
||||
Deploy or sign up for Logto
|
||||
</h2>
|
||||
<p class="text-sm text-base-content/70">
|
||||
Choose one of the options below. Both are free to start.
|
||||
</p>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-2">
|
||||
<div class="border border-base-300 rounded-lg p-4 space-y-2">
|
||||
<p class="font-semibold text-sm">☁️ Logto Cloud (recommended)</p>
|
||||
<p class="text-xs text-base-content/60">
|
||||
Sign up at
|
||||
<a href="https://cloud.logto.io" target="_blank" rel="noopener" class="link link-primary">cloud.logto.io</a>.
|
||||
The free tier supports unlimited users.
|
||||
</p>
|
||||
</div>
|
||||
<div class="border border-base-300 rounded-lg p-4 space-y-2">
|
||||
<p class="font-semibold text-sm">🐳 Self-hosted (Docker)</p>
|
||||
<p class="text-xs text-base-content/60">
|
||||
Add Logto to your <code class="font-mono">docker-compose.yml</code>
|
||||
(see the
|
||||
<a href="https://docs.logto.io/docs/recipes/deployment/" target="_blank" rel="noopener" class="link link-primary">Logto deployment guide</a>).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2 -->
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-lg">
|
||||
<span class="badge badge-primary badge-lg mr-2">2</span>
|
||||
Create a "Traditional Web" application in Logto
|
||||
</h2>
|
||||
<ol class="list-decimal list-inside text-sm text-base-content/70 space-y-1 mt-1">
|
||||
<li>Open the <strong>Logto Console</strong> → <em>Applications</em> → <em>Create application</em>.</li>
|
||||
<li>Choose <strong>Traditional Web</strong>.</li>
|
||||
<li>Enter a name, e.g. <em>DMARQ</em>.</li>
|
||||
<li>
|
||||
Set the <strong>Redirect URI</strong> to:<br>
|
||||
<code class="font-mono bg-base-200 px-2 py-0.5 rounded text-xs break-all">
|
||||
<your-dmarq-url>/api/v1/auth/callback
|
||||
</code>
|
||||
</li>
|
||||
<li>
|
||||
Set the <strong>Post Sign-out Redirect URI</strong> to:<br>
|
||||
<code class="font-mono bg-base-200 px-2 py-0.5 rounded text-xs break-all">
|
||||
<your-dmarq-url>/login
|
||||
</code>
|
||||
</li>
|
||||
<li>Save and note the <strong>App ID</strong> and <strong>App Secret</strong>.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3 -->
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-lg">
|
||||
<span class="badge badge-primary badge-lg mr-2">3</span>
|
||||
Set environment variables and restart {{ app_name }}
|
||||
</h2>
|
||||
<p class="text-sm text-base-content/70 mb-3">
|
||||
Add the following to your <code class="font-mono">.env</code> file or Docker environment:
|
||||
</p>
|
||||
<div class="mockup-code text-xs">
|
||||
<pre><code># Logto endpoint – the base URL of your Logto instance
|
||||
LOGTO_ENDPOINT=https://<your-tenant>.logto.app
|
||||
|
||||
# Application credentials from the Logto Console
|
||||
LOGTO_APP_ID=<your-app-id>
|
||||
LOGTO_APP_SECRET=<your-app-secret>
|
||||
|
||||
# Optional: override the callback URL (defaults to <base_url>/api/v1/auth/callback)
|
||||
# LOGTO_REDIRECT_URI=https://dmarc.example.com/api/v1/auth/callback</code></pre>
|
||||
</div>
|
||||
<div role="alert" class="alert alert-info mt-4 text-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M12 2a10 10 0 100 20A10 10 0 0012 2z"/>
|
||||
</svg>
|
||||
<p>
|
||||
For Docker Compose, set these in the <code class="font-mono">environment:</code> section
|
||||
of the <em>backend</em> service and run <code class="font-mono">docker compose up -d --force-recreate</code>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4 -->
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-lg">
|
||||
<span class="badge badge-primary badge-lg mr-2">4</span>
|
||||
(Optional) Add authentication providers in Logto
|
||||
</h2>
|
||||
<p class="text-sm text-base-content/70">
|
||||
Logto lets you enable social providers (Google, GitHub, Microsoft, …),
|
||||
SMS / email passwordless, and multi-factor authentication entirely through
|
||||
its console – no code changes needed in {{ app_name }}.
|
||||
</p>
|
||||
<p class="text-sm text-base-content/70 mt-2">
|
||||
See the
|
||||
<a href="https://docs.logto.io/docs/recipes/configure-connectors/" target="_blank" rel="noopener" class="link link-primary">Logto connector docs</a>
|
||||
for details.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alternative: disable auth entirely -->
|
||||
<div class="card bg-base-100 shadow border border-warning/40">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-lg text-warning">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
|
||||
</svg>
|
||||
Alternative: disable authentication entirely
|
||||
</h2>
|
||||
<p class="text-sm text-base-content/70">
|
||||
If you're running {{ app_name }} locally or behind a trusted reverse proxy that
|
||||
already handles authentication, you can skip Logto and grant everyone full access
|
||||
by setting:
|
||||
</p>
|
||||
<div class="mockup-code text-xs mt-2">
|
||||
<pre><code>AUTH_DISABLED=true</code></pre>
|
||||
</div>
|
||||
<div role="alert" class="alert alert-warning mt-3 text-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
|
||||
</svg>
|
||||
<p>
|
||||
<strong>Never</strong> set <code class="font-mono">AUTH_DISABLED=true</code> on a
|
||||
publicly reachable instance. Anyone with network access will have full admin access.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if logto_configured %}
|
||||
<div class="text-center mt-4">
|
||||
<a href="/login" class="btn btn-primary btn-lg">Go to Sign-in</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div><!-- /space-y-6 -->
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
if (localStorage.getItem('darkMode') === 'true') {
|
||||
document.documentElement.setAttribute('data-theme', 'dmarqdark');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,295 @@
|
||||
"""
|
||||
Tests for the Logto-based authentication layer.
|
||||
|
||||
These tests exercise:
|
||||
- Session-token creation and decoding (app.core.logto)
|
||||
- CookieStorage read/write/delete semantics
|
||||
- sync_logto_user DB upsert logic
|
||||
- /api/v1/auth/me – authenticated and unauthenticated
|
||||
- /api/v1/auth/sign-in – Logto not configured → 503
|
||||
- /api/v1/auth/sign-out – always clears the session cookie
|
||||
|
||||
All tests use the in-memory SQLite fixture from conftest.py.
|
||||
Logto SDK calls are mocked so no live Logto instance is needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.logto import (
|
||||
SESSION_COOKIE,
|
||||
CookieStorage,
|
||||
create_session_token,
|
||||
decode_session_token,
|
||||
sync_logto_user,
|
||||
)
|
||||
from app.models.user import User
|
||||
|
||||
# ── Session token helpers ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSessionToken:
|
||||
def test_roundtrip(self):
|
||||
token = create_session_token(user_id=7)
|
||||
assert decode_session_token(token) == 7
|
||||
|
||||
def test_invalid_token_returns_none(self):
|
||||
assert decode_session_token("not.a.token") is None
|
||||
|
||||
def test_wrong_type_returns_none(self):
|
||||
"""A generic JWT without the dmarq_session type claim should be rejected."""
|
||||
from jose import jwt
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
s = get_settings()
|
||||
payload = {"sub": "5", "type": "other"}
|
||||
bad_token = jwt.encode(payload, s.SECRET_KEY, algorithm=s.ALGORITHM)
|
||||
assert decode_session_token(bad_token) is None
|
||||
|
||||
|
||||
# ── CookieStorage ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCookieStorage:
|
||||
def _make_request(self, cookies: dict = None):
|
||||
req = MagicMock()
|
||||
req.cookies = cookies or {}
|
||||
return req
|
||||
|
||||
def _make_response(self):
|
||||
from starlette.responses import Response
|
||||
|
||||
return Response()
|
||||
|
||||
def test_get_from_request_cookies(self):
|
||||
req = self._make_request({"logto_idToken": "abc123"})
|
||||
storage = CookieStorage(req)
|
||||
assert storage.get("idToken") == "abc123"
|
||||
|
||||
def test_pending_write_shadows_cookie(self):
|
||||
req = self._make_request({"logto_idToken": "old"})
|
||||
storage = CookieStorage(req)
|
||||
storage.set("idToken", "new")
|
||||
assert storage.get("idToken") == "new"
|
||||
|
||||
def test_delete_shadows_cookie(self):
|
||||
req = self._make_request({"logto_idToken": "exists"})
|
||||
storage = CookieStorage(req)
|
||||
storage.delete("idToken")
|
||||
assert storage.get("idToken") is None
|
||||
|
||||
def test_apply_to_response_sets_cookies(self):
|
||||
storage = CookieStorage(self._make_request())
|
||||
storage.set("idToken", "tok123")
|
||||
resp = self._make_response()
|
||||
storage.apply_to_response(resp)
|
||||
# Cookie header should contain the key
|
||||
header_str = str(resp.headers.get("set-cookie", ""))
|
||||
assert "logto_idToken" in header_str
|
||||
|
||||
def test_apply_to_response_deletes_cookies(self):
|
||||
req = self._make_request({"logto_idToken": "old"})
|
||||
storage = CookieStorage(req)
|
||||
storage.delete("idToken")
|
||||
resp = self._make_response()
|
||||
storage.apply_to_response(resp)
|
||||
header_str = str(resp.headers.get("set-cookie", ""))
|
||||
assert "logto_idToken" in header_str
|
||||
# A deleted cookie is set with max-age=0
|
||||
assert "Max-Age=0" in header_str or "expires" in header_str.lower()
|
||||
|
||||
|
||||
# ── sync_logto_user ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSyncLogtoUser:
|
||||
def _claims(self, sub="logto-sub-1", email="user@example.com", name="Test User"):
|
||||
claims = MagicMock()
|
||||
claims.sub = sub
|
||||
claims.email = email
|
||||
claims.name = name
|
||||
claims.username = None
|
||||
claims.picture = None
|
||||
claims.email_verified = True
|
||||
return claims
|
||||
|
||||
def test_creates_new_user(self, db_session):
|
||||
claims = self._claims()
|
||||
user = sync_logto_user(claims, db_session)
|
||||
assert user.id is not None
|
||||
assert user.logto_id == "logto-sub-1"
|
||||
assert user.email == "user@example.com"
|
||||
assert user.full_name == "Test User"
|
||||
assert user.is_superuser is True
|
||||
|
||||
def test_returns_existing_user_by_logto_id(self, db_session):
|
||||
# Create user first
|
||||
claims = self._claims()
|
||||
user1 = sync_logto_user(claims, db_session)
|
||||
uid = user1.id
|
||||
|
||||
# Second call with same sub → same user, no duplicate
|
||||
user2 = sync_logto_user(claims, db_session)
|
||||
assert user2.id == uid
|
||||
total = db_session.query(User).count()
|
||||
assert total == 1
|
||||
|
||||
def test_links_existing_user_by_email(self, db_session):
|
||||
"""Legacy user with matching email but no logto_id gets linked."""
|
||||
legacy = User(email="user@example.com", is_active=True, is_superuser=True)
|
||||
db_session.add(legacy)
|
||||
db_session.commit()
|
||||
|
||||
claims = self._claims(sub="new-sub", email="user@example.com")
|
||||
user = sync_logto_user(claims, db_session)
|
||||
|
||||
assert user.id == legacy.id
|
||||
assert user.logto_id == "new-sub"
|
||||
|
||||
def test_updates_profile_on_subsequent_login(self, db_session):
|
||||
claims = self._claims(name="Old Name")
|
||||
sync_logto_user(claims, db_session)
|
||||
|
||||
claims2 = self._claims(name="New Name")
|
||||
user = sync_logto_user(claims2, db_session)
|
||||
assert user.full_name == "New Name"
|
||||
|
||||
|
||||
# ── /api/v1/auth/me ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAuthMeEndpoint:
|
||||
def test_me_unauthenticated_returns_401(self, client: TestClient):
|
||||
res = client.get("/api/v1/auth/me")
|
||||
assert res.status_code == 401
|
||||
|
||||
def test_me_with_valid_session_returns_user(self, client: TestClient, db_session):
|
||||
# Create a user in the DB
|
||||
user = User(
|
||||
email="me@example.com",
|
||||
logto_id="sub-me",
|
||||
is_active=True,
|
||||
is_superuser=True,
|
||||
)
|
||||
db_session.add(user)
|
||||
db_session.commit()
|
||||
db_session.refresh(user)
|
||||
|
||||
token = create_session_token(user.id)
|
||||
res = client.get("/api/v1/auth/me", cookies={SESSION_COOKIE: token})
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["email"] == "me@example.com"
|
||||
assert data["logto_id"] == "sub-me"
|
||||
|
||||
def test_me_with_invalid_session_returns_401(self, client: TestClient):
|
||||
res = client.get("/api/v1/auth/me", cookies={SESSION_COOKIE: "garbage"})
|
||||
assert res.status_code == 401
|
||||
|
||||
def test_me_with_inactive_user_returns_401(self, client: TestClient, db_session):
|
||||
user = User(
|
||||
email="inactive@example.com",
|
||||
logto_id="sub-inactive",
|
||||
is_active=False,
|
||||
is_superuser=True,
|
||||
)
|
||||
db_session.add(user)
|
||||
db_session.commit()
|
||||
db_session.refresh(user)
|
||||
|
||||
token = create_session_token(user.id)
|
||||
res = client.get("/api/v1/auth/me", cookies={SESSION_COOKIE: token})
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
# ── /api/v1/auth/sign-in ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSignInEndpoint:
|
||||
def test_sign_in_without_logto_config_returns_503(self, client: TestClient):
|
||||
"""When Logto is not configured the endpoint must return 503."""
|
||||
with patch("app.api.api_v1.endpoints.auth.settings") as mock_settings:
|
||||
mock_settings.logto_configured = False
|
||||
res = client.get("/api/v1/auth/sign-in", follow_redirects=False)
|
||||
assert res.status_code == 503
|
||||
|
||||
|
||||
# ── /api/v1/auth/sign-out ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSignOutEndpoint:
|
||||
def test_sign_out_clears_session_cookie(self, client: TestClient):
|
||||
"""Sign-out must delete the dmarq_session cookie regardless of Logto config."""
|
||||
token = create_session_token(user_id=1)
|
||||
# Use allow_redirects=False so we see the redirect response with cookies
|
||||
res = client.get(
|
||||
"/api/v1/auth/sign-out",
|
||||
cookies={SESSION_COOKIE: token},
|
||||
follow_redirects=False,
|
||||
)
|
||||
# Should redirect (to /login or Logto end_session)
|
||||
assert res.status_code in (302, 307)
|
||||
# The session cookie must be cleared (max-age=0 or expires in past)
|
||||
set_cookie = res.headers.get("set-cookie", "")
|
||||
assert SESSION_COOKIE in set_cookie
|
||||
assert "Max-Age=0" in set_cookie or "max-age=0" in set_cookie
|
||||
|
||||
|
||||
# ── AUTH_DISABLED mode ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAuthDisabled:
|
||||
"""Verify the AUTH_DISABLED=true no-auth fallback mode."""
|
||||
|
||||
def test_me_returns_synthetic_admin_when_auth_disabled(self, client: TestClient):
|
||||
"""With AUTH_DISABLED, /me must return the synthetic admin profile."""
|
||||
with patch("app.api.api_v1.endpoints.auth.settings") as mock_settings:
|
||||
mock_settings.AUTH_DISABLED = True
|
||||
res = client.get("/api/v1/auth/me")
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["is_superuser"] is True
|
||||
assert data["auth_disabled"] is True
|
||||
assert data["email"] == "admin@localhost"
|
||||
|
||||
def test_sign_out_redirects_to_root_when_auth_disabled(self, client: TestClient):
|
||||
"""With AUTH_DISABLED, sign-out should redirect to / (no Logto session to clear)."""
|
||||
with patch("app.api.api_v1.endpoints.auth.settings") as mock_settings:
|
||||
mock_settings.AUTH_DISABLED = True
|
||||
res = client.get("/api/v1/auth/sign-out", follow_redirects=False)
|
||||
assert res.status_code == 302
|
||||
assert res.headers["location"] == "/"
|
||||
|
||||
def test_require_admin_auth_passes_when_disabled(self):
|
||||
"""require_admin_auth must return a synthetic context when AUTH_DISABLED=True."""
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.core.security import require_admin_auth
|
||||
|
||||
with patch("app.core.security.settings") as mock_settings:
|
||||
mock_settings.AUTH_DISABLED = True
|
||||
mock_req = MagicMock()
|
||||
mock_req.cookies = {}
|
||||
result = asyncio.get_event_loop().run_until_complete(
|
||||
require_admin_auth(request=mock_req, api_key=None, bearer=None)
|
||||
)
|
||||
assert result["auth_type"] == "disabled"
|
||||
|
||||
def test_middleware_passes_all_requests_when_auth_disabled(self, client: TestClient):
|
||||
"""The auth middleware must let every request through when AUTH_DISABLED=True."""
|
||||
# The middleware does `from app.core.config import get_settings` inside dispatch,
|
||||
# so we patch the canonical location used at call time.
|
||||
with patch("app.core.config.get_settings") as mock_get_settings:
|
||||
mock_cfg = MagicMock()
|
||||
mock_cfg.AUTH_DISABLED = True
|
||||
mock_get_settings.return_value = mock_cfg
|
||||
# Even without a session cookie, the middleware lets the request through.
|
||||
# The endpoint itself then handles auth (API key or 401), but it must
|
||||
# never be a 302 redirect from the middleware.
|
||||
res = client.get("/settings", follow_redirects=False)
|
||||
assert res.status_code != 302
|
||||
@@ -9,8 +9,8 @@ import base64
|
||||
import email as email_mod
|
||||
import json
|
||||
from email import encoders as email_encoders
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.base import MIMEBase
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from typing import Optional
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -19,7 +19,6 @@ import pytest
|
||||
|
||||
from app.services.gmail_client import GmailClient
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -234,9 +233,7 @@ class TestGetGmailEmail:
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_on_exception(self):
|
||||
with patch(
|
||||
"app.services.gmail_client.httpx.get", side_effect=Exception("network error")
|
||||
):
|
||||
with patch("app.services.gmail_client.httpx.get", side_effect=Exception("network error")):
|
||||
result = GmailClient.get_gmail_email("some-token")
|
||||
|
||||
assert result is None
|
||||
@@ -264,8 +261,9 @@ class TestBuildService:
|
||||
client._mock_creds.refresh_token = "ref"
|
||||
|
||||
mock_service = MagicMock()
|
||||
with patch("app.services.gmail_client.build", return_value=mock_service), patch(
|
||||
"app.services.gmail_client.Request"
|
||||
with (
|
||||
patch("app.services.gmail_client.build", return_value=mock_service),
|
||||
patch("app.services.gmail_client.Request"),
|
||||
):
|
||||
svc = client._build_service()
|
||||
|
||||
@@ -278,9 +276,7 @@ class TestBuildService:
|
||||
client._mock_creds.refresh_token = "ref"
|
||||
client._mock_creds.refresh.side_effect = Exception("refresh failed")
|
||||
|
||||
with patch("app.services.gmail_client.Request"), patch(
|
||||
"app.services.gmail_client.build"
|
||||
):
|
||||
with patch("app.services.gmail_client.Request"), patch("app.services.gmail_client.build"):
|
||||
with pytest.raises(Exception, match="refresh failed"):
|
||||
client._build_service()
|
||||
|
||||
@@ -397,9 +393,7 @@ class TestProcessMessage:
|
||||
def test_fetches_and_processes_message(self):
|
||||
"""Happy path: message fetched, attachments processed."""
|
||||
client = _make_client()
|
||||
raw_email = _make_raw_email(
|
||||
[{"filename": "report.xml", "content": b"<xml/>"}]
|
||||
)
|
||||
raw_email = _make_raw_email([{"filename": "report.xml", "content": b"<xml/>"}])
|
||||
raw_b64 = _b64_raw(raw_email)
|
||||
|
||||
service = MagicMock()
|
||||
@@ -440,9 +434,7 @@ class TestProcessMessage:
|
||||
class TestProcessAttachments:
|
||||
def test_no_attachments_returns_zero(self):
|
||||
client = _make_client()
|
||||
msg = email_mod.message_from_bytes(
|
||||
b"From: a@b.com\r\nTo: c@d.com\r\n\r\nHello"
|
||||
)
|
||||
msg = email_mod.message_from_bytes(b"From: a@b.com\r\nTo: c@d.com\r\n\r\nHello")
|
||||
stats = {"reports_found": 0, "errors": []}
|
||||
count = client._process_attachments(msg, stats)
|
||||
assert count == 0
|
||||
@@ -450,9 +442,7 @@ class TestProcessAttachments:
|
||||
def test_non_dmarc_attachment_skipped(self):
|
||||
"""An inline or non-DMARC file should not count as a report."""
|
||||
client = _make_client()
|
||||
raw = _make_raw_email(
|
||||
[{"filename": "photo.png", "content": b"\x89PNG"}]
|
||||
)
|
||||
raw = _make_raw_email([{"filename": "photo.png", "content": b"\x89PNG"}])
|
||||
msg = email_mod.message_from_bytes(raw)
|
||||
stats = {"reports_found": 0, "errors": []}
|
||||
count = client._process_attachments(msg, stats)
|
||||
@@ -462,9 +452,7 @@ class TestProcessAttachments:
|
||||
def test_dmarc_xml_attachment_is_parsed(self):
|
||||
"""A .xml attachment is parsed via DMARCParser and counts as a report."""
|
||||
client = _make_client()
|
||||
raw = _make_raw_email(
|
||||
[{"filename": "report.xml", "content": b"<xml_content/>"}]
|
||||
)
|
||||
raw = _make_raw_email([{"filename": "report.xml", "content": b"<xml_content/>"}])
|
||||
msg = email_mod.message_from_bytes(raw)
|
||||
stats = {"reports_found": 0, "errors": []}
|
||||
|
||||
@@ -484,9 +472,7 @@ class TestProcessAttachments:
|
||||
"""A DMARC-named attachment with truly empty payload is skipped gracefully."""
|
||||
client = _make_client()
|
||||
# Build an attachment with empty bytes – base64 of b"" is b""
|
||||
raw = _make_raw_email(
|
||||
[{"filename": "report.zip", "content": b""}]
|
||||
)
|
||||
raw = _make_raw_email([{"filename": "report.zip", "content": b""}])
|
||||
msg = email_mod.message_from_bytes(raw)
|
||||
stats = {"reports_found": 0, "errors": []}
|
||||
count = client._process_attachments(msg, stats)
|
||||
@@ -536,9 +522,7 @@ class TestProcessAttachments:
|
||||
class TestFetchReports:
|
||||
def test_returns_failure_when_build_service_raises(self):
|
||||
client = _make_client()
|
||||
with patch.object(
|
||||
client, "_build_service", side_effect=Exception("auth error")
|
||||
):
|
||||
with patch.object(client, "_build_service", side_effect=Exception("auth error")):
|
||||
result = client.fetch_reports()
|
||||
|
||||
assert result["success"] is False
|
||||
@@ -547,8 +531,9 @@ class TestFetchReports:
|
||||
def test_returns_failure_when_list_messages_raises(self):
|
||||
client = _make_client()
|
||||
mock_service = MagicMock()
|
||||
with patch.object(client, "_build_service", return_value=mock_service), patch.object(
|
||||
client, "_list_dmarc_message_ids", side_effect=Exception("list error")
|
||||
with (
|
||||
patch.object(client, "_build_service", return_value=mock_service),
|
||||
patch.object(client, "_list_dmarc_message_ids", side_effect=Exception("list error")),
|
||||
):
|
||||
result = client.fetch_reports()
|
||||
|
||||
@@ -557,8 +542,9 @@ class TestFetchReports:
|
||||
def test_returns_success_with_no_messages(self):
|
||||
client = _make_client()
|
||||
mock_service = MagicMock()
|
||||
with patch.object(client, "_build_service", return_value=mock_service), patch.object(
|
||||
client, "_list_dmarc_message_ids", return_value=[]
|
||||
with (
|
||||
patch.object(client, "_build_service", return_value=mock_service),
|
||||
patch.object(client, "_list_dmarc_message_ids", return_value=[]),
|
||||
):
|
||||
result = client.fetch_reports()
|
||||
|
||||
@@ -568,9 +554,11 @@ class TestFetchReports:
|
||||
def test_skips_already_ingested_messages(self):
|
||||
client = _make_client(already_ingested=["id1"])
|
||||
mock_service = MagicMock()
|
||||
with patch.object(client, "_build_service", return_value=mock_service), patch.object(
|
||||
client, "_list_dmarc_message_ids", return_value=["id1", "id2"]
|
||||
), patch.object(client, "_process_message", return_value=0) as mock_proc:
|
||||
with (
|
||||
patch.object(client, "_build_service", return_value=mock_service),
|
||||
patch.object(client, "_list_dmarc_message_ids", return_value=["id1", "id2"]),
|
||||
patch.object(client, "_process_message", return_value=0) as mock_proc,
|
||||
):
|
||||
result = client.fetch_reports()
|
||||
|
||||
# Only id2 should be processed; id1 is already ingested
|
||||
@@ -582,9 +570,11 @@ class TestFetchReports:
|
||||
def test_tracks_new_ingested_ids(self):
|
||||
client = _make_client()
|
||||
mock_service = MagicMock()
|
||||
with patch.object(client, "_build_service", return_value=mock_service), patch.object(
|
||||
client, "_list_dmarc_message_ids", return_value=["id1", "id2"]
|
||||
), patch.object(client, "_process_message", return_value=0):
|
||||
with (
|
||||
patch.object(client, "_build_service", return_value=mock_service),
|
||||
patch.object(client, "_list_dmarc_message_ids", return_value=["id1", "id2"]),
|
||||
patch.object(client, "_process_message", return_value=0),
|
||||
):
|
||||
result = client.fetch_reports()
|
||||
|
||||
assert "id1" in result["new_ingested_ids"]
|
||||
@@ -612,9 +602,11 @@ class TestFetchReports:
|
||||
stats["reports_found"] += 1
|
||||
return 1
|
||||
|
||||
with patch.object(client, "_build_service", return_value=mock_service), patch.object(
|
||||
client, "_list_dmarc_message_ids", return_value=["id1"]
|
||||
), patch.object(client, "_process_message", side_effect=_process_side_effect):
|
||||
with (
|
||||
patch.object(client, "_build_service", return_value=mock_service),
|
||||
patch.object(client, "_list_dmarc_message_ids", return_value=["id1"]),
|
||||
patch.object(client, "_process_message", side_effect=_process_side_effect),
|
||||
):
|
||||
result = client.fetch_reports()
|
||||
|
||||
assert "newdomain.example" in result["new_domains"]
|
||||
|
||||
@@ -19,7 +19,6 @@ import pytest
|
||||
from app.services.imap_client import IMAPClient
|
||||
from app.services.report_store import ReportStore
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -316,9 +315,7 @@ class TestIsDmarcReportEmail:
|
||||
|
||||
def test_dmarc_sender_matches(self):
|
||||
client = self._make_client()
|
||||
msg = self._make_msg(
|
||||
subject="Weekly report", from_addr="noreply@google.com"
|
||||
)
|
||||
msg = self._make_msg(subject="Weekly report", from_addr="noreply@google.com")
|
||||
assert client._is_dmarc_report_email(msg) is True
|
||||
|
||||
def test_xml_attachment_matches(self):
|
||||
@@ -443,9 +440,7 @@ class TestProcessAttachments:
|
||||
|
||||
def test_bad_attachment_does_not_raise(self):
|
||||
client = self._make_client()
|
||||
msg = email.message_from_bytes(
|
||||
_make_email_with_attachment("report.xml", b"not xml at all")
|
||||
)
|
||||
msg = email.message_from_bytes(_make_email_with_attachment("report.xml", b"not xml at all"))
|
||||
# Should not raise; just returns 0
|
||||
count = client._process_attachments(msg)
|
||||
assert count == 0
|
||||
|
||||
@@ -503,9 +503,7 @@ class TestGmailAPIMailSource:
|
||||
return_value=mock_gmail_client,
|
||||
):
|
||||
# First set the access token directly
|
||||
with patch(
|
||||
"app.api.api_v1.endpoints.mail_sources._get_source_or_404"
|
||||
) as mock_get:
|
||||
with patch("app.api.api_v1.endpoints.mail_sources._get_source_or_404") as mock_get:
|
||||
mock_source = MagicMock()
|
||||
mock_source.method = "GMAIL_API"
|
||||
mock_source.gmail_access_token = "valid-token"
|
||||
@@ -627,10 +625,9 @@ class TestGmailAPIMailSource:
|
||||
mock_client.fetch_reports.return_value = mock_fetch_results
|
||||
mock_client.get_refreshed_tokens.return_value = None
|
||||
|
||||
with patch(
|
||||
"app.api.api_v1.endpoints.mail_sources._get_source_or_404"
|
||||
) as mock_get, patch(
|
||||
"app.api.api_v1.endpoints.mail_sources.GmailClient", return_value=mock_client
|
||||
with (
|
||||
patch("app.api.api_v1.endpoints.mail_sources._get_source_or_404") as mock_get,
|
||||
patch("app.api.api_v1.endpoints.mail_sources.GmailClient", return_value=mock_client),
|
||||
):
|
||||
mock_source = MagicMock()
|
||||
mock_source.method = "GMAIL_API"
|
||||
@@ -843,9 +840,7 @@ class TestGmailCallbackGet:
|
||||
)
|
||||
source_id = create_resp.json()["id"]
|
||||
|
||||
resp = authed_client.get(
|
||||
f"/api/v1/mail-sources/{source_id}/gmail/callback?code=xyz"
|
||||
)
|
||||
resp = authed_client.get(f"/api/v1/mail-sources/{source_id}/gmail/callback?code=xyz")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_callback_token_exchange_error_returns_html_400(self, authed_client: TestClient):
|
||||
@@ -860,9 +855,7 @@ class TestGmailCallbackGet:
|
||||
"app.api.api_v1.endpoints.mail_sources.GmailClient.exchange_code_for_tokens",
|
||||
side_effect=ValueError("bad token"),
|
||||
):
|
||||
resp = authed_client.get(
|
||||
f"/api/v1/mail-sources/{source_id}/gmail/callback?code=abc"
|
||||
)
|
||||
resp = authed_client.get(f"/api/v1/mail-sources/{source_id}/gmail/callback?code=abc")
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert "token exchange failed" in resp.text.lower() or "failed" in resp.text.lower()
|
||||
@@ -879,9 +872,7 @@ class TestGmailCallbackGet:
|
||||
"app.api.api_v1.endpoints.mail_sources.GmailClient.exchange_code_for_tokens",
|
||||
return_value={}, # empty – no access_token key
|
||||
):
|
||||
resp = authed_client.get(
|
||||
f"/api/v1/mail-sources/{source_id}/gmail/callback?code=abc"
|
||||
)
|
||||
resp = authed_client.get(f"/api/v1/mail-sources/{source_id}/gmail/callback?code=abc")
|
||||
|
||||
assert resp.status_code == 400
|
||||
|
||||
@@ -893,16 +884,17 @@ class TestGmailCallbackGet:
|
||||
)
|
||||
source_id = create_resp.json()["id"]
|
||||
|
||||
with patch(
|
||||
"app.api.api_v1.endpoints.mail_sources.GmailClient.exchange_code_for_tokens",
|
||||
return_value={"access_token": "acc", "refresh_token": "ref"},
|
||||
), patch(
|
||||
"app.api.api_v1.endpoints.mail_sources.GmailClient.get_gmail_email",
|
||||
return_value="user@gmail.com",
|
||||
with (
|
||||
patch(
|
||||
"app.api.api_v1.endpoints.mail_sources.GmailClient.exchange_code_for_tokens",
|
||||
return_value={"access_token": "acc", "refresh_token": "ref"},
|
||||
),
|
||||
patch(
|
||||
"app.api.api_v1.endpoints.mail_sources.GmailClient.get_gmail_email",
|
||||
return_value="user@gmail.com",
|
||||
),
|
||||
):
|
||||
resp = authed_client.get(
|
||||
f"/api/v1/mail-sources/{source_id}/gmail/callback?code=abc"
|
||||
)
|
||||
resp = authed_client.get(f"/api/v1/mail-sources/{source_id}/gmail/callback?code=abc")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert "connected successfully" in resp.text.lower() or "gmail" in resp.text.lower()
|
||||
@@ -920,16 +912,17 @@ class TestGmailCallbackGet:
|
||||
)
|
||||
source_id = create_resp.json()["id"]
|
||||
|
||||
with patch(
|
||||
"app.api.api_v1.endpoints.mail_sources.GmailClient.exchange_code_for_tokens",
|
||||
return_value={"access_token": "acc"}, # no refresh token
|
||||
), patch(
|
||||
"app.api.api_v1.endpoints.mail_sources.GmailClient.get_gmail_email",
|
||||
return_value=None,
|
||||
with (
|
||||
patch(
|
||||
"app.api.api_v1.endpoints.mail_sources.GmailClient.exchange_code_for_tokens",
|
||||
return_value={"access_token": "acc"}, # no refresh token
|
||||
),
|
||||
patch(
|
||||
"app.api.api_v1.endpoints.mail_sources.GmailClient.get_gmail_email",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
resp = authed_client.get(
|
||||
f"/api/v1/mail-sources/{source_id}/gmail/callback?code=abc"
|
||||
)
|
||||
resp = authed_client.get(f"/api/v1/mail-sources/{source_id}/gmail/callback?code=abc")
|
||||
|
||||
assert resp.status_code == 200
|
||||
get_resp = authed_client.get(f"/api/v1/mail-sources/{source_id}")
|
||||
@@ -999,12 +992,15 @@ class TestGmailCallbackPost:
|
||||
)
|
||||
source_id = create_resp.json()["id"]
|
||||
|
||||
with patch(
|
||||
"app.api.api_v1.endpoints.mail_sources.GmailClient.exchange_code_for_tokens",
|
||||
return_value={"access_token": "acc", "refresh_token": "ref"},
|
||||
), patch(
|
||||
"app.api.api_v1.endpoints.mail_sources.GmailClient.get_gmail_email",
|
||||
return_value="user@gmail.com",
|
||||
with (
|
||||
patch(
|
||||
"app.api.api_v1.endpoints.mail_sources.GmailClient.exchange_code_for_tokens",
|
||||
return_value={"access_token": "acc", "refresh_token": "ref"},
|
||||
),
|
||||
patch(
|
||||
"app.api.api_v1.endpoints.mail_sources.GmailClient.get_gmail_email",
|
||||
return_value="user@gmail.com",
|
||||
),
|
||||
):
|
||||
resp = authed_client.post(
|
||||
f"/api/v1/mail-sources/{source_id}/gmail/callback",
|
||||
@@ -1052,10 +1048,9 @@ class TestGmailFetchExtra:
|
||||
mock_client.fetch_reports.return_value = mock_fetch_results
|
||||
mock_client.get_refreshed_tokens.return_value = None
|
||||
|
||||
with patch(
|
||||
"app.api.api_v1.endpoints.mail_sources._get_source_or_404"
|
||||
) as mock_get, patch(
|
||||
"app.api.api_v1.endpoints.mail_sources.GmailClient", return_value=mock_client
|
||||
with (
|
||||
patch("app.api.api_v1.endpoints.mail_sources._get_source_or_404") as mock_get,
|
||||
patch("app.api.api_v1.endpoints.mail_sources.GmailClient", return_value=mock_client),
|
||||
):
|
||||
mock_source = MagicMock()
|
||||
mock_source.method = "GMAIL_API"
|
||||
@@ -1099,10 +1094,9 @@ class TestGmailFetchExtra:
|
||||
"refresh_token": "new_refresh",
|
||||
}
|
||||
|
||||
with patch(
|
||||
"app.api.api_v1.endpoints.mail_sources._get_source_or_404"
|
||||
) as mock_get, patch(
|
||||
"app.api.api_v1.endpoints.mail_sources.GmailClient", return_value=mock_client
|
||||
with (
|
||||
patch("app.api.api_v1.endpoints.mail_sources._get_source_or_404") as mock_get,
|
||||
patch("app.api.api_v1.endpoints.mail_sources.GmailClient", return_value=mock_client),
|
||||
):
|
||||
mock_source = MagicMock()
|
||||
mock_source.method = "GMAIL_API"
|
||||
@@ -1141,11 +1135,12 @@ class TestGmailFetchExtra:
|
||||
mock_client.fetch_reports.return_value = mock_fetch_results
|
||||
mock_client.get_refreshed_tokens.return_value = None
|
||||
|
||||
with patch(
|
||||
"app.api.api_v1.endpoints.mail_sources._get_source_or_404"
|
||||
) as mock_get, patch(
|
||||
"app.api.api_v1.endpoints.mail_sources.GmailClient", return_value=mock_client
|
||||
) as mock_gmail_class:
|
||||
with (
|
||||
patch("app.api.api_v1.endpoints.mail_sources._get_source_or_404") as mock_get,
|
||||
patch(
|
||||
"app.api.api_v1.endpoints.mail_sources.GmailClient", return_value=mock_client
|
||||
) as mock_gmail_class,
|
||||
):
|
||||
# Configure the class-level static helpers used inside the endpoint
|
||||
mock_gmail_class.load_ingested_ids.return_value = ["id1"]
|
||||
mock_gmail_class.dump_ingested_ids.return_value = '["id1","id2","id3"]'
|
||||
@@ -1188,18 +1183,19 @@ class TestGmailTestConnectionFailure:
|
||||
source_id = create_resp.json()["id"]
|
||||
|
||||
mock_service = MagicMock()
|
||||
mock_service.users.return_value.getProfile.return_value.execute.side_effect = (
|
||||
Exception("internal oauth error: token expired")
|
||||
mock_service.users.return_value.getProfile.return_value.execute.side_effect = Exception(
|
||||
"internal oauth error: token expired"
|
||||
)
|
||||
mock_gmail_client = MagicMock()
|
||||
mock_gmail_client._build_service.return_value = mock_service
|
||||
|
||||
with patch(
|
||||
"app.api.api_v1.endpoints.mail_sources.GmailClient",
|
||||
return_value=mock_gmail_client,
|
||||
), patch(
|
||||
"app.api.api_v1.endpoints.mail_sources._get_source_or_404"
|
||||
) as mock_get:
|
||||
with (
|
||||
patch(
|
||||
"app.api.api_v1.endpoints.mail_sources.GmailClient",
|
||||
return_value=mock_gmail_client,
|
||||
),
|
||||
patch("app.api.api_v1.endpoints.mail_sources._get_source_or_404") as mock_get,
|
||||
):
|
||||
mock_source = MagicMock()
|
||||
mock_source.method = "GMAIL_API"
|
||||
mock_source.gmail_access_token = "tok"
|
||||
@@ -1274,10 +1270,11 @@ class TestPollSingleGmailSource:
|
||||
mock_db.__exit__ = MagicMock(return_value=False)
|
||||
mock_db.query.return_value.get.return_value = mock_db_source
|
||||
|
||||
with patch("app.main.GmailClient", return_value=mock_client), patch(
|
||||
"app.main.SessionLocal", return_value=mock_db
|
||||
), patch("app.main.GmailClient.load_ingested_ids", return_value=[]), patch(
|
||||
"app.main.GmailClient.dump_ingested_ids", return_value='["id1","id2"]'
|
||||
with (
|
||||
patch("app.main.GmailClient", return_value=mock_client),
|
||||
patch("app.main.SessionLocal", return_value=mock_db),
|
||||
patch("app.main.GmailClient.load_ingested_ids", return_value=[]),
|
||||
patch("app.main.GmailClient.dump_ingested_ids", return_value='["id1","id2"]'),
|
||||
):
|
||||
_poll_single_gmail_source(src)
|
||||
|
||||
@@ -1303,9 +1300,11 @@ class TestPollSingleGmailSource:
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.get.return_value = MagicMock()
|
||||
|
||||
with patch("app.main.GmailClient", return_value=mock_client), patch(
|
||||
"app.main.SessionLocal", return_value=mock_db
|
||||
), patch("app.main.GmailClient.load_ingested_ids", return_value=[]):
|
||||
with (
|
||||
patch("app.main.GmailClient", return_value=mock_client),
|
||||
patch("app.main.SessionLocal", return_value=mock_db),
|
||||
patch("app.main.GmailClient.load_ingested_ids", return_value=[]),
|
||||
):
|
||||
_poll_single_gmail_source(src) # should not raise
|
||||
|
||||
def test_logs_error_on_failure(self):
|
||||
@@ -1329,9 +1328,11 @@ class TestPollSingleGmailSource:
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.get.return_value = MagicMock()
|
||||
|
||||
with patch("app.main.GmailClient", return_value=mock_client), patch(
|
||||
"app.main.SessionLocal", return_value=mock_db
|
||||
), patch("app.main.GmailClient.load_ingested_ids", return_value=[]):
|
||||
with (
|
||||
patch("app.main.GmailClient", return_value=mock_client),
|
||||
patch("app.main.SessionLocal", return_value=mock_db),
|
||||
patch("app.main.GmailClient.load_ingested_ids", return_value=[]),
|
||||
):
|
||||
_poll_single_gmail_source(src) # should not raise
|
||||
|
||||
def test_persists_refreshed_tokens(self):
|
||||
@@ -1358,9 +1359,11 @@ class TestPollSingleGmailSource:
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.get.return_value = mock_db_source
|
||||
|
||||
with patch("app.main.GmailClient", return_value=mock_client), patch(
|
||||
"app.main.SessionLocal", return_value=mock_db
|
||||
), patch("app.main.GmailClient.load_ingested_ids", return_value=[]):
|
||||
with (
|
||||
patch("app.main.GmailClient", return_value=mock_client),
|
||||
patch("app.main.SessionLocal", return_value=mock_db),
|
||||
patch("app.main.GmailClient.load_ingested_ids", return_value=[]),
|
||||
):
|
||||
_poll_single_gmail_source(src)
|
||||
|
||||
assert mock_db_source.gmail_access_token == "new-acc"
|
||||
@@ -1432,9 +1435,11 @@ class TestTriggerPollGmailSource:
|
||||
mock_gc.get_refreshed_tokens.return_value = None
|
||||
mock_db = MagicMock()
|
||||
|
||||
with patch("app.main.GmailClient", return_value=mock_gc), patch(
|
||||
"app.main.GmailClient.load_ingested_ids", return_value=[]
|
||||
), patch("app.main.GmailClient.dump_ingested_ids", return_value='["id1"]'):
|
||||
with (
|
||||
patch("app.main.GmailClient", return_value=mock_gc),
|
||||
patch("app.main.GmailClient.load_ingested_ids", return_value=[]),
|
||||
patch("app.main.GmailClient.dump_ingested_ids", return_value='["id1"]'),
|
||||
):
|
||||
result = _trigger_poll_gmail_source(src, mock_db)
|
||||
|
||||
assert result["success"] is True
|
||||
@@ -1459,8 +1464,9 @@ class TestTriggerPollGmailSource:
|
||||
}
|
||||
mock_db = MagicMock()
|
||||
|
||||
with patch("app.main.GmailClient", return_value=mock_gc), patch(
|
||||
"app.main.GmailClient.load_ingested_ids", return_value=[]
|
||||
with (
|
||||
patch("app.main.GmailClient", return_value=mock_gc),
|
||||
patch("app.main.GmailClient.load_ingested_ids", return_value=[]),
|
||||
):
|
||||
_trigger_poll_gmail_source(src, mock_db)
|
||||
|
||||
@@ -1510,9 +1516,7 @@ class TestPollSourceForTrigger:
|
||||
src.id = 3
|
||||
src.name = "Gmail exc"
|
||||
|
||||
with patch(
|
||||
"app.main._trigger_poll_gmail_source", side_effect=Exception("boom")
|
||||
):
|
||||
with patch("app.main._trigger_poll_gmail_source", side_effect=Exception("boom")):
|
||||
result = _poll_source_for_trigger(src, MagicMock())
|
||||
|
||||
assert result["success"] is False
|
||||
@@ -1541,9 +1545,7 @@ class TestPollSourceForTrigger:
|
||||
src.id = 5
|
||||
src.name = "IMAP exc"
|
||||
|
||||
with patch(
|
||||
"app.main._trigger_poll_imap_source", side_effect=Exception("imap fail")
|
||||
):
|
||||
with patch("app.main._trigger_poll_imap_source", side_effect=Exception("imap fail")):
|
||||
result = _poll_source_for_trigger(src, MagicMock())
|
||||
|
||||
assert result["success"] is False
|
||||
@@ -1576,9 +1578,10 @@ class TestPollAllEnabledSources:
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.all.return_value = [src]
|
||||
|
||||
with patch("app.main.SessionLocal", return_value=mock_db), patch(
|
||||
"app.main._poll_single_gmail_source"
|
||||
) as mock_gmail:
|
||||
with (
|
||||
patch("app.main.SessionLocal", return_value=mock_db),
|
||||
patch("app.main._poll_single_gmail_source") as mock_gmail,
|
||||
):
|
||||
_poll_all_enabled_sources()
|
||||
|
||||
mock_gmail.assert_called_once_with(src)
|
||||
@@ -1594,9 +1597,10 @@ class TestPollAllEnabledSources:
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.all.return_value = [src]
|
||||
|
||||
with patch("app.main.SessionLocal", return_value=mock_db), patch(
|
||||
"app.main._poll_single_imap_source"
|
||||
) as mock_imap:
|
||||
with (
|
||||
patch("app.main.SessionLocal", return_value=mock_db),
|
||||
patch("app.main._poll_single_imap_source") as mock_imap,
|
||||
):
|
||||
_poll_all_enabled_sources()
|
||||
|
||||
mock_imap.assert_called_once_with(src)
|
||||
@@ -1612,8 +1616,9 @@ class TestPollAllEnabledSources:
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.all.return_value = [src]
|
||||
|
||||
with patch("app.main.SessionLocal", return_value=mock_db), patch(
|
||||
"app.main._poll_single_gmail_source", side_effect=Exception("crash")
|
||||
with (
|
||||
patch("app.main.SessionLocal", return_value=mock_db),
|
||||
patch("app.main._poll_single_gmail_source", side_effect=Exception("crash")),
|
||||
):
|
||||
_poll_all_enabled_sources() # should not raise
|
||||
|
||||
@@ -1628,8 +1633,9 @@ class TestPollAllEnabledSources:
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.all.return_value = [src]
|
||||
|
||||
with patch("app.main.SessionLocal", return_value=mock_db), patch(
|
||||
"app.main._poll_single_imap_source", side_effect=Exception("imap crash")
|
||||
with (
|
||||
patch("app.main.SessionLocal", return_value=mock_db),
|
||||
patch("app.main._poll_single_imap_source", side_effect=Exception("imap crash")),
|
||||
):
|
||||
_poll_all_enabled_sources() # should not raise
|
||||
|
||||
@@ -1653,8 +1659,8 @@ class TestTriggerPollEndpoint:
|
||||
|
||||
def test_trigger_poll_with_enabled_sources(self):
|
||||
"""With enabled sources, the endpoint dispatches and returns results."""
|
||||
from app.main import app as main_app
|
||||
from app.core.security import require_admin_auth
|
||||
from app.main import app as main_app
|
||||
|
||||
async def mock_auth():
|
||||
return {"auth_type": "api_key"}
|
||||
@@ -1681,8 +1687,9 @@ class TestTriggerPollEndpoint:
|
||||
}
|
||||
|
||||
with TestClient(main_app) as tc:
|
||||
with patch("app.main.SessionLocal", return_value=mock_db), patch(
|
||||
"app.main._poll_source_for_trigger", return_value=mock_result
|
||||
with (
|
||||
patch("app.main.SessionLocal", return_value=mock_db),
|
||||
patch("app.main._poll_source_for_trigger", return_value=mock_result),
|
||||
):
|
||||
resp = tc.post("/api/v1/admin/trigger-poll")
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ from app.core.security import (
|
||||
verify_token,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# create_access_token
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -84,6 +83,16 @@ class TestVerifyToken:
|
||||
|
||||
|
||||
class TestRequireAdminAuth:
|
||||
"""Unit tests for the require_admin_auth dependency."""
|
||||
|
||||
def _make_request(self, cookies: dict = None):
|
||||
"""Build a minimal mock Request with optional cookies."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
req = MagicMock()
|
||||
req.cookies = cookies or {}
|
||||
return req
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_api_key_returns_auth_context(self):
|
||||
from app.core.security import require_admin_auth
|
||||
@@ -91,7 +100,9 @@ class TestRequireAdminAuth:
|
||||
key = generate_api_key()
|
||||
add_api_key(key)
|
||||
try:
|
||||
result = await require_admin_auth(api_key=key, bearer=None)
|
||||
result = await require_admin_auth(
|
||||
request=self._make_request(), api_key=key, bearer=None
|
||||
)
|
||||
assert result["auth_type"] == "api_key"
|
||||
finally:
|
||||
from app.core.security import _api_keys
|
||||
@@ -106,7 +117,7 @@ class TestRequireAdminAuth:
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
|
||||
creds = HTTPAuthorizationCredentials(scheme="Bearer", credentials=token)
|
||||
result = await require_admin_auth(api_key=None, bearer=creds)
|
||||
result = await require_admin_auth(request=self._make_request(), api_key=None, bearer=creds)
|
||||
assert result["auth_type"] == "jwt"
|
||||
assert result["payload"]["sub"] == "admin-user"
|
||||
|
||||
@@ -117,11 +128,9 @@ class TestRequireAdminAuth:
|
||||
|
||||
from app.core.security import require_admin_auth
|
||||
|
||||
creds = HTTPAuthorizationCredentials(
|
||||
scheme="Bearer", credentials="bad.token.value"
|
||||
)
|
||||
creds = HTTPAuthorizationCredentials(scheme="Bearer", credentials="bad.token.value")
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await require_admin_auth(api_key=None, bearer=creds)
|
||||
await require_admin_auth(request=self._make_request(), api_key=None, bearer=creds)
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -131,9 +140,24 @@ class TestRequireAdminAuth:
|
||||
from app.core.security import require_admin_auth
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await require_admin_auth(api_key=None, bearer=None)
|
||||
await require_admin_auth(request=self._make_request(), api_key=None, bearer=None)
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_session_cookie_returns_auth_context(self):
|
||||
"""A valid dmarq_session cookie should authenticate successfully."""
|
||||
from app.core.logto import create_session_token
|
||||
from app.core.security import require_admin_auth
|
||||
|
||||
token = create_session_token(user_id=42)
|
||||
result = await require_admin_auth(
|
||||
request=self._make_request(cookies={"dmarq_session": token}),
|
||||
api_key=None,
|
||||
bearer=None,
|
||||
)
|
||||
assert result["auth_type"] == "session"
|
||||
assert result["user_id"] == 42
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_api_key dependency
|
||||
|
||||
@@ -89,18 +89,14 @@ class TestDomainStatistics:
|
||||
response = client.get("/api/v1/stats/domain/example.com?force_refresh=true")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_domain_stats_force_refresh_calls_invalidate_with_domain(
|
||||
self, client: TestClient
|
||||
):
|
||||
def test_domain_stats_force_refresh_calls_invalidate_with_domain(self, client: TestClient):
|
||||
"""Verify invalidate_cache is called with the domain ID."""
|
||||
with patch("app.api.api_v1.endpoints.stats.StatsSummarizer") as MockSummarizer:
|
||||
mock_instance = MagicMock()
|
||||
mock_instance.calculate_summary_statistics.return_value = {"total": 0}
|
||||
MockSummarizer.return_value = mock_instance
|
||||
|
||||
response = client.get(
|
||||
"/api/v1/stats/domain/example.com?force_refresh=true"
|
||||
)
|
||||
response = client.get("/api/v1/stats/domain/example.com?force_refresh=true")
|
||||
assert response.status_code == 200
|
||||
mock_instance.invalidate_cache.assert_called_once_with("example.com")
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ pydantic-settings>=2.0.0
|
||||
python-jose[cryptography]>=3.3.0
|
||||
passlib[bcrypt]>=1.7.4
|
||||
python-multipart>=0.0.6
|
||||
fastapi-users[sqlalchemy]>=12.0.0
|
||||
logto>=0.2.0
|
||||
aiohttp>=3.8.0
|
||||
alembic>=1.11.0
|
||||
pytest>=7.3.1
|
||||
pytest-asyncio>=0.21.0
|
||||
|
||||
Reference in New Issue
Block a user