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:
@@ -0,0 +1,78 @@
|
||||
"""Bootstrap auth: initialize DB and seed admin user at install time.
|
||||
|
||||
Run once during installation. Writes config/auth/config.json with a
|
||||
generated JWT secret and creates the admin user in SQLite.
|
||||
|
||||
Usage:
|
||||
python scripts/bootstrap_auth.py --project-dir /path/to/project \
|
||||
--username admin \
|
||||
--password secret \
|
||||
--domain wall.example.com
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure project lib is importable
|
||||
PROJECT_DIR = Path(".")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Bootstrap Vacuum Wall auth")
|
||||
parser.add_argument("--project-dir", required=True, help="Project root directory")
|
||||
parser.add_argument("--username", required=True, help="Admin username")
|
||||
parser.add_argument("--password", required=True, help="Admin password")
|
||||
parser.add_argument("--domain", required=True, help="Management domain (rp_id)")
|
||||
args = parser.parse_args()
|
||||
|
||||
project_dir = Path(args.project_dir).resolve()
|
||||
sys.path.insert(0, str(project_dir))
|
||||
|
||||
# Set DB path before importing lib modules
|
||||
db_path = str(project_dir / "data" / "auth.db")
|
||||
os.environ["VACUUM_WALL_DB_BACKEND"] = "sqlite"
|
||||
os.environ["VACUUM_WALL_DB_PATH"] = db_path
|
||||
|
||||
from lib.auth_users import ALL_SUBSYSTEMS, create_user
|
||||
|
||||
# Generate JWT secret
|
||||
secret = os.urandom(32).hex()
|
||||
|
||||
# Write config
|
||||
config_dir = project_dir / "config" / "auth"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
config_path = config_dir / "config.json"
|
||||
|
||||
config = {
|
||||
"jwt": {
|
||||
"access_token_ttl": 900,
|
||||
"refresh_token_ttl": 604800,
|
||||
"algorithm": "HS256",
|
||||
"secret": secret,
|
||||
},
|
||||
"webauthn": {
|
||||
"rp_name": "Vacuum Wall",
|
||||
"rp_id": args.domain,
|
||||
"origin": f"https://{args.domain}",
|
||||
},
|
||||
}
|
||||
|
||||
with open(config_path, "w") as f:
|
||||
json.dump(config, f, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
print(f"Wrote auth config: {config_path}")
|
||||
|
||||
# Initialize DB and create admin user
|
||||
permissions = {sub: "rw" for sub in ALL_SUBSYSTEMS}
|
||||
user = create_user(args.username, args.password, permissions)
|
||||
|
||||
print(f"Created admin user: {user['username']} (id={user['id']})")
|
||||
print(f"Permissions: {len(permissions)} subsystems, all 'rw'")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+22
-24
@@ -41,8 +41,8 @@ while [[ $# -gt 0 ]]; do
|
||||
" --user, -u USER WebUI user (created if it does not exist, required for non-dev mode)" \
|
||||
" --path, -p DIR Install directory (default: repo root)" \
|
||||
" --dev Dev mode: auto-detect repo owner, skip safety warning" \
|
||||
" --mgmt-pass PASS WebUI basic auth password (required)" \
|
||||
" --mgmt-user USER WebUI basic auth username (default: admin)" \
|
||||
" --mgmt-pass PASS Initial admin password (required)" \
|
||||
" --mgmt-user USER Initial admin username (default: admin)" \
|
||||
" --mgmt-domain DOMAIN Management domain (auto-detected)" \
|
||||
" --wan-iface IFACE WAN interface name (auto-detected)" \
|
||||
" --lan-ifaces IFC,... LAN interface names, comma-separated (auto-detected)" \
|
||||
@@ -69,9 +69,9 @@ done
|
||||
# --- Resolve config: CLI flag > env var > default ---
|
||||
REPO_DIR="$(cd "$(dirname "$0")/../" && pwd)"
|
||||
|
||||
# Required settings (no defaults — must be provided)
|
||||
MGMT_PASS="${_cli_mgmt_pass:-${MGMT_PASS:-}}"
|
||||
# Optional settings with defaults
|
||||
# MGMT_PASS is strictly required — admin user is created at install time
|
||||
MGMT_PASS="${_cli_mgmt_pass:-${MGMT_PASS:-}}"
|
||||
MGMT_USER="${_cli_mgmt_user:-${MGMT_USER:-admin}}"
|
||||
|
||||
# MGMT_DOMAIN — CLI > env > auto-detect from hostname
|
||||
@@ -104,19 +104,7 @@ LAN_IFACES="${_cli_lan_ifaces:-${LAN_IFACES:-}}"
|
||||
[[ -f /etc/debian_version ]] || warn "This script is designed for Debian/Ubuntu."
|
||||
|
||||
# --- Validate required settings ---
|
||||
missing=()
|
||||
[[ -z "$MGMT_PASS" ]] && missing+=("MGMT_PASS (--mgmt-pass)")
|
||||
|
||||
if (( ${#missing[@]} )); then
|
||||
echo -e "${RED}[!!]${NC} Missing required settings:"
|
||||
for v in "${missing[@]}"; do
|
||||
case "$v" in
|
||||
"MGMT_PASS (--mgmt-pass)") echo ' export MGMT_PASS="your-password" # or --mgmt-pass';;
|
||||
esac
|
||||
done
|
||||
printf '\nTo run: MGMT_PASS=pass ./scripts/install.sh\n'
|
||||
exit 1
|
||||
fi
|
||||
[[ -n "$MGMT_PASS" ]] || err "MGMT_PASS is required (set --mgmt-pass or MGMT_PASS env var)"
|
||||
ACME_HOME="$PROJECT_DIR/data/acme"
|
||||
|
||||
# Dev mode: auto-detect repo owner as service user
|
||||
@@ -356,7 +344,20 @@ else
|
||||
echo ""
|
||||
echo " Setting up initial management configuration..."
|
||||
|
||||
# htpasswd is created by the daemon via /nginx/domains/add (writes to data/.htpasswd)
|
||||
# Bootstrap auth: generate config + seed admin user
|
||||
if [[ ! -f "${PROJECT_DIR}/config/auth/config.json" ]]; then
|
||||
echo ""
|
||||
echo " Bootstrapping auth (creating admin user: $MGMT_USER)..."
|
||||
|
||||
"${PROJECT_DIR}/.venv/bin/python3" "${PROJECT_DIR}/scripts/bootstrap_auth.py" \
|
||||
--project-dir "$PROJECT_DIR" \
|
||||
--username "$MGMT_USER" \
|
||||
--password "$MGMT_PASS" \
|
||||
--domain "$DOMAIN"
|
||||
|
||||
chown -R "$USER_DAEMON_NAME:$USER_GROUP" "${PROJECT_DIR}/config/auth"
|
||||
chown -R "$USER_DAEMON_NAME:$USER_GROUP" "${PROJECT_DIR}/data/auth.db" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Helper: POST JSON to daemon API over Unix socket
|
||||
_daemon_post() {
|
||||
@@ -379,18 +380,15 @@ else
|
||||
# 1A. Self-signed certificate for management domain
|
||||
_daemon_post "/acme/self-signed" "{\"domain\":\"$DOMAIN\"}" "Self-signed certificate"
|
||||
|
||||
# 1C. Management proxy domain — try idempotent update first, fall back to add
|
||||
# 1C. Management proxy domain (no auth — JWT auth is handled by Flask)
|
||||
local mgmt_json
|
||||
mgmt_json="$(jq -n \
|
||||
--arg domain "$DOMAIN" \
|
||||
--arg user "$MGMT_USER" \
|
||||
--arg pass "$MGMT_PASS" \
|
||||
'{
|
||||
domain: $domain,
|
||||
backend: "webui",
|
||||
cert: "selfsigned",
|
||||
force_ssl: true,
|
||||
auth: {user: $user, pass: $pass}
|
||||
force_ssl: true
|
||||
}')"
|
||||
_daemon_post "/nginx/domains/update" "$mgmt_json" "Management domain configured" || \
|
||||
_daemon_post "/nginx/domains/add" "$mgmt_json" "Management domain configured"
|
||||
@@ -430,7 +428,7 @@ echo -e " ${GREEN}Vacuum Wall installed successfully!${NC}"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo " Management UI: https://$DOMAIN"
|
||||
echo " User: $MGMT_USER"
|
||||
echo " Admin user: $MGMT_USER"
|
||||
echo " Daemon service: vacuum-walld.service"
|
||||
echo " WebUI service: vacuum-wall.service"
|
||||
echo " ACME renewal: vacuum-wall-acme.timer"
|
||||
|
||||
Reference in New Issue
Block a user