L’application a ses pages, ses utilisateurs et une API JSON toute neuve. Le jour où un script de synchronisation, une application mobile ou un autre service veut lire les articles de quelqu’un, ce client n’a ni page de connexion ni cookie : il n’a qu’un en-tête HTTP à sa disposition, et il attend qu’on lui dise quoi y mettre.

Ce chapitre construit ce pont, et j’ai été surpris de constater combien Phoenix avait déjà posé les pierres. Le jeton existe, la table qui le garde existe, la fonction qui le vérifie existe, et le plug à écrire est la copie d’un plug généré, dont seule la première ligne change. Tout ce qui suit est fait dans un projet Phoenix 1.8.14 sur Elixir 1.19.5, après mix phx.gen.auth Accounts User users --live puis mix phx.gen.json Api Article articles title body:text, dans cet ordre, parce que l’ordre compte (voir Les scopes de Phoenix 1.8).

Trois pièces se partagent le travail, et il vaut mieux savoir dès le départ qui fait quoi :

Besoin Responsable
Reconnaître un navigateur, par son cookie de session UserAuth.fetch_current_scope_for_user, généré, dans le pipeline :browser
Reconnaître un client sans navigateur, par son en-tête Authorization un plug à écrire, dans le pipeline :api
Savoir pour qui l’on travaille, quel que soit le chemin d’arrivée %Scope{} dans conn.assigns.current_scope, que les contextes reçoivent en premier argument

Ce que le générateur a préparé, et ce qu’il laisse en suspens

phx.gen.json lancé après phx.gen.auth écrit un contrôleur dont chaque action commence de la même façon :

def index(conn, _params) do
  articles = Api.list_articles(conn.assigns.current_scope)
  render(conn, :index, articles: articles)
end

Le contexte Api filtre tout sur ce scope, c’est le sujet de l’article précédent. La question est de savoir qui le remplit, et la réponse tient dans le routeur :

pipeline :browser do
  # ...
  plug :fetch_current_scope_for_user
end

pipeline :api do
  plug :accepts, ["json"]
end

Le pipeline :api accepte du JSON et s’arrête là. Une fois collée dans le routeur la ligne resources "/articles", ArticleController, except: [:new, :edit] que le générateur affiche en sortie, les six tests du contrôleur, pourtant munis d’un setup :register_and_log_in_user, le disent tous de la même voix :

1) test index lists all articles (DemoApiWeb.ArticleControllerTest)
   ** (KeyError) key :current_scope not found in:

       %{}

Le détail qui compte est dans ce setup : le test a connecté un utilisateur, par le même chemin que le navigateur, un jeton de session déposé dans le cookie. Et cela ne change rien, parce que rien dans :api ne lit la session. C’est une bonne nouvelle déguisée : le contrat est en place, le contrôleur sait exactement ce qu’il attend, et il ne reste qu’à fournir l’assign par un autre chemin que le cookie.

Le jeton que l’on possède déjà

Avant d’inventer quoi que ce soit, regardons ce que phx.gen.auth a mis dans lib/mon_app/accounts/user_token.ex :

@rand_size 32
@session_validity_in_days 14

def build_session_token(user) do
  token = :crypto.strong_rand_bytes(@rand_size)
  dt = user.authenticated_at || DateTime.utc_now(:second)
  {token, %UserToken{token: token, context: "session", user_id: user.id, authenticated_at: dt}}
end

Un jeton de session, c’est trente-deux octets tirés au sort, rangés tels quels dans users_tokens avec l’identifiant de l’utilisateur et la date de création. Il ne prouve rien par lui-même, ce n’est qu’une clé dans une table : pour le vérifier, on cherche la ligne, on vérifie qu’elle a moins de quatorze jours, et l’on rend l’utilisateur. Pour le révoquer, on efface la ligne. Trois fonctions du contexte Accounts font exactement cela, et elles sont déjà écrites :

