feat: Stage 2 — authentication and setup flow

Backend (tasks 2.1–2.6, 2.10):
- settings_repo: get/set/delete/get_all CRUD for the key-value settings table
- session_repo: create/get/delete/delete_expired for session rows
- setup_service: bcrypt password hashing, one-time-only enforcement,
  run_setup() / is_setup_complete() / get_password_hash()
- auth_service: login() with bcrypt verify + token creation,
  validate_session() with expiry check, logout()
- setup router: GET /api/setup (status), POST /api/setup (201 / 409)
- auth router: POST /api/auth/login (token + HttpOnly cookie),
               POST /api/auth/logout (clears cookie, idempotent)
- SetupRedirectMiddleware: 307 → /api/setup for all API paths until setup done
- require_auth dependency: cookie or Bearer token → Session or 401
- conftest.py: manually bootstraps app.state.db for router tests
  (ASGITransport does not trigger ASGI lifespan)
- 85 tests pass; ruff 0 errors; mypy --strict 0 errors

Frontend (tasks 2.7–2.9):
- types/auth.ts, types/setup.ts, api/auth.ts, api/setup.ts
- AuthProvider: sessionStorage-backed context (isAuthenticated, login, logout)
- RequireAuth: guard component → /login?next=<path> when unauthenticated
- SetupPage: Fluent UI form, client-side validation, inline errors
- LoginPage: single password input, ?next= redirect after success
- DashboardPage: placeholder (full impl Stage 5)
- App.tsx: full route tree (/setup, /login, /, *)
This commit is contained in:
2026-02-28 21:33:30 +01:00
parent 7392c930d6
commit 750785680b
26 changed files with 2075 additions and 49 deletions

View File

