Repository: etalab/transport-site
Pull request #4516: API : plug token_auth sans compter les requêtes
Author: login: AntoineAugusti
Created: 2025-04-07T14:22:25Z | Merged: 2025-04-18T07:20:08Z

Suite de #4505

Ajoute une couche d'autorisation dans l'API. Le secret peut être passé dans un header HTTP `authorization`, cette valeur sera ensuite comparée avec ce qui existe en BDD et la requête sera autorisée ou non si un token n'est pas trouvé.

Quand le header HTTP n'est pas présent la requête est autorisée.

C'est donc une PR très peu impactante. Plus tard j'ajouterai du code pour bien compter les requêtes HTTP.


Diff:
diff --git a/apps/transport/lib/transport_web/api/plugs/token_auth.ex b/apps/transport/lib/transport_web/api/plugs/token_auth.ex
new file mode 100644
index 0000000000..d0a164477c
--- /dev/null
+++ b/apps/transport/lib/transport_web/api/plugs/token_auth.ex
@@ -0,0 +1,39 @@
+defmodule TransportWeb.API.Plugs.TokenAuth do
+  @moduledoc """
+  A plug handling authorization for HTTP requests through tokens stored
+  in the database in the `token` table.
+
+  When an `authorization` HTTP header is set:
+  - If the request is authorized, the plug adds the token to the conn assigns for further use.
+  - Otherwise, respond with a 401 response.
+
+  If the `authorization` HTTP header is not set the request can go through.
+
+  Later we will want to count requests by path/token etc.
+  """
+  import Plug.Conn
+
+  def init(default), do: default
+
+  def call(%Plug.Conn{} = conn, _) do
+    case find_client(get_req_header(conn, "authorization")) do
+      {:ok, token} ->
+        conn |> assign(:token, token)
+
+      :error ->
+        conn
+        |> put_status(:unauthorized)
+        |> Phoenix.Controller.json(%{error: "You must set a valid Authorization header"})
+        |> halt()
+    end
+  end
+
+  defp find_client([authorization]) do
+    case DB.Repo.get_by(DB.Token, secret_hash: authorization) do
+      %DB.Token{} = token -> {:ok, token}
+      nil -> :error
+    end
+  end
+
+  defp find_client(_), do: {:ok, nil}
+end
diff --git a/apps/transport/lib/transport_web/api/router.ex b/apps/transport/lib/transport_web/api/router.ex
index df3f8104aa..7b8a94da2c 100644
--- a/apps/transport/lib/transport_web/api/router.ex
+++ b/apps/transport/lib/transport_web/api/router.ex
@@ -17,12 +17,19 @@ defmodule TransportWeb.API.Router do
     plug(TransportWeb.API.Plugs.PublicCache, max_age: 60)
   end
 
+  # Authorization for the GTFS validator.
+  # The list of `(user, secret)` is set manually through the app config.
   pipeline :simple_token_auth do
     plug(TransportWeb.API.Plugs.Auth)
   end
 
+  # Authenticate users using the `token` table.
+  pipeline :token_auth do
+    plug(TransportWeb.API.Plugs.TokenAuth)
+  end
+
   scope "/api/" do
-    pipe_through([:accept_json, :api])
+    pipe_through([:accept_json, :api, :token_auth])
     get("/", TransportWeb.Redirect, to: "/swaggerui")
 
     scope "/aoms" do
@@ -59,9 +66,12 @@ defmodule TransportWeb.API.Router do
     end
 
     get("/gtfs-stops", TransportWeb.API.GTFSStopsController, :index)
+  end
+
+  scope "/api" do
+    pipe_through(:simple_token_auth)
 
     scope "/validators" do
-      pipe_through(:simple_token_auth)
       get("/gtfs-transport", TransportWeb.API.ValidatorsController, :gtfs_transport)
     end
   end