Accounts.generate_user_session_token(user)   # insère et rend les 32 octets
Accounts.get_user_by_session_token(token)    # {user, token_inserted_at} ou nil
Accounts.delete_user_session_token(token)    # :ok

C’est précisément ce qu’il faut à un client sans navigateur : un secret opaque, à durée de vie bornée, révocable côté serveur. La seule différence avec le cookie est le transport. Trente-deux octets bruts ne passent pas dans un en-tête, on les encode en base64 sans remplissage, la variante prévue pour les URL et les en-têtes, ce qui donne quarante-trois caractères :

Base.url_encode64(token, padding: false)
# "CRYE1RIQoJaUx-6iYk1r-DX8Pqn01y-UXJDQCZlon9U"

Le plug généré pour le navigateur mérite d’être relu, parce que le nôtre en sera la copie :

def fetch_current_scope_for_user(conn, _opts) do
  with {token, conn} <- ensure_user_token(conn),
       {user, token_inserted_at} <- Accounts.get_user_by_session_token(token) do
    conn
    |> assign(:current_scope, Scope.for_user(user))
    |> maybe_reissue_user_session_token(user, token_inserted_at)
  else
    nil -> assign(conn, :current_scope, Scope.for_user(nil))
  end
end

La tête cherche un jeton dans la session ou dans le cookie « se souvenir de moi ». La queue construit le scope. Voici le même plug pour l’API, dans le même module UserAuth, avec une tête qui lit Authorization: Bearer ... et décode le base64, et la même queue :

def fetch_current_scope_for_api_user(conn, _opts) do
  with ["Bearer " <> encoded] <- get_req_header(conn, "authorization"),
       {:ok, token} <- Base.url_decode64(encoded, padding: false),
       {user, _token_inserted_at} <- Accounts.get_user_by_session_token(token) do
    assign(conn, :current_scope, Scope.for_user(user))
  else
    _ -> assign(conn, :current_scope, Scope.for_user(nil))
  end
end

Le with refuse à trois endroits : pas d’en-tête, ou un en-tête d’une autre forme ; un base64 invalide ; un jeton inconnu ou expiré. Dans les trois cas, le scope est vide plutôt qu’absent, et c’est ce qui compte pour la suite : le contrôleur ne verra plus jamais de KeyError, il verra un scope sans utilisateur. Il n’y a pas de réémission du jeton comme dans la version navigateur, un client d’API redemande un jeton quand le sien expire.

Le second plug décide qui passe. Là aussi le modèle existe, require_authenticated_user, à ceci près qu’il redirige vers la page de connexion avec un message flash, ce qui n’a aucun sens pour un client qui n’a pas de page. La version API répond 401 en JSON, et pose l’en-tête www-authenticate qui, selon la RFC 6750, indique au client le schéma attendu :

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)
    |> put_view(json: MonAppWeb.ErrorJSON)
    |> render(:"401")
    |> halt()
  end
end

ErrorJSON est le module généré par phx.new : il transforme le nom du gabarit en message, "401" devient {"errors": {"detail": "Unauthorized"}}, le même format que le 404 et le 500. Le halt() final arrête le pipeline avant le contrôleur.

Le routeur : un plug pour tout le monde, un plug pour les routes protégées

Le premier plug rejoint le pipeline :api, pour que toute requête JSON ait un scope, vide ou non. Le second ne s’applique qu’aux routes qui exigent quelqu’un, dans un scope séparé, comme le fait le routeur généré pour les pages de réglages :

pipeline :api do
  plug :accepts, ["json"]
  plug :fetch_current_scope_for_api_user
end

scope "/api", MonAppWeb do
  pipe_through :api

  post "/session", ApiSessionController, :create
end

scope "/api", MonAppWeb do
  pipe_through [:api, :require_authenticated_api_user]

  delete "/session", ApiSessionController, :delete
  resources "/articles", ArticleController, except: [:new, :edit]
