Un lien "Mot de passe oublié ?" (login.html) mène à /forgot-password : si l'adresse saisie correspond à un compte, un jeton à haute entropie (secrets.token_urlsafe, valable 1h) est généré et envoyé par email via smtplib (auth/send_email.py, aucune dépendance ajoutée) — configuré uniquement par variables d'environnement (SMTP_HOST/PORT/USER/PASSWORD/ FROM, voir .env.example et docker-compose.prod.yml), n'importe quel serveur SMTP existant convient (Mailcow compris). Seul le hash SHA-256 du jeton est stocké (auth/password_reset.py, table _password_reset_tokens) : un jeton envoyé par email reste inutilisable même en cas de fuite de la base. Le même message générique s'affiche que l'adresse corresponde à un compte ou non, pour ne jamais permettre à ce formulaire de servir à deviner quelles adresses sont déjà inscrites. Un échec d'envoi (SMTP non configuré) est journalisé côté serveur seulement, jamais révélé à l'utilisateur. /reset-password/<token> vérifie le jeton (non expiré, non déjà utilisé), applique les mêmes règles de mot de passe fort qu'à l'inscription (même schéma visuel), puis consomme le jeton et remet à zéro le compteur anti-bruteforce du compte (auth/set_password.py) — une identité prouvée par email est une voie de récupération légitime même pour un compte verrouillé. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
66 lines
2.6 KiB
Python
66 lines
2.6 KiB
Python
from .connection import connect
|
|
|
|
|
|
def ensure_users_schema():
|
|
conn = connect()
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS _users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
email TEXT NOT NULL UNIQUE,
|
|
password_hash TEXT NOT NULL,
|
|
nom TEXT NOT NULL,
|
|
prenom TEXT NOT NULL,
|
|
role TEXT NOT NULL DEFAULT 'user',
|
|
totp_secret TEXT NOT NULL,
|
|
totp_confirmed INTEGER NOT NULL DEFAULT 0,
|
|
project_slug TEXT,
|
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
"""
|
|
)
|
|
# Anti-bruteforce (voir rate_limit.py) : ajoutées en ALTER TABLE pour
|
|
# ne rien casser sur une base de comptes déjà créée avant cette
|
|
# fonctionnalité (le compte admin déjà inscrit sur ce poste, par
|
|
# exemple) — CREATE TABLE IF NOT EXISTS ci-dessus n'aurait sinon
|
|
# aucun effet sur une table qui existe déjà sans ces colonnes.
|
|
cols = {r["name"] for r in conn.execute("PRAGMA table_info(_users)").fetchall()}
|
|
if "failed_attempts" not in cols:
|
|
conn.execute("ALTER TABLE _users ADD COLUMN failed_attempts INTEGER NOT NULL DEFAULT 0")
|
|
if "locked_until" not in cols:
|
|
conn.execute("ALTER TABLE _users ADD COLUMN locked_until TEXT")
|
|
# Codes de récupération 2FA (voir recovery_codes.py) : table séparée
|
|
# plutôt qu'une colonne JSON sur _users, pour pouvoir marquer/consommer
|
|
# chaque code UN PAR UN (used_at) sans avoir à relire/réécrire toute
|
|
# la liste à chaque connexion.
|
|
conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS _recovery_codes (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL,
|
|
code_hash TEXT NOT NULL,
|
|
used_at TEXT,
|
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
"""
|
|
)
|
|
# Jetons de réinitialisation de mot de passe (voir password_reset.py) :
|
|
# seul leur hash (SHA-256, un jeton aléatoire à haute entropie n'a pas
|
|
# besoin d'un hash de mot de passe volontairement lent) est stocké, un
|
|
# jeton envoyé par email reste donc inutilisable même en cas de fuite
|
|
# de cette base.
|
|
conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS _password_reset_tokens (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL,
|
|
token_hash TEXT NOT NULL UNIQUE,
|
|
expires_at TEXT NOT NULL,
|
|
used_at TEXT,
|
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
"""
|
|
)
|
|
conn.commit()
|
|
conn.close()
|