Initial commit

This commit is contained in:
2025-03-13 22:24:53 +01:00
commit 2acccb2574
26 changed files with 766 additions and 0 deletions

196
app/__init__.py Normal file
View File

@@ -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)

5
app/exceptions.py Normal file
View File

@@ -0,0 +1,5 @@
class LoginNotFoundException(Exception):
pass
class MultipleUsersFoundForLoginException(Exception):
pass

39
app/models.py Normal file
View File

@@ -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}>"

View File

@@ -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>

View File

@@ -0,0 +1 @@
{% block content %}{% endblock %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -0,0 +1 @@
contactez <a class="text-blue-600 hover:underline" href="mailto:brouard@u-pec.fr">Régis Brouard</a>