end

POST /api/session est la seule route ouverte, puisque c’est là que l’on obtient le jeton. Tout le reste passe derrière le second plug.

La porte d’entrée : un mot de passe contre un jeton

Le contrôleur de session tient en deux actions. La première vérifie le couple courriel et mot de passe avec la fonction générée, génère un jeton et le rend encodé, avec le code 201 puisqu’une session vient d’être créée. Un échec vaut un 401, sans préciser si c’est le courriel ou le mot de passe qui cloche, et il passe par le FallbackController pour que le format d’erreur soit le même partout :

defmodule MonAppWeb.ApiSessionController do
  use MonAppWeb, :controller

  alias MonApp.Accounts

  action_fallback MonAppWeb.FallbackController

  def create(conn, %{"email" => email, "password" => password}) do
    if user = Accounts.get_user_by_email_and_password(email, password) do
      token = Accounts.generate_user_session_token(user)

      conn
      |> put_status(:created)
      |> json(%{token: Base.url_encode64(token, padding: false)})
    else
      {:error, :unauthorized}
    end
  end

  def delete(conn, _params) do
    ["Bearer " <> encoded] = get_req_header(conn, "authorization")
    {:ok, token} = Base.url_decode64(encoded, padding: false)
    Accounts.delete_user_session_token(token)
    send_resp(conn, :no_content, "")
  end
end

La seconde action relit le jeton qui vient de franchir le plug, ce qui autorise les deux = sans filet, et l’efface. Le client qui l’utilisait encore se retrouve devant un 401 à la requête suivante. Le FallbackController reçoit une clause de plus, jumelle de celle du plug :

def call(conn, {:error, :unauthorized}) do
  conn
  |> put_resp_header("www-authenticate", "Bearer")
  |> put_status(:unauthorized)
  |> put_view(json: MonAppWeb.ErrorJSON)
  |> render(:"401")
end

Un point que l’article sur phx.gen.auth rend prévisible : en 1.8, un compte s’inscrit par lien magique, et son hashed_password est nil tant que l’utilisateur ne s’en est pas donné un. Pour lui, get_user_by_email_and_password/2 rend nil, quel que soit le mot de passe proposé. Le chemin naturel est donc que l’utilisateur pose son mot de passe dans ses réglages, la page /users/settings générée, avant d’appeler l’API. L’autre chemin, tout aussi valable, est de lui offrir sur cette même page un bouton « générer un jeton » qui appelle generate_user_session_token et affiche le résultat une seule fois : le plug reste le même, seule la porte change.

Les tests obtiennent leur jeton par une fonction de ConnCase

Le générateur a écrit log_in_user/3 dans test/support/conn_case.ex : un jeton, déposé dans la session de test. Son équivalent pour l’API dépose le même jeton dans l’en-tête, et une fonction de setup l’enveloppe, calquée sur register_and_log_in_user :

def register_and_log_in_api_user(%{conn: conn}) do
  user = MonApp.AccountsFixtures.user_fixture()
  scope = MonApp.Accounts.Scope.for_user(user)
  %{conn: log_in_api_user(conn, user), user: user, scope: scope}
end

def log_in_api_user(conn, user) do
  token = MonApp.Accounts.generate_user_session_token(user)
  encoded = Base.url_encode64(token, padding: false)
  Plug.Conn.put_req_header(conn, "authorization", "Bearer " <> encoded)
end

Dans article_controller_test.exs, une ligne change, setup :register_and_log_in_user devient setup :register_and_log_in_api_user, et les six tests passent. Le contrôleur généré n’a pas été touché.

Le contrôleur de session mérite ses propres tests, et ils racontent bien ce qu’on vient de construire. La fixture set_password/1, générée elle aussi, donne un mot de passe à un compte inscrit par lien magique :

setup %{conn: conn} do
  user = user_fixture() |> set_password()
  {:ok, conn: put_req_header(conn, "accept", "application/json"), user: user}
