refactor and docs
This commit is contained in:
parent
4ba03821a2
commit
5ce220862f
13 changed files with 1321 additions and 174 deletions
|
|
@ -5,41 +5,139 @@ defmodule MvWeb.LinkOidcAccountLive do
|
|||
This page is shown when a user tries to log in via OIDC using an email
|
||||
that already exists with a password-only account. The user must verify
|
||||
their password before the OIDC account can be linked.
|
||||
|
||||
## Flow
|
||||
1. User attempts OIDC login with email that has existing password account
|
||||
2. System raises `PasswordVerificationRequired` error
|
||||
3. AuthController redirects here with user_id and oidc_user_info in session
|
||||
4. User enters password to verify identity
|
||||
5. On success, oidc_id is linked to user account
|
||||
6. User is redirected to complete OIDC login
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
require Ash.Query
|
||||
require Logger
|
||||
|
||||
@impl true
|
||||
def mount(_params, session, socket) do
|
||||
user_id = Map.get(session, "oidc_linking_user_id")
|
||||
oidc_user_info = Map.get(session, "oidc_linking_user_info")
|
||||
|
||||
if user_id && oidc_user_info do
|
||||
# Load the user
|
||||
case Ash.get(Mv.Accounts.User, user_id) do
|
||||
{:ok, user} ->
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:user, user)
|
||||
|> assign(:oidc_user_info, oidc_user_info)
|
||||
|> assign(:password, "")
|
||||
|> assign(:error, nil)
|
||||
|> assign(:form, to_form(%{"password" => ""}))}
|
||||
|
||||
{:error, _} ->
|
||||
{:ok,
|
||||
socket
|
||||
|> put_flash(:error, dgettext("auth", "Session expired. Please try again."))
|
||||
|> redirect(to: ~p"/sign-in")}
|
||||
with user_id when not is_nil(user_id) <- Map.get(session, "oidc_linking_user_id"),
|
||||
oidc_user_info when not is_nil(oidc_user_info) <-
|
||||
Map.get(session, "oidc_linking_user_info"),
|
||||
{:ok, user} <- Ash.get(Mv.Accounts.User, user_id) do
|
||||
# Check if user is passwordless
|
||||
if passwordless?(user) do
|
||||
# Auto-link passwordless user immediately
|
||||
{:ok, auto_link_passwordless_user(socket, user, oidc_user_info)}
|
||||
else
|
||||
# Show password form for password-protected user
|
||||
{:ok, initialize_socket(socket, user, oidc_user_info)}
|
||||
end
|
||||
else
|
||||
{:ok,
|
||||
socket
|
||||
|> put_flash(:error, dgettext("auth", "Invalid session. Please try again."))
|
||||
|> redirect(to: ~p"/sign-in")}
|
||||
nil ->
|
||||
{:ok, redirect_with_error(socket, dgettext("auth", "Invalid session. Please try again."))}
|
||||
|
||||
{:error, _} ->
|
||||
{:ok, redirect_with_error(socket, dgettext("auth", "Session expired. Please try again."))}
|
||||
end
|
||||
end
|
||||
|
||||
defp passwordless?(user) do
|
||||
is_nil(user.hashed_password)
|
||||
end
|
||||
|
||||
defp reload_user!(user_id) do
|
||||
Mv.Accounts.User
|
||||
|> Ash.Query.filter(id == ^user_id)
|
||||
|> Ash.read_one!()
|
||||
end
|
||||
|
||||
defp reset_password_form(socket) do
|
||||
assign(socket, :form, to_form(%{"password" => ""}))
|
||||
end
|
||||
|
||||
defp auto_link_passwordless_user(socket, user, oidc_user_info) do
|
||||
oidc_id = Map.get(oidc_user_info, "sub") || Map.get(oidc_user_info, "id")
|
||||
|
||||
case user.id
|
||||
|> reload_user!()
|
||||
|> Ash.Changeset.for_update(:link_oidc_id, %{
|
||||
oidc_id: oidc_id,
|
||||
oidc_user_info: oidc_user_info
|
||||
})
|
||||
|> Ash.update() do
|
||||
{:ok, updated_user} ->
|
||||
Logger.info(
|
||||
"Passwordless account auto-linked to OIDC: user_id=#{updated_user.id}, oidc_id=#{oidc_id}"
|
||||
)
|
||||
|
||||
socket
|
||||
|> put_flash(
|
||||
:info,
|
||||
dgettext("auth", "Account activated! Redirecting to complete sign-in...")
|
||||
)
|
||||
|> Phoenix.LiveView.redirect(to: ~p"/auth/user/rauthy")
|
||||
|
||||
{:error, error} ->
|
||||
Logger.warning(
|
||||
"Failed to auto-link passwordless account: user_id=#{user.id}, error=#{inspect(error)}"
|
||||
)
|
||||
|
||||
error_message = extract_user_friendly_error(error)
|
||||
|
||||
socket
|
||||
|> put_flash(:error, error_message)
|
||||
|> redirect(to: ~p"/sign-in")
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_user_friendly_error(%Ash.Error.Invalid{errors: errors}) do
|
||||
# Check for specific error types
|
||||
Enum.find_value(errors, fn
|
||||
%Ash.Error.Changes.InvalidAttribute{field: :oidc_id, message: message} ->
|
||||
if String.contains?(message, "already been taken") do
|
||||
dgettext(
|
||||
"auth",
|
||||
"This OIDC account is already linked to another user. Please contact support."
|
||||
)
|
||||
else
|
||||
nil
|
||||
end
|
||||
|
||||
%Ash.Error.Changes.InvalidAttribute{field: :email, message: message} ->
|
||||
if String.contains?(message, "already been taken") do
|
||||
dgettext(
|
||||
"auth",
|
||||
"The email address from your OIDC provider is already registered to another account. Please change your email in the identity provider or contact support."
|
||||
)
|
||||
else
|
||||
nil
|
||||
end
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end) ||
|
||||
dgettext("auth", "Failed to link account. Please try again or contact support.")
|
||||
end
|
||||
|
||||
defp extract_user_friendly_error(_error) do
|
||||
dgettext("auth", "Failed to link account. Please try again or contact support.")
|
||||
end
|
||||
|
||||
defp initialize_socket(socket, user, oidc_user_info) do
|
||||
socket
|
||||
|> assign(:user, user)
|
||||
|> assign(:oidc_user_info, oidc_user_info)
|
||||
|> assign(:password, "")
|
||||
|> assign(:error, nil)
|
||||
|> reset_password_form()
|
||||
end
|
||||
|
||||
defp redirect_with_error(socket, message) do
|
||||
socket
|
||||
|> put_flash(:error, message)
|
||||
|> redirect(to: ~p"/sign-in")
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"password" => password}, socket) do
|
||||
{:noreply, assign(socket, :password, password)}
|
||||
|
|
@ -57,11 +155,13 @@ defmodule MvWeb.LinkOidcAccountLive do
|
|||
link_oidc_account(socket, verified_user, oidc_user_info)
|
||||
|
||||
{:error, _reason} ->
|
||||
# Password incorrect
|
||||
# Password incorrect - log security event
|
||||
Logger.warning("Failed password verification for OIDC linking: user_email=#{user.email}")
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:error, dgettext("auth", "Incorrect password. Please try again."))
|
||||
|> assign(:form, to_form(%{"password" => ""}))}
|
||||
|> reset_password_form()}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -88,17 +188,20 @@ defmodule MvWeb.LinkOidcAccountLive do
|
|||
oidc_id = Map.get(oidc_user_info, "sub") || Map.get(oidc_user_info, "id")
|
||||
|
||||
# Update the user with the OIDC ID
|
||||
case Mv.Accounts.User
|
||||
|> Ash.Query.filter(id == ^user.id)
|
||||
|> Ash.read_one!()
|
||||
case user.id
|
||||
|> reload_user!()
|
||||
|> Ash.Changeset.for_update(:link_oidc_id, %{
|
||||
oidc_id: oidc_id,
|
||||
oidc_user_info: oidc_user_info
|
||||
})
|
||||
|> Ash.update() do
|
||||
{:ok, _updated_user} ->
|
||||
{:ok, updated_user} ->
|
||||
# After successful linking, redirect to OIDC login
|
||||
# Since the user now has an oidc_id, the next OIDC login will succeed
|
||||
Logger.info(
|
||||
"OIDC account successfully linked after password verification: user_id=#{updated_user.id}, oidc_id=#{oidc_id}"
|
||||
)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(
|
||||
|
|
@ -111,13 +214,16 @@ defmodule MvWeb.LinkOidcAccountLive do
|
|||
|> Phoenix.LiveView.redirect(to: ~p"/auth/user/rauthy")}
|
||||
|
||||
{:error, error} ->
|
||||
Logger.warning(
|
||||
"Failed to link OIDC account after password verification: user_id=#{user.id}, error=#{inspect(error)}"
|
||||
)
|
||||
|
||||
error_message = extract_user_friendly_error(error)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(
|
||||
:error,
|
||||
dgettext("auth", "Failed to link account: %{error}", error: inspect(error))
|
||||
)
|
||||
|> assign(:form, to_form(%{"password" => ""}))}
|
||||
|> assign(:error, error_message)
|
||||
|> reset_password_form()}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue