diff --git a/.env.example b/.env.example index 3cea5779d89..518718d2d2e 100644 --- a/.env.example +++ b/.env.example @@ -13,9 +13,13 @@ # SALESFORCE_CLIENT_ID=3MVG9_ghE # SALESFORCE_CLIENT_SECRET=703777B -# Set this up to handle Google OAuth credentials (ex: GoogleSheets) -# GOOGLE_CLIENT_ID=660274980707 -# GOOGLE_CLIENT_SECRET=GOCSPX-ua +# Set GITHUB_CLIENT_ID/GITHUB_CLIENT_SECRET to enable GitHub SSO sign-in +# GITHUB_CLIENT_ID= +# GITHUB_CLIENT_SECRET= + +# Set GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET to enable Google SSO sign-in +# GOOGLE_CLIENT_ID= +# GOOGLE_CLIENT_SECRET= # Choose an admin email address and configure a mailer. If you don't specify # mailer details the local test adaptor will be used and mail previews can be diff --git a/lib/lightning/accounts.ex b/lib/lightning/accounts.ex index 4787db4be93..f2bec9fa7d6 100644 --- a/lib/lightning/accounts.ex +++ b/lib/lightning/accounts.ex @@ -14,6 +14,7 @@ defmodule Lightning.Accounts do alias Lightning.Accounts.Events alias Lightning.Accounts.User alias Lightning.Accounts.UserBackupCode + alias Lightning.Accounts.UserIdentity alias Lightning.Accounts.UserNotifier alias Lightning.Accounts.UserToken alias Lightning.Accounts.UserTOTP @@ -172,7 +173,126 @@ defmodule Lightning.Accounts do def get_user_by_email_and_password(email, password) when is_binary(email) and is_binary(password) do user = Repo.get_by(User, email: email) - if User.valid_password?(user, password), do: user + + cond do + is_nil(user) -> + User.valid_password?(user, password) + nil + + is_nil(user.hashed_password) -> + User.valid_password?(user, password) + {:error, :sso_account} + + User.valid_password?(user, password) -> + user + + true -> + nil + end + end + + @doc """ + Looks up a user by their SSO provider identity. + + Returns the `%User{}` if found, otherwise `nil`. + """ + def get_user_by_identity(provider, uid) do + from(u in User, + join: i in UserIdentity, + on: i.user_id == u.id, + where: i.provider == ^provider and i.uid == ^uid + ) + |> Repo.one() + end + + @doc """ + Links an SSO provider identity to an existing user account. + + Silently succeeds if the identity already exists (on_conflict: :nothing). + """ + def link_user_identity(%User{id: user_id}, provider, uid) do + %UserIdentity{} + |> UserIdentity.changeset(%{user_id: user_id, provider: provider, uid: uid}) + |> Repo.insert(on_conflict: :nothing, conflict_target: [:provider, :uid]) + end + + @doc """ + Returns the SSO identities linked to a user, ordered by provider name. + """ + def list_user_identities(%User{id: user_id}) do + from(i in UserIdentity, + where: i.user_id == ^user_id, + order_by: [asc: i.provider] + ) + |> Repo.all() + end + + @doc """ + Gets a user's identity for a given provider, or `nil` if not linked. + """ + def get_user_identity(%User{id: user_id}, provider) do + Repo.get_by(UserIdentity, user_id: user_id, provider: provider) + end + + @doc """ + Removes the SSO identity for the given user and provider. + + Refuses to remove the last identity for an SSO-only user (no password set), + since that would lock them out. Such users can set a password by going + through the password reset flow first. + + Returns: + * `{:ok, identity}` when the identity is removed + * `{:error, :not_linked}` when the user has no identity for the provider + * `{:error, :would_lock_out}` when removing would leave an SSO-only user + with no way to log in + """ + def unlink_user_identity(%User{} = user, provider) do + case get_user_identity(user, provider) do + nil -> + {:error, :not_linked} + + %UserIdentity{} = identity -> + if can_remove_identity?(user, identity) do + Repo.delete(identity) + else + {:error, :would_lock_out} + end + end + end + + defp can_remove_identity?(%User{hashed_password: hp}, _identity) + when is_binary(hp), + do: true + + defp can_remove_identity?(%User{} = user, %UserIdentity{id: identity_id}) do + other_count = + from(i in UserIdentity, + where: i.user_id == ^user.id and i.id != ^identity_id, + select: count(i.id) + ) + |> Repo.one() + + other_count > 0 + end + + @doc """ + Registers a brand-new user via SSO. + + The user is created without a password and confirmed immediately. + A `user_registered` event is broadcast on success. + """ + def register_user_from_sso(attrs, provider, uid) do + attrs = Map.put(attrs, :sso_identity, %{provider: provider, uid: uid}) + + Repo.transact(fn -> + AccountHook.handle_register_user(attrs) + end) + |> tap(fn result -> + with {:ok, user} <- result do + Events.user_registered(user) + end + end) end @doc """ diff --git a/lib/lightning/accounts/user.ex b/lib/lightning/accounts/user.ex index f7670cc2947..082a4804b17 100644 --- a/lib/lightning/accounts/user.ex +++ b/lib/lightning/accounts/user.ex @@ -50,6 +50,8 @@ defmodule Lightning.Accounts.User do has_many :backup_codes, Lightning.Accounts.UserBackupCode, on_replace: :delete + has_many :user_identities, Lightning.Accounts.UserIdentity + timestamps() end @@ -337,6 +339,22 @@ defmodule Lightning.Accounts.User do change(user, confirmed_at: now) end + @doc """ + A changeset for registering a user via SSO. No password is required; + the account is confirmed immediately at registration time. + """ + def sso_registration_changeset(user, attrs) do + user + |> cast(attrs, [:first_name, :last_name, :email]) + |> validate_required([:first_name, :last_name, :email]) + |> validate_email_format() + |> validate_email_exists() + |> put_change( + :confirmed_at, + DateTime.utc_now() |> DateTime.truncate(:second) + ) + end + @spec remove_github_token_changeset(t()) :: Ecto.Changeset.t() def remove_github_token_changeset(user) do change(user, github_oauth_token: nil) diff --git a/lib/lightning/accounts/user_identity.ex b/lib/lightning/accounts/user_identity.ex new file mode 100644 index 00000000000..1514e1f3b22 --- /dev/null +++ b/lib/lightning/accounts/user_identity.ex @@ -0,0 +1,25 @@ +defmodule Lightning.Accounts.UserIdentity do + @moduledoc """ + Schema for tracking SSO provider identities linked to user accounts. + + A user may have multiple identities (one per SSO provider). The combination + of provider and uid is globally unique. + """ + use Lightning.Schema + + alias Lightning.Accounts.User + + schema "user_identities" do + field :provider, :string + field :uid, :string + belongs_to :user, User + timestamps() + end + + def changeset(identity, attrs) do + identity + |> cast(attrs, [:provider, :uid, :user_id]) + |> validate_required([:provider, :uid, :user_id]) + |> unique_constraint([:provider, :uid]) + end +end diff --git a/lib/lightning/auth_providers/cache_warmer.ex b/lib/lightning/auth_providers/cache_warmer.ex index 21881a7796f..19d6d8d63c7 100644 --- a/lib/lightning/auth_providers/cache_warmer.ex +++ b/lib/lightning/auth_providers/cache_warmer.ex @@ -10,6 +10,14 @@ defmodule Lightning.AuthProviders.CacheWarmer do # https://github.com/whitfin/cachex/issues/276 @dialyzer {:nowarn_function, init: 1} + # `GithubHandler.build/0` and `GoogleHandler.build/0` read their client + # credentials through `Lightning.Config.*_oauth/1`, which dispatches + # dynamically through an extension module. Dialyzer can't see that the + # binary branch is reachable, so it concludes `build/0` only ever returns + # `{:error, :not_configured}` and flags the `{:ok, _}` pattern here as + # unreachable. The runtime behaviour is fine. + @dialyzer {:nowarn_function, execute: 1} + @doc """ Returns the interval for this warmer. """ @@ -20,12 +28,34 @@ defmodule Lightning.AuthProviders.CacheWarmer do Executes this cache warmer with a connection. """ def execute(_state) do - with %AuthProviders.AuthConfig{name: name} = config <- - AuthProviders.get_existing() || :ignore, - {:ok, handler} <- AuthProviders.Handler.from_model(config) do - {:ok, [{name, handler}]} - else - _error -> :ignore + db_entries = + try do + with %AuthProviders.AuthConfig{name: name} = config <- + AuthProviders.get_existing() || :not_found, + {:ok, handler} <- AuthProviders.Handler.from_model(config) do + [{name, handler}] + else + _ -> [] + end + rescue + _ -> [] + end + + github_entries = + case Lightning.AuthProviders.GithubHandler.build() do + {:ok, handler} -> [{handler.name, handler}] + _ -> [] + end + + google_entries = + case Lightning.AuthProviders.GoogleHandler.build() do + {:ok, handler} -> [{handler.name, handler}] + _ -> [] + end + + case db_entries ++ github_entries ++ google_entries do + [] -> :ignore + entries -> {:ok, entries} end end end diff --git a/lib/lightning/auth_providers/github_handler.ex b/lib/lightning/auth_providers/github_handler.ex new file mode 100644 index 00000000000..f43c308dbcd --- /dev/null +++ b/lib/lightning/auth_providers/github_handler.ex @@ -0,0 +1,42 @@ +defmodule Lightning.AuthProviders.GithubHandler do + @moduledoc """ + Builds a Handler for GitHub OAuth2 SSO login from environment configuration. + + Set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET to enable GitHub login. + """ + + alias Lightning.AuthProviders.Handler + alias Lightning.AuthProviders.WellKnown + + @name "github" + @authorization_endpoint "https://github.com/login/oauth/authorize" + @token_endpoint "https://github.com/login/oauth/access_token" + @userinfo_endpoint "https://api.github.com/user" + + def handler_name, do: @name + + @spec build() :: {:ok, Handler.t()} | {:error, :not_configured} + def build do + client_id = Lightning.Config.github_oauth(:client_id) + client_secret = Lightning.Config.github_oauth(:client_secret) + redirect_uri = Lightning.Config.github_oauth(:redirect_uri) + + if client_id && client_secret && redirect_uri do + wellknown = %WellKnown{ + authorization_endpoint: @authorization_endpoint, + token_endpoint: @token_endpoint, + userinfo_endpoint: @userinfo_endpoint + } + + Handler.new(@name, + client_id: client_id, + client_secret: client_secret, + redirect_uri: redirect_uri, + wellknown: wellknown, + scope: "read:user user:email" + ) + else + {:error, :not_configured} + end + end +end diff --git a/lib/lightning/auth_providers/google_handler.ex b/lib/lightning/auth_providers/google_handler.ex new file mode 100644 index 00000000000..7b54f222e80 --- /dev/null +++ b/lib/lightning/auth_providers/google_handler.ex @@ -0,0 +1,42 @@ +defmodule Lightning.AuthProviders.GoogleHandler do + @moduledoc """ + Builds a Handler for Google OAuth2 SSO login from environment configuration. + + Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET to enable Google login. + """ + + alias Lightning.AuthProviders.Handler + alias Lightning.AuthProviders.WellKnown + + @name "google" + @authorization_endpoint "https://accounts.google.com/o/oauth2/v2/auth" + @token_endpoint "https://oauth2.googleapis.com/token" + @userinfo_endpoint "https://openidconnect.googleapis.com/v1/userinfo" + + def handler_name, do: @name + + @spec build() :: {:ok, Handler.t()} | {:error, :not_configured} + def build do + client_id = Lightning.Config.google_oauth(:client_id) + client_secret = Lightning.Config.google_oauth(:client_secret) + redirect_uri = Lightning.Config.google_oauth(:redirect_uri) + + if client_id && client_secret && redirect_uri do + wellknown = %WellKnown{ + authorization_endpoint: @authorization_endpoint, + token_endpoint: @token_endpoint, + userinfo_endpoint: @userinfo_endpoint + } + + Handler.new(@name, + client_id: client_id, + client_secret: client_secret, + redirect_uri: redirect_uri, + wellknown: wellknown, + scope: "openid email profile" + ) + else + {:error, :not_configured} + end + end +end diff --git a/lib/lightning/auth_providers/handler.ex b/lib/lightning/auth_providers/handler.ex index 9e814bec24f..c3757180518 100644 --- a/lib/lightning/auth_providers/handler.ex +++ b/lib/lightning/auth_providers/handler.ex @@ -10,17 +10,19 @@ defmodule Lightning.AuthProviders.Handler do @type t :: %__MODULE__{ name: String.t(), client: OAuth2.Client.t(), - wellknown: WellKnown.t() + wellknown: WellKnown.t(), + scope: String.t() } @type opts :: [ client_id: String.t(), client_secret: String.t(), redirect_uri: String.t(), - wellknown: WellKnown.t() + wellknown: WellKnown.t(), + scope: String.t() ] - defstruct [:name, :client, :wellknown] + defstruct [:name, :client, :wellknown, scope: "openid email profile"] @doc """ Create a new Provider struct, expects a name and opts: @@ -41,6 +43,7 @@ defmodule Lightning.AuthProviders.Handler do :ok -> wellknown = opts[:wellknown] + scope = opts[:scope] || "openid email profile" client = OAuth2.Client.new( @@ -54,7 +57,12 @@ defmodule Lightning.AuthProviders.Handler do |> OAuth2.Client.put_serializer("application/json", Jason) {:ok, - struct!(__MODULE__, name: name, client: client, wellknown: wellknown)} + struct!(__MODULE__, + name: name, + client: client, + wellknown: wellknown, + scope: scope + )} end end @@ -80,7 +88,7 @@ defmodule Lightning.AuthProviders.Handler do @spec authorize_url(handler :: __MODULE__.t()) :: String.t() def authorize_url(handler) do - OAuth2.Client.authorize_url!(handler.client, scope: "openid email profile") + OAuth2.Client.authorize_url!(handler.client, scope: handler.scope) end @spec get_token(handler :: __MODULE__.t(), code :: String.t()) :: @@ -88,7 +96,7 @@ defmodule Lightning.AuthProviders.Handler do def get_token(handler, code) when is_binary(code) do case OAuth2.Client.get_token(handler.client, code: code, - scope: "openid email profile" + scope: handler.scope ) do {:ok, client} -> {:ok, client.token} {:error, %OAuth2.Response{body: body}} -> {:error, body} diff --git a/lib/lightning/config.ex b/lib/lightning/config.ex index 67ba7a744d9..5e4c0f6cab5 100644 --- a/lib/lightning/config.ex +++ b/lib/lightning/config.ex @@ -113,6 +113,18 @@ defmodule Lightning.Config do |> Keyword.get(key) end + @impl true + def github_oauth(key) do + Application.get_env(:lightning, :github_oauth, []) + |> Keyword.get(key) + end + + @impl true + def google_oauth(key) do + Application.get_env(:lightning, :google_oauth, []) + |> Keyword.get(key) + end + @impl true def check_flag?(flag) do Application.get_env(:lightning, flag) @@ -459,6 +471,8 @@ defmodule Lightning.Config do @callback env() :: :dev | :test | :prod @callback get_extension_mod(key :: atom()) :: any() @callback google(key :: atom()) :: any() + @callback github_oauth(key :: atom()) :: any() + @callback google_oauth(key :: atom()) :: any() @callback grace_period() :: integer() @callback instance_admin_email() :: String.t() @callback kafka_alternate_storage_enabled?() :: boolean() @@ -613,6 +627,14 @@ defmodule Lightning.Config do impl().google(key) end + def github_oauth(key) do + impl().github_oauth(key) + end + + def google_oauth(key) do + impl().google_oauth(key) + end + def cors_origin do impl().cors_origin() end diff --git a/lib/lightning/config/bootstrap.ex b/lib/lightning/config/bootstrap.ex index cb7003eb39b..4d14c746ad3 100644 --- a/lib/lightning/config/bootstrap.ex +++ b/lib/lightning/config/bootstrap.ex @@ -503,6 +503,32 @@ defmodule Lightning.Config.Bootstrap do cors_origin: env!("CORS_ORIGIN", :string, "*") |> String.split(",") |> List.wrap() + github_client_id = env!("GITHUB_CLIENT_ID", :string, nil) + github_client_secret = env!("GITHUB_CLIENT_SECRET", :string, nil) + + if github_client_id && github_client_secret do + github_redirect_uri = + sso_redirect_uri(url_scheme, host, url_port, "github") + + config :lightning, :github_oauth, + client_id: github_client_id, + client_secret: github_client_secret, + redirect_uri: github_redirect_uri + end + + google_client_id = env!("GOOGLE_CLIENT_ID", :string, nil) + google_client_secret = env!("GOOGLE_CLIENT_SECRET", :string, nil) + + if google_client_id && google_client_secret do + google_redirect_uri = + sso_redirect_uri(url_scheme, host, url_port, "google") + + config :lightning, :google_oauth, + client_id: google_client_id, + client_secret: google_client_secret, + redirect_uri: google_redirect_uri + end + if config_env() == :prod do unless database_url do raise """ @@ -1013,6 +1039,14 @@ defmodule Lightning.Config.Bootstrap do {:error, worker_key_error("could not be parsed: #{Exception.message(e)}")} end + defp sso_redirect_uri(url_scheme, host, url_port, provider) do + if url_port in [80, 443] do + "#{url_scheme}://#{host}/authenticate/#{provider}/callback" + else + "#{url_scheme}://#{host}:#{url_port}/authenticate/#{provider}/callback" + end + end + defp worker_key_error(reason) do """ WORKER_RUNS_PRIVATE_KEY #{reason} diff --git a/lib/lightning/extensions/account_hook.ex b/lib/lightning/extensions/account_hook.ex index d10bdade6a4..bc192cc047b 100644 --- a/lib/lightning/extensions/account_hook.ex +++ b/lib/lightning/extensions/account_hook.ex @@ -4,9 +4,24 @@ defmodule Lightning.Extensions.AccountHook do alias Ecto.Changeset alias Lightning.Accounts.User + alias Lightning.Accounts.UserIdentity alias Lightning.Repo @spec handle_register_user(map()) :: {:ok, User.t()} | {:error, Changeset.t()} + def handle_register_user( + %{sso_identity: %{provider: provider, uid: uid}} = attrs + ) do + attrs = Map.delete(attrs, :sso_identity) + + with {:ok, user} <- + %User{} + |> User.sso_registration_changeset(attrs) + |> Repo.insert(), + {:ok, _identity} <- link_identity(user, provider, uid) do + {:ok, user} + end + end + def handle_register_user(attrs) do with {:ok, data} <- User.user_registration_changeset(attrs) @@ -31,4 +46,10 @@ defmodule Lightning.Extensions.AccountHook do |> User.changeset(attrs) |> Repo.insert() end + + defp link_identity(%User{id: user_id}, provider, uid) do + %UserIdentity{} + |> UserIdentity.changeset(%{user_id: user_id, provider: provider, uid: uid}) + |> Repo.insert(on_conflict: :nothing, conflict_target: [:provider, :uid]) + end end diff --git a/lib/lightning_web/components/sso_icons.ex b/lib/lightning_web/components/sso_icons.ex new file mode 100644 index 00000000000..38a947b64aa --- /dev/null +++ b/lib/lightning_web/components/sso_icons.ex @@ -0,0 +1,47 @@ +defmodule LightningWeb.Components.SsoIcons do + @moduledoc """ + SVG icons for SSO providers, shared between sign-in, sign-up, and the profile + identities section. + """ + use LightningWeb, :component + + attr :name, :string, required: true + attr :class, :string, default: "h-4 w-4 inline-block" + + def provider_icon(%{name: "github"} = assigns) do + ~H""" + + """ + end + + def provider_icon(%{name: "google"} = assigns) do + ~H""" + + """ + end + + def provider_icon(assigns) do + ~H""" + <.icon name="hero-identification" class={@class} /> + """ + end +end diff --git a/lib/lightning_web/controllers/oidc_controller.ex b/lib/lightning_web/controllers/oidc_controller.ex index 42f04da94a5..7feb724bb3f 100644 --- a/lib/lightning_web/controllers/oidc_controller.ex +++ b/lib/lightning_web/controllers/oidc_controller.ex @@ -2,6 +2,7 @@ defmodule LightningWeb.OidcController do use LightningWeb, :controller alias Lightning.Accounts + alias Lightning.Accounts.User alias Lightning.AuthProviders alias Lightning.AuthProviders.Handler alias LightningWeb.OauthCredentialHelper @@ -9,6 +10,9 @@ defmodule LightningWeb.OidcController do action_fallback LightningWeb.FallbackController + @link_intent_session_key :sso_link_intent_provider + @pending_signup_session_key :sso_pending_signup + plug :fetch_current_user @doc """ @@ -25,38 +29,54 @@ defmodule LightningWeb.OidcController do end end + @doc """ + Initiates an SSO link flow for an already-authenticated user. The + provider is recorded in the session and the user is redirected to the + provider's authorize URL. On callback the controller links the resulting + identity to the current account rather than logging in. + """ + def link(conn, %{"provider" => provider}) do + with {:ok, handler} <- AuthProviders.get_handler(provider) do + if conn.assigns.current_user do + conn + |> put_session(@link_intent_session_key, provider) + |> redirect(external: Handler.authorize_url(handler)) + else + redirect(conn, to: Routes.user_session_path(conn, :new)) + end + end + end + @doc """ Once the user has completed the authorization flow from above, they are returned here, and the authorization code is used to log them in. """ def new(conn, %{"provider" => provider, "code" => code}) do + {link_intent, conn} = pop_link_intent(conn) + with {:ok, handler} <- AuthProviders.get_handler(provider), - {:ok, token} <- Handler.get_token(handler, code) do - userinfo = Handler.get_userinfo(handler, token) - email = Map.fetch!(userinfo, "email") - - case Accounts.get_user_by_email(email) do - nil -> - conn - |> put_flash(:error, "Could not find user account") - |> redirect(to: Routes.user_session_path(conn, :new)) - - %{mfa_enabled: true} = user -> - conn - |> UserAuth.log_in_user(user) - |> UserAuth.mark_totp_pending() - |> redirect( - to: - Routes.user_totp_path(conn, :new, user: %{"remember_me" => "true"}) - ) - - user -> - conn - |> UserAuth.log_in_user(user) - |> UserAuth.redirect_with_return_to(%{ - "remember_me" => "true" - }) + {:ok, token} <- Handler.get_token(handler, code), + userinfo <- Handler.get_userinfo(handler, token), + {:ok, email} <- fetch_email(userinfo), + {:ok, uid} <- fetch_uid(userinfo) do + if link_intent == provider && conn.assigns.current_user do + handle_sso_link(conn, conn.assigns.current_user, provider, uid) + else + handle_sso_login(conn, provider, uid, email, userinfo) end + else + {:error, :no_email} -> + conn + |> put_flash( + :error, + "Could not retrieve your email from the provider. Please ensure your email address is accessible." + ) + |> redirect(to: failure_redirect(conn, link_intent)) + + {:error, _reason} -> + conn + |> put_flash(:error, "Authentication failed") + |> redirect(to: failure_redirect(conn, link_intent)) end end @@ -70,6 +90,225 @@ defmodule LightningWeb.OidcController do close_browser_window(conn) end + @doc """ + Renders the confirmation page shown after a successful SSO callback that + would create a brand-new account. We ask the user to confirm before + provisioning so they aren't surprised by an account they didn't realise was + being created. + """ + def confirm_signup(conn, _params) do + case get_session(conn, @pending_signup_session_key) do + %{} = pending -> + render(conn, :confirm_signup, pending: pending) + + _ -> + conn + |> clear_pending_signup() + |> put_flash(:error, "No pending sign-up to confirm.") + |> redirect(to: Routes.user_session_path(conn, :new)) + end + end + + @doc """ + Confirms a pending SSO signup. Creates the account, links the identity, and + logs the user in. + """ + def complete_signup(conn, _params) do + case get_session(conn, @pending_signup_session_key) do + %{ + "provider" => provider, + "uid" => uid, + "email" => email, + "first_name" => first_name, + "last_name" => last_name + } -> + attrs = %{ + email: email, + first_name: first_name, + last_name: last_name + } + + case Accounts.register_user_from_sso(attrs, provider, uid) do + {:ok, user} -> + conn + |> clear_pending_signup() + |> do_log_in(user) + + {:error, _changeset} -> + conn + |> clear_pending_signup() + |> put_flash( + :error, + "Could not create your account. Please try again." + ) + |> redirect(to: Routes.user_session_path(conn, :new)) + end + + _ -> + conn + |> clear_pending_signup() + |> put_flash(:error, "No pending sign-up to confirm.") + |> redirect(to: Routes.user_session_path(conn, :new)) + end + end + + @doc """ + Cancels a pending SSO signup, clearing the stashed state. + """ + def cancel_signup(conn, _params) do + conn + |> clear_pending_signup() + |> redirect(to: Routes.user_session_path(conn, :new)) + end + + defp clear_pending_signup(conn) do + delete_session(conn, @pending_signup_session_key) + end + + defp handle_sso_link(conn, %User{} = current_user, provider, uid) do + case Accounts.get_user_by_identity(provider, uid) do + %User{id: id} when id == current_user.id -> + conn + |> put_flash( + :info, + "Your #{display_name(provider)} account is already linked." + ) + |> redirect(to: ~p"/profile") + + %User{} -> + conn + |> put_flash( + :error, + "This #{display_name(provider)} identity is already linked to a different account." + ) + |> redirect(to: ~p"/profile") + + nil -> + case Accounts.link_user_identity(current_user, provider, uid) do + {:ok, _identity} -> + conn + |> put_flash( + :info, + "Linked your #{display_name(provider)} account." + ) + |> redirect(to: ~p"/profile") + + {:error, _reason} -> + conn + |> put_flash( + :error, + "Could not link your #{display_name(provider)} account. Please try again." + ) + |> redirect(to: ~p"/profile") + end + end + end + + defp handle_sso_login(conn, provider, uid, email, userinfo) do + case Accounts.get_user_by_identity(provider, uid) do + %User{} = user -> + do_log_in(conn, user) + + nil -> + case Accounts.get_user_by_email(email) do + %User{} -> + conn + |> put_flash( + :info, + "An account already exists for #{email}. Sign in and link your #{display_name(provider)} account from your profile settings to use single sign-on." + ) + |> redirect(to: Routes.user_session_path(conn, :new)) + + nil -> + request_signup_confirmation(conn, provider, uid, email, userinfo) + end + end + end + + defp request_signup_confirmation(conn, provider, uid, email, userinfo) do + %{first_name: first_name, last_name: last_name} = extract_name(userinfo) + + pending = %{ + "provider" => provider, + "uid" => uid, + "email" => email, + "first_name" => first_name, + "last_name" => last_name + } + + conn + |> put_session(@pending_signup_session_key, pending) + |> redirect(to: ~p"/authenticate/signup/confirm") + end + + defp do_log_in(conn, %{mfa_enabled: true} = user) do + conn + |> UserAuth.log_in_user(user) + |> UserAuth.mark_totp_pending() + |> redirect( + to: Routes.user_totp_path(conn, :new, user: %{"remember_me" => "true"}) + ) + end + + defp do_log_in(conn, user) do + conn + |> UserAuth.log_in_user(user) + |> UserAuth.redirect_with_return_to(%{"remember_me" => "true"}) + end + + defp fetch_email(userinfo) do + case extract_email(userinfo) do + nil -> {:error, :no_email} + email -> {:ok, email} + end + end + + defp fetch_uid(userinfo) do + case extract_uid(userinfo) do + nil -> {:error, :no_uid} + uid -> {:ok, uid} + end + end + + defp extract_email(%{"email" => email}) when is_binary(email), do: email + defp extract_email(_), do: nil + + defp extract_uid(%{"sub" => sub}) when is_binary(sub), do: sub + defp extract_uid(%{"id" => id}) when is_integer(id), do: to_string(id) + defp extract_uid(%{"id" => id}) when is_binary(id), do: id + defp extract_uid(_), do: nil + + defp extract_name(%{"given_name" => first, "family_name" => last}) + when is_binary(first) and is_binary(last) do + %{first_name: first, last_name: last} + end + + defp extract_name(%{"name" => name}) when is_binary(name) do + case String.split(name, " ", parts: 2) do + [first, last] -> %{first_name: first, last_name: last} + [first] -> %{first_name: first, last_name: ""} + end + end + + defp extract_name(_), do: %{first_name: "", last_name: ""} + + defp pop_link_intent(conn) do + case get_session(conn, @link_intent_session_key) do + nil -> {nil, conn} + provider -> {provider, delete_session(conn, @link_intent_session_key)} + end + end + + defp failure_redirect(conn, link_intent) do + if link_intent && conn.assigns.current_user do + ~p"/profile" + else + Routes.user_session_path(conn, :new) + end + end + + defp display_name(provider), do: String.capitalize(provider) + defp broadcast_message(state, data) do [subscription_id, mod, component_id, current_tab] = OauthCredentialHelper.decode_state(state) diff --git a/lib/lightning_web/controllers/oidc_html.ex b/lib/lightning_web/controllers/oidc_html.ex new file mode 100644 index 00000000000..8e47ad2153f --- /dev/null +++ b/lib/lightning_web/controllers/oidc_html.ex @@ -0,0 +1,7 @@ +defmodule LightningWeb.OidcHTML do + @moduledoc false + + use LightningWeb, :html + + embed_templates "oidc_html/*" +end diff --git a/lib/lightning_web/controllers/oidc_html/confirm_signup.html.heex b/lib/lightning_web/controllers/oidc_html/confirm_signup.html.heex new file mode 100644 index 00000000000..37e36a21434 --- /dev/null +++ b/lib/lightning_web/controllers/oidc_html/confirm_signup.html.heex @@ -0,0 +1,64 @@ + + + <:header> + + <:title>Create your account + + + +
+ <.form + :let={_f} + for={%{}} + as={:signup} + action={~p"/authenticate/signup/confirm"} + > +
+
+ <%= if error = Phoenix.Flash.get(@flash, :error) do %> + + <% end %> + +

