From d8b728727dc47e99f8ba9ca48fc15e7760c8e54c Mon Sep 17 00:00:00 2001 From: Paul Swartz Date: Mon, 13 Nov 2023 16:36:46 -0500 Subject: [PATCH] feat: authenticate users via API key --- config/config.exs | 1 + config/runtime.exs | 5 +- lib/arrow_web/controllers/auth_controller.ex | 2 - .../controllers/my_token_controller.ex | 2 +- lib/arrow_web/try_api_token_auth.ex | 51 +--------------- lib/arrow_web/try_api_token_auth/cognito.ex | 56 ++++++++++++++++++ lib/arrow_web/try_api_token_auth/keycloak.ex | 58 +++++++++++++++++++ mix.lock | 4 +- .../controllers/auth_controller_test.exs | 4 +- test/arrow_web/try_api_token_auth_test.exs | 2 +- test/support/conn_case.ex | 2 +- 11 files changed, 128 insertions(+), 59 deletions(-) create mode 100644 lib/arrow_web/try_api_token_auth/cognito.ex create mode 100644 lib/arrow_web/try_api_token_auth/keycloak.ex diff --git a/config/config.exs b/config/config.exs index 16acd14d6..83244a0c2 100644 --- a/config/config.exs +++ b/config/config.exs @@ -21,6 +21,7 @@ config :arrow, "arrow-admin" => "admin" }, ueberauth_provider: :cognito, + api_login_module: ArrowWeb.TryApiTokenAuth.Cognito, required_roles: %{ view_disruption: ["read-only", "admin"], create_disruption: ["admin"], diff --git a/config/runtime.exs b/config/runtime.exs index 6cfca786f..c81fdf339 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -5,7 +5,10 @@ is_test? = config_env() == :test case System.fetch_env("KEYCLOAK_DISCOVERY_URI") do {:ok, keycloak_uri} when keycloak_uri != "" and not is_test? -> config :arrow, - ueberauth_provider: :keycloak + ueberauth_provider: :keycloak, + api_login_module: ArrowWeb.TryApiTokenAuth.Keycloak, + keycloak_client_uuid: System.fetch_env!("KEYCLOAK_CLIENT_UUID"), + keycloak_api_base: System.fetch_env!("KEYCLOAK_API_BASE") keycloak_opts = [ discovery_document_uri: keycloak_uri, diff --git a/lib/arrow_web/controllers/auth_controller.ex b/lib/arrow_web/controllers/auth_controller.ex index eb0a17f5f..765a71e8a 100644 --- a/lib/arrow_web/controllers/auth_controller.ex +++ b/lib/arrow_web/controllers/auth_controller.ex @@ -42,7 +42,6 @@ defmodule ArrowWeb.AuthController do }, ttl: {expiration - current_time, :seconds} ) - |> put_session(:arrow_username, username) |> redirect(to: Routes.disruption_path(conn, :index)) end @@ -64,7 +63,6 @@ defmodule ArrowWeb.AuthController do }, ttl: {expiration - current_time, :seconds} ) - |> put_session(:arrow_username, username) |> redirect(to: Routes.disruption_path(conn, :index)) end diff --git a/lib/arrow_web/controllers/my_token_controller.ex b/lib/arrow_web/controllers/my_token_controller.ex index edbba5960..3e059ab7b 100644 --- a/lib/arrow_web/controllers/my_token_controller.ex +++ b/lib/arrow_web/controllers/my_token_controller.ex @@ -6,7 +6,7 @@ defmodule ArrowWeb.MyTokenController do @spec show(Plug.Conn.t(), Plug.Conn.params()) :: Plug.Conn.t() def show(conn, _params) do - token = conn |> get_session(:arrow_username) |> AuthToken.get_or_create_token_for_user() + token = conn |> Guardian.Plug.current_resource() |> AuthToken.get_or_create_token_for_user() render(conn, "index.html", token: token) end diff --git a/lib/arrow_web/try_api_token_auth.ex b/lib/arrow_web/try_api_token_auth.ex index 765f1ad46..dfc317ce6 100644 --- a/lib/arrow_web/try_api_token_auth.ex +++ b/lib/arrow_web/try_api_token_auth.ex @@ -6,9 +6,6 @@ defmodule ArrowWeb.TryApiTokenAuth do import Plug.Conn require Logger - @aws_cognito_target "AWSCognitoIdentityProviderService" - @cognito_groups Application.compile_env!(:arrow, :cognito_groups) - def init(options), do: options def call(conn, _opts) do @@ -25,53 +22,9 @@ defmodule ArrowWeb.TryApiTokenAuth do if is_nil(auth_token) do conn |> send_resp(401, "unauthenticated") |> halt() else - user_pool_id = - :ueberauth - |> Application.get_env(Ueberauth.Strategy.Cognito) - |> Keyword.get(:user_pool_id) - |> config_value - - data = %{ - "Username" => auth_token.username, - "UserPoolId" => user_pool_id - } - - headers = [ - {"x-amz-target", "#{@aws_cognito_target}.AdminListGroupsForUser"}, - {"content-type", "application/x-amz-json-1.1"} - ] - - operation = ExAws.Operation.JSON.new(:"cognito-idp", data: data, headers: headers) - - {module, function} = Application.get_env(:arrow, :ex_aws_requester) - - roles = - case apply(module, function, [operation]) do - {:ok, %{"Groups" => groups}} -> - Enum.flat_map(groups, fn %{"GroupName" => group} -> - case @cognito_groups[group] do - role when is_binary(role) -> [role] - _ -> [] - end - end) - - response -> - :ok = Logger.warn("unexpected_aws_api_response: #{inspect(response)}") - [] - end - - conn - |> Guardian.Plug.sign_in( - ArrowWeb.AuthManager, - auth_token.username, - %{roles: roles} - ) - |> put_session(:arrow_username, auth_token.username) + api_login_module = Application.get_env(:arrow, :api_login_module) + api_login_module.sign_in(conn, auth_token) end end end - - @spec config_value(binary() | {module(), atom(), [any()]}) :: any() - defp config_value(value) when is_binary(value), do: value - defp config_value({m, f, a}), do: apply(m, f, a) end diff --git a/lib/arrow_web/try_api_token_auth/cognito.ex b/lib/arrow_web/try_api_token_auth/cognito.ex new file mode 100644 index 000000000..61896a714 --- /dev/null +++ b/lib/arrow_web/try_api_token_auth/cognito.ex @@ -0,0 +1,56 @@ +defmodule ArrowWeb.TryApiTokenAuth.Cognito do + @moduledoc """ + Signs in an API client via Cognito. + """ + + require Logger + + @aws_cognito_target "AWSCognitoIdentityProviderService" + @cognito_groups Application.compile_env!(:arrow, :cognito_groups) + + def sign_in(conn, auth_token) do + user_pool_id = + :ueberauth + |> Application.get_env(Ueberauth.Strategy.Cognito) + |> Keyword.get(:user_pool_id) + |> config_value + + data = %{ + "Username" => auth_token.username, + "UserPoolId" => user_pool_id + } + + headers = [ + {"x-amz-target", "#{@aws_cognito_target}.AdminListGroupsForUser"}, + {"content-type", "application/x-amz-json-1.1"} + ] + + operation = ExAws.Operation.JSON.new(:"cognito-idp", data: data, headers: headers) + + {module, function} = Application.get_env(:arrow, :ex_aws_requester) + + roles = + case apply(module, function, [operation]) do + {:ok, %{"Groups" => groups}} -> + for %{"GroupName" => group} <- groups, + {:ok, role} <- [Map.fetch(@cognito_groups, group)] do + role + end + + response -> + :ok = Logger.warn("unexpected_aws_api_response: #{inspect(response)}") + [] + end + + conn + |> Guardian.Plug.sign_in( + ArrowWeb.AuthManager, + auth_token.username, + %{roles: roles} + ) + end + + @spec config_value(binary() | {module(), atom(), [any()]}) :: any() + defp config_value(value) when is_binary(value), do: value + defp config_value({m, f, a}), do: apply(m, f, a) +end diff --git a/lib/arrow_web/try_api_token_auth/keycloak.ex b/lib/arrow_web/try_api_token_auth/keycloak.ex new file mode 100644 index 000000000..e8ceab70a --- /dev/null +++ b/lib/arrow_web/try_api_token_auth/keycloak.ex @@ -0,0 +1,58 @@ +defmodule ArrowWeb.TryApiTokenAuth.Keycloak do + @moduledoc """ + Signs in an API client via Keycloak. + """ + + require Logger + + def sign_in(conn, auth_token) do + with {:ok, user_id} <- lookup_user_id(auth_token.username), + {:ok, roles} <- lookup_user_roles(user_id) do + conn + |> Guardian.Plug.sign_in( + ArrowWeb.AuthManager, + auth_token.username, + %{roles: roles} + ) + else + other -> + Logger.warn("unexpected response when logging in via Keycloak API: #{inspect(other)}") + conn + end + end + + defp lookup_user_id(email) do + with {:ok, [%{"id" => user_id}]} <- + keycloak_api("users", %{ + max: 1, + email: email, + exact: true, + briefRepresentation: true + }) do + {:ok, user_id} + end + end + + defp lookup_user_roles(user_id) do + client_uuid = Application.get_env(:arrow, :keycloak_client_uuid) + url = "users/#{user_id}/role-mappings/clients/#{client_uuid}/composite" + + with {:ok, response} <- keycloak_api(url) do + roles = for r <- response, do: r["name"] + {:ok, roles} + end + end + + defp keycloak_api(url, params \\ %{}) do + base_url = Application.get_env(:arrow, :keycloak_api_base) + opts = Map.new(Application.get_env(:ueberauth, Ueberauth.Strategy.OIDC)[:keycloak]) + + with {:ok, tokens} <- + OpenIDConnect.fetch_tokens(opts, %{grant_type: "client_credentials", scope: "openid"}), + headers = [{"authorization", "#{tokens["token_type"]} #{tokens["access_token"]}"}], + {:ok, %{status_code: 200} = response} <- + HTTPoison.get("#{base_url}#{url}", headers, params: params) do + Jason.decode(response.body) + end + end +end diff --git a/mix.lock b/mix.lock index f4109335f..35a759462 100644 --- a/mix.lock +++ b/mix.lock @@ -41,7 +41,7 @@ "nimble_options": {:hex, :nimble_options, "1.0.2", "92098a74df0072ff37d0c12ace58574d26880e522c22801437151a159392270e", [:mix], [], "hexpm", "fd12a8db2021036ce12a309f26f564ec367373265b53e25403f0ee697380f1b8"}, "nimble_pool": {:hex, :nimble_pool, "1.0.0", "5eb82705d138f4dd4423f69ceb19ac667b3b492ae570c9f5c900bb3d2f50a847", [:mix], [], "hexpm", "80be3b882d2d351882256087078e1b1952a28bf98d0a287be87e4a24a710b67a"}, "oauth2": {:hex, :oauth2, "2.0.1", "70729503e05378697b958919bb2d65b002ba6b28c8112328063648a9348aaa3f", [:mix], [{:hackney, "~> 1.13", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "c64e20d4d105bcdbcbe03170fb530d0eddc3a3e6b135a87528a22c8aecf74c52"}, - "openid_connect": {:git, "https://github.com/DockYard/openid_connect.git", "ddafedc3c81a5bc91919d13630b6cb5ea2595ffe", [ref: "ddafedc3c81a5bc91919d13630b6cb5ea2595ffe"]}, + "openid_connect": {:git, "https://github.com/firezone/openid_connect.git", "13320ed8b0d347330d07e1375a9661f3089b9c03", [ref: "13320ed8b0d347330d07e1375a9661f3089b9c03"]}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "phoenix": {:hex, :phoenix, "1.6.9", "648e660040cdc758c5401972e0f592ce622d4ce9cd16d2d9c33dda32d0c9f7fa", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be2fe497597d6bf297dcbf9f4416b4929dbfbdcc25edc1acf6d4dcaecbe898a6"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"}, @@ -67,7 +67,7 @@ "ueberauth": {:hex, :ueberauth, "0.10.5", "806adb703df87e55b5615cf365e809f84c20c68aa8c08ff8a416a5a6644c4b02", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "3efd1f31d490a125c7ed453b926f7c31d78b97b8a854c755f5c40064bf3ac9e1"}, "ueberauth_cognito": {:hex, :ueberauth_cognito, "0.4.0", "62daa3f675298c2b03002d2e1b7e5a30cbc513400e5732a264864a26847e71ac", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:jose, "~> 1.0", [hex: :jose, repo: "hexpm", optional: false]}, {:ueberauth, "~> 0.7", [hex: :ueberauth, repo: "hexpm", optional: false]}], "hexpm", "62378f4f34c8569cd95cc4e7463c56e9981c8afc83fdc516922065f0e1302a35"}, "ueberauth_keycloak_strategy": {:hex, :ueberauth_keycloak_strategy, "0.4.0", "51e975874564ef4a6eb0044b9f0c6a08be4ba6086e62e41d385e7dd52fe9568b", [:mix], [{:oauth2, "~> 2.0", [hex: :oauth2, repo: "hexpm", optional: false]}, {:ueberauth, "~> 0.7", [hex: :ueberauth, repo: "hexpm", optional: false]}], "hexpm", "c03027937bddcbd9ff499e457f9bb05f79018fa321abf79ebcfed2af0007211b"}, - "ueberauth_oidc": {:git, "https://github.com/mbta/ueberauth_oidc.git", "22857f8eeb03dac47c2fbaebe4a169e45af9656f", []}, + "ueberauth_oidc": {:git, "https://github.com/mbta/ueberauth_oidc.git", "6216cb2a93bf075b76bcd97db89579233c44314f", []}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, "wallaby": {:hex, :wallaby, "0.28.1", "0487ac4e76a5ffcc9b0ac3ddc35b931b0c2f4cac87b30b029a0f4e7e5ee20ff3", [:mix], [{:ecto_sql, ">= 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}, {:httpoison, "~> 0.12 or ~> 1.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_ecto, ">= 3.0.0", [hex: :phoenix_ecto, repo: "hexpm", optional: true]}, {:web_driver_client, "~> 0.1.0", [hex: :web_driver_client, repo: "hexpm", optional: false]}], "hexpm", "618538448e21dc8b0a02f6810472eb48a05badf14db4a0441dc562a1eac896e9"}, "web_driver_client": {:hex, :web_driver_client, "0.1.0", "19466a989c76b7ec803c796cec0fec4611a64f445fd5120ce50c9e3817e09c2c", [:mix], [{:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:tesla, "~> 1.3.0", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "c9c031ca915e8fc75b5e24ac93503244f3cc406dd7f53047087a45aa62d60e9e"}, diff --git a/test/arrow_web/controllers/auth_controller_test.exs b/test/arrow_web/controllers/auth_controller_test.exs index 955b488c6..52c5e883e 100644 --- a/test/arrow_web/controllers/auth_controller_test.exs +++ b/test/arrow_web/controllers/auth_controller_test.exs @@ -23,7 +23,7 @@ defmodule ArrowWeb.Controllers.AuthControllerTest do assert response =~ Routes.disruption_path(conn, :index) assert Enum.sort(Guardian.Plug.current_claims(conn)["roles"]) == ["admin", "read-only"] - assert get_session(conn, :arrow_username) == "foo@mbta.com" + assert Guardian.Plug.current_resource(conn) == "foo@mbta.com" end test "redirects on success (keycloak)", %{conn: conn} do @@ -54,7 +54,7 @@ defmodule ArrowWeb.Controllers.AuthControllerTest do assert response =~ Routes.disruption_path(conn, :index) assert Guardian.Plug.current_claims(conn)["roles"] == ["admin"] - assert get_session(conn, :arrow_username) == "foo@mbta.com" + assert Guardian.Plug.current_resource(conn) == "foo@mbta.com" end test "handles generic failure", %{conn: conn} do diff --git a/test/arrow_web/try_api_token_auth_test.exs b/test/arrow_web/try_api_token_auth_test.exs index 6a77ca0e8..2ce1bef58 100644 --- a/test/arrow_web/try_api_token_auth_test.exs +++ b/test/arrow_web/try_api_token_auth_test.exs @@ -53,7 +53,7 @@ defmodule ArrowWeb.TryApiTokenAuthTest do assert claims["sub"] == "foo@mbta.com" assert claims["typ"] == "access" assert claims["roles"] == ["admin"] - assert get_session(conn, :arrow_username) == "foo@mbta.com" + assert Guardian.Plug.current_resource(conn) == "foo@mbta.com" end test "handles unexpected response from Cognito API", %{conn: conn} do diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index e98f0d1b7..ed01e4d97 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -56,7 +56,7 @@ defmodule ArrowWeb.ConnCase do defp build_conn(user, roles) do Phoenix.ConnTest.build_conn() |> Plug.Conn.put_req_header("x-forwarded-proto", "https") - |> init_test_session(%{arrow_username: user}) + |> init_test_session(%{}) |> Guardian.Plug.sign_in(ArrowWeb.AuthManager, user, %{roles: roles}) end end