feat(backend): add local auth + group-based RBAC matching F11 (B0.6)
- Permission enum + GroupName enum + GROUP_PERMISSIONS mapping mirror the F11 matrix in code (verifiable against the spec table in tests). - @require_perm decorator: 401 on anonymous, 403 on missing permission, passes through otherwise. Pure-function user_has() for unit-testing. - AuthUser (Flask-Login wrapper) resolves the permission set from a User's groups; load_user is the Flask-Login user_loader. - bcrypt password hashing helpers (12 rounds by default, configurable). - SOC opaque token (D-006): secrets.token_urlsafe(32), bcrypt-hashed at rest, plain value returned once at creation and never re-displayable. - Group-based RBAC from day one (D-003) — Keycloak OIDC in v2 maps onto the same group model.
This commit is contained in:
44
backend/src/mimic/auth/soc_token.py
Normal file
44
backend/src/mimic/auth/soc_token.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""SOC opaque token generation + verification.
|
||||
|
||||
Decision D-006: clear token returned once at creation, bcrypt hash persisted.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
|
||||
import bcrypt
|
||||
|
||||
_TOKEN_BYTES = 32 # 256 bits of entropy
|
||||
_DEFAULT_ROUNDS = 12
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SocTokenMaterial:
|
||||
plain: str
|
||||
hashed: str
|
||||
|
||||
|
||||
def generate_token(*, rounds: int = _DEFAULT_ROUNDS) -> SocTokenMaterial:
|
||||
"""Generate a fresh SOC token and its bcrypt hash."""
|
||||
plain = secrets.token_urlsafe(_TOKEN_BYTES)
|
||||
salt = bcrypt.gensalt(rounds=rounds)
|
||||
hashed = bcrypt.hashpw(plain.encode("utf-8"), salt).decode("utf-8")
|
||||
return SocTokenMaterial(plain=plain, hashed=hashed)
|
||||
|
||||
|
||||
def verify_token(plain: str, hashed: str) -> bool:
|
||||
"""Constant-time bcrypt verification of a SOC token."""
|
||||
if not plain or not hashed:
|
||||
return False
|
||||
try:
|
||||
return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def safe_compare(a: str, b: str) -> bool:
|
||||
"""Constant-time string compare for non-hashed contexts."""
|
||||
return hmac.compare_digest(a, b)
|
||||
Reference in New Issue
Block a user