Initial commit

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

14
.env.example Normal file

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

12
.gitignore vendored Normal file

@ -0,0 +1,12 @@
__pycache__/
venv/
package-lock.json
node_modules/
.idea/
.vscode/
.env
app/static/style.css

17
README.md Normal file

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

196
app/__init__.py Normal 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

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

39
app/models.py Normal 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}>"

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

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

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

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

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

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

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

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

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

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

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

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

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

1
migrations/README Normal file

@ -0,0 +1 @@
Single-database configuration for Flask.

50
migrations/alembic.ini Normal file

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

113
migrations/env.py Normal file

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

24
migrations/script.py.mako Normal file

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

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

6
package.json Normal file

@ -0,0 +1,6 @@
{
"dependencies": {
"@tailwindcss/cli": "^4.0.9",
"tailwindcss": "^4.0.9"
}
}

21
requirements.txt Normal file

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