diff --git a/apps/transport/test/support/factory.ex b/apps/transport/test/support/factory.ex
index 01d97e641f..412098415a 100644
--- a/apps/transport/test/support/factory.ex
+++ b/apps/transport/test/support/factory.ex
@@ -338,16 +338,6 @@ defmodule DB.Factory do
     }
   end
 
-  def token_factory do
-    %DB.Token{
-      name: sequence(:name, &"datagouv_id-#{&1}"),
-      secret: "secret",
-      secret_hash: "secret_hash",
-      organization: build(:organization),
-      contact: insert_contact()
-    }
-  end
-
   def reuse_factory do
     %DB.Reuse{
       datagouv_id: sequence(:datagouv_id, &"datagouv_id-#{&1}"),
@@ -375,6 +365,20 @@ defmodule DB.Factory do
     }
   end
 
+  def insert_token(%{} = args \\ %{}) do
+    args =
+      %{
+        secret: "secret",
+        name: "name",
+        contact_id: insert_contact().id,
+        organization_id: insert(:organization).id
+      }
+      |> Map.merge(args)
+
+    DB.Token.changeset(%DB.Token{}, args)
+    |> DB.Repo.insert!()
+  end
+
   def insert_contact(%{} = args \\ %{}) do
     %{
       first_name: "John",
diff --git a/apps/transport/test/transport_web/controllers/api/datasets_controller_test.exs b/apps/transport/test/transport_web/controllers/api/datasets_controller_test.exs
index a9ddd4a822..558e2c4d3c 100644
--- a/apps/transport/test/transport_web/controllers/api/datasets_controller_test.exs
+++ b/apps/transport/test/transport_web/controllers/api/datasets_controller_test.exs
@@ -24,6 +24,36 @@ defmodule TransportWeb.API.DatasetControllerTest do
     assert_schema(json, "DatasetsResponse", api_spec)
   end
 
+  describe "token_auth" do
+    test "GET /api/datasets with an invalid token", %{conn: conn} do
+      assert conn
+             |> put_req_header("authorization", "invalid")
+             |> get(Helpers.dataset_path(conn, :datasets))
+             |> json_response(401) == %{"error" => "You must set a valid Authorization header"}
+    end
+
+    test "GET /api/datasets with a valid token", %{conn: conn} do
+      token = insert_token()
+
+      assert conn
+             |> put_req_header("authorization", token.secret)
+             |> get(Helpers.dataset_path(conn, :datasets))
+             |> json_response(200)
+    end
+
+    test "GET /api/datasets/:id with a valid token", %{conn: conn} do
+      token = insert_token()
+      dataset = insert(:dataset)
+
+      setup_empty_history_resources()
+
+      assert conn
+             |> put_req_header("authorization", token.secret)
+             |> get(Helpers.dataset_path(conn, :by_id, dataset.datagouv_id))
+             |> json_response(200)
+    end
+  end
+
   test "GET /api/datasets does not include inactive or hidden datasets", %{conn: conn} do
     insert(:dataset, is_active: false)
     insert(:dataset, is_active: true, is_hidden: true)
diff --git a/apps/transport/test/transport_web/controllers/reuser_space_controller_test.exs b/apps/transport/test/transport_web/controllers/reuser_space_controller_test.exs
index e20dee3ba1..d1da35f5e8 100644
--- a/apps/transport/test/transport_web/controllers/reuser_space_controller_test.exs
+++ b/apps/transport/test/transport_web/controllers/reuser_space_controller_test.exs
@@ -244,7 +244,7 @@ defmodule TransportWeb.ReuserSpaceControllerTest do
           organizations: [organization |> Map.from_struct()]
         })
 
-      token = insert(:token, organization: organization, contact: contact, name: "Default")
+      token = insert_token(%{organization_id: organization.id, contact_id: contact.id, name: "Default"})
 
       assert conn
              |> Plug.Test.init_test_session(%{current_user: %{"id" => contact.datagouv_user_id}})


