Files
nvcurve/nvcurve/auth.py
T
ARIA bbd692ea2e feat: multi-user authentication (dual mode)
Add optional login protection for the web UI/API, intended for shared
machines (e.g. AI servers). Dual mode: with no users configured the API
and web UI are open (as before); once at least one user exists, every
/api/* and /ws/* endpoint requires a valid session.

- bcrypt password hashing: passwords stored as $2b$ hashes in
  /etc/nvcurve/users.json (0600, root-owned); plaintext never persisted.
- 24-hour sessions: HttpOnly cookie for browsers, Authorization: Bearer
  token for CLI/scripts; in-memory, invalidated on server restart.
- Multi-user: multiple named accounts (no shared-password mode).
- New CLI: nvcurve user add|list|remove|set-password (root for mutating
  ops; password always prompted, never a CLI argument).
- New endpoints: GET /api/ping (public), /api/auth/status|login|logout|users.
- Web UI: sign-in screen when auth is enabled; status bar shows the
  signed-in user with sign-out; expired sessions (401) re-show sign-in.
- Brute-force lockout: 10 failed logins/IP within 5 min -> 15 min lockout.
- New dependency: bcrypt.

Also: LSP config (pyrightconfig.json) pointing at the project .venv, and
small error-handling cleanups in daemon.py/server.py.
2026-09-02 15:21:35 +02:00

290 lines
9.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Authentication: bcrypt user store, 24-hour sessions, dual-mode gating.
Dual mode
---------
- If the user store file exists and contains at least one user, the API
requires authentication for all ``/api/*`` and ``/ws/*`` endpoints
(except the public auth endpoints and ``/api/ping``).
- If the user store is absent or empty, the server runs with no
authentication at all — exactly like before.
Passwords are hashed with bcrypt (``$2b$``). Plaintext passwords are never
stored. Sessions are in-memory random 256-bit tokens valid for 24 hours;
they are presented either as an HttpOnly cookie (browser) or as an
``Authorization: Bearer <token>`` header (CLI / scripts).
A simple per-IP lockout slows down brute-force attempts against login.
"""
import json
import os
import secrets
import time
from typing import Any
import bcrypt
# ── Constants ─────────────────────────────────────────────────────────────────
SESSION_TTL_S = 24 * 60 * 60 # sessions live for 24 hours
COOKIE_NAME = "nvcurve_session"
BCRYPT_ROUNDS = 12
MAX_PASSWORD_BYTES = 72 # bcrypt only uses the first 72 bytes
# Brute-force lockout: MAX_FAILURES within WINDOW_S → locked for LOCKOUT_S.
_MAX_FAILURES = 10
_WINDOW_S = 300
_LOCKOUT_S = 900
# ── User store ────────────────────────────────────────────────────────────────
# File format (JSON): {"users": {"alice": "$2b$12$...", "bob": "$2b$12$..."}}
def _valid_username(name: str) -> bool:
if not name or len(name) > 32:
return False
return all(c.isalnum() or c in "_-." for c in name)
def load_users(path: str) -> dict[str, str]:
"""Load the user store. Returns {} if the file is absent or unreadable."""
try:
with open(path) as f:
data = json.load(f)
except (FileNotFoundError, json.JSONDecodeError, OSError):
return {}
users = data.get("users", {})
if not isinstance(users, dict):
return {}
return {k: v for k, v in users.items() if isinstance(k, str) and isinstance(v, str)}
def save_users(path: str, users: dict[str, str]) -> None:
"""Write the user store with restrictive permissions (0600, root-owned)."""
directory = os.path.dirname(path)
if directory:
try:
os.makedirs(directory, exist_ok=True)
except OSError as exc:
raise ValueError(
f"cannot create user store directory {directory!r}: {exc}"
) from exc
try:
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
except OSError as exc:
raise ValueError(f"cannot write user store {path!r}: {exc}") from exc
with os.fdopen(fd, "w") as f:
json.dump({"users": users}, f, indent=2)
f.write("\n")
def auth_enabled(path: str) -> bool:
"""True if at least one user is configured (→ authentication required)."""
return bool(load_users(path))
def list_users(path: str) -> list[str]:
return sorted(load_users(path))
def user_store_state(path: str) -> str:
"""Report the user store state without requiring read access.
Returns one of:
- "absent": no file (auth disabled)
- "empty": file exists but has no users (auth disabled)
- "unreadable": file exists but can't be read (e.g. non-root on a 0600
root-owned store) — auth is almost certainly enabled
- "enabled": file readable and has at least one user (auth enabled)
"""
if not os.path.exists(path):
return "absent"
try:
with open(path) as f:
data = json.load(f)
except (OSError, json.JSONDecodeError):
return "unreadable"
users = data.get("users", {}) if isinstance(data, dict) else {}
if not isinstance(users, dict) or not users:
return "empty"
return "enabled"
def add_user(path: str, username: str, password: str) -> None:
"""Create a new user with a bcrypt-hashed password."""
if not _valid_username(username):
raise ValueError(
f"invalid username {username!r} — use 1–32 chars: letters, digits, '_', '-', '.'"
)
if not password:
raise ValueError("password must not be empty")
if len(password.encode("utf-8")) > MAX_PASSWORD_BYTES:
raise ValueError(f"password too long (max {MAX_PASSWORD_BYTES} bytes)")
users = load_users(path)
if username in users:
raise ValueError(f"user {username!r} already exists")
users[username] = hash_password(password)
save_users(path, users)
def remove_user(path: str, username: str) -> bool:
"""Remove a user. Returns False if the user did not exist."""
users = load_users(path)
if username not in users:
return False
del users[username]
save_users(path, users)
return True
def set_password(path: str, username: str, password: str) -> None:
"""Replace the password of an existing user."""
if not password:
raise ValueError("password must not be empty")
if len(password.encode("utf-8")) > MAX_PASSWORD_BYTES:
raise ValueError(f"password too long (max {MAX_PASSWORD_BYTES} bytes)")
users = load_users(path)
if username not in users:
raise ValueError(f"user {username!r} does not exist")
users[username] = hash_password(password)
save_users(path, users)
# ── Password hashing ──────────────────────────────────────────────────────────
def _pw_bytes(plain: str) -> bytes:
"""Encode a plaintext password, truncating to bcrypt's 72-byte limit.
bcrypt only uses the first 72 bytes and raises ValueError for longer
input; truncating here keeps the check total (no 500 on an over-long
login password) and matches what bcrypt does internally.
"""
return plain.encode("utf-8")[:MAX_PASSWORD_BYTES]
def hash_password(plain: str) -> str:
"""Hash a plaintext password with bcrypt ($2b$)."""
return bcrypt.hashpw(_pw_bytes(plain), bcrypt.gensalt(rounds=BCRYPT_ROUNDS)).decode(
"ascii"
)
def verify_password(plain: str, hashed: str) -> bool:
"""Constant-time check of a plaintext password against a bcrypt hash."""
try:
return bcrypt.checkpw(_pw_bytes(plain), hashed.encode("ascii"))
except (ValueError, TypeError):
return False
_dummy_hash: bytes | None = None
def check_credentials(users: dict[str, str], username: str, password: str) -> bool:
"""Verify username+password. Unknown users burn the same bcrypt time as
known ones so response timing does not reveal which usernames exist."""
global _dummy_hash
hashed = users.get(username)
if hashed is None:
if _dummy_hash is None:
_dummy_hash = bcrypt.hashpw(
b"nvcurve-timing-equalizer", bcrypt.gensalt(rounds=BCRYPT_ROUNDS)
)
bcrypt.checkpw(_pw_bytes(password), _dummy_hash)
return False
return verify_password(password, hashed)
# ── Sessions (in-memory, 24 h) ────────────────────────────────────────────────
_sessions: dict[str, dict[str, Any]] = {} # token -> {"username", "expires_at"}
def create_session(username: str) -> tuple[str, float]:
"""Create a session. Returns (token, expires_at_unix)."""
_purge_expired()
token = secrets.token_urlsafe(32)
expires_at = time.time() + SESSION_TTL_S
_sessions[token] = {"username": username, "expires_at": expires_at}
return token, expires_at
def get_session(token: str | None) -> str | None:
"""Return the username for a valid, unexpired token — else None."""
if not token:
return None
sess = _sessions.get(token)
if sess is None:
return None
if time.time() > sess["expires_at"]:
del _sessions[token]
return None
return sess["username"]
def get_session_expires_at(token: str | None) -> float | None:
if not token:
return None
sess = _sessions.get(token)
if sess is None:
return None
return sess["expires_at"]
def destroy_session(token: str | None) -> None:
if token:
_sessions.pop(token, None)
def _purge_expired() -> None:
now = time.time()
for t in [t for t, s in _sessions.items() if now > s["expires_at"]]:
del _sessions[t]
# ── Brute-force lockout (per client IP) ───────────────────────────────────────
_failures: dict[str, list[float]] = {} # ip -> recent failure timestamps
_lockouts: dict[str, float] = {} # ip -> unix time when lockout lifts
def is_locked_out(ip: str) -> bool:
until = _lockouts.get(ip, 0.0)
if until > time.time():
return True
if until:
del _lockouts[ip]
return False
def record_failure(ip: str) -> None:
now = time.time()
recent = [t for t in _failures.get(ip, []) if now - t < _WINDOW_S]
recent.append(now)
_failures[ip] = recent
if len(recent) >= _MAX_FAILURES:
_lockouts[ip] = now + _LOCKOUT_S
del _failures[ip]
def clear_failures(ip: str) -> None:
_failures.pop(ip, None)
# ── Token extraction ──────────────────────────────────────────────────────────
def extract_token(request: Any) -> str | None:
"""Pull the session token from an Authorization header or the cookie.
Works with both starlette Request and WebSocket objects (both expose
``.headers`` / ``.cookies``).
"""
auth_header = request.headers.get("authorization", "")
if auth_header.lower().startswith("bearer "):
token = auth_header[7:].strip()
if token:
return token
return request.cookies.get(COOKIE_NAME)