end

test "returns a token for a valid email and password", %{conn: conn, user: user} do
  conn = post(conn, ~p"/api/session", email: user.email, password: valid_user_password())
  assert %{"token" => token} = json_response(conn, 201)
  assert {:ok, _raw} = Base.url_decode64(token, padding: false)
end

test "refuses an account without a password", %{conn: conn} do
  user = user_fixture()
  conn = post(conn, ~p"/api/session", email: user.email, password: valid_user_password())
  assert json_response(conn, 401)
end

test "is not a browser session: the cookie is ignored", %{conn: conn, user: user} do
  conn = log_in_user(conn, user)
  assert conn |> get(~p"/api/articles") |> json_response(401)
end

test "stops working once revoked", %{conn: conn, user: user} do
  conn = log_in_api_user(conn, user)
  assert conn |> get(~p"/api/articles") |> json_response(200)

  assert conn |> delete(~p"/api/session") |> response(204)
  assert conn |> get(~p"/api/articles") |> json_response(401)
end

test "expires with the session token", %{conn: conn, user: user} do
  token = MonApp.Accounts.generate_user_session_token(user)
  offset_user_token(token, -15, :day)
  encoded = Base.url_encode64(token, padding: false)
  conn = put_req_header(conn, "authorization", "Bearer " <> encoded)
  assert conn |> get(~p"/api/articles") |> json_response(401)
end

Le troisième test est celui que je garderais si je ne devais en garder qu’un : le cookie d’un navigateur connecté n’ouvre pas l’API. Les deux pipelines lisent des endroits différents, et c’est voulu, un jeton copié dans un script ne donne pas accès aux pages, et une session de navigateur ne fuit pas vers l’API. offset_user_token/3, autre fixture générée, recule la date du jeton en base pour prouver l’expiration sans attendre deux semaines. Au total, 135 tests, 0 échec.

Vu du client

Ce que voit un client qui ne connaît de l’application que son URL, son courriel et son mot de passe. Les réponses ci-dessous sont réelles, obtenues avec Req branché directement sur l’endpoint par son option plug:, ce qui exécute tout le pipeline sans ouvrir de port :

req = Req.new(plug: MonAppWeb.Endpoint, retry: false)

Req.get!(req, url: "/api/articles")
# HTTP 401, www-authenticate: Bearer
# %{"errors" => %{"detail" => "Unauthorized"}}

Req.post!(req, url: "/api/session", json: %{email: "ada@example.com", password: "nope"})
# HTTP 401, www-authenticate: Bearer

resp = Req.post!(req, url: "/api/session", json: %{email: "ada@example.com", password: "correct horse battery"})
# HTTP 201
# %{"token" => "CRYE1RIQoJaUx-6iYk1r-DX8Pqn01y-UXJDQCZlon9U"}
jeton = resp.body["token"]

Req.get!(req, url: "/api/articles", auth: {:bearer, jeton})
# HTTP 200
# %{"data" => []}

Req.post!(req, url: "/api/articles", auth: {:bearer, jeton}, json: %{article: %{title: "Premier", body: "Bonjour"}})
# HTTP 201, location: /api/articles/1

Req.delete!(req, url: "/api/session", auth: {:bearer, jeton})
# HTTP 204

Req.get!(req, url: "/api/articles", auth: {:bearer, jeton})
# HTTP 401, www-authenticate: Bearer

auth: {:bearer, jeton} pose l’en-tête Authorization: Bearer ... ; en curl, c’est -H "Authorization: Bearer $JETON". Et dans les journaux du serveur, la ligne Parameters: %{"email" => "ada@example.com", "password" => "[FILTERED]"} rappelle que Phoenix masque le mot de passe par défaut ; l’en-tête Authorization, lui, n’est pas journalisé.

Lundi matin, dans un vrai projet

