La double authentification par SMS, ce n'est pas forcément la feature la plus excitante à coder. Pourtant, dès qu'un projet Rails arrive en production, c'est souvent l'une des premières briques de sécurité qu'on ajoute.
Un simple mot de passe ne suffit plus vraiment aujourd'hui. Fuites de données, phishing, mots de passe recyclés... les risques sont partout. Ajouter une deuxième étape de validation devient vite indispensable.
Dans cet article, on va voir comment mettre en place une 2FA par SMS avec Ruby et Rails, de manière propre et maintenable.
On va couvrir deux cas très courants :
une webapp Rails classique (sessions + cookies)
un backend Rails API consommé par une application mobile Flutter
L'objectif n'est pas de vendre un provider SMS, mais plutôt de comprendre comment structurer une implémentation solide côté Rails.
Pourquoi utiliser le SMS pour la 2FA
Soyons honnêtes : le SMS n'est pas la méthode de 2FA la plus sécurisée du monde. Les attaques par SIM swap existent et certains services sensibles préfèrent d'autres approches.
Mais dans la pratique, pour beaucoup de produits web, le SMS reste un excellent compromis.
Pourquoi ?
c'est simple pour l'utilisateur
aucune application supplémentaire à installer
mise en place rapide
amélioration immédiate de la sécurité
Pour beaucoup de projets Rails, surtout en B2C, c'est souvent la première marche vers une authentification plus robuste.
Architecture globale d'une 2FA
Que tu développes une webapp ou une API mobile, le principe reste exactement le même.
l'utilisateur se connecte avec son login et son mot de passe
le backend génère un code temporaire
ce code est envoyé par SMS
l'utilisateur saisit ce code
le backend vérifie la validité du code
La différence se situe ensuite dans la validation finale :
une session Rails dans une webapp
un token (JWT ou autre) dans une API
Le coeur du système reste identique.
Choisir un provider SMS
La plupart des providers offrent aujourd'hui des API HTTP très simples à utiliser.
Ce qu'on attend surtout :
une API fiable
une bonne délivrabilité
une facturation claire
Les providers les plus utilisés avec Ruby :
Twilio
OVH SMS
Vonage (anciennement Nexmo)
Dans cet article on prendra Twilio comme exemple, mais l'architecture fonctionne avec n'importe quel service.
Isoler l'envoi de SMS dans un service
Une règle simple : ne jamais appeler directement un provider SMS depuis un controller.
Créer un service dédié permet :
de simplifier les tests
de remplacer facilement le provider
de garder des controllers lisibles
Exemple :
# app/services/sms_sender.rb
class SmsSender
def self.send(to:, message:)
client = Twilio::REST::Client.new(
ENV["TWILIO_ACCOUNT_SID"],
ENV["TWILIO_AUTH_TOKEN"]
)
client.messages.create(
from: ENV["TWILIO_PHONE_NUMBER"],
to: to,
body: message
)
end
end
Ce service ne contient volontairement aucune logique métier.
Son rôle est simple : envoyer un SMS.
Générer et stocker un code de vérification
Le coeur de la 2FA est le code temporaire.
Une implémentation simple fonctionne très bien dans la majorité des cas :
un code numérique court
une durée de validité limitée
un seul code actif
Modèle ActiveRecord
# app/models/two_factor_code.rb
class TwoFactorCode < ApplicationRecord
belongs_to :user
before_create :generate_code
def expired?
created_at < 10.minutes.ago
end
private
def generate_code
self.code = rand(100_000..999_999)
end
end
Quelques bonnes pratiques :
éviter de stocker ces codes trop longtemps
ne jamais logger leur valeur
éventuellement hasher le code si le niveau de sécurité l'exige
Envoyer le code par SMS
Quand la 2FA est déclenchée, on crée un code et on l'envoie.
code = user.two_factor_codes.create!
SmsSender.send(
to: user.phone_number,
message: "Votre code de verification est #{code.code}"
)
Dans une vraie application, cet envoi doit passer par un ActiveJob pour ne pas bloquer la requête HTTP.
Cas 1 : Webapp Rails classique
Dans une application Rails traditionnelle, le flow ressemble à ça :
login + mot de passe valide
génération du code
envoi du SMS
redirection vers l'écran 2FA
validation du code
activation complète de la session
Controller de verification
class TwoFactorController < ApplicationController
def create
record = current_user.two_factor_codes.last
if record&.code == params[:code].to_i && !record.expired?
session[:two_factor_passed] = true
redirect_to dashboard_path
else
flash[:alert] = "Code invalide ou expire"
render :new
end
end
end
Tant que two_factor_passed n'est pas présent dans la session, l'utilisateur n'a pas accès aux pages sensibles.
Cas 2 : Backend Rails API pour mobile
Pour une API consommée par Flutter ou une autre app mobile, la logique est la même mais l'interface change.
Versionner les endpoints
Une API mobile peut rester longtemps sans mise à jour.
Le versionnement est donc essentiel.
POST /api/v1/auth/2fa/send
POST /api/v1/auth/2fa/verify
Routes
namespace :api do
namespace :v1 do
namespace :auth do
post "2fa/send", to: "two_factor#send_code"
post "2fa/verify", to: "two_factor#verify"
end
end
end
Controller API
class Api::V1::Auth::TwoFactorController < Api::V1::BaseController
def send_code
code = current_user.two_factor_codes.create!
SmsSender.send(
to: current_user.phone_number,
message: "Votre code est #{code.code}"
)
render json: { success: true }
end
def verify
record = current_user.two_factor_codes.last
if record&.code == params[:code].to_i && !record.expired?
token = JwtEncoder.encode(user_id: current_user.id)
render json: { success: true, token: token }
else
render json: { success: false }, status: :unauthorized
end
end
end
L'application Flutter se contente d'appeler ces endpoints.
Toute la logique de sécurité reste côté backend.
Points importants a ne pas oublier
Rate limiting
Sans limite, un utilisateur pourrait demander des dizaines de SMS en quelques minutes.
Il faut donc ajouter :
un delai minimum entre deux envois
un nombre maximum de codes par jour
Envoi asynchrone avec ActiveJob
Ne jamais envoyer un SMS dans une requête synchrone.
Créer un job dédié :
class SendTwoFactorSmsJob < ApplicationJob
queue_as :default
def perform(user_id, code)
user = User.find(user_id)
SmsSender.send(
to: user.phone_number,
message: "Votre code est #{code}"
)
end
end