Dev #4
@@ -26,6 +26,9 @@ from .password_reset import (
|
||||
TOKEN_TTL_MINUTES,
|
||||
)
|
||||
from .send_email import send_password_reset_email, EmailNotConfiguredError
|
||||
from .update_profile import update_profile
|
||||
from .count_admins import count_admins
|
||||
from .delete_user import delete_user
|
||||
|
||||
__all__ = [
|
||||
"users_db_path", "ensure_users_schema", "is_first_user",
|
||||
@@ -40,4 +43,5 @@ __all__ = [
|
||||
"set_password", "create_password_reset_token", "get_user_id_for_valid_token",
|
||||
"consume_password_reset_token", "TOKEN_TTL_MINUTES",
|
||||
"send_password_reset_email", "EmailNotConfiguredError",
|
||||
"update_profile", "count_admins", "delete_user",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
from .connection import connect
|
||||
|
||||
|
||||
def count_admins():
|
||||
conn = connect()
|
||||
n = conn.execute("SELECT COUNT(*) AS n FROM _users WHERE role = 'admin'").fetchone()["n"]
|
||||
conn.close()
|
||||
return n
|
||||
@@ -0,0 +1,16 @@
|
||||
from .connection import connect
|
||||
|
||||
|
||||
def delete_user(user_id):
|
||||
"""Supprime le compte et tout ce qui lui est directement rattaché
|
||||
(codes de récupération, jetons de réinitialisation encore en cours) —
|
||||
le dossier de projet, lui, est géré par l'appelant (voir
|
||||
routes/auth/profile.py) : un compte "admin" peut posséder plusieurs
|
||||
jeux qu'il n'a pas forcément envie de perdre en même temps que son
|
||||
compte."""
|
||||
conn = connect()
|
||||
conn.execute("DELETE FROM _recovery_codes WHERE user_id = ?", (user_id,))
|
||||
conn.execute("DELETE FROM _password_reset_tokens WHERE user_id = ?", (user_id,))
|
||||
conn.execute("DELETE FROM _users WHERE id = ?", (user_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -0,0 +1,8 @@
|
||||
from .connection import connect
|
||||
|
||||
|
||||
def update_profile(user_id, nom, prenom):
|
||||
conn = connect()
|
||||
conn.execute("UPDATE _users SET nom = ?, prenom = ? WHERE id = ?", (nom, prenom, user_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -1 +1 @@
|
||||
from . import register, register_2fa, login, login_2fa, logout, forgot_password, reset_password
|
||||
from . import register, register_2fa, login, login_2fa, logout, forgot_password, reset_password, profile
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from flask import g, redirect, render_template, request, session, url_for
|
||||
|
||||
import auth
|
||||
import db
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
def _render(error=None, success=None):
|
||||
user = auth.get_user_by_id(g.current_user["id"])
|
||||
return render_template(
|
||||
"auth/profile.html", user=user, error=error, success=success,
|
||||
min_score_required=auth.MIN_SCORE_REQUIRED,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/profile", methods=["GET"])
|
||||
def profile():
|
||||
return _render()
|
||||
|
||||
|
||||
@app.route("/profile/update-name", methods=["POST"])
|
||||
def profile_update_name():
|
||||
nom = request.form.get("nom", "").strip()
|
||||
prenom = request.form.get("prenom", "").strip()
|
||||
if not nom or not prenom:
|
||||
return _render(error="Nom et prénom sont requis.")
|
||||
auth.update_profile(g.current_user["id"], nom, prenom)
|
||||
return _render(success="Informations mises à jour.")
|
||||
|
||||
|
||||
@app.route("/profile/change-password", methods=["POST"])
|
||||
def profile_change_password():
|
||||
user = g.current_user
|
||||
current_password = request.form.get("current_password", "")
|
||||
new_password = request.form.get("new_password", "")
|
||||
new_password_confirm = request.form.get("new_password_confirm", "")
|
||||
if not auth.verify_password(user, current_password):
|
||||
return _render(error="Mot de passe actuel incorrect.")
|
||||
if new_password != new_password_confirm:
|
||||
return _render(error="Les nouveaux mots de passe ne correspondent pas.")
|
||||
if not auth.password_strength(new_password)["valid"]:
|
||||
return _render(error="Nouveau mot de passe trop faible — respecte les critères affichés.")
|
||||
auth.set_password(user["id"], new_password)
|
||||
return _render(success="Mot de passe modifié.")
|
||||
|
||||
|
||||
@app.route("/profile/delete", methods=["POST"])
|
||||
def profile_delete():
|
||||
user = g.current_user
|
||||
current_password = request.form.get("current_password", "")
|
||||
confirm_text = request.form.get("confirm_text", "").strip().upper()
|
||||
if not auth.verify_password(user, current_password):
|
||||
return _render(error="Mot de passe incorrect — suppression annulée.")
|
||||
if confirm_text != "SUPPRIMER":
|
||||
return _render(error='Tape "SUPPRIMER" pour confirmer — suppression annulée.')
|
||||
if user["role"] == "admin" and auth.count_admins() <= 1:
|
||||
return _render(error="Impossible de supprimer l'unique compte administrateur.")
|
||||
|
||||
# Un compte "user" ne possède qu'UN SEUL projet, créé automatiquement
|
||||
# pour lui et impossible à supprimer autrement (voir core/auth_guard.py,
|
||||
# game_delete bloqué pour ce rôle) — supprimer le compte est donc la
|
||||
# seule façon de s'en débarrasser aussi, sans quoi il resterait orphelin
|
||||
# sur le disque sans plus aucun propriétaire. Un "admin" peut posséder
|
||||
# plusieurs jeux qui ne lui sont pas dédiés de la même façon : ses
|
||||
# projets restent intacts.
|
||||
if user["role"] != "admin" and user.get("project_slug"):
|
||||
game_dir = db.game_dir(user["project_slug"])
|
||||
if os.path.isdir(game_dir):
|
||||
shutil.rmtree(game_dir)
|
||||
|
||||
auth.delete_user(user["id"])
|
||||
session.clear()
|
||||
return redirect(url_for("login"))
|
||||
@@ -0,0 +1,121 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Mon profil — Forge Engine{% endblock %}
|
||||
{% block content %}
|
||||
<section class="section">
|
||||
<div class="container" style="max-width:560px;">
|
||||
<h1 class="title">Mon profil</h1>
|
||||
|
||||
{% if error %}
|
||||
<div class="notification is-danger">{{ error }}</div>
|
||||
{% endif %}
|
||||
{% if success %}
|
||||
<div class="notification is-success">{{ success }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="box">
|
||||
<h2 class="title is-5">Informations</h2>
|
||||
<p class="hint mb-4">Adresse email : <strong>{{ user.email }}</strong> ({{ 'compte administrateur' if user.role == 'admin' else 'compte utilisateur' }})</p>
|
||||
<form method="post" action="{{ url_for('profile_update_name') }}">
|
||||
<div class="field">
|
||||
<label class="label">Prénom</label>
|
||||
<div class="control"><input class="input" type="text" name="prenom" value="{{ user.prenom }}" required></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">Nom</label>
|
||||
<div class="control"><input class="input" type="text" name="nom" value="{{ user.nom }}" required></div>
|
||||
</div>
|
||||
<div class="field mt-4">
|
||||
<button type="submit" class="button primary">Enregistrer</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<h2 class="title is-5">Changer de mot de passe</h2>
|
||||
<form method="post" action="{{ url_for('profile_change_password') }}" autocomplete="off">
|
||||
<div class="field">
|
||||
<label class="label">Mot de passe actuel</label>
|
||||
<div class="control"><input class="input" type="password" name="current_password" required autocomplete="current-password"></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">Nouveau mot de passe</label>
|
||||
<div class="control"><input class="input" type="password" id="password" name="new_password" required autocomplete="new-password"></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="passwordStrengthBar"><div class="passwordStrengthFill" id="pwFill"></div></div>
|
||||
<ul class="passwordChecklist" id="pwChecklist">
|
||||
<li data-check="longueur">Au moins 8 caractères</li>
|
||||
<li data-check="majuscule">Une majuscule</li>
|
||||
<li data-check="minuscule">Une minuscule</li>
|
||||
<li data-check="chiffre">Un chiffre</li>
|
||||
<li data-check="special">Un caractère spécial (ex. ! ? # -)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">Confirmer le nouveau mot de passe</label>
|
||||
<div class="control"><input class="input" type="password" name="new_password_confirm" required autocomplete="new-password"></div>
|
||||
</div>
|
||||
<div class="field mt-4">
|
||||
<button type="submit" class="button primary">Changer le mot de passe</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="box" style="border:1px solid #b33;">
|
||||
<h2 class="title is-5">Supprimer mon compte</h2>
|
||||
<p class="hint mb-4">
|
||||
{% if user.role != 'admin' %}
|
||||
Action définitive et irréversible : ton compte ET ton jeu ({{ user.project_slug }}) seront supprimés, sans possibilité de récupération.
|
||||
{% else %}
|
||||
Action définitive et irréversible : ton compte sera supprimé (tes jeux existants ne sont pas touchés).
|
||||
{% endif %}
|
||||
</p>
|
||||
<form method="post" action="{{ url_for('profile_delete') }}" autocomplete="off">
|
||||
<div class="field">
|
||||
<label class="label">Mot de passe actuel</label>
|
||||
<div class="control"><input class="input" type="password" name="current_password" required autocomplete="current-password"></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">Tape "SUPPRIMER" pour confirmer</label>
|
||||
<div class="control"><input class="input" type="text" name="confirm_text" required></div>
|
||||
</div>
|
||||
<div class="field mt-4">
|
||||
<button type="submit" class="button is-danger">Supprimer définitivement mon compte</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
var MIN_SCORE_REQUIRED = {{ min_score_required }};
|
||||
var PW_CHECKS = {
|
||||
longueur: function (p) { return p.length >= 8; },
|
||||
majuscule: function (p) { return /[A-Z]/.test(p); },
|
||||
minuscule: function (p) { return /[a-z]/.test(p); },
|
||||
chiffre: function (p) { return /\d/.test(p); },
|
||||
special: function (p) { return /[^A-Za-z0-9]/.test(p); },
|
||||
};
|
||||
(function () {
|
||||
var input = document.getElementById('password');
|
||||
var fill = document.getElementById('pwFill');
|
||||
var items = document.querySelectorAll('#pwChecklist li');
|
||||
if (!input) return;
|
||||
function update() {
|
||||
var value = input.value || '';
|
||||
var score = 0;
|
||||
items.forEach(function (li) {
|
||||
var ok = PW_CHECKS[li.dataset.check](value);
|
||||
li.classList.toggle('ok', ok);
|
||||
if (ok) score++;
|
||||
});
|
||||
var pct = (score / items.length) * 100;
|
||||
fill.style.width = pct + '%';
|
||||
fill.className = 'passwordStrengthFill' +
|
||||
(score >= items.length ? ' strong' : score >= MIN_SCORE_REQUIRED ? ' good' : score >= 2 ? ' medium' : ' weak');
|
||||
}
|
||||
input.addEventListener('input', update);
|
||||
update();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
+1
-1
@@ -47,7 +47,7 @@
|
||||
</div>
|
||||
{% if g.get('current_user') %}
|
||||
<div class="navbar-end" style="margin-left:auto; display:flex; align-items:center; gap:10px; padding-right:12px;">
|
||||
<span class="hint">{{ g.current_user.prenom }} {{ g.current_user.nom }}{{ ' (admin)' if g.current_user.role == 'admin' else '' }}</span>
|
||||
<a href="{{ url_for('profile') }}" class="hint" style="text-decoration:underline;">{{ g.current_user.prenom }} {{ g.current_user.nom }}{{ ' (admin)' if g.current_user.role == 'admin' else '' }}</a>
|
||||
<form method="post" action="{{ url_for('logout') }}"><button type="submit" class="button is-small">Déconnexion</button></form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Page de profil (routes/auth/profile.py) : modifier nom/prénom, changer
|
||||
de mot de passe, demander la suppression de son compte (avec mot de passe
|
||||
+ confirmation textuelle, voir templates/auth/profile.html)."""
|
||||
import pyotp
|
||||
|
||||
import auth
|
||||
import db
|
||||
|
||||
from tests.test_auth import _register, _confirm_2fa, _cleanup_project, anon_client # noqa: F401
|
||||
|
||||
|
||||
def test_profile_page_shows_current_user_info(anon_client):
|
||||
_register(anon_client, "profileinfo@example.com", prenom="Ada", nom="Lovelace")
|
||||
_confirm_2fa(anon_client)
|
||||
try:
|
||||
html = anon_client.get("/profile").get_data(as_text=True)
|
||||
assert "profileinfo@example.com" in html
|
||||
assert 'value="Ada"' in html
|
||||
assert 'value="Lovelace"' in html
|
||||
finally:
|
||||
_cleanup_project("profileinfo@example.com")
|
||||
|
||||
|
||||
def test_update_name(anon_client):
|
||||
_register(anon_client, "updatename@example.com", prenom="Old", nom="Name")
|
||||
_confirm_2fa(anon_client)
|
||||
try:
|
||||
resp = anon_client.post("/profile/update-name", data={"prenom": "New", "nom": "Name2"})
|
||||
assert "mises à jour" in resp.get_data(as_text=True)
|
||||
user = auth.get_user_by_email("updatename@example.com")
|
||||
assert user["prenom"] == "New"
|
||||
assert user["nom"] == "Name2"
|
||||
finally:
|
||||
_cleanup_project("updatename@example.com")
|
||||
|
||||
|
||||
def test_update_name_requires_both_fields(anon_client):
|
||||
_register(anon_client, "updatenameblank@example.com")
|
||||
_confirm_2fa(anon_client)
|
||||
try:
|
||||
resp = anon_client.post("/profile/update-name", data={"prenom": "", "nom": "Test"})
|
||||
assert "requis" in resp.get_data(as_text=True)
|
||||
finally:
|
||||
_cleanup_project("updatenameblank@example.com")
|
||||
|
||||
|
||||
def test_change_password_requires_correct_current_password(anon_client):
|
||||
_register(anon_client, "changepwwrong@example.com")
|
||||
_confirm_2fa(anon_client)
|
||||
try:
|
||||
resp = anon_client.post("/profile/change-password", data={
|
||||
"current_password": "wrong", "new_password": "Nouveau$ecret1", "new_password_confirm": "Nouveau$ecret1",
|
||||
})
|
||||
assert "incorrect" in resp.get_data(as_text=True)
|
||||
finally:
|
||||
_cleanup_project("changepwwrong@example.com")
|
||||
|
||||
|
||||
def test_change_password_rejects_mismatched_confirmation(anon_client):
|
||||
_register(anon_client, "changepwmismatch@example.com")
|
||||
_confirm_2fa(anon_client)
|
||||
try:
|
||||
resp = anon_client.post("/profile/change-password", data={
|
||||
"current_password": "Sup3r$ecret!", "new_password": "Nouveau$ecret1", "new_password_confirm": "Autre$ecret2",
|
||||
})
|
||||
assert "ne correspondent pas" in resp.get_data(as_text=True)
|
||||
finally:
|
||||
_cleanup_project("changepwmismatch@example.com")
|
||||
|
||||
|
||||
def test_change_password_rejects_a_weak_new_password(anon_client):
|
||||
_register(anon_client, "changepwweak@example.com")
|
||||
_confirm_2fa(anon_client)
|
||||
try:
|
||||
resp = anon_client.post("/profile/change-password", data={
|
||||
"current_password": "Sup3r$ecret!", "new_password": "faible", "new_password_confirm": "faible",
|
||||
})
|
||||
assert "faible" in resp.get_data(as_text=True)
|
||||
finally:
|
||||
_cleanup_project("changepwweak@example.com")
|
||||
|
||||
|
||||
def test_change_password_success_lets_login_with_new_password(anon_client):
|
||||
_register(anon_client, "changepwok@example.com")
|
||||
_confirm_2fa(anon_client)
|
||||
try:
|
||||
resp = anon_client.post("/profile/change-password", data={
|
||||
"current_password": "Sup3r$ecret!", "new_password": "Nouveau$ecret1", "new_password_confirm": "Nouveau$ecret1",
|
||||
})
|
||||
assert "modifié" in resp.get_data(as_text=True)
|
||||
|
||||
anon_client.post("/logout")
|
||||
resp = anon_client.post("/login", data={"email": "changepwok@example.com", "password": "Nouveau$ecret1"}, follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
assert resp.headers["Location"] == "/login/2fa"
|
||||
finally:
|
||||
_cleanup_project("changepwok@example.com")
|
||||
|
||||
|
||||
def test_delete_account_requires_correct_password(anon_client):
|
||||
_register(anon_client, "deletewrongpw@example.com")
|
||||
_confirm_2fa(anon_client)
|
||||
try:
|
||||
resp = anon_client.post("/profile/delete", data={"current_password": "wrong", "confirm_text": "SUPPRIMER"})
|
||||
assert "incorrect" in resp.get_data(as_text=True)
|
||||
assert auth.get_user_by_email("deletewrongpw@example.com") is not None
|
||||
finally:
|
||||
_cleanup_project("deletewrongpw@example.com")
|
||||
|
||||
|
||||
def test_delete_account_requires_the_confirmation_phrase(anon_client):
|
||||
_register(anon_client, "deletenoconfirm@example.com")
|
||||
_confirm_2fa(anon_client)
|
||||
try:
|
||||
resp = anon_client.post("/profile/delete", data={"current_password": "Sup3r$ecret!", "confirm_text": "oups"})
|
||||
assert "annulée" in resp.get_data(as_text=True)
|
||||
assert auth.get_user_by_email("deletenoconfirm@example.com") is not None
|
||||
finally:
|
||||
_cleanup_project("deletenoconfirm@example.com")
|
||||
|
||||
|
||||
def test_delete_account_removes_user_and_their_project(anon_client):
|
||||
_register(anon_client, "deleteforreal@example.com")
|
||||
_confirm_2fa(anon_client)
|
||||
user = auth.get_user_by_email("deleteforreal@example.com")
|
||||
slug = user["project_slug"]
|
||||
import os
|
||||
assert os.path.isdir(db.game_dir(slug))
|
||||
|
||||
resp = anon_client.post("/profile/delete", data={"current_password": "Sup3r$ecret!", "confirm_text": "SUPPRIMER"}, follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
assert resp.headers["Location"] == "/login"
|
||||
assert auth.get_user_by_email("deleteforreal@example.com") is None
|
||||
assert not os.path.isdir(db.game_dir(slug))
|
||||
|
||||
|
||||
def test_the_only_admin_account_cannot_be_deleted(client):
|
||||
"""`client` (conftest.py) est déjà connecté en tant qu'admin unique de
|
||||
la session de tests."""
|
||||
resp = client.post("/profile/delete", data={"current_password": "Test1234!", "confirm_text": "SUPPRIMER"})
|
||||
assert "administrateur" in resp.get_data(as_text=True)
|
||||
import tests.conftest as conftest_module
|
||||
assert auth.get_user_by_id(conftest_module._TEST_ADMIN_ID) is not None
|
||||
Reference in New Issue
Block a user