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:
+358
@@ -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)
|
||||
@@ -0,0 +1,258 @@
|
||||
"""User CRUD and permission management for Vacuum Wall authentication.
|
||||
|
||||
All operations use DB query IDs through the Database abstract layer.
|
||||
Password hashing uses Argon2id via lib.password.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from lib.auth import blacklist_active_refresh_token, blacklist_expired
|
||||
from lib.db import (
|
||||
Q_DELETE_PERMISSIONS,
|
||||
Q_DELETE_USER,
|
||||
Q_INSERT_USER,
|
||||
Q_SELECT_ALL_USERS,
|
||||
Q_SELECT_PERMISSIONS,
|
||||
Q_SELECT_USER_BY_NAME,
|
||||
Q_UPDATE_PASSWORD,
|
||||
Q_UPSERT_PERMISSION,
|
||||
get_db,
|
||||
)
|
||||
from lib.password import hash_password, verify_password
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# All subsystem names for default permission assignment
|
||||
ALL_SUBSYSTEMS = [
|
||||
"firewall",
|
||||
"network",
|
||||
"dhcp",
|
||||
"proxy",
|
||||
"certs",
|
||||
"wireguard",
|
||||
"logs",
|
||||
"status",
|
||||
"auth",
|
||||
]
|
||||
|
||||
_USERNAME_RE = re.compile(r"^[a-zA-Z0-9_-]{3,32}$")
|
||||
|
||||
|
||||
def _get_permissions(username: str) -> dict[str, str]:
|
||||
"""Load permissions for *username* from the database.
|
||||
|
||||
Args:
|
||||
username: The username to load permissions for.
|
||||
|
||||
Returns:
|
||||
Dict mapping subsystem names to permission levels.
|
||||
"""
|
||||
db = get_db()
|
||||
rows = db.query(Q_SELECT_PERMISSIONS, (username,))
|
||||
return {row["subsystem"]: row["level"] for row in rows}
|
||||
|
||||
|
||||
def get_user(username: str) -> dict[str, Any] | None:
|
||||
"""Get a user by username (without password hash).
|
||||
|
||||
Args:
|
||||
username: The username to look up.
|
||||
|
||||
Returns:
|
||||
User dict with id, username, permissions, or None if not found.
|
||||
"""
|
||||
user = find_user(username)
|
||||
if user is None:
|
||||
return None
|
||||
return {
|
||||
"id": user["id"],
|
||||
"username": user["username"],
|
||||
"permissions": _get_permissions(username),
|
||||
}
|
||||
|
||||
|
||||
def find_user(username: str) -> dict[str, Any] | None:
|
||||
"""Find a user by username, including password hash.
|
||||
|
||||
Used for password verification. Not returned through APIs.
|
||||
|
||||
Args:
|
||||
username: The username to look up.
|
||||
|
||||
Returns:
|
||||
User dict from the database row, or None.
|
||||
"""
|
||||
db = get_db()
|
||||
rows = db.query(Q_SELECT_USER_BY_NAME, (username,))
|
||||
if not rows:
|
||||
return None
|
||||
return rows[0]
|
||||
|
||||
|
||||
def verify_user_password(username: str, password: str) -> dict[str, Any] | None:
|
||||
"""Verify a user's password using Argon2id.
|
||||
|
||||
Args:
|
||||
username: The username to verify.
|
||||
password: Plain-text password.
|
||||
|
||||
Returns:
|
||||
User dict (without password hash) if the password is correct, None otherwise.
|
||||
"""
|
||||
user = find_user(username)
|
||||
if user is None:
|
||||
return None
|
||||
if not verify_password(password, user["password_hash"]):
|
||||
return None
|
||||
return {
|
||||
"id": user["id"],
|
||||
"username": user["username"],
|
||||
"permissions": _get_permissions(username),
|
||||
}
|
||||
|
||||
|
||||
def create_user(
|
||||
username: str,
|
||||
password: str,
|
||||
permissions: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new user with the given password and permissions.
|
||||
|
||||
Args:
|
||||
username: The new username (3-32 alphanumeric chars, dash, underscore).
|
||||
password: Plain-text password that will be hashed with Argon2id.
|
||||
permissions: Dict mapping subsystem names to "read" or "rw".
|
||||
|
||||
Returns:
|
||||
The created user dict with id, username, permissions.
|
||||
|
||||
Raises:
|
||||
ValueError: If username is invalid or already exists.
|
||||
"""
|
||||
if not _USERNAME_RE.match(username):
|
||||
raise ValueError(
|
||||
"Username must be 3-32 characters: letters, digits, dash, underscore"
|
||||
)
|
||||
|
||||
existing = find_user(username)
|
||||
if existing is not None:
|
||||
raise ValueError(f"User {username!r} already exists")
|
||||
|
||||
password_hash = hash_password(password)
|
||||
|
||||
db = get_db()
|
||||
with db.in_transaction() as tx:
|
||||
tx.run_one(Q_INSERT_USER, (username, password_hash))
|
||||
if permissions:
|
||||
for subsystem, level in permissions.items():
|
||||
tx.run(Q_UPSERT_PERMISSION, (username, subsystem, level))
|
||||
|
||||
return {
|
||||
"id": db.query(Q_SELECT_USER_BY_NAME, (username,))[0]["id"],
|
||||
"username": username,
|
||||
"permissions": _get_permissions(username),
|
||||
}
|
||||
|
||||
|
||||
def update_password(username: str, old_password: str, new_password: str) -> bool:
|
||||
"""Update a user's password and invalidate all active refresh tokens.
|
||||
|
||||
Old access tokens expire naturally (15 min TTL). The active refresh
|
||||
token is immediately blacklisted to prevent token reuse.
|
||||
|
||||
Args:
|
||||
username: The username.
|
||||
old_password: Current password.
|
||||
new_password: New plain-text password.
|
||||
|
||||
Returns:
|
||||
True if password was updated.
|
||||
|
||||
Raises:
|
||||
ValueError: If old password is incorrect.
|
||||
"""
|
||||
if not verify_user_password(username, old_password):
|
||||
raise ValueError("Current password is incorrect")
|
||||
|
||||
blacklist_active_refresh_token(username)
|
||||
new_hash = hash_password(new_password)
|
||||
db = get_db()
|
||||
db.run(Q_UPDATE_PASSWORD, (new_hash, username))
|
||||
_cleanup_blacklist()
|
||||
return True
|
||||
|
||||
|
||||
def update_permissions(username: str, permissions: dict[str, str]) -> None:
|
||||
"""Update a user's permissions.
|
||||
|
||||
Replaces all existing permissions with the provided mapping.
|
||||
|
||||
Args:
|
||||
username: The username.
|
||||
permissions: Dict mapping subsystem names to permission levels.
|
||||
"""
|
||||
user = find_user(username)
|
||||
if user is None:
|
||||
raise ValueError(f"User {username!r} not found")
|
||||
|
||||
db = get_db()
|
||||
with db.in_transaction() as tx:
|
||||
tx.run(Q_DELETE_PERMISSIONS, (username,))
|
||||
for subsystem, level in permissions.items():
|
||||
tx.run(Q_UPSERT_PERMISSION, (username, subsystem, level))
|
||||
|
||||
|
||||
def list_users() -> list[dict[str, Any]]:
|
||||
"""List all users with their permissions.
|
||||
|
||||
Returns:
|
||||
List of user summary dicts.
|
||||
"""
|
||||
db = get_db()
|
||||
rows = db.query(Q_SELECT_ALL_USERS, ())
|
||||
|
||||
result = []
|
||||
for row in rows:
|
||||
username = row["username"]
|
||||
permissions = _get_permissions(username)
|
||||
result.append(
|
||||
{
|
||||
"id": row["id"],
|
||||
"username": username,
|
||||
"permissions": permissions,
|
||||
"created_at": row["created_at"],
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def delete_user(username: str) -> bool:
|
||||
"""Delete a user and all their associated data.
|
||||
|
||||
Permissions and WebAuthn credentials are CASCADE-deleted by the schema.
|
||||
|
||||
Args:
|
||||
username: The username to delete.
|
||||
|
||||
Returns:
|
||||
True if the user was deleted.
|
||||
|
||||
Raises:
|
||||
ValueError: If the user does not exist.
|
||||
"""
|
||||
user = find_user(username)
|
||||
if user is None:
|
||||
raise ValueError(f"User {username!r} not found")
|
||||
|
||||
db = get_db()
|
||||
db.run(Q_DELETE_USER, (username,))
|
||||
return True
|
||||
|
||||
|
||||
def _cleanup_blacklist() -> None:
|
||||
"""Clean up expired blacklist entries."""
|
||||
blacklist_expired()
|
||||
@@ -13,6 +13,8 @@ from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from passlib.hash import sha256_crypt
|
||||
|
||||
_APPLY_HASH_KEY = "_last_applied_hash"
|
||||
|
||||
|
||||
@@ -171,6 +173,22 @@ def ensure_dirs(*dirs: Path) -> None:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _hash_password(password: str) -> str:
|
||||
"""Hash *password* using SHA-256 crypt (``$5$`` format) via passlib.
|
||||
|
||||
Used for nginx htpasswd files. NOT used for auth user passwords —
|
||||
those use Argon2id via ``lib.password``.
|
||||
|
||||
Args:
|
||||
password: Plain-text password to hash.
|
||||
|
||||
Returns:
|
||||
The hashed password string suitable for ``.htpasswd``
|
||||
(e.g. ``$5$rounds=…$…``).
|
||||
"""
|
||||
return sha256_crypt.hash(password)
|
||||
|
||||
|
||||
def get_interface_ip(iface: str) -> str | None:
|
||||
"""Return the primary IPv4 address of *iface* (without CIDR), or ``None``.
|
||||
|
||||
@@ -192,6 +210,7 @@ def get_interface_ip(iface: str) -> str | None:
|
||||
|
||||
__all__ = [
|
||||
"_APPLY_HASH_KEY",
|
||||
"_hash_password",
|
||||
"config_hash",
|
||||
"deep_merge",
|
||||
"ensure_dirs",
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Abstract database layer for Vacuum Wall.
|
||||
|
||||
Provides query ID constants and an abstract Database baseclass so subsystems
|
||||
interact with the database through opaque query identifiers, never raw SQL.
|
||||
Backend implementations (SQLite, PostgreSQL) provide the actual SQL.
|
||||
|
||||
Usage:
|
||||
from lib.db import Q_INSERT_USER, Database, get_db
|
||||
|
||||
class SQLiteBackend(Database):
|
||||
QUERY_MAP = {
|
||||
Q_INSERT_USER: "INSERT INTO users (username, password_hash) VALUES (?, ?)",
|
||||
...
|
||||
}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Query ID constants — single source of truth for all database operations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Q_INIT_TABLES = "init_tables"
|
||||
Q_INSERT_USER = "insert_user"
|
||||
Q_SELECT_USER_BY_NAME = "select_user_by_name"
|
||||
Q_SELECT_USER_BY_ID = "select_user_by_id"
|
||||
Q_UPDATE_PASSWORD = "update_password"
|
||||
Q_DELETE_USER = "delete_user"
|
||||
Q_UPSERT_PERMISSION = "upsert_permission"
|
||||
Q_SELECT_PERMISSIONS = "select_permissions"
|
||||
Q_DELETE_PERMISSIONS = "delete_permissions"
|
||||
Q_INSERT_BLACKLIST = "insert_blacklist"
|
||||
Q_SELECT_BLACKLIST = "select_blacklist_jti"
|
||||
Q_DELETE_EXPIRED_BLACKLIST = "delete_expired_blacklist"
|
||||
Q_UPSERT_REFRESH_TOKEN = "upsert_refresh_token"
|
||||
Q_SELECT_REFRESH_TOKEN = "select_refresh_token"
|
||||
Q_DELETE_REFRESH_TOKEN = "delete_refresh_token"
|
||||
Q_INSERT_WEBAUTHN = "insert_webauthn"
|
||||
Q_SELECT_WEBAUTHN_USER = "select_webauthn_user"
|
||||
Q_SELECT_WEBAUTHN_ID = "select_webauthn_id"
|
||||
Q_SELECT_WEBAUTHN_COUNTS = "select_webauthn_counts"
|
||||
Q_DELETE_WEBAUTHN = "delete_webauthn"
|
||||
Q_UPDATE_WEBAUTHN_SIGN_COUNT = "update_webauthn_sign_count"
|
||||
Q_SELECT_ALL_USERS = "select_all_users"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema DDL
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
INIT_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS permissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL REFERENCES users(username) ON DELETE CASCADE,
|
||||
subsystem TEXT NOT NULL,
|
||||
level TEXT NOT NULL CHECK (level IN ('read', 'rw')),
|
||||
UNIQUE(username, subsystem)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS token_blacklist (
|
||||
jti TEXT PRIMARY KEY,
|
||||
token_type TEXT NOT NULL,
|
||||
expires INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
jti TEXT NOT NULL,
|
||||
issued_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS webauthn_creds (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL REFERENCES users(username) ON DELETE CASCADE,
|
||||
credential_id TEXT NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
sign_count INTEGER NOT NULL DEFAULT 0,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
transports TEXT NOT NULL DEFAULT '[]',
|
||||
UNIQUE(username, credential_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS init_sequence (
|
||||
seq INTEGER PRIMARY KEY
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
class Transaction:
|
||||
"""Context manager for database transactions.
|
||||
|
||||
Provides BEGIN/COMMIT/ROLLBACK semantics. Auto-commit is suppressed
|
||||
inside the transaction block.
|
||||
|
||||
Usage:
|
||||
with db.in_transaction() as tx:
|
||||
tx.run(Q_INSERT_USER, ("user1", "hash"))
|
||||
tx.run(Q_UPSERT_PERMISSION, ("user1", "firewall", "rw"))
|
||||
"""
|
||||
|
||||
def __init__(self, parent: Database) -> None:
|
||||
self._parent = parent
|
||||
|
||||
def __enter__(self) -> Transaction:
|
||||
self._parent._begin()
|
||||
self._parent._suppress_auto_commit()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> bool:
|
||||
try:
|
||||
if exc_type is None:
|
||||
self._parent._commit()
|
||||
else:
|
||||
self._parent._rollback()
|
||||
finally:
|
||||
self._parent._restore_auto_commit()
|
||||
return False
|
||||
|
||||
def query(self, query_id: str, params: tuple = ()) -> list[dict]:
|
||||
return self._parent.query(query_id, params)
|
||||
|
||||
def run(self, query_id: str, params: tuple = ()) -> int:
|
||||
return self._parent.run(query_id, params)
|
||||
|
||||
def run_one(self, query_id: str, params: tuple = ()) -> int | dict:
|
||||
return self._parent.run_one(query_id, params)
|
||||
|
||||
|
||||
class Database(ABC):
|
||||
"""Abstract database interface.
|
||||
|
||||
All subsystems interact with the database through this interface.
|
||||
Queries are identified by string IDs (e.g. Q_INSERT_USER) — never
|
||||
raw SQL strings.
|
||||
|
||||
Connection is cached via the ``conn`` property. Prepared statements
|
||||
are auto-cached on first use.
|
||||
"""
|
||||
|
||||
QUERY_MAP: ClassVar[dict[str, str]] = {}
|
||||
|
||||
def __init__(self, connection_string: str) -> None:
|
||||
self._connection_string = connection_string
|
||||
self._conn: Any = None
|
||||
self._prepared: dict[str, Any] = {}
|
||||
self._in_transaction = False
|
||||
|
||||
@property
|
||||
def conn(self) -> Any:
|
||||
"""Return the cached database connection, creating it lazily."""
|
||||
if self._conn is None:
|
||||
self._conn = self._connect(self._connection_string)
|
||||
return self._conn
|
||||
|
||||
@abstractmethod
|
||||
def _connect(self, cs: str) -> Any: ...
|
||||
|
||||
@abstractmethod
|
||||
def _prepare(self, sql: str) -> Any: ...
|
||||
|
||||
@abstractmethod
|
||||
def _execute(self, stmt: Any, params: tuple) -> tuple[list[dict], int]:
|
||||
"""Execute a prepared statement. Returns (rows, rowcount)."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def _last_insert_id(self, stmt: Any) -> int | dict: ...
|
||||
|
||||
@abstractmethod
|
||||
def _execute_direct(self, sql: str) -> None:
|
||||
"""Execute raw SQL without prepared statements (for DDL, not implemented by base)."""
|
||||
|
||||
def _begin(self) -> None: # noqa: B027
|
||||
"""Begin a transaction (not implemented by base)."""
|
||||
|
||||
def _commit(self) -> None: # noqa: B027
|
||||
"""Commit a transaction (not implemented by base)."""
|
||||
|
||||
def _rollback(self) -> None: # noqa: B027
|
||||
"""Rollback a transaction (not implemented by base)."""
|
||||
|
||||
def _suppress_auto_commit(self) -> None: # noqa: B027
|
||||
"""Suppress auto-commit (not implemented by base)."""
|
||||
|
||||
def _restore_auto_commit(self) -> None: # noqa: B027
|
||||
"""Restore auto-commit (not implemented by base)."""
|
||||
|
||||
def query(self, query_id: str, params: tuple = ()) -> list[dict]:
|
||||
"""Execute a SELECT query. Returns list of row dicts."""
|
||||
stmt = self._get_prepared(query_id)
|
||||
rows, _ = self._execute(stmt, params)
|
||||
return rows
|
||||
|
||||
def run(self, query_id: str, params: tuple = ()) -> int:
|
||||
"""Execute an INSERT/UPDATE/DELETE. Returns affected row count."""
|
||||
stmt = self._get_prepared(query_id)
|
||||
_, count = self._execute(stmt, params)
|
||||
return count
|
||||
|
||||
def run_one(self, query_id: str, params: tuple = ()) -> int | dict:
|
||||
"""Execute and return the last insert ID or row dict."""
|
||||
stmt = self._get_prepared(query_id)
|
||||
_, _ = self._execute(stmt, params)
|
||||
return self._last_insert_id(stmt)
|
||||
|
||||
def in_transaction(self) -> Transaction:
|
||||
"""Return a transaction context manager."""
|
||||
return Transaction(self)
|
||||
|
||||
def init_tables(self) -> None:
|
||||
"""Create schema tables if they don't exist."""
|
||||
self._execute_direct(INIT_SQL)
|
||||
|
||||
def _get_prepared(self, query_id: str) -> Any:
|
||||
if query_id not in self._prepared:
|
||||
if query_id not in self.QUERY_MAP:
|
||||
raise KeyError(f"Unknown query ID: {query_id!r}")
|
||||
self._prepared[query_id] = self._prepare(self.QUERY_MAP[query_id])
|
||||
return self._prepared[query_id]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Singleton accessor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_db_instance: Database | None = None
|
||||
|
||||
|
||||
def _get_backend_name() -> str:
|
||||
return os.environ.get("VACUUM_WALL_DB_BACKEND", "sqlite")
|
||||
|
||||
|
||||
def _get_db_path() -> str:
|
||||
return os.environ.get("VACUUM_WALL_DB_PATH", str(PROJECT_DIR / "data" / "auth.db"))
|
||||
|
||||
|
||||
def get_db() -> Database:
|
||||
"""Return the singleton Database instance.
|
||||
|
||||
Creates the instance on first call using the backend specified by
|
||||
``VACUUM_WALL_DB_BACKEND`` env var (default: sqlite).
|
||||
|
||||
Call this at application startup to ensure the DB is initialized.
|
||||
"""
|
||||
global _db_instance
|
||||
if _db_instance is None:
|
||||
backend = _get_backend_name()
|
||||
if backend == "sqlite":
|
||||
from lib.db_sqlite import SQLiteBackend
|
||||
|
||||
path = _get_db_path()
|
||||
_db_instance = SQLiteBackend(path)
|
||||
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
||||
_db_instance.init_tables()
|
||||
else:
|
||||
raise ValueError(f"Unknown database backend: {backend!r}")
|
||||
return _db_instance
|
||||
|
||||
|
||||
def reset_db_for_test() -> None:
|
||||
"""Reset the singleton — only for tests."""
|
||||
global _db_instance
|
||||
if _db_instance is not None:
|
||||
_db_instance = None
|
||||
@@ -0,0 +1,168 @@
|
||||
"""SQLite backend for Vacuum Wall database.
|
||||
|
||||
Concrete implementation of the Database abstract class using SQLite3.
|
||||
Uses Python 3.13+ sqlite3.Statement for prepared statements.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import sqlite3
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from lib.db import (
|
||||
Q_DELETE_EXPIRED_BLACKLIST,
|
||||
Q_DELETE_PERMISSIONS,
|
||||
Q_DELETE_REFRESH_TOKEN,
|
||||
Q_DELETE_USER,
|
||||
Q_DELETE_WEBAUTHN,
|
||||
Q_INSERT_BLACKLIST,
|
||||
Q_INSERT_USER,
|
||||
Q_INSERT_WEBAUTHN,
|
||||
Q_SELECT_ALL_USERS,
|
||||
Q_SELECT_BLACKLIST,
|
||||
Q_SELECT_PERMISSIONS,
|
||||
Q_SELECT_REFRESH_TOKEN,
|
||||
Q_SELECT_USER_BY_ID,
|
||||
Q_SELECT_USER_BY_NAME,
|
||||
Q_SELECT_WEBAUTHN_COUNTS,
|
||||
Q_SELECT_WEBAUTHN_ID,
|
||||
Q_SELECT_WEBAUTHN_USER,
|
||||
Q_UPDATE_PASSWORD,
|
||||
Q_UPDATE_WEBAUTHN_SIGN_COUNT,
|
||||
Q_UPSERT_PERMISSION,
|
||||
Q_UPSERT_REFRESH_TOKEN,
|
||||
Database,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SQLiteBackend(Database):
|
||||
"""SQLite implementation of the Database interface.
|
||||
|
||||
Uses ``sqlite3.Connection.execute()`` for statement execution and
|
||||
``sqlite3.Row`` for row-factory dict access.
|
||||
"""
|
||||
|
||||
def __init__(self, connection_string: str) -> None:
|
||||
super().__init__(connection_string)
|
||||
self._last_rowid: int = 0
|
||||
|
||||
QUERY_MAP: ClassVar[dict[str, str]] = {
|
||||
# Schema init is handled by direct execution, not prepared statements
|
||||
# init_tables is called as _execute_direct(INIT_SQL)
|
||||
# Users
|
||||
Q_INSERT_USER: ("INSERT INTO users (username, password_hash) VALUES (?, ?)"),
|
||||
Q_SELECT_USER_BY_NAME: (
|
||||
"SELECT id, username, password_hash, created_at FROM users WHERE username = ?"
|
||||
),
|
||||
Q_SELECT_USER_BY_ID: (
|
||||
"SELECT id, username, password_hash, created_at FROM users WHERE id = ?"
|
||||
),
|
||||
Q_UPDATE_PASSWORD: "UPDATE users SET password_hash = ? WHERE username = ?",
|
||||
Q_DELETE_USER: "DELETE FROM users WHERE username = ?",
|
||||
Q_SELECT_ALL_USERS: (
|
||||
"SELECT id, username, created_at FROM users ORDER BY username"
|
||||
),
|
||||
# Permissions
|
||||
Q_UPSERT_PERMISSION: (
|
||||
"INSERT INTO permissions (username, subsystem, level) "
|
||||
"VALUES (?, ?, ?) "
|
||||
"ON CONFLICT(username, subsystem) DO UPDATE SET level = excluded.level"
|
||||
),
|
||||
Q_SELECT_PERMISSIONS: (
|
||||
"SELECT subsystem, level FROM permissions WHERE username = ?"
|
||||
),
|
||||
Q_DELETE_PERMISSIONS: ("DELETE FROM permissions WHERE username = ?"),
|
||||
# Token blacklist
|
||||
Q_INSERT_BLACKLIST: (
|
||||
"INSERT OR IGNORE INTO token_blacklist (jti, token_type, expires) VALUES (?, ?, ?)"
|
||||
),
|
||||
Q_SELECT_BLACKLIST: "SELECT jti FROM token_blacklist WHERE jti = ?",
|
||||
Q_DELETE_EXPIRED_BLACKLIST: "DELETE FROM token_blacklist WHERE expires < ?",
|
||||
# Refresh tokens
|
||||
Q_UPSERT_REFRESH_TOKEN: (
|
||||
"INSERT INTO refresh_tokens (username, jti, issued_at) "
|
||||
"VALUES (?, ?, ?) "
|
||||
"ON CONFLICT(username) DO UPDATE SET jti = excluded.jti, issued_at = excluded.issued_at"
|
||||
),
|
||||
Q_SELECT_REFRESH_TOKEN: "SELECT username, jti, issued_at FROM refresh_tokens WHERE username = ?",
|
||||
Q_DELETE_REFRESH_TOKEN: "DELETE FROM refresh_tokens WHERE username = ?",
|
||||
# WebAuthn
|
||||
Q_INSERT_WEBAUTHN: (
|
||||
"INSERT INTO webauthn_creds "
|
||||
"(username, credential_id, public_key, sign_count, name, transports) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)"
|
||||
),
|
||||
Q_SELECT_WEBAUTHN_USER: (
|
||||
"SELECT id, credential_id, public_key, sign_count, name, transports "
|
||||
"FROM webauthn_creds WHERE username = ?"
|
||||
),
|
||||
Q_SELECT_WEBAUTHN_ID: (
|
||||
"SELECT id, username, credential_id, public_key, sign_count, name, transports "
|
||||
"FROM webauthn_creds WHERE credential_id = ?"
|
||||
),
|
||||
Q_SELECT_WEBAUTHN_COUNTS: (
|
||||
"SELECT username, COUNT(*) as cred_count FROM webauthn_creds "
|
||||
"GROUP BY username"
|
||||
),
|
||||
Q_UPDATE_WEBAUTHN_SIGN_COUNT: (
|
||||
"UPDATE webauthn_creds SET sign_count = ? WHERE credential_id = ?"
|
||||
),
|
||||
Q_DELETE_WEBAUTHN: "DELETE FROM webauthn_creds WHERE credential_id = ?",
|
||||
}
|
||||
|
||||
def _connect(self, cs: str) -> Any:
|
||||
"""Create a SQLite connection with WAL mode and row factory."""
|
||||
conn = sqlite3.connect(cs, isolation_level=None)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def _prepare(self, sql: str) -> Any:
|
||||
"""Store the SQL string for later execution.
|
||||
|
||||
SQLite in-memory DB doesn't support the Python 3.13 conn.prepare()
|
||||
API, so we store the raw SQL and execute via conn.execute().
|
||||
"""
|
||||
return sql
|
||||
|
||||
def _execute(self, stmt: Any, params: tuple) -> tuple[list[dict], int]:
|
||||
"""Execute a prepared statement, returning (rows, rowcount)."""
|
||||
cursor = self.conn.execute(stmt, params)
|
||||
self._last_rowid = cursor.lastrowid
|
||||
result = cursor.fetchall()
|
||||
rows: list[dict] = []
|
||||
for row in result:
|
||||
rows.append(dict(row))
|
||||
return rows, cursor.rowcount
|
||||
|
||||
def _last_insert_id(self, stmt: Any) -> int | dict:
|
||||
"""Return the last insert row ID from the most recent execute."""
|
||||
return self._last_rowid
|
||||
|
||||
def _begin(self) -> None:
|
||||
self.conn.execute("BEGIN")
|
||||
|
||||
def _commit(self) -> None:
|
||||
self.conn.execute("COMMIT")
|
||||
|
||||
def _rollback(self) -> None:
|
||||
with contextlib.suppress(sqlite3.Error):
|
||||
self.conn.execute("ROLLBACK")
|
||||
|
||||
def _suppress_auto_commit(self) -> None:
|
||||
self._in_transaction = True
|
||||
|
||||
def _restore_auto_commit(self) -> None:
|
||||
self._in_transaction = False
|
||||
|
||||
def _execute_direct(self, sql: str) -> None:
|
||||
"""Execute raw SQL without prepared statements (for DDL)."""
|
||||
for line in sql.split(";"):
|
||||
line = line.strip()
|
||||
if line:
|
||||
self.conn.execute(line)
|
||||
+1
-26
@@ -14,7 +14,7 @@ from typing import Any
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from lib.acme import find_cert_dir
|
||||
from lib.common import ensure_dirs, load_json, save_json
|
||||
from lib.common import _hash_password, ensure_dirs, load_json, save_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -50,10 +50,6 @@ DEFAULT_SSL: dict[str, Any] = {
|
||||
WEBUI_BACKEND: dict[str, Any] = {
|
||||
"label": "Vacuum Wall WebUI",
|
||||
"builtin": True,
|
||||
"auth": {
|
||||
"user": "admin",
|
||||
"htpasswd": str(HTPASSWD_FILE),
|
||||
},
|
||||
"paths": {
|
||||
"/": {
|
||||
"backend": {"host": "127.0.0.1", "port": 9090, "proto": "http"},
|
||||
@@ -120,13 +116,6 @@ def _ensure_webui_backend(raw: dict[str, Any]) -> None:
|
||||
return
|
||||
backends["webui"] = deepcopy(WEBUI_BACKEND)
|
||||
backends["webui"]["_migrated"] = True
|
||||
# Harvest auth from legacy path-level auth if present
|
||||
for dom in raw.get("domains", {}).values():
|
||||
paths = dom.get("paths", {})
|
||||
root = paths.get("/", {})
|
||||
if root.get("auth"):
|
||||
backends["webui"]["auth"] = root["auth"]
|
||||
break
|
||||
|
||||
|
||||
def _migrate_mgmt_domains(raw: dict[str, Any]) -> None:
|
||||
@@ -577,20 +566,6 @@ def write_htpasswd(user: str, password: str) -> None:
|
||||
os.replace(tmp, HTPASSWD_FILE)
|
||||
|
||||
|
||||
def _hash_password(password: str) -> str:
|
||||
"""Hash *password* using SHA-256 crypt (``$5$`` format) via passlib.
|
||||
|
||||
Args:
|
||||
password: Plain-text password to hash.
|
||||
|
||||
Returns:
|
||||
The hashed password string suitable for ``.htpasswd`` (e.g. ``$5$rounds=…$…``).
|
||||
"""
|
||||
from passlib.hash import sha256_crypt
|
||||
|
||||
return sha256_crypt.hash(password)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"WEBUI_BACKEND",
|
||||
"_ensure_webui_backend",
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Password hashing with Argon2id.
|
||||
|
||||
Handles user password storage for the auth DB. Uses argon2-cffi (C
|
||||
implementation of the Argon2id memory-hard KDF). Random 16-byte salt is
|
||||
generated per hash by the library.
|
||||
|
||||
Argon2id is used for auth user passwords ONLY. Nginx basic-auth htpasswd
|
||||
files continue to use sha256_crypt (passlib) — that's a separate concern
|
||||
with different constraints (htpasswd format is standardized).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import InvalidHashError, VerifyMismatchError
|
||||
from argon2.low_level import Type
|
||||
|
||||
# Argon2id: OWASP recommended parameters
|
||||
# 64 MiB memory, 3 iterations, 4 parallel threads
|
||||
_PH = PasswordHasher(
|
||||
time_cost=3,
|
||||
memory_cost=65536, # 64 MiB
|
||||
parallelism=4,
|
||||
hash_len=32,
|
||||
salt_len=16,
|
||||
type=Type.ID,
|
||||
)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Generate an Argon2id hash of *password*.
|
||||
|
||||
A random 16-byte salt is generated automatically by argon2.
|
||||
The resulting hash string starts with ``$argon2id$`` and encodes
|
||||
the algorithm version, parameters, salt, and hash output.
|
||||
|
||||
Args:
|
||||
password: Plain-text password string.
|
||||
|
||||
Returns:
|
||||
Full Argon2id hash string (e.g. ``$argon2id$v=19$m=65536,t=3,p=4$...``).
|
||||
|
||||
Raises:
|
||||
TypeError: If *password* is not a string or contains NUL bytes.
|
||||
"""
|
||||
return _PH.hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, hash_string: str) -> bool:
|
||||
"""Verify *password* against an Argon2id *hash_string*.
|
||||
|
||||
The hash string must have been produced by :func:`hash_password`.
|
||||
|
||||
Args:
|
||||
password: Plain-text password to verify.
|
||||
hash_string: Argon2id hash string to compare against.
|
||||
|
||||
Returns:
|
||||
``True`` if the password matches, ``False`` otherwise.
|
||||
|
||||
Raises:
|
||||
TypeError: If *hash_string* is not a valid Argon2id hash.
|
||||
"""
|
||||
try:
|
||||
_PH.verify(hash_string, password)
|
||||
return True
|
||||
except (InvalidHashError, VerifyMismatchError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def needs_rehash(hash_string: str) -> bool:
|
||||
"""Check if *hash_string* needs to be rehashed with updated parameters.
|
||||
|
||||
Returns True if the hash was not produced with the current parameters
|
||||
of the hasher, indicating it should be rehashed on next login.
|
||||
|
||||
Args:
|
||||
hash_string: Argon2id hash string to check.
|
||||
|
||||
Returns:
|
||||
``True`` if the hash parameters should be upgraded.
|
||||
"""
|
||||
return _PH.check_needs_rehash(hash_string)
|
||||
+355
@@ -0,0 +1,355 @@
|
||||
"""WebAuthn passkey support for Vacuum Wall.
|
||||
|
||||
Uses the Duo Labs webauthn library (v3) to handle the FIDO2/WebAuthn ceremony:
|
||||
registration, authentication, and credential management.
|
||||
|
||||
Credential data is stored in the database webauthn_creds table.
|
||||
Configuration comes from config/auth/config.json (webauthn section).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from webauthn import (
|
||||
generate_authentication_options,
|
||||
generate_registration_options,
|
||||
options_to_json,
|
||||
verify_authentication_response,
|
||||
verify_registration_response,
|
||||
)
|
||||
from webauthn.helpers.cose import COSEAlgorithmIdentifier
|
||||
from webauthn.helpers.structs import (
|
||||
AttestationConveyancePreference,
|
||||
AuthenticatorSelectionCriteria,
|
||||
PublicKeyCredentialDescriptor,
|
||||
ResidentKeyRequirement,
|
||||
UserVerificationRequirement,
|
||||
)
|
||||
|
||||
from lib.auth import AUTH_CONFIG_PATH
|
||||
from lib.common import load_json
|
||||
from lib.db import (
|
||||
Q_DELETE_WEBAUTHN,
|
||||
Q_INSERT_WEBAUTHN,
|
||||
Q_SELECT_WEBAUTHN_COUNTS,
|
||||
Q_SELECT_WEBAUTHN_ID,
|
||||
Q_SELECT_WEBAUTHN_USER,
|
||||
Q_UPDATE_WEBAUTHN_SIGN_COUNT,
|
||||
get_db,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Crypto algorithms we support
|
||||
_SUPPORTED_ALGS = [
|
||||
COSEAlgorithmIdentifier.ECDSA_SHA_256,
|
||||
COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256,
|
||||
COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_384,
|
||||
COSEAlgorithmIdentifier.ECDSA_SHA_512,
|
||||
COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_512,
|
||||
COSEAlgorithmIdentifier.EDDSA,
|
||||
]
|
||||
|
||||
|
||||
# b64url helpers
|
||||
def b64u_encode(b: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(b).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def b64u_decode(s: str) -> bytes:
|
||||
padding = 4 - len(s) % 4
|
||||
if padding != 4:
|
||||
s += "=" * padding
|
||||
return base64.urlsafe_b64decode(s)
|
||||
|
||||
|
||||
def _get_webauthn_config() -> dict[str, Any]:
|
||||
"""Load WebAuthn configuration from auth config."""
|
||||
raw = load_json(AUTH_CONFIG_PATH)
|
||||
return raw.get("webauthn", {})
|
||||
|
||||
|
||||
def get_rp_id() -> str:
|
||||
"""Return the Relying Party ID from config."""
|
||||
return _get_webauthn_config().get("rp_id", "localhost")
|
||||
|
||||
|
||||
def get_rp_name() -> str:
|
||||
"""Return the Relying Party name from config."""
|
||||
return _get_webauthn_config().get("rp_name", "Vacuum Wall")
|
||||
|
||||
|
||||
def get_origin() -> str:
|
||||
"""Return the WebAuthn origin from config."""
|
||||
return _get_webauthn_config().get("origin", "http://localhost")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def create_registration_options(username: str) -> dict[str, Any]:
|
||||
"""Create WebAuthn registration options for a new credential.
|
||||
|
||||
Returns a dict serializable to JSON, matching the format expected by
|
||||
``navigator.credentials.create()``.
|
||||
"""
|
||||
# Load existing credential IDs to exclude
|
||||
db = get_db()
|
||||
rows = db.query(Q_SELECT_WEBAUTHN_USER, (username,))
|
||||
exclude_credentials = [
|
||||
PublicKeyCredentialDescriptor(id=b64u_decode(row["credential_id"]))
|
||||
for row in rows
|
||||
]
|
||||
|
||||
# Pad username to >= 8 bytes (required for user_id)
|
||||
user_id = username.encode("utf-8")
|
||||
if len(user_id) < 8:
|
||||
user_id = user_id + b"\x00" * (8 - len(user_id))
|
||||
|
||||
options = generate_registration_options(
|
||||
rp_id=get_rp_id(),
|
||||
rp_name=get_rp_name(),
|
||||
user_name=username,
|
||||
user_display_name=username,
|
||||
user_id=user_id,
|
||||
attestation=AttestationConveyancePreference.NONE,
|
||||
authenticator_selection=AuthenticatorSelectionCriteria(
|
||||
resident_key=ResidentKeyRequirement.PREFERRED,
|
||||
user_verification=UserVerificationRequirement.PREFERRED,
|
||||
),
|
||||
supported_pub_key_algs=_SUPPORTED_ALGS,
|
||||
exclude_credentials=exclude_credentials,
|
||||
)
|
||||
|
||||
# Serialize using the library's built-in function
|
||||
return json.loads(options_to_json(options))
|
||||
|
||||
|
||||
def verify_registration(
|
||||
username: str,
|
||||
credential_response: dict[str, Any],
|
||||
registration_options: dict[str, Any],
|
||||
credential_name: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Verify a registration response and persist the credential.
|
||||
|
||||
Args:
|
||||
username: The user registering the credential.
|
||||
credential_response: Browser response from ``credentials.create()``.
|
||||
registration_options: The options dict from ``create_registration_options``.
|
||||
credential_name: Optional human-readable label.
|
||||
|
||||
Returns:
|
||||
Dict with ``id``, ``name``, ``transports``, ``sign_count``.
|
||||
"""
|
||||
expected_origin = get_origin()
|
||||
expected_rp_id = get_rp_id()
|
||||
challenge = b64u_decode(registration_options["challenge"])
|
||||
|
||||
# The library accepts the credential response as a JSON-serializable dict
|
||||
# We pass it directly — the library handles the parsing
|
||||
col = verify_registration_response(
|
||||
credential=credential_response,
|
||||
expected_challenge=challenge,
|
||||
expected_origin=expected_origin,
|
||||
expected_rp_id=expected_rp_id,
|
||||
require_user_verification=False,
|
||||
)
|
||||
|
||||
new_cred = col.credential
|
||||
new_credential_id = b64u_encode(new_cred.id)
|
||||
new_public_key = b64u_encode(new_cred.public_key)
|
||||
new_sign_count = new_cred.sign_count
|
||||
|
||||
transports = []
|
||||
if hasattr(new_cred.response, "transports") and new_cred.response.transports:
|
||||
transports = [str(t) for t in new_cred.response.transports]
|
||||
if not transports:
|
||||
transports_raw = credential_response.get("response", {}).get("transports", [])
|
||||
transports = [
|
||||
t
|
||||
for t in transports_raw
|
||||
if t
|
||||
in (
|
||||
"internal",
|
||||
"hybrid",
|
||||
"nfc",
|
||||
"ble",
|
||||
"usb",
|
||||
"smart-card",
|
||||
)
|
||||
] or ["internal"]
|
||||
|
||||
db = get_db()
|
||||
db.run(
|
||||
Q_INSERT_WEBAUTHN,
|
||||
(
|
||||
username,
|
||||
new_credential_id,
|
||||
new_public_key,
|
||||
new_sign_count,
|
||||
credential_name,
|
||||
json.dumps(transports),
|
||||
),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"WebAuthn credential registered for %s: %s",
|
||||
username,
|
||||
credential_name or new_credential_id[:16],
|
||||
)
|
||||
|
||||
return {
|
||||
"id": new_credential_id,
|
||||
"name": credential_name,
|
||||
"transports": transports,
|
||||
"sign_count": new_sign_count,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Authentication
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def create_authentication_options(username: str) -> dict[str, Any] | None:
|
||||
"""Create authentication options for a user.
|
||||
|
||||
Returns a dict serializable to JSON (for ``navigator.credentials.get()``),
|
||||
or ``None`` if the user has no registered credentials.
|
||||
"""
|
||||
db = get_db()
|
||||
rows = db.query(Q_SELECT_WEBAUTHN_USER, (username,))
|
||||
if not rows:
|
||||
return None
|
||||
|
||||
allow_credentials = [
|
||||
PublicKeyCredentialDescriptor(id=b64u_decode(row["credential_id"]))
|
||||
for row in rows
|
||||
]
|
||||
|
||||
options = generate_authentication_options(
|
||||
rp_id=get_rp_id(),
|
||||
allow_credentials=allow_credentials,
|
||||
user_verification=UserVerificationRequirement.PREFERRED,
|
||||
)
|
||||
|
||||
return json.loads(options_to_json(options))
|
||||
|
||||
|
||||
def verify_authentication(
|
||||
username: str,
|
||||
assertion_response: dict[str, Any],
|
||||
auth_options: dict[str, Any],
|
||||
) -> bool:
|
||||
"""Verify an authentication assertion.
|
||||
|
||||
Args:
|
||||
username: The user authenticating.
|
||||
assertion_response: Browser response from ``credentials.get()``.
|
||||
auth_options: The options dict from ``create_authentication_options``.
|
||||
|
||||
Returns:
|
||||
True on successful verification.
|
||||
|
||||
Raises:
|
||||
ValueError: On verification failure.
|
||||
"""
|
||||
expected_origin = get_origin()
|
||||
expected_rp_id = get_rp_id()
|
||||
challenge = b64u_decode(auth_options["challenge"])
|
||||
cred_id_str = assertion_response["id"]
|
||||
|
||||
# Load credential from DB
|
||||
db = get_db()
|
||||
cred_rows = db.query(Q_SELECT_WEBAUTHN_ID, (cred_id_str,))
|
||||
if not cred_rows:
|
||||
raise ValueError("Credential not found")
|
||||
|
||||
cred_row = cred_rows[0]
|
||||
public_key = b64u_decode(cred_row["public_key"])
|
||||
old_sign_count = cred_row["sign_count"]
|
||||
|
||||
col = verify_authentication_response(
|
||||
credential=assertion_response,
|
||||
expected_challenge=challenge,
|
||||
expected_origin=expected_origin,
|
||||
expected_rp_id=expected_rp_id,
|
||||
credential_public_key=public_key,
|
||||
credential_current_sign_count=old_sign_count,
|
||||
require_user_verification=False,
|
||||
)
|
||||
|
||||
new_sign_count = col.credential_sign_count
|
||||
if new_sign_count > old_sign_count:
|
||||
db.run(
|
||||
Q_UPDATE_WEBAUTHN_SIGN_COUNT,
|
||||
(new_sign_count, cred_id_str),
|
||||
)
|
||||
|
||||
logger.info("WebAuthn assertion verified for %s", username)
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Credential management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def list_credentials(username: str) -> list[dict[str, Any]]:
|
||||
"""List all registered credentials for a user."""
|
||||
db = get_db()
|
||||
rows = db.query(Q_SELECT_WEBAUTHN_USER, (username,))
|
||||
|
||||
result = []
|
||||
for row in rows:
|
||||
transports_str = row.get("transports", "[]")
|
||||
try:
|
||||
transports = json.loads(transports_str)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
transports = []
|
||||
|
||||
result.append(
|
||||
{
|
||||
"id": row["credential_id"],
|
||||
"name": row.get("name") or "",
|
||||
"transports": transports,
|
||||
"sign_count": row.get("sign_count", 0),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def remove_credential(username: str, credential_id: str) -> bool:
|
||||
"""Remove a credential.
|
||||
|
||||
Raises:
|
||||
ValueError: If credential not found or doesn't belong to user.
|
||||
"""
|
||||
db = get_db()
|
||||
rows = db.query(Q_SELECT_WEBAUTHN_ID, (credential_id,))
|
||||
if not rows:
|
||||
raise ValueError(f"Credential {credential_id!r} not found")
|
||||
if rows[0]["username"] != username:
|
||||
raise ValueError("Credential does not belong to this user")
|
||||
|
||||
db.run(Q_DELETE_WEBAUTHN, (credential_id,))
|
||||
logger.info("WebAuthn credential removed: %s, %s", username, credential_id)
|
||||
return True
|
||||
|
||||
|
||||
def get_all_credential_counts() -> dict[str, int]:
|
||||
"""Return credential counts for all users.
|
||||
|
||||
Returns:
|
||||
Dict mapping usernames to credential counts.
|
||||
"""
|
||||
db = get_db()
|
||||
rows = db.query(Q_SELECT_WEBAUTHN_COUNTS, ())
|
||||
return {row["username"]: row["cred_count"] for row in rows}
|
||||
Reference in New Issue
Block a user