Lets each mule-image user (matched via OIDC preferred_username, overridable in Settings) browse their Nextcloud files/ tree from the mule-image UI and register subfolders as per-user SourceRoots. Reads stay direct on the bind-mounted /nextcloud-users path; mutations (upload, delete, rename, move within NC) dispatch through Nextcloud WebDAV so oc_filecache, trashbin, comments, and desktop-sync clients stay coherent. Backend: - users.nextcloud_username + nextcloud_app_password_enc (Fernet at rest, key derived from SECRET_KEY) — alembic 0016 - services/nextcloud_dav.py: minimal WebDAV client (PUT, MKCOL, DELETE, MOVE) with HTTP Basic auth via the per-user app password - routers/nextcloud.py: GET /browse, /whoami, GET/POST/DELETE /source-roots (path-scoped to current_user.nextcloud_username with realpath traversal guard) - PATCH /api/v1/auth/me to update nextcloud_username and app password - OIDC callback defaults nextcloud_username from preferred_username on first login; backfill on existing users; never overwrites a manual override - routers/upload.py: stream upload to NamedTemporaryFile, then PUT to WebDAV (with MKCOL chain) when destination is NC-rooted; existing Photo row creation runs unchanged - routers/discard.py empty-trash: WebDAV DELETE for NC files - routers/photos.py rename + move: WebDAV MOVE for NC paths; cross-system move/copy returns a clean error - routers/folders.py rename + create + permanent-delete: dispatch via WebDAV when targeting NC-rooted paths Frontend: - AuthUser carries nextcloud_username + has_nextcloud_app_password - services/api.ts: nextcloud + account namespaces - components/dialogs/NextcloudFolderPicker.tsx: lazy tree browser, name + submit -> POST /source-roots - SettingsDialog: new "Nextcloud library" card with username override + validate, app-password input, list/remove of NC libraries, and the picker entry point docker-compose.yml: NEXTCLOUD_USERS_HOST_PATH bind to /nextcloud-users on backend + 3 workers; NEXTCLOUD_USERS_ROOT + NEXTCLOUD_BASE_URL env. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
553 lines
20 KiB
Python
553 lines
20 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
|
|
# Nextcloud integration — username override (defaults to OIDC
|
|
# preferred_username) and a flag for whether the user has stored
|
|
# an app password. Cleartext passwords are never serialized.
|
|
nextcloud_username: Optional[str] = None
|
|
has_nextcloud_app_password: bool = False
|
|
|
|
|
|
class UpdateMeRequest(BaseModel):
|
|
"""PATCH /me payload. Every field is optional — only what's set
|
|
gets touched. Setting `nextcloud_app_password` to "" clears it."""
|
|
nextcloud_username: Optional[str] = None
|
|
nextcloud_app_password: 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,
|
|
nextcloud_username=user.nextcloud_username,
|
|
has_nextcloud_app_password=bool(user.nextcloud_app_password_enc),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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)
|
|
|
|
|
|
_NC_USERNAME_RE = re.compile(r"^[a-zA-Z0-9._@-]{1,64}$")
|
|
|
|
|
|
@router.patch("/me", response_model=UserResponse)
|
|
async def update_me(
|
|
body: UpdateMeRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Update the authenticated user's Nextcloud integration settings.
|
|
|
|
`nextcloud_username` overrides the OIDC `preferred_username` default
|
|
so e.g. the local mule-image user `dtoro` can map to Nextcloud user
|
|
`admin`. `nextcloud_app_password` is encrypted at rest via the
|
|
Fernet helper in `services/secrets.py`; passing an empty string
|
|
clears it.
|
|
"""
|
|
from app.services.secrets import encrypt
|
|
|
|
changed = False
|
|
if body.nextcloud_username is not None:
|
|
candidate = body.nextcloud_username.strip()
|
|
if candidate and not _NC_USERNAME_RE.match(candidate):
|
|
raise HTTPException(status_code=400, detail="Invalid Nextcloud username")
|
|
current_user.nextcloud_username = candidate or None
|
|
changed = True
|
|
|
|
if body.nextcloud_app_password is not None:
|
|
if body.nextcloud_app_password == "":
|
|
current_user.nextcloud_app_password_enc = None
|
|
else:
|
|
current_user.nextcloud_app_password_enc = encrypt(body.nextcloud_app_password)
|
|
changed = True
|
|
|
|
if changed:
|
|
await db.commit()
|
|
await db.refresh(current_user)
|
|
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()
|
|
|
|
# 3. Last-resort link by preferred_username. Off by default; only
|
|
# used in trusted single-tenant setups where local accounts
|
|
# predate OIDC and never collected email (the app has no UI for
|
|
# it). Guarded by OIDC_LINK_BY_USERNAME to avoid hijacking
|
|
# accounts in shared instances.
|
|
if user is None and settings.oidc_link_by_username:
|
|
preferred = claims.get("preferred_username")
|
|
if preferred:
|
|
user = (await db.execute(
|
|
select(User).where(User.username == preferred)
|
|
)).scalar_one_or_none()
|
|
if user is not None:
|
|
logger.info(
|
|
"OIDC linked existing user %s by preferred_username",
|
|
preferred,
|
|
)
|
|
|
|
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,
|
|
# Default the Nextcloud username from preferred_username so
|
|
# the common case "same name on both sides" needs zero
|
|
# configuration. Override is exposed in Settings for the
|
|
# mismatch case (e.g. authentik dtoro ↔ Nextcloud admin).
|
|
nextcloud_username=(claims.get("preferred_username") or None),
|
|
)
|
|
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
|
|
# Backfill nextcloud_username on first OIDC login for users that
|
|
# predate the column. NEVER overwrites a value the user already
|
|
# set in Settings — once the override is non-null, it wins.
|
|
if not user.nextcloud_username:
|
|
preferred = claims.get("preferred_username")
|
|
if preferred:
|
|
user.nextcloud_username = preferred
|
|
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)
|