Ouvrir une API à un client sans navigateur : le jeton Bearer à nu
Section
Retour vers le sommaire des tips Accueil
Mix.install([
{:phoenix, "~> 1.8"},
{:plug, "~> 1.16"},
{:jason, "~> 1.4"},
{:req, "~> 0.5"},
{:pbkdf2_elixir, "~> 2.0"},
{:kino, "~> 0.19.0"}
])
De quoi s'agit-il ?
Une application Phoenix sait déjà reconnaître ses utilisateurs quand ils viennent avec un navigateur : phx.gen.auth a posé un cookie de session, et un plug le lit à chaque requête. Un script, une application mobile ou un autre service n'ont pas de cookie. Ils ont un en-tête Authorization, et c'est tout.
Ce notebook construit, en miniature et sans base de données, ce qu'il faut pour que ce client-là soit reconnu lui aussi : une porte d'entrée qui échange un mot de passe contre un jeton, un plug qui lit le jeton et remplit le même %Scope{} que le cookie, et une révocation. Chaque étape se prouve par une requête réelle, envoyée par Req directement dans le routeur, sans ouvrir de port.
L'article jumeau, qui fait la même chose dans une application complète produite par phx.gen.auth et phx.gen.json : Ouvrir son API à un client sans navigateur : le jeton Bearer, du mot de passe au scope.
1. Le décor : des comptes en mémoire, un routeur, et un client sans réseau
Trois modules tiennent lieu d'application. Comptes garde des utilisateurs dans un Agent, avec un mot de passe haché par Pbkdf2, exactement comme le ferait le schéma généré. Comptes.Scope est la copie de celui que phx.gen.auth écrit : une struct avec un champ user, et for_user/1 qui rend nil quand il n'y a personne. Articles range des articles par utilisateur, et ses fonctions prennent un scope en premier argument, comme celles que phx.gen.json produit après l'authentification.
defmodule Comptes.Scope do
defstruct user: nil
def for_user(%{id: _} = user), do: %__MODULE__{user: user}
def for_user(nil), do: nil
end
defmodule Comptes do
use Agent
def start_link(_), do: Agent.start_link(fn -> %{utilisateurs: %{}, jetons: %{}} end, name: __MODULE__)
def inscrire(email, mot_de_passe) do
utilisateur = %{
id: System.unique_integer([:positive]),
email: email,
hashed_password: Pbkdf2.hash_pwd_salt(mot_de_passe)
}
Agent.update(__MODULE__, &put_in(&1, [:utilisateurs, email], utilisateur))
utilisateur
end
def utilisateur_par_email_et_mot_de_passe(email, mot_de_passe) do
utilisateur = Agent.get(__MODULE__, &get_in(&1, [:utilisateurs, email]))
if utilisateur && Pbkdf2.verify_pass(mot_de_passe, utilisateur.hashed_password) do
utilisateur
else
Pbkdf2.no_user_verify()
nil
end
end
end
defmodule Articles do
use Agent
def start_link(_), do: Agent.start_link(fn -> [] end, name: __MODULE__)
def lister(%Comptes.Scope{user: user}) do
Agent.get(__MODULE__, &Enum.filter(&1, fn a -> a.user_id == user.id end))
end
def creer(%Comptes.Scope{user: user}, %{"title" => titre}) do
article = %{id: System.unique_integer([:positive]), title: titre, user_id: user.id}
Agent.update(__MODULE__, &[article | &1])
{:ok, article}
end
end
Kino.start_child({Comptes, []})
Kino.start_child({Articles, []})
ada = Comptes.inscrire("ada@example.com", "correct horse battery")
ada.email
Le contrôleur est celui que phx.gen.json écrit : chaque action lit conn.assigns.current_scope et le passe au contexte. Le routeur est un Phoenix.Router ordinaire, dont le pipeline :api fait ce que fait celui d'un projet neuf, accepter le JSON et le décoder.
defmodule Api.ArticleController do
use Phoenix.Controller, formats: [:json]
import Plug.Conn
def index(conn, _params) do
json(conn, %{data: Articles.lister(conn.assigns.current_scope)})
end
def create(conn, %{"article" => attrs}) do
{:ok, article} = Articles.creer(conn.assigns.current_scope, attrs)
conn
|> put_status(:created)
|> json(%{data: article})
end
end
defmodule Api.RouteurSansJeton do
use Phoenix.Router
import Plug.Conn
import Phoenix.Controller
pipeline :api do
plug :accepts, ["json"]
plug Plug.Parsers, parsers: [:json], json_decoder: Jason
end
scope "/api", Api do
pipe_through :api
get "/articles", ArticleController, :index
post "/articles", ArticleController, :create
end
end
Le client sera Req. Son option plug: lui fait exécuter le vrai pipeline, encodage de la requête et décodage de la réponse compris, en remettant la conn au routeur au lieu de l'envoyer sur le réseau. Rien n'écoute sur aucun port, et pourtant ce qui suit est exactement ce qu'un client HTTP verrait.
client = Req.new(plug: Api.RouteurSansJeton, retry: false)
try do
Req.get!(client, url: "/api/articles")
rescue
e in Plug.Conn.WrapperError ->
erreur = Exception.normalize(e.kind, e.reason, e.stack)
"#{inspect(erreur.__struct__)} : #{Exception.message(erreur)}"
end
key :current_scope not found, que le routeur enveloppe dans un Plug.Conn.WrapperError pour conserver la conn au moment de l'erreur. Le pipeline :api n'a rien assigné, et le contrôleur, lui, compte dessus. C'est le point de départ, et c'est le même dans un projet réel : phx.gen.json lancé après phx.gen.auth produit ce contrôleur, et les six tests qu'il génère échouent sur cette clé.
2. Le jeton : trente-deux octets tirés au sort, gardés côté serveur
Ce qu'on va remettre au client est ce que phx.gen.auth appelle un jeton de session : une suite d'octets aléatoires, rangée telle quelle avec l'identifiant de l'utilisateur et sa date de création. Un jeton ne prouve rien par lui-même, il n'est qu'une clé dans une table, et c'est précisément ce qui permet de le révoquer en effaçant la ligne. Sa validité est celle du générateur, quatorze jours.
Sur le fil, le jeton voyage en base64 sans remplissage, la variante prévue pour les URL et les en-têtes.
defmodule Comptes.Jetons do
@validite_en_jours 14
def generer(utilisateur) do
jeton = :crypto.strong_rand_bytes(32)
Agent.update(
Comptes,
&put_in(&1, [:jetons, jeton], %{user_id: utilisateur.id, inserted_at: DateTime.utc_now()})
)
jeton
end
def utilisateur_par_jeton(jeton) do
with %{user_id: id, inserted_at: cree_le} <- Agent.get(Comptes, &get_in(&1, [:jetons, jeton])),
true <- DateTime.diff(DateTime.utc_now(), cree_le, :day) < @validite_en_jours do
Agent.get(Comptes, fn etat -> Enum.find(Map.values(etat.utilisateurs), &(&1.id == id)) end)
else
_ -> nil
end
end
def supprimer(jeton), do: Agent.update(Comptes, &update_in(&1, [:jetons], fn j -> Map.delete(j, jeton) end))
def encoder(jeton), do: Base.url_encode64(jeton, padding: false)
def decoder(chaine), do: Base.url_decode64(chaine, padding: false)
end
jeton = Comptes.Jetons.generer(ada)
{byte_size(jeton), Comptes.Jetons.encoder(jeton), Comptes.Jetons.utilisateur_par_jeton(jeton).email}
Trente-deux octets, quarante-trois caractères une fois encodés, et le jeton retrouve Ada.
3. Deux plugs : lire l'en-tête, puis exiger quelqu'un
Le plug de lecture a la même forme que fetch_current_scope_for_user, celui que phx.gen.auth a mis dans le pipeline :browser. Seule la tête change : au lieu de chercher un jeton dans la session, on le lit dans Authorization: Bearer .... La queue est identique, Scope.for_user(user) dans l'assign :current_scope, et un scope vide quand rien ne va.
Le second plug refuse en JSON, avec le code 401 et l'en-tête www-authenticate qui indique au client la forme attendue. Il n'y a pas de redirection vers une page de connexion, puisque le client n'a pas de page.
defmodule Api.Auth do
import Plug.Conn
import Phoenix.Controller, only: [json: 2]
def fetch_current_scope_for_api_user(conn, _opts) do
with ["Bearer " <> encode] <- get_req_header(conn, "authorization"),
{:ok, jeton} <- Comptes.Jetons.decoder(encode),
%{} = utilisateur <- Comptes.Jetons.utilisateur_par_jeton(jeton) do
assign(conn, :current_scope, Comptes.Scope.for_user(utilisateur))
else
_ -> assign(conn, :current_scope, Comptes.Scope.for_user(nil))
end
end
def require_authenticated_api_user(conn, _opts) do
if conn.assigns.current_scope && conn.assigns.current_scope.user do
conn
else
conn
|> put_resp_header("www-authenticate", "Bearer")
|> put_status(:unauthorized)
|> json(%{errors: %{detail: "Unauthorized"}})
|> halt()
end
end
end
Le premier plug rejoint le pipeline :api, pour que toute requête JSON ait un scope, vide ou non. Le second ne protège que les routes qui l'exigent.
defmodule Api.Routeur do
use Phoenix.Router
import Plug.Conn
import Phoenix.Controller
import Api.Auth
pipeline :api do
plug :accepts, ["json"]
plug Plug.Parsers, parsers: [:json], json_decoder: Jason
plug :fetch_current_scope_for_api_user
end
scope "/api", Api do
pipe_through :api
post "/session", SessionController, :create
end
scope "/api", Api do
pipe_through [:api, :require_authenticated_api_user]
delete "/session", SessionController, :delete
get "/articles", ArticleController, :index
post "/articles", ArticleController, :create
end
end
Le routeur cite un SessionController qui n'existe pas encore : Phoenix le signale par un avertissement à la compilation, mais ne le cherche vraiment qu'au moment où la route est appelée, ce qui laisse le temps de l'écrire à la section suivante. L'avertissement disparaît quand on réexécute cette cellule après la section 4. En attendant, la route des articles répond déjà, et elle répond non.
client = Req.new(plug: Api.Routeur, retry: false)
reponse = Req.get!(client, url: "/api/articles")
{reponse.status, Req.Response.get_header(reponse, "www-authenticate"), reponse.body}
Plus de KeyError : le scope existe, il est vide, et le second plug s'arrête là. Avec le jeton de la section précédente dans l'en-tête, la même route répond 200. Req sait poser cet en-tête, c'est l'option auth: {:bearer, ...}.
reponse = Req.get!(client, url: "/api/articles", auth: {:bearer, Comptes.Jetons.encoder(jeton)})
{reponse.status, reponse.body}
4. La porte d'entrée : un mot de passe contre un jeton
Il reste à ce que le client obtienne son jeton lui-même. POST /api/session reçoit un courriel et un mot de passe, et rend un jeton fraîchement généré, encodé pour le fil, avec le code 201 puisqu'une session vient d'être créée. Un mot de passe faux vaut un 401, sans dire si c'est le courriel ou le mot de passe qui cloche.
DELETE /api/session fait l'inverse : il relit le jeton qui a servi à passer le plug, et l'efface. Le client qui l'utilisait encore se retrouve devant un 401 à la requête suivante.
defmodule Api.SessionController do
use Phoenix.Controller, formats: [:json]
import Plug.Conn
def create(conn, %{"email" => email, "password" => mot_de_passe}) do
if utilisateur = Comptes.utilisateur_par_email_et_mot_de_passe(email, mot_de_passe) do
jeton = Comptes.Jetons.generer(utilisateur)
conn
|> put_status(:created)
|> json(%{token: Comptes.Jetons.encoder(jeton)})
else
conn
|> put_resp_header("www-authenticate", "Bearer")
|> put_status(:unauthorized)
|> json(%{errors: %{detail: "Unauthorized"}})
end
end
def delete(conn, _params) do
["Bearer " <> encode] = get_req_header(conn, "authorization")
{:ok, jeton} = Comptes.Jetons.decoder(encode)
Comptes.Jetons.supprimer(jeton)
send_resp(conn, :no_content, "")
end
end
mauvais = Req.post!(client, url: "/api/session", json: %{email: ada.email, password: "nope"})
bon = Req.post!(client, url: "/api/session", json: %{email: ada.email, password: "correct horse battery"})
{mauvais.status, bon.status, bon.body}
5. Toute la conversation, vue du client
Voici ce qu'écrit un client qui ne connaît de l'application que son URL, son courriel et son mot de passe. Chaque ligne du tableau est une requête réelle passée par le routeur.
etape = fn nom, reponse -> %{etape: nom, statut: reponse.status, corps: inspect(reponse.body, limit: 6)} end
session = Req.post!(client, url: "/api/session", json: %{email: ada.email, password: "correct horse battery"})
jeton_du_client = session.body["token"]
avec_jeton = Req.merge(client, auth: {:bearer, jeton_du_client})
[
etape.("sans en-tête", Req.get!(client, url: "/api/articles")),
etape.("POST /api/session", session),
etape.("GET /api/articles", Req.get!(avec_jeton, url: "/api/articles")),
etape.("POST /api/articles", Req.post!(avec_jeton, url: "/api/articles", json: %{article: %{title: "Premier"}})),
etape.("GET /api/articles", Req.get!(avec_jeton, url: "/api/articles")),
etape.("DELETE /api/session", Req.delete!(avec_jeton, url: "/api/session")),
etape.("GET après révocation", Req.get!(avec_jeton, url: "/api/articles"))
]
|> Kino.DataTable.new(keys: [:etape, :statut, :corps])
Une chose que le tableau ne montre pas : le jeton n'est pas la session du navigateur. Un cookie posé par phx.gen.auth n'ouvre pas /api/articles, et un jeton Bearer n'ouvre pas /users/settings. Les deux pipelines lisent des endroits différents, et c'est voulu : un jeton volé dans un script ne donne pas accès aux pages, et une session de navigateur ne fuit pas vers l'API.
Reste l'expiration. Le jeton meurt avec sa date : reculons-la de quinze jours et regardons.
nouveau = Comptes.Jetons.generer(ada)
vieux_de_quinze_jours = DateTime.add(DateTime.utc_now(), -15, :day)
Agent.update(Comptes, &put_in(&1, [:jetons, nouveau, :inserted_at], vieux_de_quinze_jours))
reponse = Req.get!(client, url: "/api/articles", auth: {:bearer, Comptes.Jetons.encoder(nouveau)})
{reponse.status, reponse.body}
6. À vous : demander un jeton
Le formulaire ci-dessous joue le client. Le mot de passe d'Ada est correct horse battery ; pour essayer avec un autre compte, ajoutez un Comptes.inscrire("vous@example.com", "un mot de passe") dans la cellule de la section 1, celle qui définit Comptes, et réexécutez-la.
formulaire =
Kino.Control.form(
[email: Kino.Input.text("Courriel", default: "ada@example.com"), password: Kino.Input.password("Mot de passe")],
submit: "Obtenir un jeton et lister mes articles"
)
zone = Kino.Frame.new()
Kino.listen(formulaire, fn %{data: %{email: email, password: mot_de_passe}} ->
session = Req.post!(client, url: "/api/session", json: %{email: email, password: mot_de_passe})
texte =
case session do
%{status: 201, body: %{"token" => t}} ->
articles = Req.get!(client, url: "/api/articles", auth: {:bearer, t})
"**201** jeton `#{t}`\n\n**#{articles.status}** `#{inspect(articles.body)}`"
%{status: statut, body: corps} ->
"**#{statut}** `#{inspect(corps)}`"
end
Kino.Frame.render(zone, Kino.Markdown.new(texte))
end)
Kino.Layout.grid([formulaire, zone])
À retenir
- Le pipeline
:apid'un projet neuf n'assigne rien : un contrôleur produit parphx.gen.jsonaprèsphx.gen.authlitconn.assigns.current_scopeet lèveKeyErrortant qu'un plug ne l'a pas rempli. - Le jeton d'API est le jeton de session du générateur : trente-deux octets aléatoires gardés côté serveur, valables quatorze jours, révocables en effaçant la ligne. Sur le fil,
Base.url_encode64(jeton, padding: false). - Le plug de lecture est
fetch_current_scope_for_userdont seule la tête change,Authorization: Bearerau lieu de la session ; la queue resteScope.for_user/1dans:current_scope. - Le plug d'exigence répond 401 en JSON avec
www-authenticate: Bearer, ethalt/1; il ne redirige jamais. POST /api/sessionéchange courriel et mot de passe contre un jeton,DELETE /api/sessionl'efface ; un compte inscrit par lien magique doit d'abord se donner un mot de passe.- Un cookie n'ouvre pas l'API et un jeton n'ouvre pas les pages : les deux pipelines lisent des endroits différents.
Req.new(plug: Routeur)exécute le vrai pipeline sans réseau, etauth: {:bearer, jeton}pose l'en-tête.- Documentation : mix phx.gen.auth, Req