- Backup/restore: per-stack tar.gz incl. named-volume snapshots (helper container), upload restore with rename/overwrite/conflict detection. - Notifications: ntfy/Discord/Slack/Gotify/generic webhooks, per-event subscriptions; wired into the update checker and stack lifecycle. - Settings page: update-check interval, webhook CRUD + test, user management (with last-admin safeguards). - Audit log page (searchable, paginated). - Mobile-responsive sidebar/layout. Multi-host agents and remote backup destinations (SFTP/S3) deferred. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
222 lines
7.1 KiB
Python
222 lines
7.1 KiB
Python
"""Authentication routes + first-launch setup wizard."""
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from collections import defaultdict, deque
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|
from sqlmodel import Session, select
|
|
|
|
import auth as auth_mod
|
|
from database import get_session
|
|
from models.user import (
|
|
LoginRequest,
|
|
RefreshRequest,
|
|
TokenPair,
|
|
User,
|
|
UserCreate,
|
|
UserRead,
|
|
UserUpdate,
|
|
)
|
|
from services import audit_service
|
|
|
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
|
|
|
# Simple in-memory rate limiter for login (max 10 / minute / IP).
|
|
_LOGIN_HITS: dict[str, deque] = defaultdict(deque)
|
|
_RATE_LIMIT = 10
|
|
_RATE_WINDOW = 60.0
|
|
|
|
|
|
def _check_rate_limit(ip: str) -> None:
|
|
now = time.monotonic()
|
|
hits = _LOGIN_HITS[ip]
|
|
while hits and now - hits[0] > _RATE_WINDOW:
|
|
hits.popleft()
|
|
if len(hits) >= _RATE_LIMIT:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
detail="Too many login attempts, slow down.",
|
|
)
|
|
hits.append(now)
|
|
|
|
|
|
def _tokens_for(user: User) -> TokenPair:
|
|
return TokenPair(
|
|
access_token=auth_mod.create_access_token(user),
|
|
refresh_token=auth_mod.create_refresh_token(user),
|
|
)
|
|
|
|
|
|
@router.get("/needs-setup")
|
|
def needs_setup(session: Session = Depends(get_session)) -> dict:
|
|
"""First-launch wizard check: True if no users exist yet."""
|
|
return {"needs_setup": not auth_mod.users_exist(session)}
|
|
|
|
|
|
@router.post("/setup", response_model=TokenPair)
|
|
def setup(
|
|
body: UserCreate, session: Session = Depends(get_session)
|
|
) -> TokenPair:
|
|
if auth_mod.users_exist(session):
|
|
raise HTTPException(status_code=400, detail="Setup already completed")
|
|
user = User(
|
|
username=body.username,
|
|
hashed_password=auth_mod.hash_password(body.password),
|
|
role="admin",
|
|
)
|
|
session.add(user)
|
|
session.commit()
|
|
session.refresh(user)
|
|
audit_service.record(
|
|
session, user=user.username, action="user.setup", target=user.username
|
|
)
|
|
return _tokens_for(user)
|
|
|
|
|
|
@router.post("/login", response_model=TokenPair)
|
|
def login(
|
|
body: LoginRequest,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
) -> TokenPair:
|
|
ip = request.client.host if request.client else "unknown"
|
|
_check_rate_limit(ip)
|
|
user = auth_mod.authenticate(session, body.username, body.password)
|
|
if not user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Incorrect username or password",
|
|
)
|
|
audit_service.record(
|
|
session, user=user.username, action="auth.login", target=user.username, ip=ip
|
|
)
|
|
return _tokens_for(user)
|
|
|
|
|
|
@router.post("/refresh", response_model=TokenPair)
|
|
def refresh(
|
|
body: RefreshRequest, session: Session = Depends(get_session)
|
|
) -> TokenPair:
|
|
payload = auth_mod.decode_token(body.refresh_token, "refresh")
|
|
user = auth_mod.get_user(session, payload.get("sub", ""))
|
|
if not user or not user.is_active:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token"
|
|
)
|
|
return _tokens_for(user)
|
|
|
|
|
|
@router.get("/me", response_model=UserRead)
|
|
def me(user: User = Depends(auth_mod.get_current_user)) -> User:
|
|
return user
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# User management (admin only)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def _ip(request: Request) -> str:
|
|
return request.client.host if request.client else "unknown"
|
|
|
|
|
|
@router.get("/users", response_model=list[UserRead])
|
|
def list_users(
|
|
session: Session = Depends(get_session),
|
|
_admin: User = Depends(auth_mod.require_admin),
|
|
) -> list[User]:
|
|
return session.exec(select(User).order_by(User.id)).all()
|
|
|
|
|
|
@router.post("/users", response_model=UserRead, status_code=201)
|
|
def create_user(
|
|
body: UserCreate,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
admin: User = Depends(auth_mod.require_admin),
|
|
) -> User:
|
|
if not body.username.strip() or not body.password:
|
|
raise HTTPException(status_code=400, detail="Username and password required")
|
|
if auth_mod.get_user(session, body.username):
|
|
raise HTTPException(status_code=409, detail="Username already exists")
|
|
role = body.role if body.role in ("admin", "user") else "user"
|
|
user = User(
|
|
username=body.username,
|
|
hashed_password=auth_mod.hash_password(body.password),
|
|
role=role,
|
|
)
|
|
session.add(user)
|
|
session.commit()
|
|
session.refresh(user)
|
|
audit_service.record(
|
|
session, user=admin.username, action="user.create", target=user.username,
|
|
detail=f"role={role}", ip=_ip(request),
|
|
)
|
|
return user
|
|
|
|
|
|
@router.patch("/users/{user_id}", response_model=UserRead)
|
|
def update_user(
|
|
user_id: int,
|
|
body: UserUpdate,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
admin: User = Depends(auth_mod.require_admin),
|
|
) -> User:
|
|
user = session.get(User, user_id)
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
# Guard against locking yourself out / demoting the last admin.
|
|
demoting = (body.role is not None and body.role != "admin") or body.is_active is False
|
|
if user.role == "admin" and demoting:
|
|
other_admins = session.exec(
|
|
select(User).where(User.role == "admin", User.is_active == True, User.id != user_id) # noqa: E712
|
|
).first()
|
|
if not other_admins:
|
|
raise HTTPException(status_code=400, detail="Cannot demote or disable the last active admin")
|
|
if body.password:
|
|
user.hashed_password = auth_mod.hash_password(body.password)
|
|
if body.role is not None:
|
|
if body.role not in ("admin", "user"):
|
|
raise HTTPException(status_code=400, detail="Invalid role")
|
|
user.role = body.role
|
|
if body.is_active is not None:
|
|
user.is_active = body.is_active
|
|
session.add(user)
|
|
session.commit()
|
|
session.refresh(user)
|
|
audit_service.record(
|
|
session, user=admin.username, action="user.update", target=user.username,
|
|
ip=_ip(request),
|
|
)
|
|
return user
|
|
|
|
|
|
@router.delete("/users/{user_id}")
|
|
def delete_user(
|
|
user_id: int,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
admin: User = Depends(auth_mod.require_admin),
|
|
) -> dict:
|
|
user = session.get(User, user_id)
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
if user.id == admin.id:
|
|
raise HTTPException(status_code=400, detail="You cannot delete your own account")
|
|
if user.role == "admin":
|
|
other_admins = session.exec(
|
|
select(User).where(User.role == "admin", User.is_active == True, User.id != user_id) # noqa: E712
|
|
).first()
|
|
if not other_admins:
|
|
raise HTTPException(status_code=400, detail="Cannot delete the last active admin")
|
|
username = user.username
|
|
session.delete(user)
|
|
session.commit()
|
|
audit_service.record(
|
|
session, user=admin.username, action="user.delete", target=username,
|
|
ip=_ip(request),
|
|
)
|
|
return {"ok": True}
|