Reviews:
user: login: ptitfred
body: Ca serait sans doute pas mal que quelqu'un d'autre review.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T12:34:35Z
user: login: AntoineAugusti
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T13:09:07Z
user: login: thbar
body: J'ai été jouer un peu avec la création de token (sur `prochainement` et en local), pour voir comment ça se traduisait dans un client HTTP (Paw), je n'ai pas rencontré de problème, tout a bien fonctionné.

Au final il faut passer directement la valeur dans `Authorization` comme le dit le code. 

Je n'ai jamais trop utilisé `Authorization` avec une valeur directe (sans `Bearer`) comme c'est ici, du coup j'étais un peu déconcerté. Je vois davantage d'API passer un header `x-api-key` (ex ici https://docs.anthropic.com/en/api/admin-api/apikeys/get-api-key), je me suis demandé si ça ne serait pas plus courant et préférable pour les utilisateurs.

Par ailleurs j'ai été voir + en détail dans Cloak car des points me surprenaient (apparition de la valeur du secret en clair dans les logs Ecto quand on requête via le hash ; ça requête bien le hash mais le log Ecto leake la valeur non hashée - j'ouvrirai un ticket sur le projet Ecto Cloak).

state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T14:40:23Z
user: login: AntoineAugusti
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T15:37:12Z
user: login: thbar
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-18T08:11:13Z
user: login: AntoineAugusti
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-18T08:22:06Z
user: login: thbar
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-18T08:22:44Z

Review comments:
diff_hunk: @@ -17,12 +17,19 @@ defmodule TransportWeb.API.Router do
     plug(TransportWeb.API.Plugs.PublicCache, max_age: 60)
   end
 
+  # Authorization for the GTFS validator.
+  # The list of `(user, secret)` is set manually through the app config.
   pipeline :simple_token_auth do
     plug(TransportWeb.API.Plugs.Auth)
   end
 
+  # Authenticate users using the `token` table.
+  pipeline :token_auth do
+    plug(TransportWeb.API.Plugs.TokenAuth)
+  end
+
   scope "/api/" do
-    pipe_through([:accept_json, :api])
+    pipe_through([:accept_json, :api, :token_auth])
path: apps/transport/lib/transport_web/api/router.ex
user: login: ptitfred
body: Cela rend-il le token obligatoire pour accéder à la doc d'API ?
created_at: 2025-04-09T12:30:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -17,12 +17,19 @@ defmodule TransportWeb.API.Router do
     plug(TransportWeb.API.Plugs.PublicCache, max_age: 60)
   end
 
+  # Authorization for the GTFS validator.
+  # The list of `(user, secret)` is set manually through the app config.
   pipeline :simple_token_auth do
     plug(TransportWeb.API.Plugs.Auth)
   end
 
+  # Authenticate users using the `token` table.
+  pipeline :token_auth do
+    plug(TransportWeb.API.Plugs.TokenAuth)
+  end
+
   scope "/api/" do
