feat: add auth subsystem with WebAuthn passkeys support
New modules: lib/auth, lib/auth_users, lib/db, lib/db_sqlite, lib/password, lib/webauthn, daemon/handlers/auth, scripts/bootstrap_auth, tests/test_auth Frontend: webui/api/auth, hoover/components/auth, pages/login, passkeys, users Updates: daemon/iface and server, lib/common and nginx, pyproject.toml deps, install script, server.py, app.js, and websocket/api clients
This commit is contained in:
+92
-1
@@ -14,10 +14,13 @@ import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, abort, request
|
||||
from flask import Flask, abort, jsonify, request
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
|
||||
from lib.auth import validate_token
|
||||
from lib.db import get_db
|
||||
from lib.logging import setup_logging
|
||||
from webui.api.auth import bp as auth_bp
|
||||
from webui.api.certs import bp as certs_bp
|
||||
from webui.api.dhcp import bp as dhcp_bp
|
||||
from webui.api.firewall import bp as firewall_bp
|
||||
@@ -86,6 +89,9 @@ app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 5 if _DEV_MODE else 31536000
|
||||
# Flask is behind nginx — trust X-Forwarded-* headers for scheme/host detection
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
|
||||
|
||||
get_db()
|
||||
|
||||
app.register_blueprint(auth_bp, url_prefix="/api/auth")
|
||||
app.register_blueprint(firewall_bp, url_prefix="/api/firewall")
|
||||
app.register_blueprint(network_bp, url_prefix="/api/network")
|
||||
app.register_blueprint(dhcp_bp, url_prefix="/api/dhcp")
|
||||
@@ -96,6 +102,7 @@ app.register_blueprint(logs_bp, url_prefix="/api/logs")
|
||||
app.register_blueprint(status_bp, url_prefix="/api/status")
|
||||
|
||||
BLUEPRINTS = [
|
||||
("auth", auth_bp),
|
||||
("firewall", firewall_bp),
|
||||
("network", network_bp),
|
||||
("dhcp", dhcp_bp),
|
||||
@@ -109,6 +116,90 @@ BLUEPRINTS = [
|
||||
for name, _ in BLUEPRINTS:
|
||||
logger.info("Registered blueprint '%s' at /api/%s", name, name)
|
||||
|
||||
# ── Public endpoints (no auth required) ──
|
||||
_AUTH_EXEMPT = {
|
||||
("GET", "/"),
|
||||
("POST", "/api/auth/login"),
|
||||
("POST", "/api/auth/refresh"),
|
||||
("POST", "/api/auth/webauthn/authenticate-begin"),
|
||||
("POST", "/api/auth/webauthn/authenticate-finish"),
|
||||
}
|
||||
|
||||
|
||||
def _subsystem_from_path(path: str) -> str | None:
|
||||
"""Extract subsystem name from API path."""
|
||||
if not path.startswith("/api/"):
|
||||
return None
|
||||
parts = path.split("/")
|
||||
if len(parts) >= 3:
|
||||
return parts[2]
|
||||
return None
|
||||
|
||||
|
||||
def _has_permission(perms: dict, subsystem: str, method: str) -> bool:
|
||||
"""Check if user has permission for subsystem + method."""
|
||||
level = perms.get(subsystem)
|
||||
if method == "GET":
|
||||
return level in ("read", "rw")
|
||||
return level == "rw"
|
||||
|
||||
|
||||
# ── JWT authentication middleware ──
|
||||
|
||||
|
||||
@app.before_request
|
||||
def _auth_middleware():
|
||||
"""Validate JWT from Authorization header for API routes.
|
||||
|
||||
Exempts: static routes, vendor files, and public auth endpoints.
|
||||
Attaches request._user_ctx with user info for downstream handlers.
|
||||
"""
|
||||
method = request.method
|
||||
path = request.path
|
||||
|
||||
# Exempt specific paths
|
||||
if (method, path) in _AUTH_EXEMPT:
|
||||
return
|
||||
if method == "GET" and path.startswith("/vendor/"):
|
||||
return
|
||||
if method in ("GET", "HEAD") and path.startswith("/static/"):
|
||||
return
|
||||
|
||||
# For non-API routes, skip auth
|
||||
if not path.startswith("/api/"):
|
||||
return
|
||||
|
||||
# Extract token from Authorization header
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if not auth_header.startswith("Bearer "):
|
||||
return jsonify({"ok": False, "error": "unauthorized"}), 401
|
||||
|
||||
token_string = auth_header[7:] # strip "Bearer "
|
||||
payload = validate_token(token_string, token_type="access")
|
||||
if payload is None:
|
||||
return jsonify({"ok": False, "error": "unauthorized"}), 401
|
||||
|
||||
username = payload.get("sub")
|
||||
if not username:
|
||||
return jsonify({"ok": False, "error": "unauthorized"}), 401
|
||||
|
||||
# Check subsystem permissions
|
||||
subsystem = _subsystem_from_path(path)
|
||||
if subsystem:
|
||||
perms = payload.get("permissions", {})
|
||||
if subsystem not in perms:
|
||||
return jsonify({"ok": False, "error": "forbidden"}), 403
|
||||
if not _has_permission(perms, subsystem, method):
|
||||
return jsonify({"ok": False, "error": "forbidden"}), 403
|
||||
|
||||
request._user_ctx = {
|
||||
"username": username,
|
||||
"permissions": perms if subsystem else payload.get("permissions", {}),
|
||||
"jti": payload.get("jti"),
|
||||
}
|
||||
return
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request logging
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user