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>
57 lines
2.2 KiB
Python
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
|