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
GIF - Démo Warden Sinatra

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