From f453b1e3bed29ef54661be04a279795b205d5c0a Mon Sep 17 00:00:00 2001 From: Antoine Augusti Date: Tue, 22 Oct 2024 11:19:20 +0200 Subject: [PATCH] API : proxy et auth pour l'API du validateur GTFS transport (#4251) * API : proxy et auth pour l'API du validateur GTFS transport * PR review --- .../api/controllers/validators_controller.ex | 28 ++++++++ .../lib/transport_web/api/plugs/auth.ex | 39 +++++++++++ .../transport/lib/transport_web/api/router.ex | 9 +++ .../api/validators_controller_test.exs | 67 +++++++++++++++++++ config/config.exs | 2 + config/test.exs | 1 + 6 files changed, 146 insertions(+) create mode 100644 apps/transport/lib/transport_web/api/controllers/validators_controller.ex create mode 100644 apps/transport/lib/transport_web/api/plugs/auth.ex create mode 100644 apps/transport/test/transport_web/controllers/api/validators_controller_test.exs diff --git a/apps/transport/lib/transport_web/api/controllers/validators_controller.ex b/apps/transport/lib/transport_web/api/controllers/validators_controller.ex new file mode 100644 index 0000000000..f41ebce13b --- /dev/null +++ b/apps/transport/lib/transport_web/api/controllers/validators_controller.ex @@ -0,0 +1,28 @@ +defmodule TransportWeb.API.ValidatorsController do + use TransportWeb, :controller + require Logger + + @doc """ + Proxies an HTTP request to the GTFS transport validator while logging a message + with the client calling the validator. + + This is used to keep the GTFS validator private, gather usage and can be used + to add further metrics, quotas or queues. + """ + def gtfs_transport(%Plug.Conn{assigns: %{client: client}} = conn, %{"url" => url}) do + Logger.info("Handling GTFS validation from #{client} for #{url}") + + case Shared.Validation.GtfsValidator.Wrapper.impl().validate_from_url(url) do + {:ok, body} -> conn |> json(body) + {:error, error} -> send_error_response(conn, error) + end + end + + def gtfs_transport(%Plug.Conn{} = conn, _) do + send_error_response(conn, "You must include a GTFS URL") + end + + def send_error_response(%Plug.Conn{} = conn, message) do + conn |> put_status(:bad_request) |> json(%{error: message}) + end +end diff --git a/apps/transport/lib/transport_web/api/plugs/auth.ex b/apps/transport/lib/transport_web/api/plugs/auth.ex new file mode 100644 index 0000000000..5064d980f4 --- /dev/null +++ b/apps/transport/lib/transport_web/api/plugs/auth.ex @@ -0,0 +1,39 @@ +defmodule TransportWeb.API.Plugs.Auth do + @moduledoc """ + A very simple plug handling authorization for HTTP requests through tokens. + It gets the list of (client, token) from the `API_AUTH_CLIENTS` environment variable. + + If the request is authorized, the plug adds the client name to the conn assigns for further use. + Otherwise, a 401 response is sent. + """ + import Plug.Conn + + def init(default), do: default + + def call(%Plug.Conn{} = conn, _) do + case find_client(get_req_header(conn, "authorization")) do + {:ok, client} -> + conn |> assign(:client, client) + + :error -> + conn + |> put_status(:unauthorized) + |> Phoenix.Controller.json(%{error: "You must set a valid Authorization header"}) + |> halt() + end + end + + defp find_client([authorization]) do + # Expected format: `client1:secret_token;client2:other_token` + Application.fetch_env!(:transport, :api_auth_clients) + |> String.split(";") + |> Enum.map(&(&1 |> String.split(":") |> List.to_tuple())) + |> Enum.find_value(:error, fn {client, secret} -> + if Plug.Crypto.secure_compare(authorization, secret) do + {:ok, client} + end + end) + end + + defp find_client(_), do: :error +end diff --git a/apps/transport/lib/transport_web/api/router.ex b/apps/transport/lib/transport_web/api/router.ex index 3519f9831f..df3f8104aa 100644 --- a/apps/transport/lib/transport_web/api/router.ex +++ b/apps/transport/lib/transport_web/api/router.ex @@ -17,6 +17,10 @@ defmodule TransportWeb.API.Router do plug(TransportWeb.API.Plugs.PublicCache, max_age: 60) end + pipeline :simple_token_auth do + plug(TransportWeb.API.Plugs.Auth) + end + scope "/api/" do pipe_through([:accept_json, :api]) get("/", TransportWeb.Redirect, to: "/swaggerui") @@ -55,6 +59,11 @@ defmodule TransportWeb.API.Router do end get("/gtfs-stops", TransportWeb.API.GTFSStopsController, :index) + + scope "/validators" do + pipe_through(:simple_token_auth) + get("/gtfs-transport", TransportWeb.API.ValidatorsController, :gtfs_transport) + end end @spec swagger_info :: %{info: %{title: binary(), version: binary()}} diff --git a/apps/transport/test/transport_web/controllers/api/validators_controller_test.exs b/apps/transport/test/transport_web/controllers/api/validators_controller_test.exs new file mode 100644 index 0000000000..91e722f5aa --- /dev/null +++ b/apps/transport/test/transport_web/controllers/api/validators_controller_test.exs @@ -0,0 +1,67 @@ +defmodule TransportWeb.API.ValidatorsControllerTest do + use TransportWeb.ConnCase, async: true + import Mox + + setup :verify_on_exit! + + describe "/gtfs-transport" do + test "without an Authorization header", %{conn: conn} do + conn = conn |> get(~p"/api/validators/gtfs-transport") + assert %{"error" => "You must set a valid Authorization header"} == json_response(conn, 401) + end + + test "with an invalid Authorization header", %{conn: conn} do + conn = conn |> put_req_header("authorization", "invalid") |> get(~p"/api/validators/gtfs-transport") + assert %{"error" => "You must set a valid Authorization header"} == json_response(conn, 401) + end + + test "with a valid Authorization header, no URL", %{conn: conn} do + conn = conn |> put_req_header("authorization", "secret_token") |> get(~p"/api/validators/gtfs-transport") + assert %{"error" => "You must include a GTFS URL"} == json_response(conn, 400) + end + + test "with a valid Authorization header, invalid URL passed", %{conn: conn} do + url = "foobar" + + Shared.Validation.Validator.Mock + |> expect(:validate_from_url, fn ^url -> {:error, "Not a valid URL"} end) + + %{token: token} = authorized_client() + + assert %{"error" => "Not a valid URL"} == + conn + |> put_req_header("authorization", token) + |> get(~p"/api/validators/gtfs-transport?url=#{url}") + |> json_response(400) + end + + test "with a valid Authorization header, success response", %{conn: conn} do + gtfs_url = "https://example.com/gtfs.zip" + validator_response = %{"validator" => "response"} + + Shared.Validation.Validator.Mock + |> expect(:validate_from_url, fn ^gtfs_url -> {:ok, validator_response} end) + + %{client: client, token: token} = authorized_client() + + logs = + ExUnit.CaptureLog.capture_log(fn -> + conn = + conn + |> put_req_header("authorization", token) + |> get(~p"/api/validators/gtfs-transport?url=#{gtfs_url}") + + assert validator_response == json_response(conn, 200) + end) + + assert logs =~ "Handling GTFS validation from #{client} for #{gtfs_url}" + end + + @spec authorized_client() :: %{client: binary(), token: binary()} + defp authorized_client do + clients = Application.fetch_env!(:transport, :api_auth_clients) + [client, token] = clients |> String.split(";") |> hd() |> String.split(":") + %{client: client, token: token} + end + end +end diff --git a/config/config.exs b/config/config.exs index 3c2df9a575..18eb0bbe9f 100644 --- a/config/config.exs +++ b/config/config.exs @@ -172,6 +172,8 @@ config :gettext, :default_locale, "fr" config :transport, domain_name: System.get_env("DOMAIN_NAME", "transport.data.gouv.fr"), export_secret_key: System.get_env("EXPORT_SECRET_KEY"), + # Expected format: `client1:secret_token;client2:other_token` + api_auth_clients: System.get_env("API_AUTH_CLIENTS"), enroute_token: System.get_env("ENROUTE_TOKEN"), enroute_validation_token: System.get_env("ENROUTE_VALIDATION_TOKEN"), max_import_concurrent_jobs: (System.get_env("MAX_IMPORT_CONCURRENT_JOBS") || "1") |> String.to_integer(), diff --git a/config/test.exs b/config/test.exs index e8cb643fbd..f70a57fd38 100644 --- a/config/test.exs +++ b/config/test.exs @@ -49,6 +49,7 @@ config :transport, }, workflow_notifier: Transport.Jobs.Workflow.ProcessNotifier, export_secret_key: "fake_export_secret_key", + api_auth_clients: "client1:secret_token;client2:other_token", enroute_token: "fake_enroute_token", enroute_validation_token: "fake_enroute_token", enroute_validator_client: Transport.EnRouteChouetteValidClient.Mock,