06/15 Phase 1 + 2 codes

This commit is contained in:
2026-06-15 11:23:05 -04:00
commit c2064b84b4
62 changed files with 2937 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
"""RBAC decorators. Never trust the client; gate on server side."""
from functools import wraps
from flask import abort
from flask_login import current_user
from app.models.enums import Role
def role_required(*roles):
"""Require the current user to hold one of the given roles."""
def decorator(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
if not current_user.is_authenticated:
abort(401)
if current_user.role not in roles:
abort(403)
return fn(*args, **kwargs)
return wrapper
return decorator
def admin_required(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
if not current_user.is_authenticated:
abort(401)
if not current_user.is_admin:
abort(403)
return fn(*args, **kwargs)
return wrapper
def moderator_required(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
if not current_user.is_authenticated:
abort(401)
if not current_user.is_moderator:
abort(403)
return fn(*args, **kwargs)
return wrapper
+42
View File
@@ -0,0 +1,42 @@
"""Security primitives: Argon2 password hashing + signed tokens for email/reset."""
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, InvalidHashError
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
from flask import current_app
_ph = PasswordHasher()
def hash_password(raw: str) -> str:
return _ph.hash(raw)
def verify_password(stored_hash: str, raw: str) -> bool:
try:
return _ph.verify(stored_hash, raw)
except (VerifyMismatchError, InvalidHashError, Exception):
return False
def needs_rehash(stored_hash: str) -> bool:
try:
return _ph.check_needs_rehash(stored_hash)
except Exception:
return False
# --- Signed tokens (email verify, password reset) ---
def _serializer(salt: str) -> URLSafeTimedSerializer:
return URLSafeTimedSerializer(current_app.config["SECRET_KEY"], salt=salt)
def generate_token(data, salt: str) -> str:
return _serializer(salt).dumps(data)
def read_token(token: str, salt: str, max_age: int):
"""Return payload or None if invalid/expired."""
try:
return _serializer(salt).loads(token, max_age=max_age)
except (BadSignature, SignatureExpired):
return None
+26
View File
@@ -0,0 +1,26 @@
"""Text normalization for accent-insensitive search.
Strips diacritics so "phở" -> "pho" and "ñandú" -> "nandu".
Used now for display/utility; drives `title_norm` shadow column in Phase 2 listings.
"""
import unicodedata
# Vietnamese đ/Đ do not decompose via NFKD, map explicitly.
_EXPLICIT = {
"đ": "d", "Đ": "d",
"ð": "d", "Ð": "d",
}
def normalize(text: str) -> str:
if not text:
return ""
out = []
for ch in text:
if ch in _EXPLICIT:
out.append(_EXPLICIT[ch])
continue
decomposed = unicodedata.normalize("NFKD", ch)
stripped = "".join(c for c in decomposed if not unicodedata.combining(c))
out.append(stripped)
return "".join(out).lower().strip()