RECORDGOD/app/auth.py
type-two 9a66f59bfe feat(auth): staff email+password login + password reset (login stays token-based under the hood)
Staff were stuck pasting a raw bearer token with no reset. Now: a proper /login page (email+password)
that exchanges to the staff member's existing token (SPA unchanged downstream). PBKDF2 hashing (stdlib,
no bcrypt dep). New: POST /auth/login, POST /auth/change-password (self), POST /admin/staff/{id}/password
(admin reset). staff gains email(unique)/phone/pay_rate/password_hash. Staff admin UI: add/edit details,
set-password, and timecards now show pay (hours × rate). admin.html redirects to /login when unauthed +
bounces expired tokens; nav gets Change-password + Sign-out. Owner master token still works (break-glass on
the login page). Verified e2e: login, wrong-pw 401, token auth, admin reset invalidates old pw.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:24:34 +10:00

57 lines
2.2 KiB
Python

import hashlib
import hmac
import os
import secrets
from fastapi import Header, HTTPException, Depends
from sqlalchemy import text
from .db import get_db
# Password hashing — stdlib PBKDF2 (no bcrypt/passlib in the image, no new dependency).
def hash_password(pw: str) -> str:
salt = secrets.token_bytes(16)
dk = hashlib.pbkdf2_hmac("sha256", pw.encode(), salt, 200_000)
return f"pbkdf2_sha256$200000${salt.hex()}${dk.hex()}"
def verify_password(pw: str, stored: str | None) -> bool:
if not stored:
return False
try:
_algo, iters, salt_hex, hash_hex = stored.split("$")
dk = hashlib.pbkdf2_hmac("sha256", pw.encode(), bytes.fromhex(salt_hex), int(iters))
return hmac.compare_digest(dk.hex(), hash_hex)
except Exception:
return False
# Single-tenant v1: one store. The env token is the OWNER (always admin); staff get their own
# tokens from the `staff` table with a role (admin | staff). Role gates the sensitive endpoints.
STORE = os.getenv("RECORDGOD_STORE", "Monster Robot Party")
PLAN = os.getenv("RECORDGOD_PLAN", "enterprise")
TOKEN = os.getenv("RECORDGOD_TOKEN") # no default — unset means "deny everything", not "open"
async def require_token(authorization: str | None = Header(None), db=Depends(get_db)):
# Bearer per WOWPLATTER_BRIEF. Owner env-token first (no DB hit); then the staff table.
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(401, "missing bearer token")
tok = authorization[7:].strip()
base = {"store": STORE, "plan": PLAN, "store_id": 1}
if TOKEN and tok == TOKEN:
return {**base, "role": "admin", "name": "Owner", "staff_id": None}
row = (await db.execute(
text("SELECT id, name, role FROM staff WHERE token = :t AND active"), {"t": tok}
)).mappings().first()
if row:
return {**base, "role": row["role"], "name": row["name"], "staff_id": row["id"]}
raise HTTPException(401, "invalid token")
async def require_admin(ident=Depends(require_token)):
"""Gate the sensitive surface (API keys / connections / staff admin) to admins only."""
if ident.get("role") != "admin":
raise HTTPException(403, "admin only")
return ident