commit 2acccb257402377152010004b3b3a66d17e2b48e
Author: Lyanis Souidi <lyanis.souidi@u-pec.fr>
Date:   Thu Mar 13 22:24:53 2025 +0100

    Initial commit

diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..9b1bff2
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,14 @@
+SECRET_KEY="change_me"
+LDAP_SERVER="ldaps://eru.iut-fbleau.fr"
+LDAP_TLS_VERIFY=True
+LDAP_USER=""
+LDAP_PASSWORD=""
+LDAP_BASE_DN="DC=arda,DC=lan"
+LDAP_SEARCH_FILTER="(&(sAMAccountName=%s)(objectClass=user)(!(objectClass=computer)))"
+LDAP_EMAIL_ATTRIBUTE="mail"
+DATABASE_URI="mariadb+mariadbconnector://user:password@gimli.iut-fbleau.fr:3306/db"
+MAIL_SERVER=""
+MAIL_PORT=25
+MAIL_DEFAULT_SENDER="Mot de passe IUTF <mdp@iut-fbleau.fr>"
+MAIL_USE_TLS=True
+MAIL_USE_SSL=False
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e6096a4
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,12 @@
+__pycache__/
+venv/
+
+package-lock.json
+node_modules/
+
+.idea/
+.vscode/
+
+.env
+
+app/static/style.css
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..99b0dd6
--- /dev/null
+++ b/README.md
@@ -0,0 +1,17 @@
+# Mot de passe IUTF
+
+Self-service permettant de réinitialiser son mot de passe en ligne.
+
+## Générer le CSS
+```bash
+npm install
+npx @tailwindcss/cli -o ./app/static/style.css
+```
+
+## Dev
+```bash
+python -m venv venv
+source ./venv/bin/activate
+pip install -r requirements.txt
+flask run --debug
+```
\ No newline at end of file
diff --git a/app/__init__.py b/app/__init__.py
new file mode 100644
index 0000000..5b6eb77
--- /dev/null
+++ b/app/__init__.py
@@ -0,0 +1,196 @@
+from datetime import datetime
+from flask import Flask, Blueprint, flash, render_template, request, url_for
+import ldap, ldap.filter, os
+from app.exceptions import LoginNotFoundException, MultipleUsersFoundForLoginException
+from flask_migrate import Migrate
+from flask_mail import Mail, Message
+from app.models import db, MagicToken
+from dotenv import load_dotenv
+load_dotenv()
+
+f_app = Flask(__name__)
+f_app.secret_key = os.getenv("SECRET_KEY")
+
+bp = Blueprint('mdp', __name__)
+
+f_app.config["SQLALCHEMY_DATABASE_URI"] = os.getenv("DATABASE_URI")
+db.init_app(f_app)
+migrate = Migrate(f_app, db)
+
+def to_bool(value, default: bool = False):
+    """
+    Convert a value to a boolean
+    """
+    if value is None:
+        return default
+    if isinstance(value, str):
+        if value.lower() in ['true', '1', 't', 'y', 'yes']:
+            return True
+        if value.lower() in ['false', '0', 'f', 'n', 'no']:
+            return False
+    if value:
+        return True
+    return default
+
+f_app.config['MAIL_SERVER']= os.getenv("MAIL_SERVER")
+f_app.config['MAIL_PORT'] = int(os.getenv("MAIL_PORT"))
+f_app.config['MAIL_USERNAME'] = os.getenv("MAIL_USERNAME")
+f_app.config['MAIL_PASSWORD'] = os.getenv("MAIL_PASSWORD")
+f_app.config['MAIL_USE_TLS'] = to_bool(os.getenv("MAIL_USE_TLS"))
+f_app.config['MAIL_USE_SSL'] = to_bool(os.getenv("MAIL_USE_SSL"))
+
+mail = Mail(f_app)
+
+@bp.route('/')
+def home():
+    return render_template('pages/home.html.j2')
+
+@bp.route('/identifiant')
+def lost_login():
+    return render_template('pages/lost_login.html.j2')
+
+@bp.route('/mdp', methods=("GET", "POST"))
+def password_link_form():
+    if request.method == 'GET':
+        return render_template('pages/password_link_form.html.j2')
+    
+    login = request.form['login']
+    error = None
+
+    if not login:
+        error = "Le champ 'identifiant' ne peut pas être vide."
+    else:
+        try:
+            user_dn, user_emails = ldap_get_dn_and_emails(login)
+            if len(user_emails) == 0:
+                error = "Aucune adresse email trouvée pour cet identifiant. Rapprochez-vous du secrétariat de votre formation pour mettre à jour votre dossier ou " + render_template("partials/contact_link.html.j2") + "."
+        except LoginNotFoundException:
+            error = "Aucun utilisateur trouvé avec cet identifiant.<br>Si vous avez perdu votre identifiant, <a  class=\"text-blue-600 hover:underline\" href=\"" + url_for("mdp.lost_login") +  "\">cliquez ici</a>."
+        except MultipleUsersFoundForLoginException:
+            error = "Plusieurs utilisateurs trouvés avec cet identifiant (cette erreur ne devrait jamais s'afficher, " + render_template("partials/contact_link.html.j2") + ")."
+        except (ldap.SERVER_DOWN, ldap.BUSY, ldap.CONNECT_ERROR, ldap.UNAVAILABLE):
+            error = "La connexion au serveur LDAP a échouée."
+        except ldap.TIMEOUT:
+            error = "Le serveur LDAP a mis trop de temps à répondre."
+        except ldap.LDAPError:
+            error = "Une erreur est survenue lors de la connexion à l'annuaire LDAP."
+
+    if error is not None:
+        flash(error)
+        return render_template('pages/password_link_form.html.j2')
+    
+    try:
+        magictoken = MagicToken.generate(user_emails, user_dn)
+        db.session.add(magictoken)
+        db.session.commit()
+    except:
+        flash("Une erreur est survenue lors de la création du lien.")
+        return render_template('pages/password_link_form.html.j2')
+
+    try:
+        send_email(user_emails, magictoken)
+        anonymized_emails = [anonymize_email(email) for email in user_emails]
+    except:
+        flash("Une erreur est survenue lors de l'envoi du mail.")
+        return render_template('pages/password_link_form.html.j2')
+
+    return render_template('pages/password_link_sent.html.j2', emails=anonymized_emails, is_student=True)
+
+@bp.route('/mdp/<token>', methods=("GET", "POST"))
+def password_reset_form(token: str = None):
+    magictoken = MagicToken.query.filter_by(token=token).first()
+    if magictoken is None:
+        return render_template('pages/invalid_password_token.html.j2')
+    if magictoken.used:
+        return render_template('pages/invalid_password_token.html.j2', used=True)
+    if magictoken.expiration_date < datetime.now():
+        return render_template('pages/invalid_password_token.html.j2', expired=True)
+    
+    if request.method == 'GET':
+        return render_template('pages/password_reset_form.html.j2')
+    
+    newpassword = request.form['newpasswd']
+    error = None
+
+    if not newpassword:
+        error = "Le champ 'mot de passe' ne peut pas être vide."
+    
+    if error is not None:
+        flash(error)
+        return render_template('pages/password_reset_form.html.j2')
+    
+    try:
+        ldap_set_password(magictoken.user_dn, newpassword)
+    except (ldap.SERVER_DOWN, ldap.BUSY, ldap.CONNECT_ERROR, ldap.UNAVAILABLE):
+            error = "La connexion au serveur LDAP a échouée."
+    except ldap.TIMEOUT:
+        error = "Le serveur LDAP a mis trop de temps à répondre."
+    except ldap.LDAPError as err:
+        error = "Erreur LDAP. " + str(err)
+    
+    if error is not None:
+        flash(error)
+        return render_template('pages/password_reset_form.html.j2')
+    
+    magictoken.used = True
+    db.session.commit()
+
+    return render_template('pages/password_reset_success.html.j2')
+
+
+def send_email(emails: list[str], magictoken: MagicToken):
+    """
+    Send the magic token to the list of emails
+    """
+    msg = Message(subject='Initialisation du mot de passe', sender=os.getenv("MAIL_DEFAULT_SENDER"), recipients=emails)
+    msg.body = render_template('emails/reset-link.txt.j2', magictoken=magictoken)
+    msg.html = render_template('emails/reset-link.html.j2', magictoken=magictoken)
+    mail.send(msg)
+
+def anonymize_email(email: str):
+    """
+    Anonymize an email address before displaying it on the website
+    """
+    email_parts = email.split('@')
+    email_parts[0] = email_parts[0][:3] + '*' * (len(email_parts[0]) - 3)
+    known_domains = ['etu.u-pec.fr', 'u-pec.fr', 'iutsf.org', 'iut-fbleau.fr']
+    if email_parts[1].lower() not in known_domains:
+        email_parts[1] = email_parts[1].rsplit('.', 1)
+        email_parts[1][0] = email_parts[1][0][:3] + '*' * (len(email_parts[1][0]) - 3)
+        email_parts[1] = '.'.join(email_parts[1])
+
+    return '@'.join(email_parts)
+
+def ldap_get_dn_and_emails(login: str):
+    """
+    Get the user's distinguished name and email addresses from the LDAP server
+    """
+    if not to_bool(os.getenv("LDAP_TLS_VERIFY")):
+        ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
+    conn = ldap.initialize(os.getenv("LDAP_SERVER"))
+    conn.simple_bind_s(os.getenv("LDAP_USER"), os.getenv("LDAP_PASSWORD"))
+    result = conn.search_s(
+        os.getenv("LDAP_BASE_DN"),
+        ldap.SCOPE_SUBTREE,
+        os.getenv("LDAP_SEARCH_FILTER") % ldap.filter.escape_filter_chars(login),
+        [os.getenv("LDAP_EMAIL_ATTRIBUTE")]
+    )
+    if len(result) == 0:
+        raise LoginNotFoundException()
+    if len(result) > 1:
+        raise MultipleUsersFoundForLoginException()
+    return result[0][0], [entry[1][os.getenv("LDAP_EMAIL_ATTRIBUTE")][0].decode('utf-8') for entry in result]
+
+def ldap_set_password(user_dn: str, newpassword: str):
+    """
+    Set the user's password in LDAP
+    """
+    if not to_bool(os.getenv("LDAP_TLS_VERIFY")):
+        ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
+    conn = ldap.initialize(os.getenv("LDAP_SERVER"))
+    conn.simple_bind_s(os.getenv("LDAP_USER"), os.getenv("LDAP_PASSWORD"))
+    # https://serverfault.com/questions/937330/update-ad-password-from-python-ldap-code-insuff-access-rights
+    conn.modify_s(user_dn, [(ldap.MOD_REPLACE, 'unicodePwd', ['"{0}"'.format(newpassword).encode('utf-16-le')])])
+
+
+f_app.register_blueprint(bp)
\ No newline at end of file
diff --git a/app/exceptions.py b/app/exceptions.py
new file mode 100644
index 0000000..d5414b5
--- /dev/null
+++ b/app/exceptions.py
@@ -0,0 +1,5 @@
+class LoginNotFoundException(Exception):
+    pass
+
+class MultipleUsersFoundForLoginException(Exception):
+    pass
diff --git a/app/models.py b/app/models.py
new file mode 100644
index 0000000..2e513dc
--- /dev/null
+++ b/app/models.py
@@ -0,0 +1,39 @@
+from datetime import datetime, timedelta
+import secrets
+from sqlalchemy.orm import DeclarativeBase
+from sqlalchemy.sql import func
+from typing import Self
+from flask import url_for
+
+from flask_sqlalchemy import SQLAlchemy
+
+db = SQLAlchemy()
+
+class Base(DeclarativeBase):
+    pass
+
+class MagicToken(db.Model):
+    id = db.Column(db.Integer, primary_key=True, autoincrement=True)
+    token = db.Column(db.String(length=255, collation="utf8mb4_bin"), nullable=False)
+    email = db.Column(db.String(length=255), nullable=False)
+    expiration_date = db.Column(db.DateTime, nullable=False, server_default=func.now())
+    used = db.Column(db.Boolean, nullable=False, default=False)
+    user_dn = db.Column(db.String(length=255), nullable=False)
+
+    def __init__(self, token, email, expiration_date, user_dn):
+        self.token = token
+        self.email = email
+        self.expiration_date = expiration_date
+        self.user_dn = user_dn
+
+    def url(self) -> str:
+        return "https://mdp.iut-fbleau.fr" + url_for('mdp.password_reset_form', token=self.token)
+
+    @staticmethod
+    def generate(emails: list[str], user_dn: str) -> Self:
+        token = secrets.token_urlsafe(128)
+        expiration_date = datetime.now() + timedelta(minutes=15)
+        return MagicToken(token, ",".join(emails), expiration_date, user_dn)
+
+    def __repr__(self):
+        return f"<MagicToken {self.id} {self.email} {self.expiration_date} {self.used} {self.user_dn}>"
\ No newline at end of file
diff --git a/app/templates/emails/base.html.j2 b/app/templates/emails/base.html.j2
new file mode 100644
index 0000000..ebe93d8
--- /dev/null
+++ b/app/templates/emails/base.html.j2
@@ -0,0 +1,33 @@
+<!DOCTYPE html>
+<html lang="fr">
+    <head>
+        {% if title is defined and title.strip() %}
+        <title>Mot de passe IUTF - {% block title %}{% endblock %}</title>
+        {% else %}
+        <title>Mot de passe IUTF</title>
+        {% endif %}
+        <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
+        <meta name="viewport" content="width=device-width, initial-scale=1.0">
+        <meta name="format-detection" content="telephone=no">
+        <meta name="format-detection" content="date=no">
+        <meta name="format-detection" content="address=no">
+        <meta name="format-detection" content="email=no">
+    </head>
+    <body>
+      <table role="presentation">
+          <tr>
+              <img src="https://emails-assets.s3.iut-fbleau.fr/logo/upec-iutsf.png" alt="UPEC | IUT Sénart-Fontainebleau" width="200" height="100">
+          </tr>
+          <tr>
+            <td>
+              <nav>
+                <h1>Mot de passe IUTF</h1>
+              </nav>
+              <section class="content">
+                {% block content %}{% endblock %}
+              </section>
+            </td>
+          </tr>
+      </table>
+    </body>
+</html>
\ No newline at end of file
diff --git a/app/templates/emails/base.txt.j2 b/app/templates/emails/base.txt.j2
new file mode 100644
index 0000000..372c1bb
--- /dev/null
+++ b/app/templates/emails/base.txt.j2
@@ -0,0 +1 @@
+{% block content %}{% endblock %}
\ No newline at end of file
diff --git a/app/templates/emails/reset-link.html.j2 b/app/templates/emails/reset-link.html.j2
new file mode 100644
index 0000000..52eda7d
--- /dev/null
+++ b/app/templates/emails/reset-link.html.j2
@@ -0,0 +1,19 @@
+{% extends 'emails/base.html.j2' %}
+
+{% block title %}Email de réinitialisation du mot de passe{% endblock %}
+
+{% block content %}
+<p>
+Bonjour,<br>
+<br>
+Nous avons reçu une demande de création d'un nouveau mot de passe associé à cette adresse email.<br>
+Ce mot de passe vous permet d'accéder aux services numériques du campus de l'IUT de Fontainebleau (Wifi IUTF, ordinateurs du campus et services en ligne).<br>
+<br>
+Si vous êtes à l'origine de cette demande, veuillez ouvrir ce lien dans votre navigateur : <a href="{{ magictoken.url() }}">{{ magictoken.url() }}</a><br>
+La durée de validité de ce lien est de 15 minutes.<br>
+<br>
+Si vous n'êtes pas l'auteur de cette demande, vous pouvez ignorer cet e-mail.<br>
+<br>
+A très bientôt.
+</p>
+{% endblock %}
\ No newline at end of file
diff --git a/app/templates/emails/reset-link.txt.j2 b/app/templates/emails/reset-link.txt.j2
new file mode 100644
index 0000000..134978d
--- /dev/null
+++ b/app/templates/emails/reset-link.txt.j2
@@ -0,0 +1,16 @@
+{% extends 'emails/base.txt.j2' %}
+
+
+{% block content %}
+Bonjour,
+
+Nous avons reçu une demande de création d'un nouveau mot de passe associé à cette adresse email.
+Ce mot de passe vous permet d'accéder aux services numériques du campus de l'IUT de Fontainebleau (Wifi IUTF, ordinateurs du campus et services en ligne).
+
+Si vous êtes à l'origine de cette demande, veuillez ouvrir ce lien dans votre navigateur : {{ magictoken.url() }}
+La durée de validité de ce lien est de 15 minutes.
+
+Si vous n'êtes pas l'auteur de cette demande, vous pouvez ignorer cet e-mail.
+
+A très bientôt.
+{% endblock %}
\ No newline at end of file
diff --git a/app/templates/pages/base.html.j2 b/app/templates/pages/base.html.j2
new file mode 100644
index 0000000..231bc06
--- /dev/null
+++ b/app/templates/pages/base.html.j2
@@ -0,0 +1,37 @@
+<!DOCTYPE html>
+<html lang="fr">
+    <head>
+        {% if title is defined %}
+        <title>{{ title }} - Mot de passe IUTF</title>
+        {% else %}
+        <title>Mot de passe IUTF</title>
+        {% endif %}
+        <meta charset="UTF-8" />
+        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+        {% if config['DEBUG'] %}
+        <script src="https://unpkg.com/@tailwindcss/browser@4"></script>
+        {% else %}
+        <link href="{{ url_for('static', filename='style.css') }}" rel="stylesheet">
+        {% endif %}
+    </head>
+    <body>
+        <div class="max-w-2xl mx-auto">
+          <header class="flex w-full justify-center items-center">
+            <a class="w-1/2" href="{{ url_for('mdp.home') }}">
+              <img src="https://public-assets.s3.iut-fbleau.fr/img/logo/upec-iutsf.png" alt="UPEC - IUT Sénart-Fontainebleau">
+            </a>
+            <div class="w-full text-center font-bold">
+              <h1 class="text-3xl"><a href="{{ url_for('mdp.home') }}">Mot de passe</a></h1>
+              {% if title is defined %}
+              <h2 class="text-xl">{{ title }}</h2>
+              {% endif %}</div>
+          </header>
+          <main class="p-5">
+            {% for message in get_flashed_messages() %}
+              <div class="border rounded p-2 bg-yellow-100 my-5">{{ message }}</div>
+            {% endfor %}
+            {% block content %}{% endblock %}
+          </main>
+        </div>
+    </body>
+</html>
\ No newline at end of file
diff --git a/app/templates/pages/home.html.j2 b/app/templates/pages/home.html.j2
new file mode 100644
index 0000000..63511d4
--- /dev/null
+++ b/app/templates/pages/home.html.j2
@@ -0,0 +1,30 @@
+{% extends 'pages/base.html.j2' %}
+
+{% block content %}
+  <div>
+    <h2 class="text-xl py-5 font-bold">Gérez votre compte numérique en toute sécurité !</h2>
+    <p>
+      Pour bénéficier de l'ensemble des services numériques offerts par le campus de l'IUT de Fontainebleau, vous devez sécuriser votre compte numérique.
+      En sécurisant votre compte, vous acceptez sans réserve les conditions d'utilisation définies dans la <a class="text-blue-600 hover:underline" href="https://iut-fbleau.fr/charte">charte informatique</a> de l'IUT.
+    </p>
+
+    <div class="border rounded p-2 bg-yellow-100 my-5">
+      <p>
+      Ce compte est <span class="underline">dédié aux services numériques de l'IUT de Fontainebleau</span> (salles TP informatique, Wifi IUTF,
+      <a class="text-blue-600 hover:underline" target="_blank" href="https://iut-fbleau.fr">services en ligne sur les sites iut-fbleau.fr</a>, etc).
+      </p>
+      <p>
+      Pour accéder aux services de l'université (<a class="text-blue-600 hover:underline" target="_blank" href="http://outlook.office.com/?realm=u-pec.fr">Messagerie étudiante</a>,
+      <a class="text-blue-600 hover:underline" target="_blank" href="https://eprel.u-pec.fr/">Eprel</a>,
+      <a class="text-blue-600 hover:underline" target="_blank" href="https://www.u-pec.fr/fr/etudiant-e/services-numeriques/wi-fi-etudiant">Eduroam</a>,
+      <a class="text-blue-600 hover:underline" target="_blank" href="https://www.u-pec.fr/fr/etudiant-e/services-numeriques">services en ligne sur les sites u-pec.fr</a> etc.),
+      vous devez utiliser activer votre compte UPEC sur <a class="text-blue-600 hover:underline" target="_blank" href="https://sesame.u-pec.fr">sesame.u-pec.fr</a>.
+      </p>
+    </div>
+
+    <div class="flex justify-center gap-5">
+        <a class="text-white bg-red-600 hover:bg-red-700 rounded-lg font-medium text-sm px-5 py-3" href="{{ url_for('mdp.lost_login') }}">J'ai perdu mon identifiant</a>
+        <a class="text-white bg-red-600 hover:bg-red-700 rounded-lg font-medium text-sm px-5 py-3" href="{{ url_for('mdp.password_link_form') }}">J'ai perdu mon mot de passe</a>
+    </div>
+  </div>
+{% endblock %}
\ No newline at end of file
diff --git a/app/templates/pages/invalid_password_token.html.j2 b/app/templates/pages/invalid_password_token.html.j2
new file mode 100644
index 0000000..f85753f
--- /dev/null
+++ b/app/templates/pages/invalid_password_token.html.j2
@@ -0,0 +1,12 @@
+{% extends 'pages/base.html.j2' %}
+
+{% set title = "Réinitialiser mon mot de passe" %}
+
+{% block content %}
+  <div>
+    <p class="py-2">
+      Ce lien de réinitialisation de mot de passe {% if expired %}a expiré (le délai de 15 minutes est écoulées){% elif used %}a déjà été utilisé{% else %}est invalide{% endif %}.
+    </p>
+    <p class="py-2">Pour demander un nouveau lien de réinitialisation de mot de passe, <a href="{{ url_for('mdp.password_link_form') }}">cliquez ici</a>.</p>
+  </div>
+{% endblock %}
\ No newline at end of file
diff --git a/app/templates/pages/lost_login.html.j2 b/app/templates/pages/lost_login.html.j2
new file mode 100644
index 0000000..1b3c107
--- /dev/null
+++ b/app/templates/pages/lost_login.html.j2
@@ -0,0 +1,17 @@
+{% extends 'pages/base.html.j2' %}
+
+{% set title = "Identifiant perdu" %}
+
+{% block content %}
+  <div>
+    <p class="py-2">
+      Votre identifiant pour accéder aux services numériques du campus de Fontainebleau est composée de votre nom de famille tronqué à 8 caractères.
+    </p>
+    <p class="py-2">
+      En cas d'homonyme, les lettres de votre prénom ou des chiffres sont utilisés pour différencier les identifiants mais le resultat est toujours tronqué à 8 caractères.
+    </p>
+    <p class="py-2">
+      Si vous avez perdu votre identifiant, {% include 'partials/contact_link.html.j2' %}.
+    </p>
+  </div>
+{% endblock %}
\ No newline at end of file
diff --git a/app/templates/pages/password_link_form.html.j2 b/app/templates/pages/password_link_form.html.j2
new file mode 100644
index 0000000..7b8bb2f
--- /dev/null
+++ b/app/templates/pages/password_link_form.html.j2
@@ -0,0 +1,18 @@
+{% extends 'pages/base.html.j2' %}
+
+{% set title = "Réinitialiser mon mot de passe" %}
+
+{% block content %}
+  <div>
+    <p class="mb-5">Remplissez le formulaire ci-dessous pour retrouver votre identifiant pour les services numérique du campus de Fontainebleau</p>
+
+    <form method="POST">
+      <div class="mb-5">
+        <label class="block mb-2 text-sm font-medium text-gray-900" for="login">Identifiant</label>
+        <input type="text" name="login" id="login" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" required>
+        <a class="text-blue-600 hover:underline text-sm" href="{{ url_for('mdp.lost_login') }}">J'ai perdu mon identifiant</a>
+      </div>
+      <button class="text-white bg-red-600 hover:bg-red-700 font-medium rounded-lg text-sm w-full sm:w-auto px-5 py-2.5 text-center" type="submit">Continuer</button>
+    </form>
+  </div>
+{% endblock %}
\ No newline at end of file
diff --git a/app/templates/pages/password_link_sent.html.j2 b/app/templates/pages/password_link_sent.html.j2
new file mode 100644
index 0000000..0a7b89f
--- /dev/null
+++ b/app/templates/pages/password_link_sent.html.j2
@@ -0,0 +1,20 @@
+{% extends 'pages/base.html.j2' %}
+
+{% set title = "Réinitialiser mon mot de passe" %}
+
+{% block content %}
+  <div>
+    <p class="py-2">Un lien pour réinitialiser votre mot de passe à été envoyé {% if emails|length > 1 %}aux addresses suivantes{% else %}à l'adresse suivante{% endif %} :</p>
+    <ul class="list-disc list-inside pb-2">
+      {% for email in emails %}
+        <li class="ps-5">{{ email }}</li>
+      {% endfor %}
+    </ul>
+    <p class="py-2">Si vous ne recevez pas l'email, vérifiez votre dossier de courrier indésirable.</p>
+    <p class="py-2">
+      Si vous ne reconnaissez pas {% if emails|length > 1 %}les addresses listées{% else %}l'adresse affichée{% endif %} ci-dessus,
+      rapprochez vous du secrétariat de votre formation pour mettre à jour votre dossier ou {% include 'partials/contact_link.html.j2' %}.
+    </p>
+    <p class="py-2">Ce lien est valable pour les 15 prochaines minutes uniquement !</p>
+  </div>
+{% endblock %}
\ No newline at end of file
diff --git a/app/templates/pages/password_reset_form.html.j2 b/app/templates/pages/password_reset_form.html.j2
new file mode 100644
index 0000000..9eaf8e1
--- /dev/null
+++ b/app/templates/pages/password_reset_form.html.j2
@@ -0,0 +1,17 @@
+{% extends 'pages/base.html.j2' %}
+
+{% set title = "Réinitialiser mon mot de passe" %}
+
+{% block content %}
+  <div>
+  <p class="mb-5">Définissez votre nouveau mot de passe</p>
+
+    <form method="POST">
+      <div class="mb-5">
+        <label class="block mb-2 text-sm font-medium text-gray-900" for="newpasswd">Nouveau mot de passe</label>
+        <input type="password" name="newpasswd" id="newpasswd" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" required>
+      </div>
+      <button class="text-white bg-red-600 hover:bg-red-700 font-medium rounded-lg text-sm w-full sm:w-auto px-5 py-2.5 text-center" type="submit">Enregistrer</button>
+    </form>
+  </div>
+{% endblock %}
\ No newline at end of file
diff --git a/app/templates/pages/password_reset_success.html.j2 b/app/templates/pages/password_reset_success.html.j2
new file mode 100644
index 0000000..ed49187
--- /dev/null
+++ b/app/templates/pages/password_reset_success.html.j2
@@ -0,0 +1,11 @@
+{% extends 'pages/base.html.j2' %}
+
+{% set title = "Réinitialiser mon mot de passe" %}
+
+{% block content %}
+  <div>
+    <p class="py-2">
+      Votre mot de passe a été modifié avec succès. Vous pouvez maintenant vous connecter avec votre nouveau mot de passe.
+    </p>
+  </div>
+{% endblock %}
\ No newline at end of file
diff --git a/app/templates/partials/contact_link.html.j2 b/app/templates/partials/contact_link.html.j2
new file mode 100644
index 0000000..e1c8e89
--- /dev/null
+++ b/app/templates/partials/contact_link.html.j2
@@ -0,0 +1 @@
+contactez <a class="text-blue-600 hover:underline" href="mailto:brouard@u-pec.fr">Régis Brouard</a>
\ No newline at end of file
diff --git a/migrations/README b/migrations/README
new file mode 100644
index 0000000..0e04844
--- /dev/null
+++ b/migrations/README
@@ -0,0 +1 @@
+Single-database configuration for Flask.
diff --git a/migrations/alembic.ini b/migrations/alembic.ini
new file mode 100644
index 0000000..ec9d45c
--- /dev/null
+++ b/migrations/alembic.ini
@@ -0,0 +1,50 @@
+# A generic, single database configuration.
+
+[alembic]
+# template used to generate migration files
+# file_template = %%(rev)s_%%(slug)s
+
+# set to 'true' to run the environment during
+# the 'revision' command, regardless of autogenerate
+# revision_environment = false
+
+
+# Logging configuration
+[loggers]
+keys = root,sqlalchemy,alembic,flask_migrate
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[logger_flask_migrate]
+level = INFO
+handlers =
+qualname = flask_migrate
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S
diff --git a/migrations/env.py b/migrations/env.py
new file mode 100644
index 0000000..4c97092
--- /dev/null
+++ b/migrations/env.py
@@ -0,0 +1,113 @@
+import logging
+from logging.config import fileConfig
+
+from flask import current_app
+
+from alembic import context
+
+# this is the Alembic Config object, which provides
+# access to the values within the .ini file in use.
+config = context.config
+
+# Interpret the config file for Python logging.
+# This line sets up loggers basically.
+fileConfig(config.config_file_name)
+logger = logging.getLogger('alembic.env')
+
+
+def get_engine():
+    try:
+        # this works with Flask-SQLAlchemy<3 and Alchemical
+        return current_app.extensions['migrate'].db.get_engine()
+    except (TypeError, AttributeError):
+        # this works with Flask-SQLAlchemy>=3
+        return current_app.extensions['migrate'].db.engine
+
+
+def get_engine_url():
+    try:
+        return get_engine().url.render_as_string(hide_password=False).replace(
+            '%', '%%')
+    except AttributeError:
+        return str(get_engine().url).replace('%', '%%')
+
+
+# add your model's MetaData object here
+# for 'autogenerate' support
+# from myapp import mymodel
+# target_metadata = mymodel.Base.metadata
+config.set_main_option('sqlalchemy.url', get_engine_url())
+target_db = current_app.extensions['migrate'].db
+
+# other values from the config, defined by the needs of env.py,
+# can be acquired:
+# my_important_option = config.get_main_option("my_important_option")
+# ... etc.
+
+
+def get_metadata():
+    if hasattr(target_db, 'metadatas'):
+        return target_db.metadatas[None]
+    return target_db.metadata
+
+
+def run_migrations_offline():
+    """Run migrations in 'offline' mode.
+
+    This configures the context with just a URL
+    and not an Engine, though an Engine is acceptable
+    here as well.  By skipping the Engine creation
+    we don't even need a DBAPI to be available.
+
+    Calls to context.execute() here emit the given string to the
+    script output.
+
+    """
+    url = config.get_main_option("sqlalchemy.url")
+    context.configure(
+        url=url, target_metadata=get_metadata(), literal_binds=True
+    )
+
+    with context.begin_transaction():
+        context.run_migrations()
+
+
+def run_migrations_online():
+    """Run migrations in 'online' mode.
+
+    In this scenario we need to create an Engine
+    and associate a connection with the context.
+
+    """
+
+    # this callback is used to prevent an auto-migration from being generated
+    # when there are no changes to the schema
+    # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
+    def process_revision_directives(context, revision, directives):
+        if getattr(config.cmd_opts, 'autogenerate', False):
+            script = directives[0]
+            if script.upgrade_ops.is_empty():
+                directives[:] = []
+                logger.info('No changes in schema detected.')
+
+    conf_args = current_app.extensions['migrate'].configure_args
+    if conf_args.get("process_revision_directives") is None:
+        conf_args["process_revision_directives"] = process_revision_directives
+
+    connectable = get_engine()
+
+    with connectable.connect() as connection:
+        context.configure(
+            connection=connection,
+            target_metadata=get_metadata(),
+            **conf_args
+        )
+
+        with context.begin_transaction():
+            context.run_migrations()
+
+
+if context.is_offline_mode():
+    run_migrations_offline()
+else:
+    run_migrations_online()
diff --git a/migrations/script.py.mako b/migrations/script.py.mako
new file mode 100644
index 0000000..2c01563
--- /dev/null
+++ b/migrations/script.py.mako
@@ -0,0 +1,24 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+# revision identifiers, used by Alembic.
+revision = ${repr(up_revision)}
+down_revision = ${repr(down_revision)}
+branch_labels = ${repr(branch_labels)}
+depends_on = ${repr(depends_on)}
+
+
+def upgrade():
+    ${upgrades if upgrades else "pass"}
+
+
+def downgrade():
+    ${downgrades if downgrades else "pass"}
diff --git a/migrations/versions/bf0456b014c1_initial_migration.py b/migrations/versions/bf0456b014c1_initial_migration.py
new file mode 100644
index 0000000..10200ab
--- /dev/null
+++ b/migrations/versions/bf0456b014c1_initial_migration.py
@@ -0,0 +1,36 @@
+"""Initial migration.
+
+Revision ID: bf0456b014c1
+Revises: 
+Create Date: 2025-02-26 11:21:42.535143
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'bf0456b014c1'
+down_revision = None
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.create_table('magic_token',
+    sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+    sa.Column('token', sa.String(length=255, collation='utf8mb4_bin'), nullable=False),
+    sa.Column('email', sa.String(length=255), nullable=False),
+    sa.Column('expiration_date', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
+    sa.Column('used', sa.Boolean(), nullable=False),
+    sa.Column('user_dn', sa.String(length=255), nullable=False),
+    sa.PrimaryKeyConstraint('id')
+    )
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_table('magic_token')
+    # ### end Alembic commands ###
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..21e3289
--- /dev/null
+++ b/package.json
@@ -0,0 +1,6 @@
+{
+  "dependencies": {
+    "@tailwindcss/cli": "^4.0.9",
+    "tailwindcss": "^4.0.9"
+  }
+}
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..9cf3a11
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,21 @@
+alembic==1.14.1
+blinker==1.9.0
+click==8.1.8
+Flask==3.1.0
+Flask-Mail==0.10.0
+Flask-Migrate==4.1.0
+Flask-SQLAlchemy==3.1.1
+greenlet==3.1.1
+itsdangerous==2.2.0
+Jinja2==3.1.5
+Mako==1.3.9
+mariadb==1.1.12
+MarkupSafe==3.0.2
+packaging==24.2
+pyasn1==0.6.1
+pyasn1_modules==0.4.1
+python-dotenv==1.0.1
+python-ldap==3.4.4
+SQLAlchemy==2.0.38
+typing_extensions==4.12.2
+Werkzeug==3.1.3
\ No newline at end of file