Files
Catubba 0cd59dcb40 fix: address re-review findings + keep-PBS-on manual jobs
Feature — manual jobs can keep the PBS awake:
- Run backup / Run GC are now available while the PBS is asleep; they wake
  it themselves and only require that no other run is in progress.
- Manual GC runs as a full wake -> GC -> power-off cycle (run_gc_cycle),
  mirroring the verify cycle, instead of failing on a sleeping PBS.
- A "Keep PBS powered on after the job" toggle on the confirm dialog leaves
  the box on instead of powering it off, defaulted to its current power
  state (already-on stays on; asleep goes back to sleep). POST
  /api/backup/run and /api/gc/run accept an optional {keep_on} body.
  Scheduled runs always power off, unchanged.

Fixes:
- BE-S9: PUT /api/account now requires the current password, so a stolen
  session alone can't rotate the admin credentials.
- BE-B1: an invalid backup cron is rejected on save (422) and, if already
  on disk, is skipped with a warning instead of crashing the scheduler on
  every restart.
- FE-C1: the setup wizard no longer clears the stored PVE token secret on
  re-save (mirrors the PBS redaction guard).
- FE-H1: exclude guest mode is shown read-only and preserved on save,
  instead of being displayed as an include set and inverted on Apply.
2026-07-10 21:58:35 +02:00

181 lines
6.6 KiB
Python

"""Authentication endpoints: first-run setup, login, logout, current user.
Mirrors the design's auth screen: a fresh install (empty password_hash) shows
"Create the first account"; afterwards it's a normal login.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field, field_validator
from ..core import security
from ..core.config_store import ConfigStore
from ..core.security import get_config_store
from .deps import LoginRateLimiter, Scheduler, get_login_limiter, get_scheduler
router = APIRouter(tags=["auth"])
class Credentials(BaseModel):
username: str = Field(min_length=1)
password: str = Field(min_length=1)
class SetupRequest(BaseModel):
# First-run minimums mirror the prototype's client-side validation.
username: str = Field(min_length=3)
password: str = Field(min_length=8)
# IANA timezone the first-run screen detects from the browser (empty => keep the
# config default: TZ env, then UTC). An invalid name is tolerated — the scheduler
# falls back to UTC (see core/scheduler.resolve_timezone).
timezone: str = ""
class AuthStatus(BaseModel):
setup_needed: bool
authenticated: bool
username: str | None = None
class UserInfo(BaseModel):
username: str
@router.get("/auth/status", response_model=AuthStatus)
def auth_status(request: Request, store: ConfigStore = Depends(get_config_store)) -> AuthStatus:
user = security.session_user(request)
return AuthStatus(
setup_needed=security.setup_needed(store),
authenticated=user is not None,
username=user,
)
@router.post("/auth/setup", response_model=UserInfo, status_code=status.HTTP_201_CREATED)
def setup(
body: SetupRequest,
request: Request,
store: ConfigStore = Depends(get_config_store),
scheduler: Scheduler = Depends(get_scheduler),
limiter: LoginRateLimiter = Depends(get_login_limiter),
) -> UserInfo:
ip = request.client.host if request.client else "unknown"
remaining = limiter.locked_for(ip)
if remaining > 0:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"Too many attempts — try again in {int(remaining) + 1}s",
)
if not security.setup_needed(store):
limiter.record_failure(ip)
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="An account already exists"
)
password_hash = security.hash_password(body.password)
timezone = body.timezone.strip()
def apply(cfg) -> None:
cfg.app.auth.username = body.username
cfg.app.auth.password_hash = password_hash
if timezone:
cfg.app.timezone = timezone
store.update(apply)
# Re-arm so the chosen timezone drives the (default) backup schedule right away,
# rather than only after the user next saves config.
if timezone:
scheduler.rearm(store.config)
limiter.reset(ip)
security.login_session(request, body.username, password_hash)
return UserInfo(username=body.username)
@router.post("/login", response_model=UserInfo)
def login(
body: Credentials,
request: Request,
store: ConfigStore = Depends(get_config_store),
limiter: LoginRateLimiter = Depends(get_login_limiter),
) -> UserInfo:
ip = request.client.host if request.client else "unknown"
remaining = limiter.locked_for(ip)
if remaining > 0:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"Too many attempts — try again in {int(remaining) + 1}s",
)
if security.setup_needed(store):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="No account configured yet — complete setup first",
)
auth = store.config.app.auth
# Always run the (slow) hash check, even on a wrong username, so response time doesn't
# reveal whether the username exists (avoids a timing-based enumeration oracle).
user_ok = body.username == auth.username
pw_ok = security.verify_password(body.password, auth.password_hash)
if not (user_ok and pw_ok):
limiter.record_failure(ip)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect username or password"
)
limiter.reset(ip)
security.login_session(request, auth.username, auth.password_hash)
return UserInfo(username=auth.username)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
def logout(request: Request) -> None:
security.logout_session(request)
@router.get("/auth/me", response_model=UserInfo)
def me(user: str = security.CurrentUser) -> UserInfo:
return UserInfo(username=user)
class AccountUpdate(BaseModel):
# Confirming the current password re-authenticates this sensitive change, so a
# hijacked session alone can't silently rotate the admin credentials (BE-S9).
current_password: str = Field(min_length=1)
username: str = Field(min_length=3)
# Optional: empty string / null / omitted all mean "keep the current password"
# (the design's "leave empty"). Length is enforced only for a real new password.
password: str | None = None
@field_validator("password")
@classmethod
def _min_len_when_set(cls, v: str | None) -> str | None:
if v and len(v) < 8:
raise ValueError("password must be at least 8 characters")
return v
@router.put("/account", response_model=UserInfo)
def update_account(
body: AccountUpdate,
request: Request,
_user: str = security.CurrentUser,
store: ConfigStore = Depends(get_config_store),
) -> UserInfo:
"""Change the admin username and (optionally) password. Auth-guarded, and gated on
re-confirming the current password (defends against session-riding takeover)."""
if not security.verify_password(body.current_password, store.config.app.auth.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect"
)
password_hash = security.hash_password(body.password) if body.password else None
def apply(cfg) -> None:
cfg.app.auth.username = body.username
if password_hash is not None:
cfg.app.auth.password_hash = password_hash
store.update(apply)
# Re-issue the acting session's cookie with the new hash so this admin stays logged
# in; other pre-existing sessions still carry the old pwv and are now revoked.
if password_hash is not None:
security.login_session(request, body.username, password_hash)
return UserInfo(username=body.username)