Adds optional SSO via Authentik (or any OIDC provider) alongside the existing password flow, and pulls profile images from the provider's `picture` claim or Gravatar so the sharing UI stops looking anonymous. Password login stays available as a recovery path; JIT provisioning and admin-group mapping are env-configurable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
469 lines
16 KiB
Python
469 lines
16 KiB
Python
"""
|
|
Authentication router — login, token refresh, profile, first-run setup,
|
|
and optional OIDC (Authentik) sign-in.
|
|
"""
|
|
import os
|
|
import re
|
|
import secrets
|
|
import logging
|
|
from typing import Optional
|
|
from urllib.parse import urlencode, urlparse
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|
from fastapi.responses import RedirectResponse
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import select, func as sa_func
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.auth import hash_password, verify_password, create_access_token, create_refresh_token, decode_token
|
|
from app.auth_oidc import get_client as get_oidc_client, is_enabled as oidc_is_enabled, provider_label, PROVIDER_NAME
|
|
from app.database import get_db
|
|
from app.dependencies import get_current_user
|
|
from app.models.user import User
|
|
from app.models.folders import SourceRoot
|
|
from app.services.gravatar import gravatar_url
|
|
from app.config import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Request / response schemas
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class LoginRequest(BaseModel):
|
|
username: str
|
|
password: str
|
|
|
|
class TokenResponse(BaseModel):
|
|
access_token: str
|
|
refresh_token: str
|
|
token_type: str = "bearer"
|
|
|
|
class RefreshRequest(BaseModel):
|
|
refresh_token: str
|
|
|
|
class UserResponse(BaseModel):
|
|
id: str
|
|
username: str
|
|
email: Optional[str]
|
|
role: str
|
|
is_active: bool
|
|
created_at: Optional[str]
|
|
avatar_url: Optional[str] = None
|
|
display_name: Optional[str] = None
|
|
|
|
class SetupRequest(BaseModel):
|
|
username: str
|
|
password: str
|
|
|
|
class ChangePasswordRequest(BaseModel):
|
|
current_password: str
|
|
new_password: str
|
|
|
|
|
|
class OidcConfig(BaseModel):
|
|
enabled: bool
|
|
label: str
|
|
login_url: str
|
|
|
|
|
|
class AuthConfigResponse(BaseModel):
|
|
# None when OIDC is disabled / not configured — the frontend uses
|
|
# that to hide the SSO button.
|
|
oidc: Optional[OidcConfig] = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def serialize_user(user: User) -> UserResponse:
|
|
"""Build the user-facing payload, computing the avatar URL with the
|
|
`provider picture > Gravatar > None` fallback chain."""
|
|
avatar = user.avatar_url or gravatar_url(user.email)
|
|
return UserResponse(
|
|
id=user.id,
|
|
username=user.username,
|
|
email=user.email,
|
|
role=user.role,
|
|
is_active=user.is_active,
|
|
created_at=user.created_at.isoformat() if user.created_at else None,
|
|
avatar_url=avatar,
|
|
display_name=user.display_name,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.get("/config", response_model=AuthConfigResponse)
|
|
async def auth_config():
|
|
"""Public — tells the frontend which login options to render.
|
|
|
|
Returns `oidc: null` when OIDC is disabled or not fully configured,
|
|
so the login page can hide the SSO button without a round-trip to
|
|
the IdP. The `login_url` is browser-navigable (full redirect); it
|
|
starts the Authlib flow that sets the PKCE cookie.
|
|
"""
|
|
if not oidc_is_enabled():
|
|
return AuthConfigResponse(oidc=None)
|
|
return AuthConfigResponse(
|
|
oidc=OidcConfig(
|
|
enabled=True,
|
|
label=provider_label(),
|
|
login_url="/api/v1/auth/oidc/login",
|
|
)
|
|
)
|
|
|
|
|
|
@router.post("/login", response_model=TokenResponse)
|
|
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
|
|
"""Authenticate with username + password, receive JWT tokens."""
|
|
result = await db.execute(
|
|
select(User).where(User.username == body.username)
|
|
)
|
|
user = result.scalar_one_or_none()
|
|
|
|
# OIDC-only users (hashed_password IS NULL) can't sign in via this
|
|
# endpoint; they must go through the SSO flow. Treat as auth failure
|
|
# so we don't leak account existence.
|
|
if (
|
|
user is None
|
|
or not user.hashed_password
|
|
or not verify_password(body.password, user.hashed_password)
|
|
):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid username or password",
|
|
)
|
|
if not user.is_active:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Account is deactivated",
|
|
)
|
|
|
|
return TokenResponse(
|
|
access_token=create_access_token(user.id, user.role),
|
|
refresh_token=create_refresh_token(user.id),
|
|
)
|
|
|
|
|
|
@router.post("/refresh", response_model=TokenResponse)
|
|
async def refresh_token(body: RefreshRequest, db: AsyncSession = Depends(get_db)):
|
|
"""Exchange a valid refresh token for a new access + refresh pair."""
|
|
try:
|
|
payload = decode_token(body.refresh_token)
|
|
if payload.get("type") != "refresh":
|
|
raise ValueError("not a refresh token")
|
|
user_id = payload["sub"]
|
|
except Exception:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid or expired refresh token",
|
|
)
|
|
|
|
result = await db.execute(select(User).where(User.id == user_id))
|
|
user = result.scalar_one_or_none()
|
|
if user is None or not user.is_active:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="User not found or deactivated",
|
|
)
|
|
|
|
return TokenResponse(
|
|
access_token=create_access_token(user.id, user.role),
|
|
refresh_token=create_refresh_token(user.id),
|
|
)
|
|
|
|
|
|
@router.get("/me", response_model=UserResponse)
|
|
async def get_me(current_user: User = Depends(get_current_user)):
|
|
"""Return the authenticated user's profile."""
|
|
return serialize_user(current_user)
|
|
|
|
|
|
@router.post("/change-password")
|
|
async def change_password(
|
|
body: ChangePasswordRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Change the authenticated user's password."""
|
|
if not current_user.hashed_password or not verify_password(
|
|
body.current_password, current_user.hashed_password
|
|
):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Current password is incorrect",
|
|
)
|
|
current_user.hashed_password = hash_password(body.new_password)
|
|
await db.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.post("/setup", response_model=TokenResponse, status_code=201)
|
|
async def setup(body: SetupRequest, db: AsyncSession = Depends(get_db)):
|
|
"""First-run only: create the initial admin account.
|
|
|
|
Returns 409 if any user already exists. This endpoint is
|
|
unauthenticated by design — it can only run once.
|
|
"""
|
|
count = (await db.execute(select(sa_func.count(User.id)))).scalar()
|
|
if count > 0:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail="Setup already completed — users exist",
|
|
)
|
|
|
|
if len(body.username.strip()) < 2:
|
|
raise HTTPException(status_code=400, detail="Username must be at least 2 characters")
|
|
if len(body.password) < 6:
|
|
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
|
|
|
|
# Every user — including the initial admin — gets their own subfolder
|
|
# under the photo mount root. Nobody owns the root directory itself.
|
|
media_path = os.path.join(settings.photo_dirs, body.username.strip())
|
|
os.makedirs(media_path, exist_ok=True)
|
|
|
|
user = User(
|
|
username=body.username.strip(),
|
|
hashed_password=hash_password(body.password),
|
|
role="admin",
|
|
media_path=media_path,
|
|
)
|
|
db.add(user)
|
|
await db.flush() # get user.id before creating source root
|
|
|
|
source_root = SourceRoot(
|
|
name=f"{user.username}'s Library",
|
|
path=media_path,
|
|
user_id=user.id,
|
|
)
|
|
db.add(source_root)
|
|
await db.commit()
|
|
|
|
logger.info(f"Initial admin account created: {user.username}")
|
|
|
|
return TokenResponse(
|
|
access_token=create_access_token(user.id, user.role),
|
|
refresh_token=create_refresh_token(user.id),
|
|
)
|
|
|
|
|
|
@router.get("/status")
|
|
async def auth_status(db: AsyncSession = Depends(get_db)):
|
|
"""Public endpoint: returns whether setup has been completed.
|
|
|
|
The frontend calls this to decide whether to show the setup page
|
|
or the login page.
|
|
"""
|
|
count = (await db.execute(select(sa_func.count(User.id)))).scalar()
|
|
return {"setup_completed": count > 0}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# OIDC (Authentik) sign-in
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_USERNAME_SANITIZER = re.compile(r"[^a-zA-Z0-9_.-]+")
|
|
|
|
|
|
def _derive_username(claims: dict, existing_usernames: set[str]) -> str:
|
|
"""Pick a local username from OIDC claims.
|
|
|
|
Order of preference:
|
|
1. `preferred_username` claim (Authentik's usual choice)
|
|
2. local-part of `email`
|
|
3. `sub` claim (always present)
|
|
|
|
Strips characters the rest of the app doesn't like in paths/URLs,
|
|
trims to 50 chars (User.username column limit), and appends a short
|
|
suffix on collision so two Authentik users can't land on the same
|
|
local row.
|
|
"""
|
|
raw = (
|
|
claims.get("preferred_username")
|
|
or (claims.get("email") or "").split("@", 1)[0]
|
|
or claims.get("sub")
|
|
or "user"
|
|
)
|
|
base = _USERNAME_SANITIZER.sub("", str(raw)).strip("._-") or "user"
|
|
base = base[:40]
|
|
candidate = base
|
|
suffix = 0
|
|
while candidate in existing_usernames:
|
|
suffix += 1
|
|
candidate = f"{base}-{suffix}"[:50]
|
|
return candidate
|
|
|
|
|
|
def _frontend_origin(request: Request) -> str:
|
|
"""Best guess at where the SPA lives so the callback redirect lands
|
|
back on the app origin. Uses the configured redirect URI's scheme +
|
|
host (strips /api/... path) when available, falling back to the
|
|
request's own origin."""
|
|
if settings.oidc_redirect_uri:
|
|
parsed = urlparse(settings.oidc_redirect_uri)
|
|
return f"{parsed.scheme}://{parsed.netloc}"
|
|
return f"{request.url.scheme}://{request.url.netloc}"
|
|
|
|
|
|
@router.get("/oidc/login")
|
|
async def oidc_login(request: Request):
|
|
"""Start the OIDC flow — redirect to Authentik's authorization URL."""
|
|
if not oidc_is_enabled():
|
|
raise HTTPException(status_code=404, detail="OIDC login is not enabled")
|
|
client = get_oidc_client()
|
|
if client is None:
|
|
raise HTTPException(status_code=500, detail="OIDC client not configured")
|
|
|
|
redirect_uri = settings.oidc_redirect_uri
|
|
return await client.authorize_redirect(request, redirect_uri)
|
|
|
|
|
|
@router.get("/oidc/callback")
|
|
async def oidc_callback(request: Request, db: AsyncSession = Depends(get_db)):
|
|
"""Handle the OIDC redirect — exchange code, provision/link user,
|
|
issue our own JWTs, bounce back to the SPA."""
|
|
if not oidc_is_enabled():
|
|
raise HTTPException(status_code=404, detail="OIDC login is not enabled")
|
|
client = get_oidc_client()
|
|
if client is None:
|
|
raise HTTPException(status_code=500, detail="OIDC client not configured")
|
|
|
|
try:
|
|
token = await client.authorize_access_token(request)
|
|
except Exception as exc:
|
|
logger.warning("OIDC callback: authorize_access_token failed: %s", exc)
|
|
return _oidc_error_redirect(request, "oidc_exchange_failed")
|
|
|
|
# `parse_id_token` verifies signature + nonce; `userinfo` fills in
|
|
# claims some IdPs don't put in the ID token (e.g. picture). We
|
|
# merge both, preferring userinfo when both are present.
|
|
claims = dict(token.get("userinfo") or {})
|
|
if not claims:
|
|
try:
|
|
claims = dict(await client.userinfo(token=token))
|
|
except Exception:
|
|
claims = {}
|
|
id_token_claims = token.get("id_token_claims") or {}
|
|
for k, v in id_token_claims.items():
|
|
claims.setdefault(k, v)
|
|
|
|
sub = claims.get("sub")
|
|
if not sub:
|
|
logger.warning("OIDC callback: claims missing `sub` — %r", claims)
|
|
return _oidc_error_redirect(request, "oidc_missing_sub")
|
|
|
|
issuer = claims.get("iss") or (settings.oidc_issuer or "").rstrip("/")
|
|
email = claims.get("email")
|
|
display_name = claims.get("name") or claims.get("preferred_username")
|
|
picture = claims.get("picture")
|
|
groups = claims.get("groups") or []
|
|
if isinstance(groups, str):
|
|
groups = [groups]
|
|
|
|
admin_groups = set(settings.oidc_admin_group_list)
|
|
role = "admin" if admin_groups and admin_groups.intersection(groups) else "user"
|
|
|
|
# 1. Match by (issuer, sub) first — stable identity key.
|
|
user = (await db.execute(
|
|
select(User).where(
|
|
User.oidc_issuer == issuer,
|
|
User.oidc_sub == sub,
|
|
)
|
|
)).scalar_one_or_none()
|
|
|
|
# 2. Fall back to email so a pre-existing local account can be
|
|
# linked on first SSO login (homelab admin keeps their row).
|
|
if user is None and email:
|
|
user = (await db.execute(
|
|
select(User).where(User.email == email)
|
|
)).scalar_one_or_none()
|
|
|
|
if user is None:
|
|
if not settings.oidc_allow_signup:
|
|
logger.info("OIDC signup disabled — rejecting unknown sub=%s email=%s", sub, email)
|
|
return _oidc_error_redirect(request, "oidc_signup_disabled")
|
|
|
|
# JIT provision.
|
|
existing = {
|
|
u for (u,) in (await db.execute(select(User.username))).all()
|
|
}
|
|
username = _derive_username(claims, existing)
|
|
media_path = os.path.join(settings.photo_dirs, username)
|
|
os.makedirs(media_path, exist_ok=True)
|
|
|
|
user = User(
|
|
username=username,
|
|
email=email,
|
|
hashed_password=None,
|
|
role=role,
|
|
is_active=True,
|
|
media_path=media_path,
|
|
oidc_issuer=issuer,
|
|
oidc_sub=sub,
|
|
avatar_url=picture,
|
|
display_name=display_name,
|
|
)
|
|
db.add(user)
|
|
await db.flush()
|
|
|
|
db.add(SourceRoot(
|
|
name=f"{user.username}'s Library",
|
|
path=media_path,
|
|
user_id=user.id,
|
|
))
|
|
await db.commit()
|
|
logger.info("OIDC JIT-created user %s (role=%s)", user.username, role)
|
|
else:
|
|
# Refresh profile bits + link identity if needed. We do *not*
|
|
# demote admins created locally; only touch role when admin
|
|
# group mapping is configured.
|
|
changed = False
|
|
if user.oidc_sub != sub or user.oidc_issuer != issuer:
|
|
user.oidc_issuer = issuer
|
|
user.oidc_sub = sub
|
|
changed = True
|
|
if email and user.email != email:
|
|
user.email = email
|
|
changed = True
|
|
if display_name and user.display_name != display_name:
|
|
user.display_name = display_name
|
|
changed = True
|
|
if picture and user.avatar_url != picture:
|
|
user.avatar_url = picture
|
|
changed = True
|
|
if admin_groups:
|
|
new_role = "admin" if admin_groups.intersection(groups) else "user"
|
|
if user.role != new_role:
|
|
user.role = new_role
|
|
changed = True
|
|
if not user.is_active:
|
|
# Don't resurrect a deactivated account — surface an error.
|
|
logger.info("OIDC login rejected — user %s is deactivated", user.username)
|
|
return _oidc_error_redirect(request, "oidc_deactivated")
|
|
if changed:
|
|
await db.commit()
|
|
|
|
# Mint our own JWTs and bounce back to the SPA. Tokens ride in the
|
|
# URL fragment-free for simplicity; the frontend callback page
|
|
# strips them from the location bar immediately.
|
|
access = create_access_token(user.id, user.role)
|
|
refresh = create_refresh_token(user.id)
|
|
|
|
params = urlencode({"access_token": access, "refresh_token": refresh})
|
|
target = f"{_frontend_origin(request)}/auth/callback?{params}"
|
|
return RedirectResponse(url=target, status_code=302)
|
|
|
|
|
|
def _oidc_error_redirect(request: Request, code: str) -> RedirectResponse:
|
|
"""Bounce back to the SPA with an `error=` query so the login page
|
|
can render something meaningful instead of a stack trace."""
|
|
target = f"{_frontend_origin(request)}/auth/callback?error={code}"
|
|
return RedirectResponse(url=target, status_code=302)
|