From 873c6f8b33ebae4c12c72f31c71865e5c0e65b03 Mon Sep 17 00:00:00 2001 From: Preston Mueller Date: Fri, 28 Jun 2024 12:16:15 -0400 Subject: [PATCH] feat: Keycloak (#21) --- .github/workflows/ci.yml | 2 +- config/config.exs | 10 +- config/dev.exs | 22 +++- config/prod.exs | 15 +++ config/runtime.exs | 20 ++- config/test.exs | 22 +++- docker-compose.yml | 4 + lib/orbit/authentication/user.ex | 28 ++++ lib/orbit_web/auth/auth.ex | 51 +++++++ lib/orbit_web/auth/guardian.ex | 41 ++++++ lib/orbit_web/auth/strategy/fake_oidcc.ex | 83 ++++++++++++ lib/orbit_web/controllers/auth_controller.ex | 124 ++++++++++++++++++ .../controllers/react_app/react_app.html.heex | 2 + lib/orbit_web/plugs/require_login.ex | 30 +++++ lib/orbit_web/router.ex | 24 ++++ mix.exs | 6 +- mix.lock | 7 + priv/repo/migrations/20240607101801_users.exs | 12 ++ test/orbit/user_test.exs | 23 ++++ .../controllers/auth_controller_test.exs | 53 ++++++++ .../controllers/react_app_controller_test.exs | 1 + test/support/conn_case.ex | 18 ++- 22 files changed, 591 insertions(+), 7 deletions(-) create mode 100644 lib/orbit/authentication/user.ex create mode 100644 lib/orbit_web/auth/auth.ex create mode 100644 lib/orbit_web/auth/guardian.ex create mode 100644 lib/orbit_web/auth/strategy/fake_oidcc.ex create mode 100644 lib/orbit_web/controllers/auth_controller.ex create mode 100644 lib/orbit_web/plugs/require_login.ex create mode 100644 priv/repo/migrations/20240607101801_users.exs create mode 100644 test/orbit/user_test.exs create mode 100644 test/orbit_web/controllers/auth_controller_test.exs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2c1967..369647a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -174,7 +174,7 @@ jobs: - name: docker compose up run: docker compose up --wait - name: ensure running properly - run: docker compose exec --no-TTY orbit wget --spider -S http://localhost:4001/ + run: docker compose exec --no-TTY orbit wget --spider -S http://localhost:4001/_health - name: show docker container logs run: docker compose logs orbit if: ${{ !cancelled() }} diff --git a/config/config.exs b/config/config.exs index c302a91..575fca4 100644 --- a/config/config.exs +++ b/config/config.exs @@ -3,7 +3,8 @@ import Config # Application config config :orbit, ecto_repos: [Orbit.Repo], - generators: [timestamp_type: :utc_datetime] + generators: [timestamp_type: :utc_datetime], + force_https?: true # Endpoint config config :orbit, OrbitWeb.Endpoint, @@ -34,6 +35,13 @@ config :phoenix, # We use logster instead of the default Phoenix logging logger: false +# Auth +config :ueberauth, Ueberauth, + providers: [ + # specified in dev.exs / prod.exs + keycloak: nil + ] + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{config_env()}.exs" diff --git a/config/dev.exs b/config/dev.exs index 76e554a..31e09d7 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -4,7 +4,8 @@ import Config config :orbit, # Enable dev routes for dashboard and mailbox dev_routes: true, - release: "local" + release: "local", + force_https?: false # Database config config :orbit, Orbit.Repo, @@ -59,6 +60,25 @@ config :phoenix_live_view, # Include HEEx debug annotations as HTML comments in rendered markup debug_heex_annotations: true +# Auth +config :orbit, OrbitWeb.Auth.Guardian, + issuer: "orbit", + secret_key: "dev key" + +config :ueberauth, Ueberauth, + providers: [ + keycloak: {OrbitWeb.Auth.Strategy.FakeOidcc, []} + ] + +config :ueberauth_oidcc, + providers: [ + keycloak: [ + issuer: "dev-issuer", + client_id: "dev-client-id", + client_secret: "dev-secret" + ] + ] + if File.exists?(Path.expand("dev.secret.exs", __DIR__)) do import_config "dev.secret.exs" end diff --git a/config/prod.exs b/config/prod.exs index cdf2b0f..08d9b4c 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -39,6 +39,21 @@ config :ehmon, :report_mf, {:ehmon, :info_report} # this tells disksup to use different df flags config :os_mon, disksup_posix_only: true +# Auth +config :orbit, OrbitWeb.Auth.Guardian, issuer: "orbit" + +config :ueberauth, Ueberauth, + providers: [ + keycloak: + {Ueberauth.Strategy.Oidcc, + [ + issuer: :keycloak_issuer, + userinfo: true, + uid_field: "email", + scopes: ~w"openid email" + ]} + ] + config :sentry, # dsn and environment_name are loaded at runtime enable_source_code_context: true, diff --git a/config/runtime.exs b/config/runtime.exs index d4e8431..f30a2d9 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -31,6 +31,24 @@ if config_env() == :prod do {Orbit.Repo, :add_prod_credentials, []} end) + # Auth + config :ueberauth_oidcc, + issuers: [ + %{ + name: :keycloak_issuer, + issuer: System.fetch_env!("KEYCLOAK_ISSUER") + } + ], + providers: [ + keycloak: [ + issuer: :keycloak_issuer, + client_id: System.fetch_env!("KEYCLOAK_CLIENT_ID"), + client_secret: System.fetch_env!("KEYCLOAK_CLIENT_SECRET") + ] + ] + + config :orbit, OrbitWeb.Auth.Guardian, secret_key: System.get_env("GUARDIAN_SECRET_KEY") + # The secret key base is used to sign/encrypt cookies and other secrets. # A default value is used in config/dev.exs and config/test.exs but you # want to use a different value for prod and you most likely don't want @@ -43,7 +61,7 @@ if config_env() == :prod do You can generate one by calling: mix phx.gen.secret """ - host = System.get_env("PHX_HOST") || "example.com" + host = System.fetch_env!("PHX_HOST") port = String.to_integer(System.get_env("PORT") || "4001") config :orbit, OrbitWeb.Endpoint, diff --git a/config/test.exs b/config/test.exs index 096c821..cd9b6e8 100644 --- a/config/test.exs +++ b/config/test.exs @@ -1,7 +1,8 @@ import Config config :orbit, - release: "test" + release: "test", + force_https?: false # Database config config :orbit, Orbit.Repo, @@ -25,3 +26,22 @@ config :logger, level: :warning # Initialize plugs at runtime for faster test compilation config :phoenix, :plug_init_mode, :runtime + +# Auth +config :ueberauth, Ueberauth, + providers: [ + keycloak: {OrbitWeb.Auth.Strategy.FakeOidcc, []} + ] + +config :ueberauth_oidcc, + providers: [ + keycloak: [ + issuer: "test-issuer", + client_id: "test-client-id", + client_secret: "test-secret" + ] + ] + +config :orbit, OrbitWeb.Auth.Guardian, + issuer: "orbit", + secret_key: "dev key" diff --git a/docker-compose.yml b/docker-compose.yml index 7eeea2e..52c4c65 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,10 @@ services: - DATABASE_HOST=db - DATABASE_NAME=postgres - DATABASE_DISABLE_SSL=insecure-yes + - PHX_HOST=localhost - SECRET_KEY_BASE=/insecure/insecure/insecure/insecure/insecure/insecure/insecure/ + - KEYCLOAK_ISSUER=https://login-sandbox.mbtace.com/auth/realms/MBTA + - KEYCLOAK_CLIENT_ID=docker_fake_keycloak_client_id + - KEYCLOAK_CLIENT_SECRET=docker_fake_keycloak_client_secret ports: - 4001 diff --git a/lib/orbit/authentication/user.ex b/lib/orbit/authentication/user.ex new file mode 100644 index 0000000..40bac41 --- /dev/null +++ b/lib/orbit/authentication/user.ex @@ -0,0 +1,28 @@ +defmodule Orbit.Authentication.User do + use Ecto.Schema + import Ecto.Changeset + + @type t :: %__MODULE__{ + id: integer(), + email: String.t(), + inserted_at: DateTime.t(), + updated_at: DateTime.t() + } + + schema "users" do + field(:email, :string) + timestamps() + end + + def create_changeset(struct, params \\ %{}) do + struct + |> cast(params, [ + :email + ]) + |> validate_required([:email]) + |> unique_constraint( + :users, + name: :users_email_index + ) + end +end diff --git a/lib/orbit_web/auth/auth.ex b/lib/orbit_web/auth/auth.ex new file mode 100644 index 0000000..e47d85c --- /dev/null +++ b/lib/orbit_web/auth/auth.ex @@ -0,0 +1,51 @@ +defmodule OrbitWeb.Auth.Auth do + alias Orbit.Authentication.User + alias Orbit.Repo + alias Plug.Conn + + @spec login(Conn.t(), String.t(), integer(), [String.t()], String.t() | nil) :: + Conn.t() + def login(conn, username, ttl_seconds, _groups, logout_url) do + email = String.downcase(username) + + case Repo.get_by(User, email: email) do + nil -> + Repo.insert!(%User{email: email}) + + u -> + u + end + + # We use username (email) as the Guardian resource + conn + |> OrbitWeb.Auth.Guardian.Plug.sign_in( + username, + # claims + %{}, + ttl: {ttl_seconds, :seconds} + ) + |> Plug.Conn.put_session(:username, username) + |> Plug.Conn.put_session(:logout_url, logout_url) + end + + @spec logged_in_user(Conn.t()) :: map() | nil + def logged_in_user(conn) do + if Map.has_key?(conn.assigns, :logged_in_user) do + conn.assigns[:logged_in_user] + else + if email = OrbitWeb.Auth.Guardian.Plug.current_resource(conn) do + email = String.downcase(email) + Repo.get_by(User, email: email) + else + nil + end + end + end + + @spec logout(Conn.t()) :: Conn.t() + def logout(conn) do + conn + |> Plug.Conn.assign(:logged_in_user, nil) + |> OrbitWeb.Auth.Guardian.Plug.sign_out() + end +end diff --git a/lib/orbit_web/auth/guardian.ex b/lib/orbit_web/auth/guardian.ex new file mode 100644 index 0000000..ddf15cb --- /dev/null +++ b/lib/orbit_web/auth/guardian.ex @@ -0,0 +1,41 @@ +defmodule OrbitWeb.Auth.Guardian do + use Guardian, otp_app: :orbit + + @spec subject_for_token(Guardian.Token.resource(), Guardian.Token.claims()) :: + {:ok, String.t()} | {:error, atom()} + def subject_for_token(resource, _claims) do + sub = resource + {:ok, sub} + end + + @spec resource_from_claims(Guardian.Token.claims()) :: + {:ok, Guardian.Token.resource()} | {:error, atom()} + def resource_from_claims(%{"sub" => id}) do + resource = id + {:ok, resource} + end +end + +defmodule OrbitWeb.Auth.Guardian.Pipeline do + use Guardian.Plug.Pipeline, + otp_app: :orbit, + error_handler: OrbitWeb.Auth.Guardian.ErrorHandler, + module: OrbitWeb.Auth.Guardian + + plug(Guardian.Plug.VerifySession, claims: %{"typ" => "access"}) + plug(Guardian.Plug.VerifyHeader, claims: %{"typ" => "access"}) + plug(Guardian.Plug.LoadResource, allow_blank: true) +end + +defmodule OrbitWeb.Auth.Guardian.ErrorHandler do + @behaviour Guardian.Plug.ErrorHandler + + @impl Guardian.Plug.ErrorHandler + def auth_error(conn, {_type, _reason}, _opts) do + conn + |> Plug.Conn.put_session(:login_target, conn.request_path) + # remove the invalid Guardian session so we don't get caught in a redirect loop + |> OrbitWeb.Auth.Auth.logout() + |> OrbitWeb.AuthController.redirect_needs_login() + end +end diff --git a/lib/orbit_web/auth/strategy/fake_oidcc.ex b/lib/orbit_web/auth/strategy/fake_oidcc.ex new file mode 100644 index 0000000..70ccee4 --- /dev/null +++ b/lib/orbit_web/auth/strategy/fake_oidcc.ex @@ -0,0 +1,83 @@ +defmodule OrbitWeb.Auth.Strategy.FakeOidcc do + use Ueberauth.Strategy, ignores_csrf_attack: true + + @impl Ueberauth.Strategy + def handle_request!(conn) do + conn + |> put_resp_content_type("text/html") + |> send_resp( + :ok, + ~s( + + +

Fake Keycloak/Oidcc

+
+ + +
+ + ) + ) + |> halt() + end + + @impl Ueberauth.Strategy + def handle_callback!(conn) do + # add a /.../callback?invalid query param to mock an invalid token for testing + if Map.has_key?(conn.params, "invalid") or is_nil(conn.params["email"]) do + set_errors!(conn, [error("invalid", "invalid callback")]) + else + conn + end + end + + @impl Ueberauth.Strategy + def uid(_conn) do + "fake_uid" + end + + @impl Ueberauth.Strategy + def credentials(_conn) do + nine_hours_in_seconds = 9 * 60 * 60 + expiration_time = System.system_time(:second) + nine_hours_in_seconds + + %Ueberauth.Auth.Credentials{ + token: "fake_access_token", + refresh_token: "fake_refresh_token", + expires: true, + expires_at: expiration_time + } + end + + @impl Ueberauth.Strategy + def info(conn) do + email = Map.get(conn.params, "email") + + %Ueberauth.Auth.Info{ + email: email + } + end + + @impl Ueberauth.Strategy + def extra(conn) do + groups = conn.params["groups"] || [] + + keycloak_client_id = + get_in(Application.get_env(:ueberauth_oidcc, :providers), [:keycloak, :client_id]) + + %Ueberauth.Auth.Extra{ + raw_info: %UeberauthOidcc.RawInfo{ + userinfo: %{ + "resource_access" => %{ + keycloak_client_id => %{"roles" => groups} + } + } + } + } + end + + @impl Ueberauth.Strategy + def handle_cleanup!(conn) do + conn + end +end diff --git a/lib/orbit_web/controllers/auth_controller.ex b/lib/orbit_web/controllers/auth_controller.ex new file mode 100644 index 0000000..3d3ea0d --- /dev/null +++ b/lib/orbit_web/controllers/auth_controller.ex @@ -0,0 +1,124 @@ +defmodule OrbitWeb.AuthController do + use OrbitWeb, :controller + plug(Ueberauth) + + alias OrbitWeb.Auth.Auth + require Logger + + def login_page(conn, _params) do + if Auth.logged_in_user(conn) do + redirect_login_successful(conn) + else + redirect(conn, to: ~p"/auth/keycloak") + end + end + + def request(conn, _params) do + # either unrecognized provider (recognized providers are caught by Ueberauth + # before this point) or other keycloak error. Check keycloak logs! + conn + |> put_status(:bad_request) + |> text("Bad Request") + end + + defp get_logout_url(conn, auth) do + case UeberauthOidcc.initiate_logout_url(auth, %{ + # We have to generate exactly what Keycloak/EntraID expect for logout redirect + post_logout_redirect_uri: + conn + |> url(~p"/login") + |> URI.parse() + # SSL is terminated before Phoenix sees requests, so we have to pretend it's https + # even if Phoenix thinks it isn't. + |> Map.put(:scheme, "https") + # We're on 80, but :80 appended to the URL also makes EntraID unhappy. + |> Map.put(:port, nil) + |> URI.to_string() + }) do + {:ok, logout_url} -> logout_url + _ -> nil + end + end + + def callback(%{assigns: %{ueberauth_auth: %{provider: :keycloak} = auth}} = conn, _params) do + username = String.replace(auth.info.email, "MBTA.com", "mbta.com") + # credentials = auth.credentials + + # Ignore auth provider's TTL, set ours to 30 days so users don't have to log back in + # expiration = credentials.expires_at + ttl_seconds = 3600 * 24 * 30 + + keycloak_client_id = + get_in(Application.get_env(:ueberauth_oidcc, :providers), [:keycloak, :client_id]) + + groups = + get_in(auth.extra.raw_info.userinfo, ["resource_access", keycloak_client_id, "roles"]) || [] + + logout_url = get_logout_url(conn, auth) + + conn + |> Auth.login( + username, + ttl_seconds, + groups, + logout_url + ) + |> redirect_login_successful() + end + + def callback(%{assigns: %{ueberauth_failure: fail}} = conn, _params) do + # what to do if sign in fails + case fail.errors do + [%Ueberauth.Failure.Error{message_key: "csrf_attack"}] -> + # Don't log CSRF issues + nil + + _ -> + Logger.warning("Ueberauth failure: #{inspect(fail)}") + Logger.info("Base: #{OrbitWeb.Endpoint.url()}") + end + + conn + |> Auth.logout() + |> redirect_needs_login() + end + + def callback(conn, _params) do + conn + |> put_status(:bad_request) + |> text("Bad Request (unrecognized provider)") + end + + def redirect_needs_login(conn) do + if get_format(conn) == "html" do + redirect(conn, to: ~p"/login") + else + conn + |> put_status(:unauthorized) + |> json("Unauthorized") + |> halt() + end + end + + defp redirect_login_successful(conn) do + redirect_to = Plug.Conn.get_session(conn, :login_target) || ~p"/" + + conn + |> Plug.Conn.delete_session(:login_target) + |> redirect(to: redirect_to) + end + + def logout(conn, _params) do + logout_url = get_session(conn, "logout_url") + + if logout_url != nil do + conn + |> Auth.logout() + |> redirect(external: logout_url) + else + conn + |> Auth.logout() + |> html(~s{Logged out. Log in again. }) + end + end +end diff --git a/lib/orbit_web/controllers/react_app/react_app.html.heex b/lib/orbit_web/controllers/react_app/react_app.html.heex index f025a12..ccb092b 100644 --- a/lib/orbit_web/controllers/react_app/react_app.html.heex +++ b/lib/orbit_web/controllers/react_app/react_app.html.heex @@ -2,5 +2,7 @@ You must enable JavaScript to view this page.
+Logged in as <%= assigns.logged_in_user.email %> +
Logout
diff --git a/lib/orbit_web/plugs/require_login.ex b/lib/orbit_web/plugs/require_login.ex new file mode 100644 index 0000000..9174c1b --- /dev/null +++ b/lib/orbit_web/plugs/require_login.ex @@ -0,0 +1,30 @@ +defmodule OrbitWeb.Plugs.RequireLogin do + @behaviour Plug + + alias OrbitWeb.Auth.Auth + + @impl Plug + def init(opts), do: opts + + @impl Plug + def call(conn, _opts) do + if user = Auth.logged_in_user(conn) do + # TODO: Sentry + # Sentry.Context.set_user_context(%{ + # email: user.email + # }) + + # TODO: Logger + # Logger.metadata(remote_ip: nil) + + conn + |> Plug.Conn.assign(:email, user.email) + |> Plug.Conn.assign(:logged_in_user, user) + else + conn + |> Plug.Conn.put_session(:login_target, conn.request_path) + |> OrbitWeb.AuthController.redirect_needs_login() + |> Plug.Conn.halt() + end + end +end diff --git a/lib/orbit_web/router.ex b/lib/orbit_web/router.ex index 098a379..c4523e5 100644 --- a/lib/orbit_web/router.ex +++ b/lib/orbit_web/router.ex @@ -2,6 +2,13 @@ defmodule OrbitWeb.Router do use OrbitWeb, :router pipeline :browser do + if Application.compile_env(:orbit, :force_https?) do + plug(Plug.SSL, + rewrite_on: [:x_forwarded_proto], + host: {System, :get_env, ["PHX_HOST"]} + ) + end + plug :fetch_session plug :fetch_live_flash plug :protect_from_forgery @@ -19,14 +26,30 @@ defmodule OrbitWeb.Router do plug :accepts, ["json"] end + pipeline :authenticated do + plug(OrbitWeb.Auth.Guardian.Pipeline) + plug(OrbitWeb.Plugs.RequireLogin) + end + + scope "/", OrbitWeb do + pipe_through :browser + pipe_through :accepts_html + + get("/login", AuthController, :login_page) + get("/auth/:provider", AuthController, :request) + get("/auth/:provider/callback", AuthController, :callback) + end + scope "/", OrbitWeb do pipe_through :browser pipe_through :accepts_html + pipe_through :authenticated # Routes that should be handled by React # Avoid using a wildcard to prevent invalid 200 responses get "/", ReactAppController, :home get "/help", ReactAppController, :home + get "/logout", AuthController, :logout end scope "/", OrbitWeb do @@ -47,6 +70,7 @@ defmodule OrbitWeb.Router do scope "/dev" do pipe_through :browser pipe_through :accepts_html + pipe_through :authenticated live_dashboard "/dashboard", metrics: OrbitWeb.Telemetry end diff --git a/mix.exs b/mix.exs index 586d982..4f56274 100644 --- a/mix.exs +++ b/mix.exs @@ -47,6 +47,8 @@ defmodule Orbit.MixProject do {:ex_aws, "== 2.5.1"}, {:ex_aws_rds, "== 2.0.2"}, {:floki, ">= 0.30.0", only: :test}, + {:guardian, "== 2.3.2"}, + {:guardian_phoenix, "== 2.0.1"}, # used by ex_aws {:hackney, "== 1.20.1"}, {:heroicons, @@ -70,7 +72,9 @@ defmodule Orbit.MixProject do {:sobelow, "== 0.13.0", only: [:dev, :test], runtime: false}, {:telemetry_metrics, "== 1.0.0"}, {:telemetry_poller, "== 1.1.0"}, - {:tz, "== 0.26.5"} + {:tz, "== 0.26.5"}, + {:ueberauth, "== 0.10.8"}, + {:ueberauth_oidcc, "== 0.4.0"} ] end diff --git a/mix.lock b/mix.lock index 204fad6..e14e5c8 100644 --- a/mix.lock +++ b/mix.lock @@ -16,11 +16,14 @@ "ex_aws_rds": {:hex, :ex_aws_rds, "2.0.2", "38dd8e83d57cf4b7286c4f6f5c978f700c40c207ffcdd6ca5d738e5eba933f9a", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}], "hexpm", "9e5b5cc168077874cbd0d29ba65d01caf1877e705fb5cecacf0667dd19bfa75c"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, "floki": {:hex, :floki, "0.36.2", "a7da0193538c93f937714a6704369711998a51a6164a222d710ebd54020aa7a3", [:mix], [], "hexpm", "a8766c0bc92f074e5cb36c4f9961982eda84c5d2b8e979ca67f5c268ec8ed580"}, + "guardian": {:hex, :guardian, "2.3.2", "78003504b987f2b189d76ccf9496ceaa6a454bb2763627702233f31eb7212881", [:mix], [{:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "b189ff38cd46a22a8a824866a6867ca8722942347f13c33f7d23126af8821b52"}, + "guardian_phoenix": {:hex, :guardian_phoenix, "2.0.1", "89a817265af09a6ddf7cb1e77f17ffca90cea2db10ff888375ef34502b2731b1", [:mix], [{:guardian, "~> 2.0", [hex: :guardian, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.3", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "21f439246715192b231f228680465d1ed5fbdf01555a4a3b17165532f5f9a08c"}, "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized"]}, "hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, + "jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"}, "logster": {:hex, :logster, "2.0.0-rc.3", "0edcafd1433e305ff3a3a4ce8b1a3b78697e165a39e9d1f20e6fb7d038e9bda1", [:mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "4dbcd4f7d0bc2d681c400a70364ae6b2f07b214c06b865730b00f22c55dd4e0f"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, @@ -28,6 +31,7 @@ "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_ownership": {:hex, :nimble_ownership, "0.3.1", "99d5244672fafdfac89bfad3d3ab8f0d367603ce1dc4855f86a1c75008bce56f", [:mix], [], "hexpm", "4bf510adedff0449a1d6e200e43e57a814794c8b5b6439071274d248d272a549"}, "oban": {:hex, :oban, "2.17.10", "c3e5bd739b5c3fdc38eba1d43ab270a8c6ca4463bb779b7705c69400b0d87678", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4afd027b8e2bc3c399b54318b4f46ee8c40251fb55a285cb4e38b5363f0ee7c4"}, + "oidcc": {:hex, :oidcc, "3.2.0", "f80a4826a946ce07dc8cbd8212392b4ff436ae3c4b4cd6680fa0d84d0ff2fec1", [:mix, :rebar3], [{:jose, "~> 1.11", [hex: :jose, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_registry, "~> 0.3.1", [hex: :telemetry_registry, repo: "hexpm", optional: false]}], "hexpm", "38fd9092ab5d5d10c71b8011b019316411afe466bef07ba57f57ec3f919278c3"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "phoenix": {:hex, :phoenix, "1.7.11", "1d88fc6b05ab0c735b250932c4e6e33bfa1c186f76dcf623d8dd52f07d6379c7", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "b1ec57f2e40316b306708fe59b92a16b9f6f4bf50ccfa41aa8c7feb79e0ec02a"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.1", "96798325fab2fed5a824ca204e877b81f9afd2e480f581e81f7b4b64a5a477f2", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.17", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "0ae544ff99f3c482b0807c5cec2c8289e810ecacabc04959d82c3337f4703391"}, @@ -46,8 +50,11 @@ "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.0.0", "29f5f84991ca98b8eb02fc208b2e6de7c95f8bb2294ef244a176675adc7775df", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f23713b3847286a534e005126d4c959ebcca68ae9582118ce436b521d1d47d5d"}, "telemetry_poller": {:hex, :telemetry_poller, "1.1.0", "58fa7c216257291caaf8d05678c8d01bd45f4bdbc1286838a28c4bb62ef32999", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9eb9d9cbfd81cbd7cdd24682f8711b6e2b691289a0de6826e58452f28c103c8f"}, + "telemetry_registry": {:hex, :telemetry_registry, "0.3.1", "14a3319a7d9027bdbff7ebcacf1a438f5f5c903057b93aee484cca26f05bdcba", [:mix, :rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6d0ca77b691cf854ed074b459a93b87f4c7f5512f8f7743c635ca83da81f939e"}, "thousand_island": {:hex, :thousand_island, "1.3.5", "6022b6338f1635b3d32406ff98d68b843ba73b3aa95cfc27154223244f3a6ca5", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2be6954916fdfe4756af3239fb6b6d75d0b8063b5df03ba76fd8a4c87849e180"}, "tz": {:hex, :tz, "0.26.5", "bfe8efa345670f90351c5c31d22455d0307c5d9895fbdede7deeb215a7b60dbe", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:mint, "~> 1.5", [hex: :mint, repo: "hexpm", optional: true]}], "hexpm", "c4f9392d710582c7108b6b8c635f4981120ec4b2072adbd242290fc842338183"}, + "ueberauth": {:hex, :ueberauth, "0.10.8", "ba78fbcbb27d811a6cd06ad851793aaf7d27c3b30c9e95349c2c362b344cd8f0", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "f2d3172e52821375bccb8460e5fa5cb91cfd60b19b636b6e57e9759b6f8c10c1"}, + "ueberauth_oidcc": {:hex, :ueberauth_oidcc, "0.4.0", "3fbfbc38735b4dba54ed8bf3e9b80f5054f73cc1ec9af6ae88b7886d25934768", [:mix], [{:oidcc, "~> 3.2.0", [hex: :oidcc, repo: "hexpm", optional: false]}, {:plug, "~> 1.11", [hex: :plug, repo: "hexpm", optional: false]}, {:ueberauth, "~> 0.10", [hex: :ueberauth, repo: "hexpm", optional: false]}], "hexpm", "cdd8517d773cfe499c0b692f795f213b2eb33119afbec34aefd8be0a85c62b21"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.5.6", "0437fe56e093fd4ac422de33bf8fc89f7bc1416a3f2d732d8b2c8fd54792fe60", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "e04378d26b0af627817ae84c92083b7e97aca3121196679b73c73b99d0d133ea"}, diff --git a/priv/repo/migrations/20240607101801_users.exs b/priv/repo/migrations/20240607101801_users.exs new file mode 100644 index 0000000..bada954 --- /dev/null +++ b/priv/repo/migrations/20240607101801_users.exs @@ -0,0 +1,12 @@ +defmodule Orbit.Repo.Migrations.Users do + use Ecto.Migration + + def change do + create table(:users) do + add(:email, :string) + timestamps() + end + + create unique_index(:users, [:email]) + end +end diff --git a/test/orbit/user_test.exs b/test/orbit/user_test.exs new file mode 100644 index 0000000..e595479 --- /dev/null +++ b/test/orbit/user_test.exs @@ -0,0 +1,23 @@ +defmodule Orbit.Authentication.UserTest do + use Orbit.DataCase + alias Orbit.Authentication.User + + test "can insert a user" do + Repo.insert!(%User{ + email: "test@mbta.com" + }) + end + + test "cannot insert a duplicate user" do + Repo.insert!(%User{ + email: "test@mbta.com" + }) + + assert_raise Ecto.ConstraintError, + fn -> + Repo.insert!(%User{ + email: "test@mbta.com" + }) + end + end +end diff --git a/test/orbit_web/controllers/auth_controller_test.exs b/test/orbit_web/controllers/auth_controller_test.exs new file mode 100644 index 0000000..7fb2a2a --- /dev/null +++ b/test/orbit_web/controllers/auth_controller_test.exs @@ -0,0 +1,53 @@ +defmodule OrbitWeb.AuthControllerTest do + use OrbitWeb.ConnCase + alias OrbitWeb.Auth.Auth + + defp login_with_groups(conn, _groups) do + conn + |> get(~p"/auth/keycloak/callback?#{%{"email" => "user@example.com"}}") + end + + describe "/login" do + test "redirects to keycloak login if unauthenticated", %{conn: conn} do + conn = get(conn, ~p"/") + assert redirected_to(conn) == "/login" + end + end + + describe "callback" do + test "valid credentials redirect to /", %{conn: conn} do + conn = login_with_groups(conn, []) + assert redirected_to(conn) == "/" + end + + test "remembers target URL", %{conn: conn} do + conn = get(conn, "/help") + assert redirected_to(conn) == ~p"/login" + + conn = login_with_groups(conn, []) + + assert redirected_to(conn) == "/help" + end + + @tag capture_log: true + test "invalid credentials redirect to /login", %{conn: conn} do + conn = get(conn, ~p"/auth/keycloak/callback?#{%{"invalid" => "true"}}") + assert redirected_to(conn) == "/login" + end + + test "returns 400 on unrecognized provider", %{conn: conn} do + conn = get(conn, ~p"/auth/badprovider/callback") + assert response(conn, 400) + end + end + + describe "logout" do + test "logs out", %{conn: conn} do + conn = login_with_groups(conn, []) + assert Auth.logged_in_user(conn) + conn = get(conn, ~p"/logout") + assert response(conn, 200) + assert Auth.logged_in_user(conn) == nil + end + end +end diff --git a/test/orbit_web/controllers/react_app_controller_test.exs b/test/orbit_web/controllers/react_app_controller_test.exs index 7f3e070..40114bb 100644 --- a/test/orbit_web/controllers/react_app_controller_test.exs +++ b/test/orbit_web/controllers/react_app_controller_test.exs @@ -1,6 +1,7 @@ defmodule OrbitWeb.ReactAppControllerTest do use OrbitWeb.ConnCase + @tag :authenticated test "GET /", %{conn: conn} do conn = get(conn, ~p"/") response = html_response(conn, 200) diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index aa2ccb8..a27fc6e 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -16,6 +16,7 @@ defmodule OrbitWeb.ConnCase do """ use ExUnit.CaseTemplate + alias OrbitWeb.Auth.Auth using do quote do @@ -33,6 +34,21 @@ defmodule OrbitWeb.ConnCase do setup tags do Orbit.DataCase.setup_sandbox(tags) - {:ok, conn: Phoenix.ConnTest.build_conn()} + + conn = + if tags[:authenticated] do + Phoenix.ConnTest.build_conn() + |> Plug.Test.init_test_session(%{}) + |> Auth.login( + "user@example.com", + 30, + [], + "https://localhost/fake/logout" + ) + else + Phoenix.ConnTest.build_conn() + end + + {:ok, conn: conn} end end