Sinatra : authentification avec Warden
Récemment, pour plusieurs sites et prototypes développés avec Sinatra, j'ai eu à me poser la question de comment faire un système d'authentification digne de ce nom. En Rails, la solution de-facto s'appelle Devise, mais étant très rails-centric, cette solution est compliquée à utiliser dans un projet Sinatra. Pour ce genre de fonctionnalités, on va alors plutôt avoir recours à la gem warden.
Warden, qu'est-ce que c'est ?
D'après la description même du projet, Warden est un "framework rack d'authentification". Plus précisément, il s'agit d'un middleware qui a pour vocation de fournir les mécanismes d'authentification à des applications Ruby. C'est d'ailleurs sur Warden que s'appuie Devise, qui est la solution la plus courante d'authentification pour les projets Rails. Autant dire que si on utilise finalement assez peu Warden directement, ça n'en reste pas moins une brique logicielle éprouvée et sur laquelle on peut s'appuyer sans craintes.
Voyons comme procéder pour utiliser Warden dans un projet Sinatra.
Que va-t-on développer
On va développer un projet assez simple :
- Un site Sinatra,
- Qui se connectera à une base de données sqlite avec activerecord,
- Qui présentera une page de login,
- Ainsi qu'une barre de navigation avec les actions habituelles (login, logout, affichage du nom de l'utilisateur connecté),
- Et qui aura une page protégée, accessible uniquement aux utilisateurs connectés.
On a déjà de quoi faire ! Je passerai vite sur la mise en place du projet et ne détaillerai pas la mise en place des vues et du code HTML, si vous êtes intéressés, vous pouvez aller jeter un oeil vers mes articles À la découverte de Sinatra 1 et son petit frère À la découverte de Sinatra 2 - Routes et templates.
Mettons Sinatra en place
Dépendances
Commençons par initier un nouveau projet Sinatra, et par mettre en place tout le nécessaire. Créons, un gemfile, ajoutons les gems nécessaires puis lançons un bundle install
pour installer tout ça :
1
$ bundle init
1
2
3
4
5
6
7
8
9
10
11
12
# -- Gemfile --
gem 'puma'
gem 'rake'
gem 'sinatra'
gem 'warden'
gem 'sinatra-activerecord'
gem 'sqlite3'
gem 'bcrypt'
gem 'rack-flash3'
1
$ bundle
On va également créer un Rakefile
pour disposer des tâches rake de sinatra/activerecord
, ça nous facilitera grandement la tâche au moment de générer la base et les migrations :
1
2
3
4
5
6
7
8
# -- Rakefile --
require "sinatra/activerecord/rake"
namespace :db do
task :load_config do
require "./app"
end
end
Fichier sinatra principal
Créons maintenant notre fichier app.rb
qui contiendra la majorité de notre petite application Sinatra. Sur ce blog, vous m'avez souvent vu utiliser la version modulaire de Sinatra. Pour changer, je vais utiliser la version classique, plus simple à mettre en oeuvre et parfaite pour un petit PoC comme celui-ci. Pour plus de détails sur les différences entre application sinatra classique et modulaire, je vous laisse lire le readme de Sinatra qui explique très bien cela.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# -- app.rb --
require 'sinatra'
require 'sinatra/activerecord'
require 'rack-flash'
require './model.rb'
# sinatra/activerecord : configuration de la connexion à la base
set :database, { adapter: "sqlite3", database: "warden.sqlite3" }
use Rack::Flash
#
# À venir : configuration de Warden
#
Modèle ActiveRecord et migration
Dans le fichier app.rb
, vous avez pu remarquer la ligne require './model.rb'
: c'est dans ce fichier que je vais définir mon modèle ActiveRecord. Créons d'ores et déjà ce fichier, avec le modèle User
. On le complètera ultérieurement :
1
2
3
4
# -- model.rb --
class User < ActiveRecord::Base
end
Notre modèle est prêt, l'accès à la base est configuré dans app.rb
, mais nous n'avons encore créé aucune base de données. Allons-y, créons une migration :
1
$ bundle exec rake db:create_migration NAME=create_users
Puis éditons cette migration pour créer une table users
basique :
1
2
3
4
5
6
7
8
9
10
# /db/migrate/20180126153804_create_users.rb
class CreateUsers < ActiveRecord::Migration[5.1]
def change
create_table :users do |t|
t.string :username
t.string :encrypted_password
t.timestamps
end
end
end
Maintenant, on peut lancer la création de la base et la migration que l'on vient d'écrire :
1
$ bundle exec rake db:create && bundle exec rake db:migrate
OK, not so bad. On est prêts à s'attaquer à la logique d'authentification.
L'authentification avec Warden
Configuration
Première étape, il faut configurer Warden et lui expliquer comment il est censé identifier nos utilisateurs. On pense tout d'abord bien à ajouter Warden aux requires de notre fichier app.rb
:
1
require 'warden'
Ajoutons maintenant un bloc de configuration de Warden dans notre fichier app.rb
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# Ne faites pas ça en prod ! Utilisez l'option `secret` + variable d'environnement
# Pour plus de détails : https://martinfowler.com/articles/session-secret.html
use Rack::Session::Cookie
use Warden::Manager do |config|
# Comment on sauve l'information représentation l'utilisateur dans une
# session. On va stocker l'ID de l'utilisateur.
config.serialize_into_session{ |user| user.id }
# Comment on retrouve l'utilisateur à partir de l'information
# récupérée depuis la session (l'id de l'utilisateur, donc)
config.serialize_from_session{ |id| User.find(id) }
# Les "stratégies" définissent comment Warden détermine si une tentative
# d'authentification réussit ou échoue. On définira notre stratégie à la
# prochaine étape.
# "action" représente une route `POST` où l'on redirige l'utilisateur
# quand `warden.authenticate!` renvoie une réponse fausse
config.scope_defaults :default, strategies: [], action: '/unauthenticated'
# Quand l'utilisateur essaie de se logger et n'y arrive pas, on doit
# spécifier vers quelle application on le redirige.
# NOTE: si on utilisait une application Sinatra modulaire, il faudrait
# indiquer ici le nom de la classe de l'application ou self
config.failure_app = Sinatra::Application
end
# Ce callback va transformer toutes les requêtes d'échec en POST. Sans ça,
# une authentification échouée dans une route GET redirigerait vers GET /unauthenticated,
# une authentification échouée dans une route POST redirigerait vers POST /unauthenticated,
# et il faudrait gérer tous ces cas dans des routes spécicifiques
Warden::Manager.before_failure do |env,opts|
env['REQUEST_METHOD'] = 'POST'
end
Stratégie
OK, comme vous avez pu le voir dans mes commentaires ci-dessus, Warden se base sur un concept de "stratégies". Une stratégie contient la logique d'authentification qui permet à la gem de déterminer si une tentative de login réussit ou échoue.
Il est possible d'utiliser plusieurs stratégies d'authentification dans une application (si l'une échoue, Warden essaiera ensuite la suivante jusqu'à les avoir toutes épuisées), mais pour notre exemple, nous allons nous contenter d'une seule stratégie "mot de passe". Créons là maintenant :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Warden::Strategies.add(:password) do
# Valide que les paramètres fournis permettent de tenter l'authentification.
# En l'occurrence, on s'assure de la présence d'un login et d'un mot de passe
def valid?
params['username'] && params['password']
end
# C'est là que la logique d'authentification se place
def authenticate!
user = User.find_by(username: params['username'])
if user && user.authenticate(params['password'])
success!(user)
else
fail!("Could not log in")
end
end
end
Sans oublier d'ajouter notre stratégie flambant neuve au bloc de configuration de Warden :
1
config.scope_defaults :default, strategies: [ :password ], action: '/unauthenticated'
Modèle : logique d'authentification et gestion de mot de passe
Vous avez vu précédemment dans la méthode authenticate!
de notre nouvelle stratégie que l'on attend que le modèle utilisateur possède une méthode authenticate
: occupons-nous maintenant de ça, et nous serons bientôt arrivés :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# -- ./models.rb --
require 'sinatra/activerecord'
require 'bcrypt'
class User < ActiveRecord::Base
include BCrypt
def password
@password ||= Password.new(self.encrypted_password)
end
def password=(new_password)
@password = Password.create(new_password)
self.encrypted_password = @password
end
def authenticate(attempted_password)
password == attempted_password
end
end
Rien de bien original, vous pourrez trouver ça sur le wiki de bcrypt :)
Contrôleurs et routes
On a déjà parcouru du chemin depuis le début de cet article. Nous avons déjà :
- Ajouté warden à notre application,
- Configuré comment il sauve un utilisateur en session
- Configuré comment il récupère l'utilisateur a partir de l'information stockée en session,
- Défini la logique à utiliser pour authentifier un utilisateur.
Occupons-nous maintenant de définir les contrôleurs et les routes (je vous laisserai gérer vous-mêmes layous et formulaires, si vous voulez voir un exemple complet, vous pouvez aller voir le repository) :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# Affiche le formulaire de login qui enverra une requête POST à /login
get '/login' do
erb :login
end
# Requête de login, qui va utiliser la logique définie dans nos stratégies.
# On accède à warden à partir de l'environnement rack, via `env['warden']`
post '/login' do
env['warden'].authenticate!
flash[:success] = 'Logged in!'
redirect( session[:return_to] || '/protected' )
end
# Quand l'utilisateur veut se déconnecter...
get '/logout' do
env['warden'].logout
flash[:success] = 'Successfully logged out'
redirect '/'
end
post '/unauthenticated' do
session[:return_to] = env['warden.options'][:attempted_path]
flash[:error] = env['warden'].message || 'You must log in'
redirect '/login'
end
# La page à accès restreint : seuls les utilisateurs authentifiés
# ont la permission d'y accéder
get '/protected' do
env['warden'].authenticate!
erb :protected
end
Testons tout cela
Créons un utilisateur
Toutes nos routes sont maintenant définies, il ne nous reste plus qu'à tester tout cela. Si vous avez cloné mon dépôt git, alors vous pouvez simplement lancer le seed avec la tâche $ rake db:seed
. Sinon, ouvrons une console ruby pour créer cet utilisateur :
1
2
3
4
5
$ irb
irb(main):001:0> require './app.rb'
irb(main):002:0> u = User.create!(username: 'test')
irb(main):003:0> u.password = 'test'
irb(main):004:0> u.save!
Et lançons notre application !
Maintenant, nous voilà donc fin prêts à lancer notre application Sinatra, naviguer jusqu'à notre page de login, et essayer de nous connecter :
1
2
3
4
5
6
7
8
9
10
11
12
$ ruby app.rb
== Sinatra (v3.0.6) has taken the stage on 4567 for development with backup from Puma
Puma starting in single mode...
* Puma version: 6.2.2 (ruby 3.1.2-p20) ("Speaking of Now")
* Min threads: 0
* Max threads: 5
* Environment: development
* PID: 60403
* Listening on http://127.0.0.1:4567
* Listening on http://[::1]:4567
Use Ctrl-C to stop

Pour plus de détails sur l'implémentation, vous pouvez bien évidemment aller voir le code lui-même, lancer l'application, la modifier et l'enrichir. J'espère en tout cas que ce billet vous aura convaincu que l'authentification avec Sinatra est finalement simple à mettre en place, une fois qu'on sait comment elle s'articule ! Encore une bonne raison de s'amuser avec Sinatra !
Bon Ruby !
Références
- Tout le code présenté dans ce billet est disponible dans ce dépot github : pabuisson/blog-sinatra-warden
- hassox/warden : le github de Warden
- codahale/bcrypt-ruby : le github de Bcrypt
- EricPlayground - Authentication with Warden, without Devise : une des rares ressources à jour et complètes sur le sujet.
- [Authentication with Sinatra and Warden par Steve Klise (utilise datamapper plutôt qu'activerecord)