Rewrite server: FastAPI + auth (users/roles/tokens) + admin WebUI + server-authoritative covers/metadata
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
"""Password hashing, API/session tokens, and FastAPI auth dependencies.
|
||||
|
||||
Stdlib-only crypto: PBKDF2-SHA256 for passwords, SHA-256 hashed bearer tokens
|
||||
for both the desktop client (kind="api") and the WebUI session (kind="session").
|
||||
"""
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, Request, status
|
||||
|
||||
from app.db import get_connection
|
||||
|
||||
_PBKDF2_ITERATIONS = 600_000
|
||||
|
||||
ADMIN_ROLE = "admin"
|
||||
EDITOR_ROLE = "editor"
|
||||
USER_ROLE = "user"
|
||||
ROLES = (ADMIN_ROLE, EDITOR_ROLE, USER_ROLE)
|
||||
|
||||
|
||||
def now_iso():
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
salt = secrets.token_bytes(16)
|
||||
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, _PBKDF2_ITERATIONS)
|
||||
return f"pbkdf2_sha256${_PBKDF2_ITERATIONS}${salt.hex()}${digest.hex()}"
|
||||
|
||||
|
||||
def verify_password(password: str, stored: str) -> bool:
|
||||
try:
|
||||
scheme, iterations, salt_hex, digest_hex = stored.split("$")
|
||||
if scheme != "pbkdf2_sha256":
|
||||
return False
|
||||
salt = bytes.fromhex(salt_hex)
|
||||
expected = bytes.fromhex(digest_hex)
|
||||
actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, int(iterations))
|
||||
return secrets.compare_digest(actual, expected)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def _hash_token(raw: str) -> str:
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def issue_token(user_id: int, kind: str = "api", label: str = "", expires_at: str = "") -> str:
|
||||
"""Create a token row and return the raw token (only shown once)."""
|
||||
raw = secrets.token_urlsafe(32)
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO api_tokens (user_id, token_hash, label, kind, created_at, last_used_at, expires_at, revoked_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, NULL)",
|
||||
(user_id, _hash_token(raw), label, kind, now_iso(), now_iso(), expires_at or None),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
return raw
|
||||
|
||||
|
||||
def revoke_token(raw: str) -> None:
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE api_tokens SET revoked_at = ? WHERE token_hash = ?",
|
||||
(now_iso(), _hash_token(raw)),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_user_by_token(raw: str):
|
||||
if not raw:
|
||||
return None
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT u.id, u.username, u.role, u.is_active FROM api_tokens t "
|
||||
"JOIN users u ON u.id = t.user_id "
|
||||
"WHERE t.token_hash = ? AND t.revoked_at IS NULL "
|
||||
"AND (t.expires_at IS NULL OR t.expires_at = '' OR t.expires_at > ?)",
|
||||
(_hash_token(raw), now_iso()),
|
||||
).fetchone()
|
||||
if row is None or not row["is_active"]:
|
||||
return None
|
||||
conn.execute(
|
||||
"UPDATE api_tokens SET last_used_at = ? WHERE token_hash = ?",
|
||||
(now_iso(), _hash_token(raw)),
|
||||
)
|
||||
conn.commit()
|
||||
return dict(row)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_current_user(request: Request, authorization: str = Header(default="")):
|
||||
"""Authenticate via ``Authorization: Bearer`` (client) or session cookie (WebUI)."""
|
||||
token = ""
|
||||
if authorization.startswith("Bearer "):
|
||||
token = authorization[7:].strip()
|
||||
else:
|
||||
token = request.cookies.get("psppt_session", "")
|
||||
user = get_user_by_token(token)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
return user
|
||||
|
||||
|
||||
def require_role(*roles):
|
||||
"""Dependency factory: only users with one of ``roles`` may pass."""
|
||||
def dependency(user=Depends(get_current_user)):
|
||||
if user["role"] not in roles:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
|
||||
return user
|
||||
return dependency
|
||||
|
||||
|
||||
def ensure_admin(username: str, password: str) -> bool:
|
||||
"""Create the initial admin account when the users table is empty."""
|
||||
conn = get_connection()
|
||||
try:
|
||||
count = conn.execute("SELECT COUNT(*) AS n FROM users").fetchone()["n"]
|
||||
if count == 0:
|
||||
conn.execute(
|
||||
"INSERT INTO users (username, password_hash, role, is_active, created_at) "
|
||||
"VALUES (?, ?, 'admin', 1, ?)",
|
||||
(username, hash_password(password), now_iso()),
|
||||
)
|
||||
conn.commit()
|
||||
return True
|
||||
finally:
|
||||
conn.close()
|
||||
return False
|
||||
Reference in New Issue
Block a user