Phase 1 — multi-user auth: - server/auth.py: bcrypt passwords, itsdangerous signed-cookie sessions, sha256 bearer tokens, FastAPI current_user/require_owner deps, login rate limit - users + api_tokens tables + jobs/assets.user_id (additive migrations) - HARD RULE enforced server-side: guests are local-only — /api/operators filters out requires_env operators, POST /api/jobs 403s cloud ops for non-owners (proven via direct POST in smoke.sh, not just UI). Settings owner-only. auth_secret hidden from the settings API. Per-user active-job cap (owner exempt). Own-asset/ own-job checks. WS auth via cookie or ?token=. Owner bootstrap prints pw once. - mb-ready: bearer MB_TOKEN; scripts/users.py for out-of-band management - Frontend: Login gate, header user chip + logout, guest note, username on jobs, Users panel in Settings (owner) Phase 2 — dashboard: - server/sysinfo.py: psutil CPU/RAM/disk + macmon Apple GPU (util/power/temp, no sudo), computed lane occupancy, 24h job summary, recent jobs w/ output thumbs; all cached (5s stats, 5min du). /api/system + /api/jobs/recent. - Dashboard.jsx: snapshot-on-refresh (no polling) — stat cards, per-core strip, lane strip, running/queued, recent grid. tests/smoke.sh rewritten for auth: 28 checks passing incl. all guest-security rules. Browser-verified owner + guest + dashboard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
189 lines
6.2 KiB
Python
189 lines
6.2 KiB
Python
"""Authentication: bcrypt passwords, signed-cookie sessions, bearer API tokens,
|
|
and the FastAPI dependency that resolves the current user.
|
|
|
|
Design premise (owner's hard rule): guests are LOCAL-ONLY. Any operator with a
|
|
non-empty requires_env is owner-only and enforced server-side (see main.py) — this
|
|
module just answers "who is this request" and "is the password right".
|
|
"""
|
|
import hashlib
|
|
import re
|
|
import secrets
|
|
import time
|
|
|
|
import bcrypt
|
|
from fastapi import HTTPException, Request
|
|
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
|
|
|
from . import db
|
|
|
|
SESSION_COOKIE = "mb_session"
|
|
SESSION_MAX_AGE = 30 * 24 * 3600 # 30 days
|
|
USERNAME_RE = re.compile(r"^[a-z0-9_-]{2,24}$")
|
|
MIN_PW_LEN = 6
|
|
|
|
# in-memory login rate limit: ip -> [failure epoch, ...]
|
|
_login_fails: dict[str, list[float]] = {}
|
|
_RATE_WINDOW = 60.0
|
|
_RATE_MAX = 5
|
|
|
|
|
|
# -- passwords ----------------------------------------------------------------
|
|
def hash_pw(pw: str) -> str:
|
|
# bcrypt caps at 72 bytes; truncate deterministically (standard practice)
|
|
return bcrypt.hashpw(pw.encode()[:72], bcrypt.gensalt()).decode()
|
|
|
|
|
|
def verify_pw(pw: str, pw_hash: str) -> bool:
|
|
try:
|
|
return bcrypt.checkpw(pw.encode()[:72], pw_hash.encode())
|
|
except (ValueError, TypeError):
|
|
return False
|
|
|
|
|
|
# -- auth secret (stored hidden in settings; never exposed via the API) -------
|
|
def ensure_auth_secret(con) -> str:
|
|
row = con.execute("SELECT value FROM settings WHERE key='auth_secret'").fetchone()
|
|
if row and row["value"]:
|
|
return row["value"]
|
|
secret = secrets.token_hex(32)
|
|
con.execute("INSERT OR REPLACE INTO settings (key, value) VALUES ('auth_secret', ?)", (secret,))
|
|
con.commit()
|
|
return secret
|
|
|
|
|
|
def _serializer(con) -> URLSafeTimedSerializer:
|
|
return URLSafeTimedSerializer(ensure_auth_secret(con), salt="mb-session")
|
|
|
|
|
|
def make_session(con, user_id: str) -> str:
|
|
return _serializer(con).dumps({"uid": user_id})
|
|
|
|
|
|
def read_session(con, token: str) -> str | None:
|
|
try:
|
|
return _serializer(con).loads(token, max_age=SESSION_MAX_AGE).get("uid")
|
|
except (BadSignature, SignatureExpired, Exception):
|
|
return None
|
|
|
|
|
|
# -- API tokens ----------------------------------------------------------------
|
|
def _token_hash(raw: str) -> str:
|
|
return hashlib.sha256(raw.encode()).hexdigest()
|
|
|
|
|
|
def create_token(con, user_id: str, name: str = "") -> str:
|
|
raw = "mbt_" + secrets.token_hex(20)
|
|
con.execute(
|
|
"INSERT INTO api_tokens (token_hash, user_id, name, created_at) VALUES (?, ?, ?, ?)",
|
|
(_token_hash(raw), user_id, name, db.now()))
|
|
con.commit()
|
|
return raw
|
|
|
|
|
|
def user_by_token(con, raw: str) -> dict | None:
|
|
row = con.execute(
|
|
"SELECT u.* FROM api_tokens t JOIN users u ON u.id = t.user_id WHERE t.token_hash = ?",
|
|
(_token_hash(raw),)).fetchone()
|
|
return dict(row) if row else None
|
|
|
|
|
|
# -- users ---------------------------------------------------------------------
|
|
def get_user(con, user_id: str) -> dict | None:
|
|
row = con.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
|
|
return dict(row) if row else None
|
|
|
|
|
|
def get_user_by_name(con, username: str) -> dict | None:
|
|
row = con.execute("SELECT * FROM users WHERE username = ?", (username.lower(),)).fetchone()
|
|
return dict(row) if row else None
|
|
|
|
|
|
def create_user(con, username: str, password: str, role: str = "guest",
|
|
max_active_jobs: int = 4) -> dict:
|
|
username = username.lower()
|
|
if not USERNAME_RE.match(username):
|
|
raise ValueError("username must be 2-24 chars of a-z 0-9 _ -")
|
|
if len(password) < MIN_PW_LEN:
|
|
raise ValueError(f"password must be >= {MIN_PW_LEN} characters")
|
|
if get_user_by_name(con, username):
|
|
raise ValueError("username already exists")
|
|
if role not in ("owner", "guest"):
|
|
raise ValueError("role must be owner or guest")
|
|
uid = db.new_id()
|
|
con.execute(
|
|
"INSERT INTO users (id, username, pw_hash, role, max_active_jobs, created_at) "
|
|
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
(uid, username, hash_pw(password), role, max_active_jobs, db.now()))
|
|
con.commit()
|
|
return get_user(con, uid)
|
|
|
|
|
|
def set_password(con, user_id: str, password: str) -> None:
|
|
if len(password) < MIN_PW_LEN:
|
|
raise ValueError(f"password must be >= {MIN_PW_LEN} characters")
|
|
con.execute("UPDATE users SET pw_hash = ? WHERE id = ?", (hash_pw(password), user_id))
|
|
con.commit()
|
|
|
|
|
|
def list_users(con) -> list[dict]:
|
|
rows = con.execute(
|
|
"SELECT id, username, role, max_active_jobs, created_at FROM users ORDER BY created_at"
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
def delete_user(con, user_id: str) -> None:
|
|
con.execute("DELETE FROM api_tokens WHERE user_id = ?", (user_id,))
|
|
con.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
|
con.commit()
|
|
|
|
|
|
def public_user(u: dict) -> dict:
|
|
return {"username": u["username"], "role": u["role"],
|
|
"is_owner": u["role"] == "owner", "max_active_jobs": u["max_active_jobs"]}
|
|
|
|
|
|
# -- rate limit ----------------------------------------------------------------
|
|
def rate_ok(ip: str) -> bool:
|
|
now = time.time()
|
|
fails = [t for t in _login_fails.get(ip, []) if now - t < _RATE_WINDOW]
|
|
_login_fails[ip] = fails
|
|
return len(fails) < _RATE_MAX
|
|
|
|
|
|
def note_fail(ip: str) -> None:
|
|
_login_fails.setdefault(ip, []).append(time.time())
|
|
|
|
|
|
# -- FastAPI dependency --------------------------------------------------------
|
|
def resolve_user(request: Request) -> dict | None:
|
|
"""Cookie session first, then bearer token. Returns user dict or None."""
|
|
con = request.app.state.con
|
|
tok = request.cookies.get(SESSION_COOKIE)
|
|
if tok:
|
|
uid = read_session(con, tok)
|
|
if uid:
|
|
u = get_user(con, uid)
|
|
if u:
|
|
return u
|
|
authz = request.headers.get("authorization", "")
|
|
if authz.startswith("Bearer "):
|
|
u = user_by_token(con, authz[7:].strip())
|
|
if u:
|
|
return u
|
|
return None
|
|
|
|
|
|
def current_user(request: Request) -> dict:
|
|
u = resolve_user(request)
|
|
if not u:
|
|
raise HTTPException(401, "authentication required")
|
|
return u
|
|
|
|
|
|
def require_owner(request: Request) -> dict:
|
|
u = current_user(request)
|
|
if u["role"] != "owner":
|
|
raise HTTPException(403, "owner only")
|
|
return u
|