refactor and docs
This commit is contained in:
parent
4ba03821a2
commit
5ce220862f
13 changed files with 1321 additions and 174 deletions
|
|
@ -228,6 +228,7 @@ defmodule Mv.Accounts.User do
|
|||
argument :user_info, :map, allow_nil?: false
|
||||
argument :oauth_tokens, :map, allow_nil?: false
|
||||
upsert? true
|
||||
# Upsert based on oidc_id (primary match for existing OIDC users)
|
||||
upsert_identity :unique_oidc_id
|
||||
|
||||
validate &__MODULE__.validate_oidc_id_present/2
|
||||
|
|
@ -242,8 +243,10 @@ defmodule Mv.Accounts.User do
|
|||
|> Ash.Changeset.change_attribute(:oidc_id, user_info["sub"] || user_info["id"])
|
||||
end
|
||||
|
||||
# Check for email collisions with existing password-only accounts
|
||||
# Check for email collisions with existing accounts
|
||||
# This validation must run AFTER email and oidc_id are set above
|
||||
# - Raises PasswordVerificationRequired for password-protected OR passwordless users
|
||||
# - The LinkOidcAccountLive will auto-link passwordless users without password prompt
|
||||
validate Mv.Accounts.User.Validations.OidcEmailCollision
|
||||
|
||||
# Sync user email to member when linking (User → Member)
|
||||
|
|
|
|||
|
|
@ -2,26 +2,29 @@ defmodule Mv.Accounts.User.Validations.OidcEmailCollision do
|
|||
@moduledoc """
|
||||
Validation that checks for email collisions during OIDC registration.
|
||||
|
||||
This validation prevents OIDC accounts from automatically taking over existing
|
||||
password-only accounts. Instead, it requires password verification.
|
||||
This validation prevents unauthorized account takeovers and enforces proper
|
||||
account linking flows based on user state.
|
||||
|
||||
## Scenarios:
|
||||
|
||||
1. **User exists with matching oidc_id**:
|
||||
- Allow (upsert will update the existing user)
|
||||
|
||||
2. **User exists with email but NO oidc_id (or empty string)**:
|
||||
- Raise PasswordVerificationRequired error
|
||||
- User must verify password before linking
|
||||
2. **User exists with different oidc_id**:
|
||||
- Hard error: Cannot link multiple OIDC providers to same account
|
||||
- No linking possible - user must use original OIDC provider
|
||||
|
||||
3. **User exists with email AND different oidc_id**:
|
||||
3. **User exists without oidc_id** (password-protected OR passwordless):
|
||||
- Raise PasswordVerificationRequired error
|
||||
- This prevents linking different OIDC providers to same account
|
||||
- User is redirected to LinkOidcAccountLive which will:
|
||||
- Show password form if user has password
|
||||
- Auto-link immediately if user is passwordless
|
||||
|
||||
4. **No user exists with this email**:
|
||||
- Allow (new user will be created)
|
||||
"""
|
||||
use Ash.Resource.Validation
|
||||
require Logger
|
||||
|
||||
alias Mv.Accounts.User.Errors.PasswordVerificationRequired
|
||||
|
||||
|
|
@ -37,13 +40,23 @@ defmodule Mv.Accounts.User.Validations.OidcEmailCollision do
|
|||
|
||||
# Only validate if we have both email and oidc_id (from OIDC registration)
|
||||
if email && oidc_id && user_info do
|
||||
check_email_collision(email, oidc_id, user_info)
|
||||
# Check if a user with this oidc_id already exists
|
||||
# If yes, this will be an upsert (email update), not a new registration
|
||||
existing_oidc_user =
|
||||
case Mv.Accounts.User
|
||||
|> Ash.Query.filter(oidc_id == ^to_string(oidc_id))
|
||||
|> Ash.read_one() do
|
||||
{:ok, user} -> user
|
||||
_ -> nil
|
||||
end
|
||||
|
||||
check_email_collision(email, oidc_id, user_info, existing_oidc_user)
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp check_email_collision(email, new_oidc_id, user_info) do
|
||||
defp check_email_collision(email, new_oidc_id, user_info, existing_oidc_user) do
|
||||
# Find existing user with this email
|
||||
case Mv.Accounts.User
|
||||
|> Ash.Query.filter(email == ^to_string(email))
|
||||
|
|
@ -52,42 +65,116 @@ defmodule Mv.Accounts.User.Validations.OidcEmailCollision do
|
|||
# No user exists with this email - OK to create new user
|
||||
:ok
|
||||
|
||||
{:ok, existing_user} ->
|
||||
# User exists - check oidc_id
|
||||
handle_existing_user(existing_user, new_oidc_id, user_info)
|
||||
{:ok, user_with_email} ->
|
||||
# User exists with this email - check if it's an upsert or registration
|
||||
is_upsert = not is_nil(existing_oidc_user)
|
||||
|
||||
handle_existing_user(
|
||||
user_with_email,
|
||||
new_oidc_id,
|
||||
user_info,
|
||||
is_upsert,
|
||||
existing_oidc_user
|
||||
)
|
||||
|
||||
{:error, error} ->
|
||||
# Database error
|
||||
{:error, field: :email, message: "Could not verify email uniqueness: #{inspect(error)}"}
|
||||
# Database error - log for debugging but don't expose internals to user
|
||||
Logger.error("Email uniqueness check failed during OIDC registration: #{inspect(error)}")
|
||||
{:error, field: :email, message: "Could not verify email uniqueness. Please try again."}
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_existing_user(existing_user, new_oidc_id, user_info) do
|
||||
existing_oidc_id = existing_user.oidc_id
|
||||
defp handle_existing_user(
|
||||
user_with_email,
|
||||
new_oidc_id,
|
||||
user_info,
|
||||
is_upsert,
|
||||
existing_oidc_user
|
||||
) do
|
||||
if is_upsert do
|
||||
handle_upsert_scenario(user_with_email, user_info, existing_oidc_user)
|
||||
else
|
||||
handle_create_scenario(user_with_email, new_oidc_id, user_info)
|
||||
end
|
||||
end
|
||||
|
||||
# Handle email update for existing OIDC user
|
||||
defp handle_upsert_scenario(user_with_email, user_info, existing_oidc_user) do
|
||||
cond do
|
||||
# Case 1: Same oidc_id - this is an upsert, allow it
|
||||
existing_oidc_id == new_oidc_id ->
|
||||
# Same user updating their own record
|
||||
not is_nil(existing_oidc_user) and user_with_email.id == existing_oidc_user.id ->
|
||||
:ok
|
||||
|
||||
# Case 2: No oidc_id set (nil or empty string) - password-only user
|
||||
is_nil(existing_oidc_id) or existing_oidc_id == "" ->
|
||||
# Different user exists with target email
|
||||
not is_nil(existing_oidc_user) and user_with_email.id != existing_oidc_user.id ->
|
||||
handle_email_conflict(user_with_email, user_info)
|
||||
|
||||
# Should not reach here
|
||||
true ->
|
||||
{:error, field: :email, message: "Unexpected error during email update"}
|
||||
end
|
||||
end
|
||||
|
||||
# Handle email conflict during upsert
|
||||
defp handle_email_conflict(user_with_email, user_info) do
|
||||
email = Map.get(user_info, "preferred_username", "unknown")
|
||||
email_user_oidc_id = user_with_email.oidc_id
|
||||
|
||||
# Check if target email belongs to another OIDC user
|
||||
if not is_nil(email_user_oidc_id) and email_user_oidc_id != "" do
|
||||
different_oidc_error(email)
|
||||
else
|
||||
email_taken_error(email)
|
||||
end
|
||||
end
|
||||
|
||||
# Handle new OIDC user registration scenarios
|
||||
defp handle_create_scenario(user_with_email, new_oidc_id, user_info) do
|
||||
email_user_oidc_id = user_with_email.oidc_id
|
||||
|
||||
cond do
|
||||
# Same oidc_id (should not happen in practice, but allow for safety)
|
||||
email_user_oidc_id == new_oidc_id ->
|
||||
:ok
|
||||
|
||||
# Different oidc_id exists (hard error)
|
||||
not is_nil(email_user_oidc_id) and email_user_oidc_id != "" and
|
||||
email_user_oidc_id != new_oidc_id ->
|
||||
email = Map.get(user_info, "preferred_username", "unknown")
|
||||
different_oidc_error(email)
|
||||
|
||||
# No oidc_id (require account linking)
|
||||
is_nil(email_user_oidc_id) or email_user_oidc_id == "" ->
|
||||
{:error,
|
||||
PasswordVerificationRequired.exception(
|
||||
user_id: existing_user.id,
|
||||
user_id: user_with_email.id,
|
||||
oidc_user_info: user_info
|
||||
)}
|
||||
|
||||
# Case 3: Different oidc_id - account conflict
|
||||
# Should not reach here
|
||||
true ->
|
||||
{:error,
|
||||
PasswordVerificationRequired.exception(
|
||||
user_id: existing_user.id,
|
||||
oidc_user_info: user_info
|
||||
)}
|
||||
{:error, field: :email, message: "Unexpected error during OIDC registration"}
|
||||
end
|
||||
end
|
||||
|
||||
# Generate error for different OIDC account conflict
|
||||
defp different_oidc_error(email) do
|
||||
{:error,
|
||||
field: :email,
|
||||
message:
|
||||
"Email '#{email}' is already linked to a different OIDC account. " <>
|
||||
"Cannot link multiple OIDC providers to the same account."}
|
||||
end
|
||||
|
||||
# Generate error for email already taken
|
||||
defp email_taken_error(email) do
|
||||
{:error,
|
||||
field: :email,
|
||||
message:
|
||||
"Cannot update email to '#{email}': This email is already registered to another account. " <>
|
||||
"Please change your email in the identity provider."}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def atomic?(), do: false
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,21 @@
|
|||
require Logger
|
||||
|
||||
defmodule MvWeb.AuthController do
|
||||
@moduledoc """
|
||||
Handles authentication callbacks for password and OIDC authentication.
|
||||
|
||||
This controller manages:
|
||||
- Successful authentication (password, OIDC, password reset, email confirmation)
|
||||
- Authentication failures with appropriate error handling
|
||||
- OIDC account linking flow when email collision occurs
|
||||
- Sign out functionality
|
||||
"""
|
||||
|
||||
use MvWeb, :controller
|
||||
use AshAuthentication.Phoenix.Controller
|
||||
|
||||
alias Mv.Accounts.User.Errors.PasswordVerificationRequired
|
||||
|
||||
def success(conn, activity, user, _token) do
|
||||
return_to = get_session(conn, :return_to) || ~p"/"
|
||||
|
||||
|
|
@ -23,107 +35,149 @@ defmodule MvWeb.AuthController do
|
|||
|> redirect(to: return_to)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles authentication failures and routes to appropriate error handling.
|
||||
|
||||
Manages:
|
||||
- OIDC email collisions (triggers password verification flow)
|
||||
- Generic OIDC authentication failures
|
||||
- Unconfirmed account errors
|
||||
- Generic authentication failures
|
||||
"""
|
||||
def failure(conn, activity, reason) do
|
||||
# Log the error for debugging
|
||||
Logger.warning(
|
||||
"Authentication failure - Activity: #{inspect(activity)}, Reason: #{inspect(reason)}"
|
||||
)
|
||||
|
||||
case {activity, reason} do
|
||||
# OIDC registration with existing email requires password verification (direct error)
|
||||
{{:rauthy, :register}, %Ash.Error.Invalid{errors: errors}} ->
|
||||
handle_oidc_email_collision(conn, errors)
|
||||
{{:rauthy, _action}, reason} ->
|
||||
handle_rauthy_failure(conn, reason)
|
||||
|
||||
# OIDC registration with existing email (wrapped in AuthenticationFailed)
|
||||
{{:rauthy, :register},
|
||||
%AshAuthentication.Errors.AuthenticationFailed{
|
||||
caused_by: %Ash.Error.Invalid{errors: errors}
|
||||
}} ->
|
||||
handle_oidc_email_collision(conn, errors)
|
||||
|
||||
# OIDC sign-in failure (wrapped)
|
||||
{{:rauthy, :sign_in}, %AshAuthentication.Errors.AuthenticationFailed{caused_by: caused_by}} ->
|
||||
# Check if it's actually a registration issue
|
||||
case caused_by do
|
||||
%Ash.Error.Invalid{errors: errors} ->
|
||||
handle_oidc_email_collision(conn, errors)
|
||||
|
||||
_ ->
|
||||
# Real sign-in failure
|
||||
conn
|
||||
|> put_flash(:error, gettext("Unable to sign in with OIDC. Please try again."))
|
||||
|> redirect(to: ~p"/sign-in")
|
||||
end
|
||||
|
||||
# OIDC callback failure (can be either sign-in or registration)
|
||||
{{:rauthy, :callback}, %AshAuthentication.Errors.AuthenticationFailed{caused_by: caused_by}} ->
|
||||
case caused_by do
|
||||
%Ash.Error.Invalid{errors: errors} ->
|
||||
handle_oidc_email_collision(conn, errors)
|
||||
|
||||
_ ->
|
||||
conn
|
||||
|> put_flash(:error, gettext("Unable to authenticate with OIDC. Please try again."))
|
||||
|> redirect(to: ~p"/sign-in")
|
||||
end
|
||||
|
||||
{_,
|
||||
%AshAuthentication.Errors.AuthenticationFailed{
|
||||
caused_by: %Ash.Error.Forbidden{
|
||||
errors: [%AshAuthentication.Errors.CannotConfirmUnconfirmedUser{}]
|
||||
}
|
||||
}} ->
|
||||
message =
|
||||
gettext("""
|
||||
You have already signed in another way, but have not confirmed your account.
|
||||
You can confirm your account using the link we sent to you, or by resetting your password.
|
||||
""")
|
||||
|
||||
conn
|
||||
|> put_flash(:error, message)
|
||||
|> redirect(to: ~p"/sign-in")
|
||||
{_, %AshAuthentication.Errors.AuthenticationFailed{caused_by: caused_by}} ->
|
||||
handle_authentication_failed(conn, caused_by)
|
||||
|
||||
_ ->
|
||||
message = gettext("Incorrect email or password")
|
||||
|
||||
conn
|
||||
|> put_flash(:error, message)
|
||||
|> redirect(to: ~p"/sign-in")
|
||||
redirect_with_error(conn, gettext("Incorrect email or password"))
|
||||
end
|
||||
end
|
||||
|
||||
# Handle OIDC email collision - user needs to verify password
|
||||
defp handle_oidc_email_collision(conn, errors) do
|
||||
password_verification_error =
|
||||
Enum.find(errors, fn err ->
|
||||
match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err)
|
||||
end)
|
||||
# Handle all Rauthy (OIDC) authentication failures
|
||||
defp handle_rauthy_failure(conn, %Ash.Error.Invalid{errors: errors}) do
|
||||
handle_oidc_email_collision(conn, errors)
|
||||
end
|
||||
|
||||
case password_verification_error do
|
||||
%Mv.Accounts.User.Errors.PasswordVerificationRequired{
|
||||
user_id: user_id,
|
||||
oidc_user_info: oidc_user_info
|
||||
} ->
|
||||
# Store the OIDC info in session for the linking flow
|
||||
conn
|
||||
|> put_session(:oidc_linking_user_id, user_id)
|
||||
|> put_session(:oidc_linking_user_info, oidc_user_info)
|
||||
|> put_flash(
|
||||
:info,
|
||||
gettext(
|
||||
"An account with this email already exists. Please verify your password to link your OIDC account."
|
||||
)
|
||||
)
|
||||
|> redirect(to: ~p"/auth/link-oidc-account")
|
||||
defp handle_rauthy_failure(conn, %AshAuthentication.Errors.AuthenticationFailed{
|
||||
caused_by: caused_by
|
||||
}) do
|
||||
case caused_by do
|
||||
%Ash.Error.Invalid{errors: errors} ->
|
||||
handle_oidc_email_collision(conn, errors)
|
||||
|
||||
_ ->
|
||||
# Other validation errors - show generic error
|
||||
conn
|
||||
|> put_flash(:error, gettext("Unable to sign in. Please try again."))
|
||||
|> redirect(to: ~p"/sign-in")
|
||||
redirect_with_error(conn, gettext("Unable to authenticate with OIDC. Please try again."))
|
||||
end
|
||||
end
|
||||
|
||||
# Handle generic AuthenticationFailed errors
|
||||
defp handle_authentication_failed(conn, %Ash.Error.Forbidden{errors: errors}) do
|
||||
if Enum.any?(errors, &match?(%AshAuthentication.Errors.CannotConfirmUnconfirmedUser{}, &1)) do
|
||||
message =
|
||||
gettext("""
|
||||
You have already signed in another way, but have not confirmed your account.
|
||||
You can confirm your account using the link we sent to you, or by resetting your password.
|
||||
""")
|
||||
|
||||
redirect_with_error(conn, message)
|
||||
else
|
||||
redirect_with_error(conn, gettext("Authentication failed. Please try again."))
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_authentication_failed(conn, _other) do
|
||||
redirect_with_error(conn, gettext("Authentication failed. Please try again."))
|
||||
end
|
||||
|
||||
# Handle OIDC email collision - user needs to verify password to link accounts
|
||||
defp handle_oidc_email_collision(conn, errors) do
|
||||
case find_password_verification_error(errors) do
|
||||
%PasswordVerificationRequired{user_id: user_id, oidc_user_info: oidc_user_info} ->
|
||||
redirect_to_account_linking(conn, user_id, oidc_user_info)
|
||||
|
||||
nil ->
|
||||
# Check if it's a "different OIDC account" error or email uniqueness error
|
||||
error_message = extract_meaningful_error_message(errors)
|
||||
redirect_with_error(conn, error_message)
|
||||
end
|
||||
end
|
||||
|
||||
# Extract meaningful error message from Ash errors
|
||||
defp extract_meaningful_error_message(errors) do
|
||||
# Look for specific error messages in InvalidAttribute errors
|
||||
meaningful_error =
|
||||
Enum.find_value(errors, fn
|
||||
%Ash.Error.Changes.InvalidAttribute{message: message, field: :email}
|
||||
when is_binary(message) ->
|
||||
cond do
|
||||
# Email update conflict during OIDC login
|
||||
String.contains?(message, "Cannot update email to") and
|
||||
String.contains?(message, "already registered to another account") ->
|
||||
gettext(
|
||||
"Cannot update email: This email is already registered to another account. Please change your email in the identity provider."
|
||||
)
|
||||
|
||||
# Different OIDC account error
|
||||
String.contains?(message, "already linked to a different OIDC account") ->
|
||||
gettext(
|
||||
"This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account."
|
||||
)
|
||||
|
||||
true ->
|
||||
nil
|
||||
end
|
||||
|
||||
%Ash.Error.Changes.InvalidAttribute{message: message}
|
||||
when is_binary(message) ->
|
||||
# Return any other meaningful message
|
||||
if String.length(message) > 20 and
|
||||
not String.contains?(message, "has already been taken") do
|
||||
message
|
||||
else
|
||||
nil
|
||||
end
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end)
|
||||
|
||||
meaningful_error || gettext("Unable to sign in. Please try again.")
|
||||
end
|
||||
|
||||
# Find PasswordVerificationRequired error in error list
|
||||
defp find_password_verification_error(errors) do
|
||||
Enum.find(errors, &match?(%PasswordVerificationRequired{}, &1))
|
||||
end
|
||||
|
||||
# Redirect to account linking page with OIDC info stored in session
|
||||
defp redirect_to_account_linking(conn, user_id, oidc_user_info) do
|
||||
conn
|
||||
|> put_session(:oidc_linking_user_id, user_id)
|
||||
|> put_session(:oidc_linking_user_info, oidc_user_info)
|
||||
|> put_flash(
|
||||
:info,
|
||||
gettext(
|
||||
"An account with this email already exists. Please verify your password to link your OIDC account."
|
||||
)
|
||||
)
|
||||
|> redirect(to: ~p"/auth/link-oidc-account")
|
||||
end
|
||||
|
||||
# Generic error redirect helper
|
||||
defp redirect_with_error(conn, message) do
|
||||
conn
|
||||
|> put_flash(:error, message)
|
||||
|> redirect(to: ~p"/sign-in")
|
||||
end
|
||||
|
||||
def sign_out(conn, _params) do
|
||||
return_to = get_session(conn, :return_to) || ~p"/"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,12 @@ defmodule MvWeb.LocaleController do
|
|||
conn
|
||||
|> put_session(:locale, locale)
|
||||
# Store locale in a cookie that persists beyond the session
|
||||
|> put_resp_cookie("locale", locale, max_age: 365 * 24 * 60 * 60, same_site: "Lax")
|
||||
|> put_resp_cookie("locale", locale,
|
||||
max_age: 365 * 24 * 60 * 60,
|
||||
same_site: "Lax",
|
||||
http_only: true,
|
||||
secure: Application.get_env(:mv, :use_secure_cookies, false)
|
||||
)
|
||||
|> redirect(to: get_referer(conn) || "/")
|
||||
end
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue