From 2dda6d7f46f3fe86b6fb6ba00835181d244abc22 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 25 Jun 2023 15:19:11 -0700 Subject: [PATCH] Add OIDC login --- config/config.exs | 13 +++- lib/frenzy.ex | 4 + lib/frenzy/application.ex | 8 ++ lib/frenzy/user.ex | 7 ++ .../controllers/account_controller.ex | 9 ++- .../controllers/login_controller.ex | 66 +++++++++++++++-- lib/frenzy_web/router.ex | 13 ++++ .../templates/account/show.html.eex | 74 ++++++++++--------- lib/frenzy_web/templates/login/login.html.eex | 7 +- mix.exs | 4 +- mix.lock | 5 ++ .../20230625214304_user_add_oidc_subject.exs | 9 +++ 12 files changed, 170 insertions(+), 49 deletions(-) create mode 100644 priv/repo/migrations/20230625214304_user_add_oidc_subject.exs diff --git a/config/config.exs b/config/config.exs index 3ccbc82..f63dd40 100644 --- a/config/config.exs +++ b/config/config.exs @@ -30,10 +30,21 @@ config :phoenix, :json_library, Jason config :logger, truncate: :infinity +config :frenzy, env: config_env() config :frenzy, sentry_enabled: false config :frenzy, external_readability: false +config :frenzy, oidc_enabled: false -config :frenzy, env: config_env() +config :ueberauth, Ueberauth, + providers: [ + oidc: + {Ueberauth.Strategy.OIDC, + [ + default: [ + provider: :default_oidc + ] + ]} + ] # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. diff --git a/lib/frenzy.ex b/lib/frenzy.ex index 1fdc8cd..5fc3235 100644 --- a/lib/frenzy.ex +++ b/lib/frenzy.ex @@ -10,4 +10,8 @@ defmodule Frenzy do def sentry_enabled? do Application.get_env(:frenzy, :sentry_enabled) end + + def oidc_enabled? do + Application.get_env(:frenzy, :oidc_enabled) + end end diff --git a/lib/frenzy/application.ex b/lib/frenzy/application.ex index 74f9dd2..15dd260 100644 --- a/lib/frenzy/application.ex +++ b/lib/frenzy/application.ex @@ -20,6 +20,14 @@ defmodule Frenzy.Application do {Frenzy.BuiltinExtractor, name: Frenzy.BuiltinExtractor} ] + children = + if Frenzy.oidc_enabled?() do + children ++ + [{OpenIDConnect.Worker, Application.get_env(:ueberauth, Ueberauth.Strategy.OIDC)}] + else + children + end + # See https://hexdocs.pm/elixir/Supervisor.html # for other strategies and supported options opts = [strategy: :one_for_one, name: Frenzy.Supervisor] diff --git a/lib/frenzy/user.ex b/lib/frenzy/user.ex index bbeb54c..1d4b768 100644 --- a/lib/frenzy/user.ex +++ b/lib/frenzy/user.ex @@ -8,6 +8,7 @@ defmodule Frenzy.User do field :password_hash, :string field :fever_password, :string, virtual: true field :fever_auth_token, :string + field :oidc_subject, :string has_many :approved_clients, Frenzy.ApprovedClient, on_delete: :delete_all @@ -25,6 +26,7 @@ defmodule Frenzy.User do password_hash: String.t(), fever_password: String.t() | nil, fever_auth_token: String.t(), + oidc_subject: String.t() | nil, approved_clients: [Frenzy.ApprovedClient.t()] | Ecto.Association.NotLoaded.t(), groups: [Frenzy.Group.t()] | Ecto.Association.NotLoaded.t(), inserted_at: NaiveDateTime.t(), @@ -61,6 +63,11 @@ defmodule Frenzy.User do |> put_fever_token() end + def set_oidc_subject_changeset(user, attrs) do + user + |> cast(attrs, [:oidc_subject]) + end + defp put_password_hash( %Ecto.Changeset{valid?: true, changes: %{password: password}} = changeset ) do diff --git a/lib/frenzy_web/controllers/account_controller.ex b/lib/frenzy_web/controllers/account_controller.ex index 1a6316d..80f9ccb 100644 --- a/lib/frenzy_web/controllers/account_controller.ex +++ b/lib/frenzy_web/controllers/account_controller.ex @@ -15,7 +15,8 @@ defmodule FrenzyWeb.AccountController do render(conn, "show.html", %{ user: user, - clients: clients + clients: clients, + can_link_oidc: Frenzy.oidc_enabled?() && user.oidc_subject in [nil, ""] }) end @@ -147,4 +148,10 @@ defmodule FrenzyWeb.AccountController do opml = Frenzy.OPML.Exporter.export(user.groups) send_download(conn, {:binary, opml}, filename: "frenzy_export.opml") end + + def link_oidc(conn, _params) do + conn + |> put_session(:continue_path, Routes.account_path(conn, :show)) + |> redirect(to: Routes.login_path(conn, :ueberauth_request, "oidc")) + end end diff --git a/lib/frenzy_web/controllers/login_controller.ex b/lib/frenzy_web/controllers/login_controller.ex index 65051bb..9783056 100644 --- a/lib/frenzy_web/controllers/login_controller.ex +++ b/lib/frenzy_web/controllers/login_controller.ex @@ -3,9 +3,15 @@ defmodule FrenzyWeb.LoginController do alias Frenzy.{Repo, User} alias FrenzyWeb.Endpoint + if Frenzy.oidc_enabled?() do + plug Ueberauth + end + def login(conn, params) do - render(conn, "login.html", %{ - continue: Map.get(params, "continue") + conn + |> put_session(:continue_path, Map.get(params, "continue")) + |> render("login.html", %{ + oidc_enabled?: Frenzy.oidc_enabled?() }) end @@ -14,16 +20,12 @@ defmodule FrenzyWeb.LoginController do case Bcrypt.check_pass(user, password) do {:ok, user} -> - user_token = Phoenix.Token.sign(Endpoint, "user token", user.id) - conn = put_session(conn, :user_token, user_token) - - redirect_uri = Map.get(params, "continue") || Routes.group_path(Endpoint, :index) - redirect(conn, to: redirect_uri) + put_user_and_redirect(conn, user) {:error, _reason} -> conn |> put_flash(:error, "Invalid username or password.") - |> redirect(to: Routes.login_path(Endpoint, :login)) + |> redirect(to: Routes.login_path(Endpoint, :login, continue: continue_path(conn))) end end @@ -33,4 +35,52 @@ defmodule FrenzyWeb.LoginController do |> clear_session() |> redirect(to: "/") end + + def ueberauth_callback(%{assigns: %{ueberauth_failure: _fails}} = conn, _params) do + conn + |> put_flash(:error, "Failed to authenticate.") + |> redirect(to: Routes.login_path(Endpoint, :login, continue: continue_path(conn))) + end + + def ueberauth_callback( + %{assigns: %{ueberauth_auth: %{credentials: %{other: %{user_info: %{"sub" => subject}}}}}} = + conn, + _params + ) do + case Repo.get_by(User, oidc_subject: subject) do + nil -> + conn = FrenzyWeb.Plug.Authenticate.call(conn, nil) + + case conn.assigns.user do + %User{} = user -> + changeset = User.set_oidc_subject_changeset(user, %{oidc_subject: subject}) + {:ok, user} = Repo.update(changeset) + + conn + |> put_flash(:info, "Successfully linked OIDC.") + |> redirect(to: continue_path(conn)) + + _ -> + # TODO: register new user for subject + conn + |> put_flash(:error, "No matching OIDC subject.") + |> redirect(to: Routes.login_path(Endpoint, :login, continue: continue_path(conn))) + end + + user -> + put_user_and_redirect(conn, user) + end + end + + defp continue_path(conn) do + get_session(conn, :continue_path) || Routes.group_path(Endpoint, :index) + end + + defp put_user_and_redirect(conn, user) do + user_token = Phoenix.Token.sign(Endpoint, "user token", user.id) + + conn + |> put_session(:user_token, user_token) + |> redirect(to: continue_path(conn)) + end end diff --git a/lib/frenzy_web/router.ex b/lib/frenzy_web/router.ex index c5a0a27..15d370e 100644 --- a/lib/frenzy_web/router.ex +++ b/lib/frenzy_web/router.ex @@ -34,6 +34,15 @@ defmodule FrenzyWeb.Router do post "/oauth/authorize", Fervor.OauthController, :authorize_post end + scope "/auth", FrenzyWeb do + pipe_through :browser + + if Frenzy.oidc_enabled?() do + get "/:unused", LoginController, :ueberauth_request + get "/:unused/callback", LoginController, :ueberauth_callback + end + end + scope "/", FrenzyWeb do pipe_through :browser pipe_through :browser_authenticate @@ -47,6 +56,10 @@ defmodule FrenzyWeb.Router do post "/account/import", AccountController, :import post "/account/export", AccountController, :export + if Frenzy.oidc_enabled?() do + get "/account/link_oidc", AccountController, :link_oidc + end + get "/", GroupController, :index resources "/groups", GroupController get "/groups/:id/read", GroupController, :read diff --git a/lib/frenzy_web/templates/account/show.html.eex b/lib/frenzy_web/templates/account/show.html.eex index c268332..6cbf238 100644 --- a/lib/frenzy_web/templates/account/show.html.eex +++ b/lib/frenzy_web/templates/account/show.html.eex @@ -20,43 +20,47 @@
-

