Files
stackpilot/backend/routers/auth.py
T
menzeljandClaude Opus 4.8 8d19b09abd Phase 4: backups w/ volumes, notifications, settings & users, audit page (0.4.0)
- 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>
2026-06-07 20:58:05 +00:00

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}