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:
2026-07-24 01:21:39 +00:00
parent 04417cf05c
commit 56b200d233
28 changed files with 4900 additions and 82 deletions
+358
View File
@@ -0,0 +1,358 @@
"""JWT authentication module for Vacuum Wall.
Handles token creation, validation, refresh, and blacklisting.
Configuration comes from config/auth/config.json.
"""
from __future__ import annotations
import logging
import secrets
import time
import uuid
from pathlib import Path
from typing import Any
import jwt
from lib.common import load_json, save_json
from lib.db import (
Q_DELETE_EXPIRED_BLACKLIST,
Q_DELETE_REFRESH_TOKEN,
Q_INSERT_BLACKLIST,
Q_SELECT_BLACKLIST,
Q_SELECT_REFRESH_TOKEN,
Q_UPSERT_REFRESH_TOKEN,
get_db,
)
logger = logging.getLogger(__name__)
AUTH_CONFIG_PATH = (
Path(__file__).resolve().parent.parent / "config" / "auth" / "config.json"
)
_DEFAULT_JWT_CONFIG = {
"access_token_ttl": 900,
"refresh_token_ttl": 604800,
"algorithm": "HS256",
"secret": "",
}
def _get_jwt_config() -> dict[str, Any]:
"""Load JWT configuration from auth config."""
raw = load_json(AUTH_CONFIG_PATH)
return raw.get("jwt", _DEFAULT_JWT_CONFIG)
def get_secret() -> str | None:
"""Return the JWT signing secret, or ``None`` if not configured."""
secret = _get_jwt_config().get("secret")
return secret if secret else None
def get_access_ttl() -> int:
"""Return access token TTL in seconds."""
return _get_jwt_config().get("access_token_ttl", 900)
def get_refresh_ttl() -> int:
"""Return refresh token TTL in seconds."""
return _get_jwt_config().get("refresh_token_ttl", 604800)
def get_algorithm() -> str:
"""Return the JWT algorithm."""
return _get_jwt_config().get("algorithm", "HS256")
def generate_access_token(username: str, permissions: dict[str, str]) -> str:
"""Generate a new access token for *username*.
Args:
username: The authenticated username.
permissions: Dict mapping subsystem names to permission levels.
Returns:
JWT token string.
Raises:
RuntimeError: If JWT secret is not configured.
"""
secret = get_secret()
if not secret:
raise RuntimeError("JWT secret is not configured")
algorithm = get_algorithm()
now = int(time.time())
payload = {
"sub": username,
"exp": now + get_access_ttl(),
"iat": now,
"jti": str(uuid.uuid4()),
"type": "access",
"permissions": permissions,
}
return jwt.encode(payload, secret, algorithm=algorithm)
def generate_refresh_token(username: str) -> str:
"""Generate a new refresh token for *username*.
Args:
username: The authenticated username.
Returns:
JWT refresh token string.
Raises:
RuntimeError: If JWT secret is not configured.
"""
secret = get_secret()
if not secret:
raise RuntimeError("JWT secret is not configured")
algorithm = get_algorithm()
now = int(time.time())
payload = {
"sub": username,
"exp": now + get_refresh_ttl(),
"iat": now,
"jti": str(uuid.uuid4()),
"type": "refresh",
}
return jwt.encode(payload, secret, algorithm=algorithm)
def generate_tokens(username: str, permissions: dict[str, str]) -> dict[str, str]:
"""Generate both access and refresh tokens.
Args:
username: The authenticated username.
permissions: Dict mapping subsystem names to permission levels.
Returns:
Dict with ``access_token`` and ``refresh_token`` keys.
"""
access_token = generate_access_token(username, permissions)
refresh_token = generate_refresh_token(username)
_persist_refresh_token(username, refresh_token)
return {
"access_token": access_token,
"refresh_token": refresh_token,
}
def _persist_refresh_token(username: str, refresh_token: str) -> None:
"""Persist the active refresh token JTI for *username* in the database.
One row per user — replaces any existing entry on upsert.
Args:
username: The username.
refresh_token: The JWT refresh token string.
"""
payload = decode_token(refresh_token)
if payload is None:
return
jti = payload.get("jti")
if not jti:
return
issued_at = payload.get("iat", int(time.time()))
db = get_db()
db.run(Q_UPSERT_REFRESH_TOKEN, (username, jti, issued_at))
def blacklist_active_refresh_token(username: str) -> None:
"""Blacklist the user's currently active refresh token from the database.
Looks up the stored JTI for *username*, blacklists it, and removes the
database entry. Safe to call when no token is registered — the query
will simply return no rows.
Args:
username: The username.
"""
db = get_db()
rows = db.query(Q_SELECT_REFRESH_TOKEN, (username,))
if rows:
blacklist_token(rows[0]["jti"], token_type="refresh")
db.run(Q_DELETE_REFRESH_TOKEN, (username,))
def clear_active_refresh_token(username: str) -> None:
"""Remove the user's stored refresh token entry without blacklisting.
Used when the refresh token has already been blacklisted (e.g., during
a successful refresh rotation).
Args:
username: The username.
"""
db = get_db()
db.run(Q_DELETE_REFRESH_TOKEN, (username,))
def decode_token(token_string: str) -> dict[str, Any] | None:
"""Decode and validate a JWT token.
Args:
token_string: The JWT token string (without Bearer prefix).
Returns:
Payload dict if valid, None if invalid/expired or secret not configured.
"""
secret = get_secret()
if not secret:
return None
algorithm = get_algorithm()
try:
payload = jwt.decode(token_string, secret, algorithms=[algorithm])
return payload
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
return None
def validate_token(
token_string: str, token_type: str = "access"
) -> dict[str, Any] | None:
"""Validate a JWT token and check it against the blacklist.
Args:
token_string: The JWT token string.
token_type: Expected token type ("access" or "refresh").
Returns:
Payload dict including permissions, or None if invalid/blacklisted.
"""
payload = decode_token(token_string)
if payload is None:
return None
if payload.get("type") != token_type:
return None
jti = payload.get("jti")
if jti and is_blacklisted(jti):
return None
return payload
def blacklist_token(jti: str, token_type: str = "access") -> None:
"""Add a JTI to the blacklist.
Args:
jti: Token UUID to blacklist.
token_type: Token type ("access" or "refresh"). Defaults to "access".
The blacklist expiry is set to current time + the token type's TTL.
"""
db = get_db()
ttl = get_refresh_ttl() if token_type == "refresh" else get_access_ttl()
db.run(Q_INSERT_BLACKLIST, (jti, token_type, int(time.time()) + ttl))
def is_blacklisted(jti: str) -> bool:
"""Check if a JTI is blacklisted.
Args:
jti: Token UUID to check.
Returns:
True if the token has been blacklisted.
"""
db = get_db()
rows = db.query(Q_SELECT_BLACKLIST, (jti,))
return len(rows) > 0
def blacklist_expired() -> None:
"""Remove expired entries from the blacklist."""
db = get_db()
now = int(time.time())
db.run(Q_DELETE_EXPIRED_BLACKLIST, (now,))
def rotate_secret() -> None:
"""Rotate the JWT secret, invalidating all existing tokens.
Used when a user's password is changed to ensure all prior sessions
are immediately terminated regardless of token expiration.
"""
raw = load_json(AUTH_CONFIG_PATH)
jwt_config = raw.get("jwt", _DEFAULT_JWT_CONFIG)
jwt_config["secret"] = secrets.token_urlsafe(48)
raw["jwt"] = jwt_config
save_json(AUTH_CONFIG_PATH, raw)
logger.warning("JWT secret rotated — all existing tokens are now invalid")
class RateLimiter:
"""Simple sliding-window rate limiter for login attempts.
Maintains per-key attempt timestamps and rejects requests that exceed
the allowed count within the window.
"""
def __init__(self, max_attempts: int = 5, window_seconds: int = 300) -> None:
self.max_attempts = max_attempts
self.window = window_seconds
self.attempts: dict[str, list[float]] = {}
def is_allowed(self, key: str) -> bool:
"""Check if a request from *key* is allowed.
Args:
key: Identifier for the rate limit bucket (e.g., username or IP).
Returns:
True if the request is allowed, False if rate limited.
"""
now = time.time()
cutoff = now - self.window
timestamps = self.attempts.get(key, [])
# Clean old entries
self.attempts[key] = [t for t in timestamps if t > cutoff]
if len(self.attempts[key]) >= self.max_attempts:
return False
self.attempts[key].append(now)
return True
def cleanup(self) -> None:
"""Remove expired entries from all buckets."""
now = time.time()
cutoff = now - self.window
for key in list(self.attempts):
self.attempts[key] = [t for t in self.attempts[key] if t > cutoff]
if not self.attempts[key]:
del self.attempts[key]
# Global rate limiters
_login_limiter = RateLimiter(max_attempts=10, window_seconds=300)
_webauthn_limiter = RateLimiter(max_attempts=5, window_seconds=600)
def check_login_rate(username: str) -> bool:
"""Check if login is rate-limited for the given username.
Args:
username: The login attempt username.
Returns:
True if the attempt is allowed, False if rate limited.
"""
return _login_limiter.is_allowed(username)
def check_webauthn_rate(username: str) -> bool:
"""Check if WebAuthn authentication is rate-limited for the given username.
Args:
username: The WebAuthn attempt username.
Returns:
True if the attempt is allowed, False if rate limited.
"""
return _webauthn_limiter.is_allowed(username)