Powered by AppSignal & Oban Pro

Ouvrir une API à un client sans navigateur : le jeton Bearer à nu

Tips/api_jeton_bearer.livemd

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 :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 lève KeyError tant 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_user dont seule la tête change, Authorization: Bearer au lieu de la session ; la queue reste Scope.for_user/1 dans :current_scope.
  • Le plug d'exigence répond 401 en JSON avec 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 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, et auth: {:bearer, jeton} pose l'en-tête.
  • Documentation : mix phx.gen.auth, Req