diff --git a/auth/__init__.py b/auth/__init__.py index d8bf8e69..abd5589b 100644 --- a/auth/__init__.py +++ b/auth/__init__.py @@ -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", ] diff --git a/auth/count_admins.py b/auth/count_admins.py new file mode 100644 index 00000000..43647438 --- /dev/null +++ b/auth/count_admins.py @@ -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 diff --git a/auth/delete_user.py b/auth/delete_user.py new file mode 100644 index 00000000..85801ea4 --- /dev/null +++ b/auth/delete_user.py @@ -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() diff --git a/auth/update_profile.py b/auth/update_profile.py new file mode 100644 index 00000000..ad9619da --- /dev/null +++ b/auth/update_profile.py @@ -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() diff --git a/routes/auth/__init__.py b/routes/auth/__init__.py index 971b94a5..1c6a0c1a 100644 --- a/routes/auth/__init__.py +++ b/routes/auth/__init__.py @@ -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 diff --git a/routes/auth/profile.py b/routes/auth/profile.py new file mode 100644 index 00000000..223bef36 --- /dev/null +++ b/routes/auth/profile.py @@ -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")) diff --git a/templates/auth/profile.html b/templates/auth/profile.html new file mode 100644 index 00000000..0869d285 --- /dev/null +++ b/templates/auth/profile.html @@ -0,0 +1,121 @@ +{% extends "base.html" %} +{% block title %}Mon profil — Forge Engine{% endblock %} +{% block content %} +
+
+

Mon profil

+ + {% if error %} +
{{ error }}
+ {% endif %} + {% if success %} +
{{ success }}
+ {% endif %} + +
+

Informations

+

Adresse email : {{ user.email }} ({{ 'compte administrateur' if user.role == 'admin' else 'compte utilisateur' }})

+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+

Changer de mot de passe

+
+
+ +
+
+
+ +
+
+
+
+
    +
  • Au moins 8 caractères
  • +
  • Une majuscule
  • +
  • Une minuscule
  • +
  • Un chiffre
  • +
  • Un caractère spécial (ex. ! ? # -)
  • +
+
+
+ +
+
+
+ +
+
+
+ +
+

Supprimer mon compte

+

+ {% 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 %} +

+
+
+ +
+
+
+ +
+
+
+ +
+
+
+
+
+ + +{% endblock %} diff --git a/templates/base.html b/templates/base.html index cfbd9168..63130863 100644 --- a/templates/base.html +++ b/templates/base.html @@ -47,7 +47,7 @@ {% if g.get('current_user') %} {% endif %} diff --git a/tests/test_profile.py b/tests/test_profile.py new file mode 100644 index 00000000..85cb3b16 --- /dev/null +++ b/tests/test_profile.py @@ -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