@@ -13,9 +13,12 @@ import structlog
from fastapi import Depends, HTTPException, Request, status
from app.config import Settings
from app.models.auth import Session
log: structlog.stdlib.BoundLogger = structlog.get_logger()
_COOKIE_NAME = "bangui_session"
async def get_db(request: Request) -> aiosqlite.Connection:
"""Provide the shared :class:`aiosqlite.Connection` from ``app.state``.
@@ -51,6 +54,51 @@ async def get_settings(request: Request) -> Settings:
return request.app.state.settings # type: ignore[no-any-return]
async def require_auth(
request: Request,
db: Annotated[aiosqlite.Connection, Depends(get_db)],
) -> Session:
"""Validate the session token and return the active session.
The token is read from the ``bangui_session`` cookie or the
``Authorization: Bearer`` header.
Args:
request: The incoming FastAPI request.
db: Injected aiosqlite connection.
Returns:
The active :class:`~app.models.auth.Session`.
Raises:
HTTPException: 401 if no valid session token is found.
"""
from app.services import auth_service # noqa: PLC0415
token: str | None = request.cookies.get(_COOKIE_NAME)
if not token:
auth_header: str = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[len("Bearer "):]
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required.",
headers={"WWW-Authenticate": "Bearer"},
)
try:
return await auth_service.validate_session(db, token)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=str(exc),
headers={"WWW-Authenticate": "Bearer"},
) from exc
# Convenience type aliases for route signatures.
DbDep = Annotated[aiosqlite.Connection, Depends(get_db)]
SettingsDep = Annotated[Settings, Depends(get_settings)]
AuthDep = Annotated[Session, Depends(require_auth)]

View File

@@ -18,19 +18,22 @@ from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import AsyncGenerator
from collections.abc import AsyncGenerator, Awaitable, Callable
from starlette.responses import Response as StarletteResponse
import aiohttp
import aiosqlite
import structlog
from apscheduler.schedulers.asyncio import AsyncIOScheduler # type: ignore[import-untyped]
from fastapi import FastAPI, Request
from fastapi import FastAPI, Request, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.responses import JSONResponse, RedirectResponse
from starlette.middleware.base import BaseHTTPMiddleware
from app.config import Settings, get_settings
from app.db import init_db
from app.routers import health
from app.routers import auth, health, setup
# ---------------------------------------------------------------------------
# Ensure the bundled fail2ban package is importable from fail2ban-master/
@@ -156,6 +159,60 @@ async def _unhandled_exception_handler(
)
# ---------------------------------------------------------------------------
# Setup-redirect middleware
# ---------------------------------------------------------------------------
# Paths that are always reachable, even before setup is complete.
_ALWAYS_ALLOWED: frozenset[str] = frozenset(
{"/api/setup", "/api/health"},
)
class SetupRedirectMiddleware(BaseHTTPMiddleware):
"""Redirect all API requests to ``/api/setup`` until setup is done.
Once setup is complete this middleware is a no-op. Paths listed in
:data:`_ALWAYS_ALLOWED` are exempt so the setup endpoint itself is
always reachable.
"""
async def dispatch(
self,
request: Request,
call_next: Callable[[Request], Awaitable[StarletteResponse]],
) -> StarletteResponse:
"""Intercept requests before they reach the router.
Args:
request: The incoming HTTP request.
call_next: The next middleware / router handler.
Returns:
Either a ``307 Temporary Redirect`` to ``/api/setup`` or the
normal router response.
"""
path: str = request.url.path.rstrip("/") or "/"
# Allow requests that don't need setup guard.
if any(path.startswith(allowed) for allowed in _ALWAYS_ALLOWED):
return await call_next(request)
# If setup is not complete, block all other API requests.
if path.startswith("/api"):
db: aiosqlite.Connection | None = getattr(request.app.state, "db", None)
if db is not None:
from app.services import setup_service # noqa: PLC0415
if not await setup_service.is_setup_complete(db):
return RedirectResponse(
url="/api/setup",
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
)
return await call_next(request)
# ---------------------------------------------------------------------------
# Application factory
# ---------------------------------------------------------------------------
@@ -199,10 +256,17 @@ def create_app(settings: Settings | None = None) -> FastAPI:
allow_headers=["*"],
)
# --- Middleware ---
# Note: middleware is applied in reverse order of registration.
# The setup-redirect must run *after* CORS, so it is added last.
app.add_middleware(SetupRedirectMiddleware)
# --- Exception handlers ---
app.add_exception_handler(Exception, _unhandled_exception_handler)
# --- Routers ---
app.include_router(health.router)
app.include_router(setup.router)
app.include_router(auth.router)
return app

View File

@@ -0,0 +1,100 @@
"""Session repository.
Provides storage, retrieval, and deletion of session records in the
``sessions`` table of the application SQLite database.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import aiosqlite
from app.models.auth import Session
async def create_session(
db: aiosqlite.Connection,
token: str,
created_at: str,
expires_at: str,
) -> Session:
"""Insert a new session row and return the domain model.
Args:
db: Active aiosqlite connection.
token: Opaque random session token (hex string).
created_at: ISO 8601 UTC creation timestamp.
expires_at: ISO 8601 UTC expiry timestamp.
Returns:
The newly created :class:`~app.models.auth.Session`.
"""
cursor = await db.execute(
"INSERT INTO sessions (token, created_at, expires_at) VALUES (?, ?, ?)",
(token, created_at, expires_at),
)
await db.commit()
return Session(
id=int(cursor.lastrowid) if cursor.lastrowid else 0,
token=token,
created_at=created_at,
expires_at=expires_at,
)
async def get_session(db: aiosqlite.Connection, token: str) -> Session | None:
"""Look up a session by its token.
Args:
db: Active aiosqlite connection.
token: The session token to retrieve.
Returns:
The :class:`~app.models.auth.Session` if found, else ``None``.
"""
async with db.execute(
"SELECT id, token, created_at, expires_at FROM sessions WHERE token = ?",
(token,),
) as cursor:
row = await cursor.fetchone()
if row is None:
return None
return Session(
id=int(row[0]),
token=str(row[1]),
created_at=str(row[2]),
expires_at=str(row[3]),
)
async def delete_session(db: aiosqlite.Connection, token: str) -> None:
"""Delete a session by token (logout / expiry clean-up).
Args:
db: Active aiosqlite connection.
token: The session token to remove.
"""
await db.execute("DELETE FROM sessions WHERE token = ?", (token,))
await db.commit()
async def delete_expired_sessions(db: aiosqlite.Connection, now_iso: str) -> int:
"""Remove all sessions whose ``expires_at`` timestamp is in the past.
Args:
db: Active aiosqlite connection.
now_iso: Current UTC time as ISO 8601 string used as the cutoff.
Returns:
Number of rows deleted.
"""
cursor = await db.execute(
"DELETE FROM sessions WHERE expires_at <= ?",
(now_iso,),
)
await db.commit()
return int(cursor.rowcount)

