From 2acccb257402377152010004b3b3a66d17e2b48e Mon Sep 17 00:00:00 2001 From: Lyanis Souidi <lyanis.souidi@u-pec.fr> Date: Thu, 13 Mar 2025 22:24:53 +0100 Subject: [PATCH] Initial commit --- .env.example | 14 ++ .gitignore | 12 ++ README.md | 17 ++ app/__init__.py | 196 ++++++++++++++++++ app/exceptions.py | 5 + app/models.py | 39 ++++ app/templates/emails/base.html.j2 | 33 +++ app/templates/emails/base.txt.j2 | 1 + app/templates/emails/reset-link.html.j2 | 19 ++ app/templates/emails/reset-link.txt.j2 | 16 ++ app/templates/pages/base.html.j2 | 37 ++++ app/templates/pages/home.html.j2 | 30 +++ .../pages/invalid_password_token.html.j2 | 12 ++ app/templates/pages/lost_login.html.j2 | 17 ++ .../pages/password_link_form.html.j2 | 18 ++ .../pages/password_link_sent.html.j2 | 20 ++ .../pages/password_reset_form.html.j2 | 17 ++ .../pages/password_reset_success.html.j2 | 11 + app/templates/partials/contact_link.html.j2 | 1 + migrations/README | 1 + migrations/alembic.ini | 50 +++++ migrations/env.py | 113 ++++++++++ migrations/script.py.mako | 24 +++ .../bf0456b014c1_initial_migration.py | 36 ++++ package.json | 6 + requirements.txt | 21 ++ 26 files changed, 766 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/exceptions.py create mode 100644 app/models.py create mode 100644 app/templates/emails/base.html.j2 create mode 100644 app/templates/emails/base.txt.j2 create mode 100644 app/templates/emails/reset-link.html.j2 create mode 100644 app/templates/emails/reset-link.txt.j2 create mode 100644 app/templates/pages/base.html.j2 create mode 100644 app/templates/pages/home.html.j2 create mode 100644 app/templates/pages/invalid_password_token.html.j2 create mode 100644 app/templates/pages/lost_login.html.j2 create mode 100644 app/templates/pages/password_link_form.html.j2 create mode 100644 app/templates/pages/password_link_sent.html.j2 create mode 100644 app/templates/pages/password_reset_form.html.j2 create mode 100644 app/templates/pages/password_reset_success.html.j2 create mode 100644 app/templates/partials/contact_link.html.j2 create mode 100644 migrations/README create mode 100644 migrations/alembic.ini create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/bf0456b014c1_initial_migration.py create mode 100644 package.json create mode 100644 requirements.txt 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