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:
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
100
backend/app/repositories/session_repo.py
Normal file
100
backend/app/repositories/session_repo.py
Normal 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)
|
||||
71
backend/app/repositories/settings_repo.py
Normal file
71
backend/app/repositories/settings_repo.py
Normal 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
128
backend/app/routers/auth.py
Normal 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
|
||||
71
backend/app/routers/setup.py
Normal file
71
backend/app/routers/setup.py
Normal 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()
|
||||
113
backend/app/services/auth_service.py
Normal file
113
backend/app/services/auth_service.py
Normal 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])
|
||||
101
backend/app/services/setup_service.py
Normal file
101
backend/app/services/setup_service.py
Normal 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)
|
||||
Reference in New Issue
Block a user