MSG_MOTOC / app /auth.py
Samuel ADONE
first commit
5e0d7d2
Raw
History Blame Contribute Delete
7.91 kB
"""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
# --- Numéro de téléphone --------------------------------------------------
_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("+")
# « 612345678 » saisi sans le zéro initial.
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 ------------------------------------------------------------------
_PIN_RE = re.compile(r"^[0-9]{6}$")
# Codes les plus fréquents dans les fuites publiques : à refuser d'office.
_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:])}
# Suites arithmétiques : 123456, 246802, 975310…
if len(steps) == 1:
raise ValueError(generic)
# Motifs répétés : ABABAB ou ABCABC.
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)
# --- Secret de signature --------------------------------------------------
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
# --- Jetons ---------------------------------------------------------------
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
# Un changement de PIN ou un blocage incrémente token_epoch et invalide
# d'un coup toutes les sessions ouvertes.
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
# --- Rôles de groupe ------------------------------------------------------
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