+ No account exists for {@pending["email"]} + yet. We'll create a new account using your + {String.capitalize(@pending["provider"])} + profile. +

+ +
+
+
Email
+
{@pending["email"]}
+
+ <%= if @pending["first_name"] != "" or @pending["last_name"] != "" do %> +
+
Name
+
{@pending["first_name"]} {@pending["last_name"]}
+
+ <% end %> +
+
Provider
+
{String.capitalize(@pending["provider"])}
+
+
+ +
+ <.button type="submit" theme="primary"> + Create account and continue + + <.link + href={~p"/authenticate/signup/cancel"} + class="text-xs text-secondary-700 text-center" + > + Cancel + +
+
+
+ +
+
+
diff --git a/lib/lightning_web/controllers/user_registration_controller.ex b/lib/lightning_web/controllers/user_registration_controller.ex index eb5508f4618..77e50c08357 100644 --- a/lib/lightning_web/controllers/user_registration_controller.ex +++ b/lib/lightning_web/controllers/user_registration_controller.ex @@ -7,7 +7,10 @@ defmodule LightningWeb.UserRegistrationController do def new(conn, _params) do changeset = Accounts.change_user_registration() - render(conn, "new.html", changeset: changeset) + render(conn, "new.html", + changeset: changeset, + auth_providers: LightningWeb.UserSessionController.auth_providers() + ) end def create(conn, %{"user" => user_params}) do @@ -21,7 +24,10 @@ defmodule LightningWeb.UserRegistrationController do |> redirect_user(user) {:error, %Ecto.Changeset{} = changeset} -> - render(conn, "new.html", changeset: changeset) + render(conn, "new.html", + changeset: changeset, + auth_providers: LightningWeb.UserSessionController.auth_providers() + ) end end diff --git a/lib/lightning_web/controllers/user_registration_html/new.html.heex b/lib/lightning_web/controllers/user_registration_html/new.html.heex index 46591056c66..014f3abce48 100644 --- a/lib/lightning_web/controllers/user_registration_html/new.html.heex +++ b/lib/lightning_web/controllers/user_registration_html/new.html.heex @@ -89,6 +89,26 @@ <.button type="submit" theme="primary"> Register + <%= if @auth_providers != [] do %> +
+ or +
+ <%= for provider <- @auth_providers do %> + <.button theme="secondary"> + +
+ + + Sign up with {String.capitalize(provider.name)} + +
+
+ + <% end %> + <% end %> diff --git a/lib/lightning_web/controllers/user_session_controller.ex b/lib/lightning_web/controllers/user_session_controller.ex index 138a048d51e..f8417d27969 100644 --- a/lib/lightning_web/controllers/user_session_controller.ex +++ b/lib/lightning_web/controllers/user_session_controller.ex @@ -8,7 +8,7 @@ defmodule LightningWeb.UserSessionController do def new(conn, _params) do render(conn, "new.html", error_message: nil, - auth_handler_url: auth_handler_url() + auth_providers: auth_providers() ) end @@ -21,7 +21,7 @@ defmodule LightningWeb.UserSessionController do conn |> put_flash(:error, "This user account is disabled") |> render("new.html", - auth_handler_url: auth_handler_url() + auth_providers: auth_providers() ) %User{scheduled_deletion: x} when x != nil -> @@ -31,7 +31,7 @@ defmodule LightningWeb.UserSessionController do "This user account is scheduled for deletion" ) |> render("new.html", - auth_handler_url: auth_handler_url() + auth_providers: auth_providers() ) %User{mfa_enabled: true} = user -> @@ -47,11 +47,21 @@ defmodule LightningWeb.UserSessionController do |> UserAuth.log_in_user(user) |> UserAuth.redirect_with_return_to(user_params) + {:error, :sso_account} -> + conn + |> put_flash( + :error, + "This account uses single sign-on. Please log in with your SSO provider." + ) + |> render("new.html", + auth_providers: auth_providers() + ) + _ -> conn |> put_flash(:error, "Invalid email or password") |> render("new.html", - auth_handler_url: auth_handler_url() + auth_providers: auth_providers() ) end end @@ -76,13 +86,18 @@ defmodule LightningWeb.UserSessionController do |> UserAuth.log_out_user() end - def auth_handler_url do + def auth_providers do case Lightning.AuthProviders.get_handlers() do {:ok, []} -> - nil + [] - {:ok, [handler | _rest]} -> - Lightning.AuthProviders.get_authorize_url(handler) + {:ok, handlers} -> + Enum.map(handlers, fn handler -> + %{ + name: handler.name, + url: Lightning.AuthProviders.get_authorize_url(handler) + } + end) end end end diff --git a/lib/lightning_web/controllers/user_session_html/new.html.heex b/lib/lightning_web/controllers/user_session_html/new.html.heex index 8e2949b9f8c..659a042ebde 100644 --- a/lib/lightning_web/controllers/user_session_html/new.html.heex +++ b/lib/lightning_web/controllers/user_session_html/new.html.heex @@ -53,23 +53,25 @@ <.button type="submit" theme="primary"> Log in - <%= if @auth_handler_url do %> + <%= if @auth_providers != [] do %>
or
- <.button theme="secondary"> - -
- <.icon - name="hero-identification" - class="h-4 w-4 inline-block" - /> - - via external provider - -
-
- + <%= for provider <- @auth_providers do %> + <.button theme="secondary"> + +
+ + + Sign in with {String.capitalize(provider.name)} + +
+
+ + <% end %> <% end %>