View File

@@ -0,0 +1,71 @@
"""Settings repository.
Provides CRUD operations for the ``settings`` key-value table in the
application SQLite database. All methods are plain async functions that
accept a :class:`aiosqlite.Connection` — no ORM, no HTTP exceptions.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import aiosqlite
async def get_setting(db: aiosqlite.Connection, key: str) -> str | None:
"""Return the value for *key*, or ``None`` if it does not exist.
Args:
db: Active aiosqlite connection.
key: The setting key to look up.
Returns:
The stored value string, or ``None`` if the key is absent.
"""
async with db.execute(
"SELECT value FROM settings WHERE key = ?",
(key,),
) as cursor:
row = await cursor.fetchone()
return str(row[0]) if row is not None else None
async def set_setting(db: aiosqlite.Connection, key: str, value: str) -> None:
"""Insert or replace the setting identified by *key*.
Args:
db: Active aiosqlite connection.
key: The setting key.
value: The value to store.
"""
await db.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
(key, value),
)
await db.commit()
async def delete_setting(db: aiosqlite.Connection, key: str) -> None:
"""Delete the setting identified by *key* if it exists.
Args:
db: Active aiosqlite connection.
key: The setting key to remove.
"""
await db.execute("DELETE FROM settings WHERE key = ?", (key,))
await db.commit()
async def get_all_settings(db: aiosqlite.Connection) -> dict[str, str]:
"""Return all settings as a plain ``dict``.
Args:
db: Active aiosqlite connection.
Returns:
A dictionary mapping every stored key to its value.
"""
async with db.execute("SELECT key, value FROM settings") as cursor:
rows = await cursor.fetchall()
return {str(row[0]): str(row[1]) for row in rows}

128
backend/app/routers/auth.py Normal file
View File

@@ -0,0 +1,128 @@
"""Authentication router.
``POST /api/auth/login`` — verify master password and issue a session.
``POST /api/auth/logout`` — revoke the current session.
The session token is returned both in the JSON body (for API-first
consumers) and as an ``HttpOnly`` cookie (for the browser SPA).
"""
from __future__ import annotations
import structlog
from fastapi import APIRouter, HTTPException, Request, Response, status
from app.dependencies import DbDep, SettingsDep
from app.models.auth import LoginRequest, LoginResponse, LogoutResponse
from app.services import auth_service
log: structlog.stdlib.BoundLogger = structlog.get_logger()
router = APIRouter(prefix="/api/auth", tags=["auth"])
_COOKIE_NAME = "bangui_session"
@router.post(
"/login",
response_model=LoginResponse,
summary="Authenticate with the master password",
)
async def login(
body: LoginRequest,
response: Response,
db: DbDep,
settings: SettingsDep,
) -> LoginResponse:
"""Verify the master password and return a session token.
On success the token is also set as an ``HttpOnly`` ``SameSite=Lax``
cookie so the browser SPA benefits from automatic credential handling.
Args:
body: Login request validated by Pydantic.
response: FastAPI response object used to set the cookie.
db: Injected aiosqlite connection.
settings: Application settings (used for session duration).
Returns:
:class:`~app.models.auth.LoginResponse` containing the token.
Raises:
HTTPException: 401 if the password is incorrect.
"""
try:
session = await auth_service.login(
db,
password=body.password,
session_duration_minutes=settings.session_duration_minutes,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=str(exc),
) from exc
response.set_cookie(
key=_COOKIE_NAME,
value=session.token,
httponly=True,
samesite="lax",
secure=False, # Set to True in production behind HTTPS
max_age=settings.session_duration_minutes * 60,
)
return LoginResponse(token=session.token, expires_at=session.expires_at)
@router.post(
"/logout",
response_model=LogoutResponse,
summary="Revoke the current session",
)
async def logout(
request: Request,
response: Response,
db: DbDep,
) -> LogoutResponse:
"""Invalidate the active session.
The session token is read from the ``bangui_session`` cookie or the
``Authorization: Bearer`` header. If no token is present the request
is silently treated as a successful logout (idempotent).
Args:
request: FastAPI request (used to extract the token).
response: FastAPI response (used to clear the cookie).
db: Injected aiosqlite connection.
Returns:
:class:`~app.models.auth.LogoutResponse`.
"""
token = _extract_token(request)
if token:
await auth_service.logout(db, token)
response.delete_cookie(key=_COOKIE_NAME)
return LogoutResponse()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _extract_token(request: Request) -> str | None:
"""Extract the session token from cookie or Authorization header.
Args:
request: The incoming FastAPI request.
Returns:
The token string, or ``None`` if absent.
"""
token: str | None = request.cookies.get(_COOKIE_NAME)
if token:
return token
auth_header: str = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
return auth_header[len("Bearer "):]
return None

View File

@@ -0,0 +1,71 @@
"""Setup router.
Exposes the ``POST /api/setup`` endpoint for the one-time first-run
configuration wizard. Once setup has been completed, subsequent calls
return ``409 Conflict``.
"""
from __future__ import annotations
import structlog
from fastapi import APIRouter, HTTPException, status
from app.dependencies import DbDep
from app.models.setup import SetupRequest, SetupResponse, SetupStatusResponse
from app.services import setup_service
log: structlog.stdlib.BoundLogger = structlog.get_logger()
router = APIRouter(prefix="/api/setup", tags=["setup"])
@router.get(
"",
response_model=SetupStatusResponse,
summary="Check whether setup has been completed",
)
async def get_setup_status(db: DbDep) -> SetupStatusResponse:
"""Return whether the initial setup wizard has been completed.
Returns:
:class:`~app.models.setup.SetupStatusResponse` with ``completed``
set to ``True`` if setup is done, ``False`` otherwise.
"""
done = await setup_service.is_setup_complete(db)
return SetupStatusResponse(completed=done)
@router.post(
"",
response_model=SetupResponse,
status_code=status.HTTP_201_CREATED,
summary="Run the initial setup wizard",
)
async def post_setup(body: SetupRequest, db: DbDep) -> SetupResponse:
"""Persist the initial BanGUI configuration.
Args:
body: Setup request payload validated by Pydantic.
db: Injected aiosqlite connection.
Returns:
:class:`~app.models.setup.SetupResponse` on success.
Raises:
HTTPException: 409 if setup has already been completed.
"""
if await setup_service.is_setup_complete(db):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Setup has already been completed.",
)
await setup_service.run_setup(
db,
master_password=body.master_password,
database_path=body.database_path,
fail2ban_socket=body.fail2ban_socket,
timezone=body.timezone,
session_duration_minutes=body.session_duration_minutes,
)
return SetupResponse()

View File

@@ -0,0 +1,113 @@
"""Authentication service.
Handles password verification, session creation, session validation, and
session expiry. Sessions are stored in the SQLite database so they
survive server restarts.
"""
from __future__ import annotations
import secrets
from typing import TYPE_CHECKING
import bcrypt
import structlog
if TYPE_CHECKING:
import aiosqlite
from app.models.auth import Session
from app.repositories import session_repo
from app.services import setup_service
from app.utils.time_utils import add_minutes, utc_now
log: structlog.stdlib.BoundLogger = structlog.get_logger()
def _check_password(plain: str, hashed: str) -> bool:
"""Return ``True`` if *plain* matches the bcrypt *hashed* password.
Args:
plain: The plain-text password to verify.
hashed: The stored bcrypt hash string.
Returns:
``True`` on a successful match, ``False`` otherwise.
"""
return bool(bcrypt.checkpw(plain.encode(), hashed.encode()))
async def login(
db: aiosqlite.Connection,
password: str,
session_duration_minutes: int,
) -> Session:
"""Verify *password* and create a new session on success.
Args:
db: Active aiosqlite connection.
password: Plain-text password supplied by the user.
session_duration_minutes: How long the new session is valid for.
Returns:
A :class:`~app.models.auth.Session` domain model for the new session.
Raises:
ValueError: If the password is incorrect or no password hash is stored.
"""
stored_hash = await setup_service.get_password_hash(db)
if stored_hash is None:
log.warning("bangui_login_no_hash")
raise ValueError("No password is configured — run setup first.")
if not _check_password(password, stored_hash):
log.warning("bangui_login_wrong_password")
raise ValueError("Incorrect password.")
token = secrets.token_hex(32)
now = utc_now()
created_iso = now.isoformat()
expires_iso = add_minutes(now, session_duration_minutes).isoformat()
session = await session_repo.create_session(
db, token=token, created_at=created_iso, expires_at=expires_iso
)
log.info("bangui_login_success", token_prefix=token[:8])
return session
async def validate_session(db: aiosqlite.Connection, token: str) -> Session:
"""Return the session for *token* if it is valid and not expired.
Args:
db: Active aiosqlite connection.
token: The opaque session token from the client.
Returns:
The :class:`~app.models.auth.Session` if it is valid.
Raises:
ValueError: If the token is not found or has expired.
"""
session = await session_repo.get_session(db, token)
if session is None:
raise ValueError("Session not found.")
now_iso = utc_now().isoformat()
if session.expires_at <= now_iso:
await session_repo.delete_session(db, token)
raise ValueError("Session has expired.")
return session
async def logout(db: aiosqlite.Connection, token: str) -> None:
"""Invalidate the session identified by *token*.
Args:
db: Active aiosqlite connection.
token: The session token to revoke.
"""
await session_repo.delete_session(db, token)
log.info("bangui_logout", token_prefix=token[:8])

View File

@@ -0,0 +1,101 @@
"""Setup service.
Implements the one-time first-run configuration wizard. Responsible for
hashing the master password, persisting all initial settings, and
enforcing the rule that setup can only run once.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import bcrypt
import structlog
if TYPE_CHECKING:
import aiosqlite
from app.repositories import settings_repo
log: structlog.stdlib.BoundLogger = structlog.get_logger()
# Keys used in the settings table.
_KEY_PASSWORD_HASH = "master_password_hash"
_KEY_SETUP_DONE = "setup_completed"
_KEY_DATABASE_PATH = "database_path"
_KEY_FAIL2BAN_SOCKET = "fail2ban_socket"
_KEY_TIMEZONE = "timezone"
_KEY_SESSION_DURATION = "session_duration_minutes"
async def is_setup_complete(db: aiosqlite.Connection) -> bool:
"""Return ``True`` if initial setup has already been performed.
Args:
db: Active aiosqlite connection.
Returns:
``True`` when the ``setup_completed`` key exists in settings.
"""
value = await settings_repo.get_setting(db, _KEY_SETUP_DONE)
return value == "1"
async def run_setup(
db: aiosqlite.Connection,
*,
master_password: str,
database_path: str,
fail2ban_socket: str,
timezone: str,
session_duration_minutes: int,
) -> None:
"""Persist the initial configuration and mark setup as complete.
Hashes *master_password* with bcrypt before storing. Raises
:class:`RuntimeError` if setup has already been completed.
Args:
db: Active aiosqlite connection.
master_password: Plain-text master password chosen by the user.
database_path: Filesystem path to the BanGUI SQLite database.
fail2ban_socket: Unix socket path for the fail2ban daemon.
timezone: IANA timezone identifier (e.g. ``"UTC"``).
session_duration_minutes: Session validity period in minutes.
Raises:
RuntimeError: If setup has already been completed.
"""
if await is_setup_complete(db):
raise RuntimeError("Setup has already been completed.")
log.info("bangui_setup_started")
# Hash the master password — bcrypt automatically generates a salt.
password_bytes = master_password.encode()
hashed = bcrypt.hashpw(password_bytes, bcrypt.gensalt()).decode()
await settings_repo.set_setting(db, _KEY_PASSWORD_HASH, hashed)
await settings_repo.set_setting(db, _KEY_DATABASE_PATH, database_path)
await settings_repo.set_setting(db, _KEY_FAIL2BAN_SOCKET, fail2ban_socket)
await settings_repo.set_setting(db, _KEY_TIMEZONE, timezone)
await settings_repo.set_setting(
db, _KEY_SESSION_DURATION, str(session_duration_minutes)
)
# Mark setup as complete — must be last so a partial failure leaves
# setup_completed unset and does not lock out the user.
await settings_repo.set_setting(db, _KEY_SETUP_DONE, "1")
log.info("bangui_setup_completed")
async def get_password_hash(db: aiosqlite.Connection) -> str | None:
"""Return the stored bcrypt password hash, or ``None`` if not set.
Args:
db: Active aiosqlite connection.
Returns:
The bcrypt hash string, or ``None``.
"""
return await settings_repo.get_setting(db, _KEY_PASSWORD_HASH)