293 lines
9.2 KiB
Elixir
293 lines
9.2 KiB
Elixir
defmodule MvWeb.LinkOidcAccountLive do
|
|
@moduledoc """
|
|
LiveView for linking an OIDC account to an existing password account.
|
|
|
|
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
|
|
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
|
|
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)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("submit", %{"password" => password}, socket) do
|
|
user = socket.assigns.user
|
|
oidc_user_info = socket.assigns.oidc_user_info
|
|
|
|
# Verify the password using AshAuthentication
|
|
case verify_password(user.email, password) do
|
|
{:ok, verified_user} ->
|
|
# Password correct - link the OIDC account
|
|
link_oidc_account(socket, verified_user, oidc_user_info)
|
|
|
|
{:error, _reason} ->
|
|
# 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."))
|
|
|> reset_password_form()}
|
|
end
|
|
end
|
|
|
|
defp verify_password(email, password) do
|
|
# Use AshAuthentication password strategy to verify
|
|
strategies = AshAuthentication.Info.authentication_strategies(Mv.Accounts.User)
|
|
password_strategy = Enum.find(strategies, fn s -> s.name == :password end)
|
|
|
|
if password_strategy do
|
|
AshAuthentication.Strategy.Password.Actions.sign_in(
|
|
password_strategy,
|
|
%{
|
|
"email" => email,
|
|
"password" => password
|
|
},
|
|
[]
|
|
)
|
|
else
|
|
{:error, "Password authentication not configured"}
|
|
end
|
|
end
|
|
|
|
defp link_oidc_account(socket, user, oidc_user_info) do
|
|
oidc_id = Map.get(oidc_user_info, "sub") || Map.get(oidc_user_info, "id")
|
|
|
|
# Update the user with the OIDC 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} ->
|
|
# 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(
|
|
:info,
|
|
dgettext(
|
|
"auth",
|
|
"Your OIDC account has been successfully linked! Redirecting to complete sign-in..."
|
|
)
|
|
)
|
|
|> 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, error_message)
|
|
|> reset_password_form()}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
~H"""
|
|
<div class="mx-auto max-w-sm mt-16">
|
|
<%!-- Language Selector --%>
|
|
<div class="flex justify-center mb-4">
|
|
<form method="post" action="/set_locale" class="text-sm">
|
|
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
|
|
<select
|
|
name="locale"
|
|
onchange="this.form.submit()"
|
|
class="select select-sm select-bordered"
|
|
>
|
|
<option value="de" selected={Gettext.get_locale() == "de"}>🇩🇪 Deutsch</option>
|
|
<option value="en" selected={Gettext.get_locale() == "en"}>🇬🇧 English</option>
|
|
</select>
|
|
</form>
|
|
</div>
|
|
|
|
<.header class="text-center">
|
|
{dgettext("auth", "Link OIDC Account")}
|
|
<:subtitle>
|
|
{dgettext(
|
|
"auth",
|
|
"An account with email %{email} already exists. Please enter your password to link your OIDC account.",
|
|
email: @user.email
|
|
)}
|
|
</:subtitle>
|
|
</.header>
|
|
|
|
<.form for={@form} id="link-oidc-form" phx-submit="submit" phx-change="validate" class="mt-8">
|
|
<div class="space-y-6">
|
|
<div>
|
|
<.input
|
|
field={@form[:password]}
|
|
type="password"
|
|
label={dgettext("auth", "Password")}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<%= if @error do %>
|
|
<div class="rounded-md bg-red-50 p-4">
|
|
<p class="text-sm text-red-800">{@error}</p>
|
|
</div>
|
|
<% end %>
|
|
|
|
<div>
|
|
<.button phx-disable-with={dgettext("auth", "Linking...")} class="w-full">
|
|
{dgettext("auth", "Link Account")}
|
|
</.button>
|
|
</div>
|
|
</div>
|
|
</.form>
|
|
|
|
<div class="mt-4 text-center text-sm">
|
|
<.link navigate={~p"/sign-in"} class="text-brand hover:underline">
|
|
{dgettext("auth", "Cancel")}
|
|
</.link>
|
|
</div>
|
|
</div>
|
|
"""
|
|
end
|
|
end
|