Les deux plugs vivent dans UserAuth, à côté de leurs jumeaux navigateur, et les tests d’authentification générés continuent de passer puisque rien n’y a bougé. C’est l’endroit où un collègue ira chercher, et l’endroit où l’on découvrira sans surprise, six mois plus tard, que les deux chemins d’arrivée sont construits de la même façon.

Deux comportements viennent gratuitement avec le choix du jeton de session, et il vaut mieux les connaître. Quand l’utilisateur change son mot de passe, update_user_password/2 efface tous ses jetons, dans une transaction : les sessions de navigateur, et donc aussi les jetons d’API. C’est exactement ce que l’on souhaite après une fuite, et c’est une chose à dire dans la documentation de l’API. Et la validité de quatorze jours est celle de @session_validity_in_days dans UserToken : la changer change les deux.

Le jour où l’on veut des jetons d’API plus longs que les sessions, ou qu’on préfère ne pas garder le secret en clair en base, le même fichier montre comment faire : les jetons de lien magique et de changement de courriel sont hachés avant d’être rangés, avec un contexte propre, et une fonction verify_*_query par contexte. Un contexte "api" construit sur ce modèle laisse le plug inchangé, seule la fonction appelée dans le with change.

Restent les sujets que cet article laisse volontairement de côté, parce qu’ils sont les autres points de la liste dressée à la fin de l’article sur l’API REST : la limitation du débit sur POST /api/session, qui est la route qu’un attaquant essaiera en boucle ; le versionnage ; et le HTTPS, sans lequel un jeton Bearer voyage en clair, ce que la RFC 6750 rappelle en toutes lettres.

Vais-je l’essayer ?

Ce qui m’a plu ici, c’est de n’avoir rien inventé. Le jeton, sa table, sa vérification et sa révocation étaient dans le code généré, et le plug à écrire était la copie d’un plug généré. Les six tests qui échouaient sont passés en changeant une ligne de setup, et le contrôleur n’a pas bougé d’un caractère. Ce qui reste, la porte d’entrée et ses tests, est le code le plus ordinaire qui soit.

Le notebook qui accompagne cet article reconstruit tout en miniature, sans base de données, avec un Agent en guise de table users_tokens et Req branché sur le routeur : la KeyError de départ, les deux plugs, la porte d’entrée, la révocation, l’expiration, et un formulaire pour demander un jeton soi-même : Ouvrir une API à un client sans navigateur : le jeton Bearer à nu.

À retenir

  • Le pipeline :api d’un projet neuf n’assigne rien : un contrôleur produit par phx.gen.json après phx.gen.auth lit conn.assigns.current_scope et ses six tests échouent sur KeyError, même quand le test a connecté un utilisateur par cookie.
  • Le jeton d’API est le jeton de session du générateur : trente-deux octets aléatoires gardés tels quels dans users_tokens, valables quatorze jours, révocables en effaçant la ligne. Sur le fil, Base.url_encode64(token, padding: false), soit quarante-trois caractères.
  • fetch_current_scope_for_api_user est fetch_current_scope_for_user dont seule la tête change, Authorization: Bearer au lieu de la session ; la queue reste Scope.for_user/1 dans :current_scope, vide plutôt qu’absent en cas d’échec.
  • require_authenticated_api_user répond 401 en JSON par ErrorJSON, pose www-authenticate: Bearer, et halt/1 ; il ne redirige jamais.
  • POST /api/session échange courriel et mot de passe contre un jeton, DELETE /api/session l’efface ; un compte inscrit par lien magique se donne d’abord un mot de passe dans /users/settings.
  • Un cookie n’ouvre pas l’API et un jeton n’ouvre pas les pages ; changer de mot de passe révoque les deux.
  • Dans les tests, log_in_api_user/2 pose l’en-tête, set_password/1 et offset_user_token/3 sont des fixtures générées ; Req.new(plug: Endpoint) rejoue l’échange complet sans réseau.