| """Identité : numéro de téléphone français, PIN à 6 chiffres, jeton de session.""" |
|
|
| from __future__ import annotations |
|
|
| import hashlib |
| import hmac |
| import os |
| import re |
| import secrets |
| import time |
| from dataclasses import dataclass |
|
|
| import jwt |
| from fastapi import Header, HTTPException, status |
|
|
| from . import config, db |
|
|
| |
|
|
| _NON_DIGITS = re.compile(r"[^0-9+]") |
| _FRENCH_MOBILE = re.compile(r"^0[67][0-9]{8}$") |
|
|
|
|
| def normalize_phone(raw: str) -> str: |
| """Ramène toutes les écritures courantes à la forme canonique 0XXXXXXXXX. |
| |
| Accepte « 06 12 34 56 78 », « +33 6 12 34 56 78 », « 0033612345678 », |
| « 612345678 ». Lève ValueError si ce n'est pas un mobile français. |
| """ |
| if not raw: |
| raise ValueError("Numéro de téléphone manquant.") |
| s = _NON_DIGITS.sub("", str(raw)) |
| if s.startswith("+33"): |
| s = "0" + s[3:] |
| elif s.startswith("0033"): |
| s = "0" + s[4:] |
| elif s.startswith("33") and len(s) == 11: |
| s = "0" + s[2:] |
| s = s.lstrip("+") |
| |
| if len(s) == 9 and s[0] in "67": |
| s = "0" + s |
| if not _FRENCH_MOBILE.match(s): |
| raise ValueError("Numéro invalide : il doit commencer par 06 ou 07 et compter 10 chiffres.") |
| return s |
|
|
|
|
| def format_phone(phone: str) -> str: |
| """0612345678 -> « 06 12 34 56 78 » (affichage).""" |
| if len(phone) != 10: |
| return phone |
| return " ".join(phone[i : i + 2] for i in range(0, 10, 2)) |
|
|
|
|
| |
|
|
| _PIN_RE = re.compile(r"^[0-9]{6}$") |
|
|
|
|
| |
| _COMMON_PINS = { |
| "123456", "654321", "012345", "543210", "111111", "000000", "121212", |
| "123123", "112233", "696969", "666666", "159753", "147258", "789456", |
| "142536", "102030", "123321", "010203", "987654", "246810", "135790", |
| "159357", "852456", "abcdef", |
| } |
|
|
|
|
| def validate_pin(pin: str) -> str: |
| """Refuse les codes triviaux : la seule barrière avant le compte est ce PIN.""" |
| pin = (pin or "").strip() |
| if not _PIN_RE.match(pin): |
| raise ValueError("Le code PIN doit contenir exactement 6 chiffres.") |
|
|
| generic = "Code PIN trop simple : choisissez une combinaison moins évidente." |
|
|
| if pin in _COMMON_PINS: |
| raise ValueError(generic) |
| if len(set(pin)) == 1: |
| raise ValueError("Code PIN trop simple : évitez six fois le même chiffre.") |
| if len(set(pin)) == 2: |
| raise ValueError("Code PIN trop simple : utilisez au moins trois chiffres différents.") |
|
|
| digits = [int(c) for c in pin] |
| steps = {b - a for a, b in zip(digits, digits[1:])} |
| |
| if len(steps) == 1: |
| raise ValueError(generic) |
| |
| if pin[:2] * 3 == pin or pin[:3] * 2 == pin: |
| raise ValueError(generic) |
|
|
| return pin |
|
|
|
|
| def hash_pin(pin: str, salt: bytes | None = None) -> tuple[str, str]: |
| """scrypt (bibliothèque standard) — pas de dépendance native supplémentaire.""" |
| salt = salt or os.urandom(16) |
| digest = hashlib.scrypt(pin.encode("utf-8"), salt=salt, n=2**14, r=8, p=1, dklen=32) |
| return digest.hex(), salt.hex() |
|
|
|
|
| def verify_pin(pin: str, pin_hash: str, pin_salt: str) -> bool: |
| if not pin_hash or not pin_salt: |
| return False |
| try: |
| candidate, _ = hash_pin(pin, bytes.fromhex(pin_salt)) |
| except (ValueError, TypeError): |
| return False |
| return hmac.compare_digest(candidate, pin_hash) |
|
|
|
|
| |
|
|
|
|
| def _secret() -> str: |
| """Secret HMAC persistant : généré une fois, conservé en base.""" |
| env = os.environ.get("SECRET_KEY", "").strip() |
| if env: |
| return env |
| existing = db.get_setting("secret_key") |
| if existing: |
| return existing |
| generated = secrets.token_urlsafe(48) |
| db.set_setting("secret_key", generated) |
| return generated |
|
|
|
|
| |
|
|
|
|
| def issue_token(phone: str, token_epoch: int) -> str: |
| now = int(time.time()) |
| payload = { |
| "sub": phone, |
| "ep": token_epoch, |
| "iat": now, |
| "exp": now + config.TOKEN_TTL_DAYS * 86400, |
| } |
| return jwt.encode(payload, _secret(), algorithm="HS256") |
|
|
|
|
| def decode_token(token: str) -> dict: |
| return jwt.decode(token, _secret(), algorithms=["HS256"]) |
|
|
|
|
| @dataclass |
| class CurrentUser: |
| phone: str |
| first_name: str |
| last_name: str |
| nickname: str |
| is_superadmin: bool |
| status: str |
|
|
| @property |
| def display_name(self) -> str: |
| return self.nickname or f"{self.first_name} {self.last_name}".strip() or format_phone(self.phone) |
|
|
|
|
| def user_from_token(token: str) -> CurrentUser: |
| """Valide un jeton et renvoie l'utilisateur, ou lève une HTTPException 401.""" |
| creds_error = HTTPException( |
| status_code=status.HTTP_401_UNAUTHORIZED, |
| detail="Session expirée ou invalide. Reconnectez-vous.", |
| ) |
| try: |
| payload = decode_token(token) |
| except jwt.PyJWTError: |
| raise creds_error |
| phone = payload.get("sub") |
| row = db.query_one( |
| "SELECT phone, first_name, last_name, nickname, is_superadmin, status, token_epoch " |
| "FROM users WHERE phone = ?", |
| (phone,), |
| ) |
| if row is None or row["status"] != "active": |
| raise creds_error |
| |
| |
| if int(payload.get("ep", -1)) != int(row["token_epoch"]): |
| raise creds_error |
| return CurrentUser( |
| phone=row["phone"], |
| first_name=row["first_name"], |
| last_name=row["last_name"], |
| nickname=row["nickname"], |
| is_superadmin=bool(row["is_superadmin"]), |
| status=row["status"], |
| ) |
|
|
|
|
| def _bearer(authorization: str | None) -> str: |
| if not authorization or not authorization.lower().startswith("bearer "): |
| raise HTTPException( |
| status_code=status.HTTP_401_UNAUTHORIZED, |
| detail="Authentification requise.", |
| ) |
| return authorization.split(" ", 1)[1].strip() |
|
|
|
|
| async def current_user(authorization: str | None = Header(default=None)) -> CurrentUser: |
| """Dépendance FastAPI : utilisateur authentifié.""" |
| return user_from_token(_bearer(authorization)) |
|
|
|
|
| async def current_superadmin(authorization: str | None = Header(default=None)) -> CurrentUser: |
| """Dépendance FastAPI : réservé au superadmin.""" |
| user = user_from_token(_bearer(authorization)) |
| if not user.is_superadmin: |
| raise HTTPException(status_code=403, detail="Action réservée au superadmin.") |
| return user |
|
|
|
|
| |
|
|
|
|
| def group_role(phone: str, group_id: int, is_superadmin: bool = False) -> str | None: |
| """Renvoie 'admin', 'member' ou None. Le superadmin est admin partout.""" |
| row = db.query_one( |
| "SELECT role FROM memberships WHERE group_id = ? AND phone = ?", |
| (group_id, phone), |
| ) |
| if row is not None: |
| return "admin" if (is_superadmin or row["role"] == "admin") else "member" |
| return "admin" if is_superadmin else None |
|
|
|
|
| def require_member(user: CurrentUser, group_id: int) -> str: |
| role = group_role(user.phone, group_id, user.is_superadmin) |
| if role is None: |
| raise HTTPException(status_code=403, detail="Vous n'êtes pas membre de ce groupe.") |
| return role |
|
|
|
|
| def require_group_admin(user: CurrentUser, group_id: int) -> str: |
| role = group_role(user.phone, group_id, user.is_superadmin) |
| if role != "admin": |
| raise HTTPException(status_code=403, detail="Action réservée aux administrateurs du groupe.") |
| return role |
|
|