Self-hosted Docker Compose manager. - Backend: FastAPI + docker-py + SQLite (JWT auth, file-first stacks, lifecycle, live status, WebSocket logs, docker-run converter, audit log) - Frontend: React + Vite + Tailwind (login/setup, dashboard, stacks, stack detail, Monaco editor, dark/light theme) - Deployment: docker-compose.yml, Dockerfiles, nginx reverse proxy Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
112 lines
3.2 KiB
Python
112 lines
3.2 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
|
|
|
|
import auth as auth_mod
|
|
from database import get_session
|
|
from models.user import (
|
|
LoginRequest,
|
|
RefreshRequest,
|
|
TokenPair,
|
|
User,
|
|
UserCreate,
|
|
UserRead,
|
|
)
|
|
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
|