Skip to content

Commit

Permalink
API : proxy et auth pour l'API du validateur GTFS transport (#4251)
Browse files Browse the repository at this point in the history
* API : proxy et auth pour l'API du validateur GTFS transport

* PR review
  • Loading branch information
AntoineAugusti authored Oct 22, 2024
1 parent ac272d3 commit f453b1e
Show file tree
Hide file tree
Showing 6 changed files with 146 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -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
39 changes: 39 additions & 0 deletions apps/transport/lib/transport_web/api/plugs/auth.ex
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions apps/transport/lib/transport_web/api/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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()}}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down

0 comments on commit f453b1e

Please sign in to comment.