Initial commit: StackPilot Phase 1 (Core)
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>
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user