Security

+

Security

-
diff --git a/lib/frenzy_web/templates/login/login.html.eex b/lib/frenzy_web/templates/login/login.html.eex index 557881d..be19266 100644 --- a/lib/frenzy_web/templates/login/login.html.eex +++ b/lib/frenzy_web/templates/login/login.html.eex @@ -1,9 +1,6 @@

Login

<%= form_tag Routes.login_path(@conn, :login_post), method: :post do %> - <%= if @continue do %> - - <% end %>
@@ -22,3 +19,7 @@
<% end %> + +<%= if @oidc_enabled? do %> + ">Log In with OIDC +<% end %> diff --git a/mix.exs b/mix.exs index fc6621e..2de00c9 100644 --- a/mix.exs +++ b/mix.exs @@ -57,7 +57,9 @@ defmodule Frenzy.MixProject do {:floki, "~> 0.30"}, {:phoenix_live_view, "~> 0.17.5"}, {:gemini, git: "https://git.shadowfacts.net/shadowfacts/gemini-ex.git", branch: "main"}, - {:sentry, "~> 8.0"} + {:sentry, "~> 8.0"}, + {:ueberauth, "~> 0.10"}, + {:ueberauth_oidc, "~> 0.1"} ] end diff --git a/mix.lock b/mix.lock index 5042d22..0f711f4 100644 --- a/mix.lock +++ b/mix.lock @@ -25,13 +25,16 @@ "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.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.3.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", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, "hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"}, "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"}, + "httpoison": {:hex, :httpoison, "1.8.2", "9eb9c63ae289296a544842ef816a85d881d4a31f518a0fec089aaa744beae290", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "2bb350d26972e30c96e2ca74a1aaf8293d61d0742ff17f01e0279fef11599921"}, "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.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, + "jose": {:hex, :jose, "1.11.5", "3bc2d75ffa5e2c941ca93e5696b54978323191988eb8d225c2e663ddfefd515e", [:mix, :rebar3], [], "hexpm", "dcd3b215bafe02ea7c5b23dafd3eb8062a5cd8f2d904fd9caa323d37034ab384"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "2.0.3", "3676436d3d1f7b81b5a2d2bd8405f412c677558c81b1c92be58c00562bb59095", [:mix], [], "hexpm", "27a30bf0db44d25eecba73755acf4068cbfe26a4372f9eb3e4ea3a45956bff6b"}, "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, "mint": {:hex, :mint, "1.4.2", "50330223429a6e1260b2ca5415f69b0ab086141bc76dc2fbf34d7c389a6675b2", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "ce75a5bbcc59b4d7d8d70f8b2fc284b1751ffb35c7b6a6302b5192f8ab4ddd80"}, "mochiweb": {:hex, :mochiweb, "2.18.0", "eb55f1db3e6e960fac4e6db4e2db9ec3602cc9f30b86cd1481d56545c3145d2e", [:rebar3], [], "hexpm"}, + "openid_connect": {:hex, :openid_connect, "0.2.2", "c05055363330deab39ffd89e609db6b37752f255a93802006d83b45596189c0b", [:mix], [{:httpoison, "~> 1.2", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "735769b6d592124b58edd0582554ce638524c0214cd783d8903d33357d74cc13"}, "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, "phoenix": {:hex, :phoenix, "1.6.12", "f8f8ac077600f84419806dd53114b2e77aedde7a502e74181a7d886355aa0643", [: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", "2d6cf5583c9c20f7103c40e6014ef802d96553b8e5d6585ad6e627bd5ddb0d12"}, "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"}, @@ -56,6 +59,8 @@ "tesla": {:hex, :tesla, "1.4.4", "bb89aa0c9745190930366f6a2ac612cdf2d0e4d7fff449861baa7875afd797b2", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.3", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, "~> 1.3", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "d5503a49f9dec1b287567ea8712d085947e247cb11b06bc54adb05bfde466457"}, "timex": {:hex, :timex, "3.6.1", "efdf56d0e67a6b956cc57774353b0329c8ab7726766a11547e529357ffdc1d56", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5 or ~> 1.0.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "f354efb2400dd7a80fd9eb6c8419068c4f632da4ac47f3d8822d6e33f08bc852"}, "tzdata": {:hex, :tzdata, "1.0.1", "f6027a331af7d837471248e62733c6ebee86a72e57c613aa071ebb1f750fc71a", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "cf1345dfbce6acdfd4e23cbb36e96e53d1981bc89181cd0b936f4f398f4c0b78"}, + "ueberauth": {:hex, :ueberauth, "0.10.5", "806adb703df87e55b5615cf365e809f84c20c68aa8c08ff8a416a5a6644c4b02", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "3efd1f31d490a125c7ed453b926f7c31d78b97b8a854c755f5c40064bf3ac9e1"}, + "ueberauth_oidc": {:hex, :ueberauth_oidc, "0.1.7", "d610cbe5ef09881dff52126906b130307adcf02791ce158c1847fd50949b283a", [:mix], [{:httpoison, "~> 1.8", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jose, "~> 1.11", [hex: :jose, repo: "hexpm", optional: false]}, {:openid_connect, "~> 0.2.2", [hex: :openid_connect, repo: "hexpm", optional: false]}, {:plug, "~> 1.11", [hex: :plug, repo: "hexpm", optional: false]}, {:ueberauth, "~> 0.7", [hex: :ueberauth, repo: "hexpm", optional: false]}], "hexpm", "34d612f66a5425af4142d6c9dece887c60188c31e1dc113e5ee8cecdc6c5e8a9"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, "xml_builder": {:hex, :xml_builder, "2.1.1", "2d6d665f09cf1319e3e1c46035755271b414d99ad8615d0bd6f337623e0c885b", [:mix], [], "hexpm", "214c16caa77e66bf0c6b74099a7059ee00de8fd07728d2a3dc32afe344a7452b"}, } diff --git a/priv/repo/migrations/20230625214304_user_add_oidc_subject.exs b/priv/repo/migrations/20230625214304_user_add_oidc_subject.exs new file mode 100644 index 0000000..a5af88c --- /dev/null +++ b/priv/repo/migrations/20230625214304_user_add_oidc_subject.exs @@ -0,0 +1,9 @@ +defmodule Frenzy.Repo.Migrations.UserAddOidcSubject do + use Ecto.Migration + + def change do + alter table(:users) do + add :oidc_subject, :string, default: nil + end + end +end