-    pipe_through([:accept_json, :api])
+    pipe_through([:accept_json, :api, :token_auth])
path: apps/transport/lib/transport_web/api/router.ex
user: login: AntoineAugusti
body: Non, le plug laisse passer toutes les requêtes quand le header `authorization` est absent pour le moment. C'est pour ça que les tests existants continuent de fonctionner.
created_at: 2025-04-09T13:09:07Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,39 @@
+defmodule TransportWeb.API.Plugs.TokenAuth do
+  @moduledoc """
+  A plug handling authorization for HTTP requests through tokens stored
+  in the database in the `token` table.
+
+  When an `authorization` HTTP header is set:
+  - If the request is authorized, the plug adds the token to the conn assigns for further use.
+  - Otherwise, respond with a 401 response.
+
+  If the `authorization` HTTP header is not set the request can go through.
+
+  Later we will want to count requests by path/token etc.
+  """
+  import Plug.Conn
+
+  def init(default), do: default
+
+  def call(%Plug.Conn{} = conn, _) do
+    case find_client(get_req_header(conn, "authorization")) do
+      {:ok, token} ->
+        conn |> assign(:token, token)
+
+      :error ->
+        conn
+        |> put_status(:unauthorized)
+        |> Phoenix.Controller.json(%{error: "You must set a valid Authorization header"})
+        |> halt()
+    end
+  end
+
+  defp find_client([authorization]) do
+    case DB.Repo.get_by(DB.Token, secret_hash: authorization) do
path: apps/transport/lib/transport_web/api/plugs/token_auth.ex
user: login: thbar
body: Note pour moi-même : ici la version "non hashée" est passée en clair, et même si on voit dans le log Ecto la version en clair aussi (j'irai poser la question de pourquoi ce point), la recherche se fait bien sur le champ hashé (`secret_hash`) et non sur `secret`.
created_at: 2025-04-17T14:17:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,39 @@
+defmodule TransportWeb.API.Plugs.TokenAuth do
+  @moduledoc """
+  A plug handling authorization for HTTP requests through tokens stored
+  in the database in the `token` table.
+
+  When an `authorization` HTTP header is set:
+  - If the request is authorized, the plug adds the token to the conn assigns for further use.
+  - Otherwise, respond with a 401 response.
+
+  If the `authorization` HTTP header is not set the request can go through.
+
+  Later we will want to count requests by path/token etc.
+  """
+  import Plug.Conn
+
+  def init(default), do: default
+
+  def call(%Plug.Conn{} = conn, _) do
+    case find_client(get_req_header(conn, "authorization")) do
+      {:ok, token} ->
+        conn |> assign(:token, token)
+
+      :error ->
+        conn
+        |> put_status(:unauthorized)
+        |> Phoenix.Controller.json(%{error: "You must set a valid Authorization header"})
+        |> halt()
+    end
+  end
+
+  defp find_client([authorization]) do
+    case DB.Repo.get_by(DB.Token, secret_hash: authorization) do
path: apps/transport/lib/transport_web/api/plugs/token_auth.ex
user: login: thbar
body: Il est possible dans un futur lointain (si on passait tout derrière une future version de ce plug, y compris les requêtes proxy) qu'il soit nécessaire d'optimiser cette partie pour éviter de faire une requête SQL additionnelle par requête HTTP.

À court/moyen terme toutefois, ça ne sera pas un problème.
created_at: 2025-04-17T14:20:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -59,9 +66,12 @@ defmodule TransportWeb.API.Router do
     end
 
     get("/gtfs-stops", TransportWeb.API.GTFSStopsController, :index)
+  end
+
+  scope "/api" do
+    pipe_through(:simple_token_auth)
 
     scope "/validators" do
-      pipe_through(:simple_token_auth)
path: apps/transport/lib/transport_web/api/router.ex
user: login: thbar
body: Je n'ai pas compris cette partie ; si je lis bien, avant on avait un seul `scope "/api/"`, et après on en a deux, est-ce bien ça @AntoineAugusti ?

La partie semble sous test donc je ne pense pas que ça ait changé quelque chose, c'était + pour comprendre ta démarche ici.

Merci !
created_at: 2025-04-17T14:29:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -59,9 +66,12 @@ defmodule TransportWeb.API.Router do
     end
 
     get("/gtfs-stops", TransportWeb.API.GTFSStopsController, :index)
+  end
+
+  scope "/api" do
+    pipe_through(:simple_token_auth)
 
     scope "/validators" do
-      pipe_through(:simple_token_auth)
path: apps/transport/lib/transport_web/api/router.ex
user: login: AntoineAugusti
body: On a 2 scopes API car pour le validateur transport on a une autre méthode d'authentification avec une liste d'utilisateurs qui est stockée dans une variable d'environnement. C'est pour distinguer les pipelines qui s'appliquent.

On pourra déprécier cette méthode d'authentification en faveur d'un token en base de données et regrouper dans un seul bloc ensuite.
created_at: 2025-04-17T15:37:12Z
author_association: MEMBER
diff_hunk: @@ -59,9 +66,12 @@ defmodule TransportWeb.API.Router do
     end
 
     get("/gtfs-stops", TransportWeb.API.GTFSStopsController, :index)
+  end
+
+  scope "/api" do
+    pipe_through(:simple_token_auth)
 
     scope "/validators" do
-      pipe_through(:simple_token_auth)
path: apps/transport/lib/transport_web/api/router.ex
user: login: thbar
body: Merci - pardon ça j'avais bien compris, c'est plutôt le fait que du coup il y a une modification sur les étapes réalisées dans le pipeline sur la partie `/validators/gtfs-transport`, dont je ne sais pas si elle est gênante ou pas:

Avant:

```elixir
  scope "/api/" do
    pipe_through([:accept_json, :api])

    # SNIP

    scope "/datasets" do
      # SNIP
    end

    # SNIP

    scope "/validators" do
      pipe_through(:simple_token_auth)
      get("/gtfs-transport", TransportWeb.API.ValidatorsController, :gtfs_transport)
    end
  end
```

Après:

```elixir
  scope "/api/" do
    pipe_through([:accept_json, :api, :token_auth])

    # SNIP

    scope "/datasets" do
      # SNIP
    end

    # SNIP
  end

  scope "/api" do
    pipe_through(:simple_token_auth)

    scope "/validators" do
      get("/gtfs-transport", TransportWeb.API.ValidatorsController, :gtfs_transport)
    end
  end
```

La différence est que `/validators/gtfs-transport` ne passe plus, si je lis bien, par les étapes suivantes:

```elixir
  pipeline :accept_json do
    plug(:accepts, ["json"])
  end

  pipeline :api do
    plug(CORSPlug, origin: "*", credentials: false)
    plug(OpenApiSpex.Plug.PutApiSpec, module: TransportWeb.API.Spec)
    plug(Sentry.PlugContext)
    plug(ETag.Plug)
  end
```

Je n'ai pas évalué l'impact : potentiellement une perte de notifications Sentry sur cette route.

Est-ce que c'est volontaire du coup car tu as vu que ce n'était pas gênant etc, ou pas ?

Merci !
created_at: 2025-04-18T08:11:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -59,9 +66,12 @@ defmodule TransportWeb.API.Router do
     end
 
     get("/gtfs-stops", TransportWeb.API.GTFSStopsController, :index)
+  end
+
+  scope "/api" do
+    pipe_through(:simple_token_auth)
 
     scope "/validators" do
-      pipe_through(:simple_token_auth)
path: apps/transport/lib/transport_web/api/router.ex
user: login: AntoineAugusti
body: https://github.com/etalab/transport-site/pull/4541 remet les plugs comme avant pour cette route
created_at: 2025-04-18T08:22:05Z
author_association: MEMBER
diff_hunk: @@ -59,9 +66,12 @@ defmodule TransportWeb.API.Router do
     end
 
     get("/gtfs-stops", TransportWeb.API.GTFSStopsController, :index)
+  end
+
+  scope "/api" do
+    pipe_through(:simple_token_auth)
 
     scope "/validators" do
-      pipe_through(:simple_token_auth)
path: apps/transport/lib/transport_web/api/router.ex
user: login: thbar
body: Top, merci @AntoineAugusti, je vais review de suite !
created_at: 2025-04-18T08:22:44Z
author_association: CONTRIBUTOR

PR comments:
user: login: AntoineAugusti
created_at: 2025-04-11T07:14:18Z
body: @thbar ou @vdegove l'un de vous peut regarder en plus de @ptitfred ?
author_association: MEMBER
user: login: thbar
created_at: 2025-04-15T15:26:23Z
body: Je regarde avant mon départ en congés fin de semaine !
author_association: CONTRIBUTOR