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

Framework : Flask

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

Créer un fichier 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>

 

Créer une page d’accueil simple 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 %}

 

Créer un fichier script 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 :

  1. exécuter le script Python
  2. 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

Créer un formulaire d’inscription (obtenu par une requête de méthode GET) 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

 

Créer la route Flask permettant d’obtenir ce formulaire :
pageSIGNUP = "signup.html"

@app.route("/signup")
def signup():
   return render_template(pageSIGNUP)

 

Tester le bon fonctionnement en tapant l’URL de cette route dans le navigateur : 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

Créer une nouvelle route Flask dédiée aux requêtes de type POST (voir documentation de Flask), et récupérer les informations du formulaire.
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 attribut  form, 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')

 

Afin de pouvoir vérifier que les informations ont bien été récupérées, modifier le formulaire de la page 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 }}.

 

Tester le bon fonctionnement : après avoir cliqué sur le bouton ‘Enregistrer’, l’utilisateur voit à nouveau cette page, avec les informations qu’il vient de saisir :

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 fonction sha256 (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_hashet check_password_hashdu module Flask werkzeug.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 :

  1. Création d’une connexion à la base : db = sqlite3.connect('fichier.db')
  2. Création d’un curseur : cur = db.cursor()
  3. Envoi d’une requête à l’aide du curseur : cur.execute(requete, arguments)
  4. Récupération de la réponse : r = cur.fetchall()
    ou bien confirmation des changements : db.commit()
  5. 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()
Intégrer ces lignes au fichier 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)

 

Créer un fichier 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()

 

Proposer une fonction 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.

 

Connexion d’un utilisateur

Créer une page de connexion 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.
  • 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')

 

Implémenter cette technique et vérifier que l’utilisateur reste bien connecté à chaque requête suivant sa connexion.

 

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é.

Depuis le navigateur, supprimer tous les cookies relatifs à localhost:5000, puis vérifier que l’utilisateur est bien déconnecté.
Effacer les cookies avec Firefox
  • Depuis la barre d’adresse :

 

Ajouter manuellement un cookie de nom  '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 !
Ajouter un cookie avec Firefox
  • Depuis le menu : Outils supplémentaires/Outils de développement
  • Sélectionner l’ongler Stockage
  • Puis la catégorie Cookies
  • Ajouter un nouveau Cookie en cliquant sur

 

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

 

Créer une fonction 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 !

 

Mettre à jour la route / en utilisant cette nouvelle fonction.

 

Ajouter le chiffrement/déchiffrement du cookie de session au programme, et vérifier le bon fonctionnement.

 

Déconnexion d’un utilisateur

Ajouter une route flask /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 à importer redirect depuis flask) plutôt que render_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>

'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

 

 

 

 

Vous aimerez aussi...

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée.