Authentification sur un site Web
Objectif de l’activité
Réaliser un module d’authentification d’un utilisateur sur un site Web.
Le dispositif doit être permettre à un utilisateur de s’authentifier de manière sécurisée :
- il demande un formulaire de connexion ou d’enregistrement s’il n’est pas encore enregistré,
- il remplit le formulaire avec son identifiant (son email par exemple) et son mot de passe,
- il accède à des pages à accès limité sur le site,
- il peut se déconnecter,
- il est automatiquement déconnecté après un certain temps sans activité.
Principe de fonctionnement
Moyens
Logiciel
Langage : Python
Serveur Web : mode « serveur de développement » de Flask
Gestionnaire de Base de données : SQLite (fichier .db
)
Accès à la base de données : module Python sqlite3
(sqlite3)
Hachage cryptographique : module Flask werkzeug.security
(fonctions generate_password_hash
et check_password_hash
)
Chiffrement symétrique : module Python cryptography
(cryptography)
Structure du module
La structure usuelle des fichiers sur un serveur basé sur Flask est la suivante :
- logs
- erreurs.log
- …
- scripts
- user.py
- …
- static
- img
- css
- js
- templates
- base.html
- signin.html
- …
- index.py
Mise en place du serveur
L’interface utilisateur
base.html
qui doit servir de base aux pages Web.<!DOCTYPE html> <html lang="fr-FR"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>{% block title %}{% endblock %}</title> </head> <body> <div> {% block content %}{%- endblock %} </div> </body> </html>
index.html
:Comme toutes les pages du site, elle est basée sur la structure de base.html
:
{% extends "base.html" %} {% block title %}Mon site Web avec authentification{% endblock %} {% block content %} Bonjour ! {% endblock %}
index.py
sur la structure suivante :# Le Framework (générateur de pages HTML) from flask import Flask, render_template # Application Flask app = Flask(__name__) # Pages HTML pageACCUEIL = "index.html" ############################################################################################ @app.route("/") def index(): return render_template(pageACCUEIL) # Script de débuggage if __name__ == "__main__": app.run(host='0.0.0.0', debug = True)
Tester le bon fonctionnement :
- exécuter le script Python
- dans un navigateur Web, taper
localhost:5000
Si tout s’est bien passé, voici ce qui doit apparaitre :
Inscription d’un utilisateur
Formulaire d’inscription, coté client
signup.html
contenant 3 champs (‘nom’, ’email’ et ‘mot de passe’), encapsulé dans une balise <div>
:{% extends "base.html" %} {% block title %}Inscription{% endblock %} {% block content %} <div> <!-- Mettre ici le formulaire --> </div> {% endblock %}
Pour la création d’un formulaire en HTML, voir sur MDN Web Docs
pageSIGNUP = "signup.html" @app.route("/signup") def signup(): return render_template(pageSIGNUP)
localhost:5000/signup
Une fois que l’utilisateur aura soumis le formulaire, il faut, coté serveur, récupérer les informations saisies, vérifier leur validité, et les enregistrer dans la base de données…
Récupération des informations coté serveur
Dans un premier temps, la réponse à cette requête sera la page d’accueil.
Pour récupérer les informations figurant dans une requête (quelle que soit la méthode employée GET ou POST), il faut utiliser la variable de contexte
flask.request
(à importer !!), qui contient un attributform
, de type dictionnaire, contenant les valeurs des champs du formulaire.Par exemple, pour récupérer la valeur du champ ‘nom’ :
nom = request.form.get('nom')
signup.html
et la route /signup
(méthode POST) pour que les champs ‘nom’, ’email’ et ‘mot de passe’ soient préremplis (attribut value
) avec les informations personnelles d’un utilisateur.Pour intégrer une variable dans du code HTML, on la fournit comme argument à mot clé dans la fonction
render_template
.Par exemple, pour faire passer la variable nom au modèle (template) :
render_template(pageACCUEIL, nom = nom)
et dans le fichier HTML, on utilise des doubles accolades :
{{ nom }}
.
Remarque : l’URL de la page est également
/signup
, mais contrairement au formulaire précédent, cette page a été obtenue par une requête POST.
Enregistrement dans la base de données
Pour des raisons de sécurité, il est fortement déconseillé de stocker des mots de passe dans une base de données d’utilisateur : si la base de données devenait accessible à une personne malveillante ou maladroite, les informations de connexion complètes seraient alors connues.
On préfèrera mémoriser leurs empreintes, en utilisant un algorithme de hachage.
Pour cela, on peut utiliser le module
hashlib
de Python et la fonctionsha256
(du nom de l’algorithme utilisé) :import hashlib def hash_mdp(mdp): return hashlib.sha256(str(mdp).encode('utf-8')).hexdigest()ou encore les fonctions
generate_password_hash
etcheck_password_hash
du module Flaskwerkzeug.security
.from werkzeug.security import generate_password_hash, check_password_hash def hash_mdp(mdp): return generate_password_hash(mdp)
Pour la base de données, on peut utiliser SQLite, qui présente l’avantage d’éviter d’installer un serveur de base de données. Les notions sont identiques aux SGBD comme MySQL :
- Création d’une connexion à la base :
db = sqlite3.connect('fichier.db')
- Création d’un curseur :
cur = db.cursor()
- Envoi d’une requête à l’aide du curseur :
cur.execute(requete, arguments)
- Récupération de la réponse :
r = cur.fetchall()
ou bien confirmation des changements :db.commit()
- Fermeture de la connexion :
db.close()
Pour l’utiliser avec Flask, il faut prendre quelques précautions, et notamment fermer la connexion à la fin du contexte de l’application (concrètement, à la fin de la requête HTTP).
Les fonctions suivantes permettent de s’assurer qu’il n’y a qu’une connexion par contexte, et de ne pas oublier de la fermer :
import sqlite3 from flask import g # g est une variable de contexte, pour stocker des données pendant un contexte d'application DATABASE = 'auth.db' def get_db(): db = getattr(g, '_database', None) if db is None: # la base de données n'est pas encore mémorisée dans le contexte db = g._database = sqlite3.connect(DATABASE) db.row_factory = sqlite3.Row return db @app.teardown_appcontext def close_connection(exception): db = getattr(g, '_database', None) if db is not None: db.close()
index.py
.
Ainsi, dans chaque fonction de traitement des requêtes, on peut utiliser get_db()
pour obtenir la connexion actuelle ouverte à la base de données.
Pour exécuter des requêtes SQL, on créé deux fonctions dédiées :
def read_db(query, args=(), one=False): cur = get_db().execute(query, args) rv = cur.fetchall() cur.close() return (rv[0] if rv else None) if one else rv def write_db(query, args=()): db = get_db() cur = db.execute(query, args) db.commit()
La fonction read_db
renvoie une unique « ligne » (si one==True
), ou bien une liste de « lignes », de type sqlite3.Row
, un type d’objet pouvant se comporter comme une liste ET comme un dictionnaire :
Exemple : si r
est une ligne sqlite3.Row
comportant 3 champs 'nom'
, 'email'
et 'mdp'
:
>>> # Accès par le nom du champ >>> r['nom'] Blaise >>> # Accès par l'indice du champ >>> r[0] Blaise >>> # Itération sur tous les champs >>> for v in r: ... print(v) Blaise blaise.pascal@blaisepascal.fr 1234
Les requêtes doivent être passées sous forme d’une chaîne de caractères, les arguments placés dans un tuple. Leurs emplacements dans la chaîne de caractères sont repérés par le caractère ?
.
Exemples :
read_db("SELECT * FROM users WHERE email=?", (email,), one = True) write_db("INSERT INTO users VALUES (?,?,?)", (nom, email, hmdp))
Avant de pouvoir ajouter des utilisateurs à la base, il faut créer une table, selon le schéma suivant :
users(nom:TEXT, email:TEXT, mdp:TEXT)
user.sql
contenant les instructions SQL permettant de créer cette base (on pourra s’aider d’une application tierce comme DB Browser pour SQLite).Pour initialiser la base de données, il suffira d’exécuter (une seule fois) la fonction suivante (ou bien utiliser DB Browser pour SQLite) :
def init_db(): with app.app_context(): db = get_db() with app.open_resource('user.sql', mode='r') as f: db.cursor().executescript(f.read()) db.commit()
get_user
, prenant en paramètre une adresse email, et renvoyant un objet sqlite3.Row
comportant l’intégralité des informations sur un utilisateur.Si l’utilisateur n’est pas dans la base de données ou que l’adresse email fournie vaut
None
, la fonction renvoie None
.
Modifier la fonction signup_post
pour :
- vérifier si l’utilisateur (son email) existe ou pas dans la base de données :
- s’il existe déjà, on renvoie à la page de connexion, nommée
pageSIGNIN
(pas encore créée …), qui devra proposer le champ ’email’ prérempli; - sinon, on l’enregistre dans la base de données et on renvoie la page d’accueil (et plus la page d’enregistrement comme auparavant) ;
- si aucun email n’a été saisi, on renvoie la page d’accueil.
- s’il existe déjà, on renvoie à la page de connexion, nommée
Connexion d’un utilisateur
signin.html
et la route flask associée /signin
, contenant un formulaire de connexion, avec les champs ’email’ et ‘mot de passe’.Penser à prévoir que les champs ‘nom’ et ’email’ peuvent être préremplis.
Créer une fonction signin_post
pour gérer la route /signin
après soumission du formulaire de connexion (méthode POST).
- Si l’utilisateur (son email) est dans la base de données, on compare les empreintes de mot de passe (celui saisi dans le formulaire et celui présent dans la base de données), à l’aide de la fonction
check_password_hash
.- Si les empreintes sont identiques, on renvoie la page d’accueil, en y affichant le nom de l’utilisateur qui vient de se connecter (pour cela, modifier le fichier
index.html
). - Sinon on renvoie à nouveau la page de connexion, avec le champ ’email’ prérempli.
- Si les empreintes sont identiques, on renvoie la page d’accueil, en y affichant le nom de l’utilisateur qui vient de se connecter (pour cela, modifier le fichier
- Sinon on renvoie la page de connexion à nouveau.
Pour intégrer une variable de type dictionnaire dans un modèle (template), on peut utiliser la notation
{{ user.nom }}
plutôt que{{ user['nom'] }}
, plus compacte.Dans ce cas, pour éviter une erreur si l’objet n’est pas défini (ou s’il vaut
None
), on encapsule sa référence dans un bloc Jinja{% if ... %}...{% endif %}
:{% if user %}value="{{ user.email }}"{% endif %}
Création d’une session
Juste après la connexion d’un utilisateur, la page d’accueil affiche son nom :
Mais lorsque l’on demande à nouveau cette page, le nom de l’utilisateur n’y figure plus !
HTTP est un protocole sans état qui n’enregistre pas l’état d’une session de communication entre deux requêtes successives. La communication est formée de paires requête-réponse indépendantes et chaque paire requête-réponse est traitée comme une transaction indépendante, sans lien avec les requêtes précédentes ou suivantes.
Cela est problématique lorsque les utilisateurs veulent interagir avec une page de façon cohérente (par exemple avec un panier d’achat sur un site de commerce en ligne).
Bien que le cœur d’HTTP soit sans état, les cookies permettent l’utilisation de sessions avec des états…
Pour créer une session, il faut, lors de la requête de connexion, déposer un cookie sur le navigateur de l’utilisateur qui vient de se connecter. Lors des requêtes suivantes, le cookie est lu et l’utilisateur est automatiquement « reconnecté ».
Un cookie HTTP est un petit ensemble de données qu’un serveur envoie au navigateur web de l’utilisateur. Le navigateur peut alors le stocker localement, puis le renvoyer à la prochaine requête vers le même serveur.
Pour déposer un cookie avec flask, il faut auparavant créer un objet réponse (à partir d’un template par exemple), lui ajouter un cookie (à l’aide de la méthode set_cookie
), avant de retourner cette réponse.
resp = make_response(render_template(pageXXXX)) # objet "réponse HTTP" resp.set_cookie('email', email) return resp
Pour lire un cookie (lors des requêtes suivantes), on utilise la propriété cookies
de la requête :
email = request.cookies.get('email')
Sécurisation
La méthode précédente présente un énorme inconvénient : n’importe qui peut se faire passer pour l’utilisateur inscrit sur le site Web, car le cookie n’est pas chiffré.
localhost:5000
, puis vérifier que l’utilisateur est bien déconnecté.
'email'
et de valeur 'blaise.pascal@blaisepascal.fr'
(ou l’adresse choisie et enregistrée précédemment) et constater que c’est suffisant pour se connecter en tant que 'Blaise'
, sans avoir saisi de mot de passe !
Il va donc falloir chiffrer la valeur du cookie de session !!
Pour chiffrer un texte en Python (chiffrement symétrique), on peut utiliser la bibliothèque
cryptography
. Son utilisation est très simple, comme le montre le petit exemple ci-dessous :from cryptography.fernet import Fernet key = Fernet.generate_key() f = Fernet(key) mess_chiff = f.encrypt("message à chiffrer".encode()) # Le message à chiffrer doit être converti au format bytes, avec .encode() mess = f.decrypt(mess_chiff.encode()).decode('utf-8') # on convertit également le message chiffré en bytes car les cookies sont nativement au format str
connected_user_id
permettant d’obtenir l’adresse email de l’utilisateur connecté (s’il y en a un), ou None
si aucun utilisateur n’est connecté.REMARQUE : inutile de fournir l’objet
request
en paramètre, car il s’agit d’une variable globale (de portée limitée au contexte d’application).ATTENTION : il est impératif d’utiliser une unique clé pour l’application. En générer une nouvelle à chaque session rendrait les cookies indéchiffrables !
/
en utilisant cette nouvelle fonction.
Déconnexion d’un utilisateur
/logout
, permettant à un utilisateur de se déconnecter. Pour supprimer un cookie, utiliser la méthode resp.delete_cookie('nom')
(resp
étant une réponse HTTP, comme celle utilisée pour set_cookie
). Après déconnexion, le site affiche la page d’accueil.ATTENTION : il faut utiliser ici la fonction
redirect('route')
(penser à importerredirect
depuisflask
) plutôt querender_template(pageXXX)
, car la deuxième solution génèrerai le code HTML AVANT la suppression du cookie, donc avant la « déconnexion » de l’utilisateur !
Menu d’authentification
Le menu d’authentification, qui permet à un utilisateur de s’enregistrer, se connecter, se déconnecter …, n’est pas toujours le même selon qu’un utilisateur est déjà connecté ou pas.
Pour que ce menu apparaisse sur toutes les pages du site, on l’inclue dans une balise <header>
du fichier base.html
:
<!DOCTYPE html> <html lang="fr-FR"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>{% block title %}{% endblock %}</title> </head> <body> <header>{% include "header.html" %}</header> <div> {% block content %}{%- endblock %} </div> </body> </html>
Mais il faut également injecter automatiquement une variable représentant l’utilisateur (de type sqlite3.Row
ou bien None
) dans le contexte de chaque modèle (template) du site. Pour cela, on utilise un processeur de contexte.
Un processeur de contexte est une fonction qui s’exécute avant que le modèle ne soit rendu et qui renvoie un dictionnaire dont les clés et les valeurs sont fusionnées avec le contexte du modèle, pour tous les modèles de l’application.
Pour que l’utilisateur connecté soit automatiquement injecté dans les contexte des modèles, on utilise la variable globale flask.g
(permettant de stocker des variables globale, de portée limitée au contexte d’application) et les fonctions définies précédemment :
@app.context_processor def inject_user(): user = getattr(g, 'user', None) if user is None: g.user = connected_user_id() return dict(user = g.user)
À partir de là, la variable user
sera disponible pour tous les modèles du site ! (on peut retirer tous les user = ...
des fonctions render_template
).
Créer un fichier header.html
en ajoutant, dans une balise <div>
:
- un bouton ‘Connexion’ (uniquement si aucun utilisateur n’est connecté)
- un bouton ‘Déconnexion’ (uniquement si un utilisateur est connecté)
- un bouton ‘Profil’ (uniquement si un utilisateur est connecté)
- un bouton ‘Enregistrement’ (uniquement si aucun utilisateur n’est connecté)
Pour les boutons, utiliser une balise <a>
.
Pour laisser à flask la tâche de créer l’URL correct vers une route, il est conseillé d’utiliser la fonction
url_for
, directement dans les modèles :Par exemple, pour générer l’URL vers la route
'signup'
:<a href="{{ url_for('signup') }}">Profil</a>
où
'signup'
est le nom de la fonction Python, pas de la route !
Ressources : https://www.digitalocean.com/community/tutorials/how-to-add-authentication-to-your-app-with-flask-login-fr
https://flask.palletsprojects.com/en/2.0.x/patterns/sqlite3/
https://fr.wikipedia.org/wiki/Protocole_sans_état