328 lines
11 KiB
Elixir
328 lines
11 KiB
Elixir
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"/"
|
|
|
|
message =
|
|
case activity do
|
|
{:confirm_new_user, :confirm} -> gettext("Your email address has now been confirmed")
|
|
{:password, :reset} -> gettext("Your password has successfully been reset")
|
|
_ -> gettext("You are now signed in")
|
|
end
|
|
|
|
conn
|
|
|> delete_session(:return_to)
|
|
|> store_in_session(user)
|
|
# If your resource has a different name, update the assign name here (i.e :current_admin)
|
|
|> assign(:current_user, user)
|
|
|> put_flash(:success, message)
|
|
|> 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_failure_safely(activity, reason)
|
|
|
|
case {activity, reason} do
|
|
{{:oidc, _action}, reason} ->
|
|
handle_oidc_failure(conn, reason)
|
|
|
|
{_, %AshAuthentication.Errors.AuthenticationFailed{caused_by: caused_by}} ->
|
|
handle_authentication_failed(conn, caused_by)
|
|
|
|
_ ->
|
|
conn
|
|
|> put_flash(:error, gettext("Incorrect email or password"))
|
|
|> redirect(to: ~p"/sign-in")
|
|
end
|
|
end
|
|
|
|
# Log authentication failures safely, avoiding sensitive data for {:oidc, _} activities
|
|
defp log_failure_safely({:oidc, _action} = activity, reason) do
|
|
# For Assent errors, use safe_assent_meta to avoid logging tokens/URLs with query params
|
|
case reason do
|
|
%Assent.ServerUnreachableError{} = err ->
|
|
meta = safe_assent_meta(err)
|
|
message = format_safe_log_message("Authentication failure", activity, meta)
|
|
Logger.warning(message)
|
|
|
|
%Assent.InvalidResponseError{} = err ->
|
|
meta = safe_assent_meta(err)
|
|
message = format_safe_log_message("Authentication failure", activity, meta)
|
|
Logger.warning(message)
|
|
|
|
_ ->
|
|
# For other OIDC errors, log only error type, not full details
|
|
error_type = get_error_type(reason)
|
|
|
|
Logger.warning(
|
|
"Authentication failure - Activity: #{inspect(activity)}, Error type: #{error_type}"
|
|
)
|
|
end
|
|
end
|
|
|
|
defp log_failure_safely(activity, reason) do
|
|
# For non-OIDC activities, safe to log full reason
|
|
Logger.warning(
|
|
"Authentication failure - Activity: #{inspect(activity)}, Reason: #{inspect(reason)}"
|
|
)
|
|
end
|
|
|
|
# Extract safe error type identifier without sensitive data
|
|
defp get_error_type(%struct{}), do: "#{struct}"
|
|
defp get_error_type(atom) when is_atom(atom), do: inspect(atom)
|
|
defp get_error_type(_other), do: "[redacted]"
|
|
|
|
# Format safe log message with metadata included in the message string
|
|
defp format_safe_log_message(base_message, activity, meta) when is_list(meta) do
|
|
activity_str = "Activity: #{inspect(activity)}"
|
|
meta_str = format_meta_string(meta)
|
|
"#{base_message} - #{activity_str}#{meta_str}"
|
|
end
|
|
|
|
defp format_meta_string([]), do: ""
|
|
|
|
defp format_meta_string(meta) when is_list(meta) do
|
|
parts =
|
|
Enum.map(meta, fn
|
|
{:request_url, url} -> "Request URL: #{url}"
|
|
{:status, status} -> "Status: #{status}"
|
|
{:http_adapter, adapter} -> "HTTP Adapter: #{inspect(adapter)}"
|
|
_ -> nil
|
|
end)
|
|
|> Enum.filter(&(&1 != nil))
|
|
|
|
if Enum.empty?(parts), do: "", else: " - " <> Enum.join(parts, ", ")
|
|
end
|
|
|
|
# Handle all OIDC authentication failures
|
|
defp handle_oidc_failure(conn, %Ash.Error.Invalid{errors: errors}) do
|
|
handle_oidc_email_collision(conn, errors)
|
|
end
|
|
|
|
defp handle_oidc_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)
|
|
|
|
_ ->
|
|
conn
|
|
|> put_flash(:error, gettext("Unable to authenticate with OIDC. Please try again."))
|
|
|> redirect(to: ~p"/sign-in")
|
|
end
|
|
end
|
|
|
|
# Handle Assent server unreachable errors (network/connectivity issues)
|
|
defp handle_oidc_failure(conn, %Assent.ServerUnreachableError{} = _err) do
|
|
# Logging already done safely in failure/3 via log_failure_safely/2
|
|
# No need to log again here to avoid duplicate logs
|
|
|
|
conn
|
|
|> put_flash(
|
|
:error,
|
|
gettext("The authentication server is currently unavailable. Please try again later.")
|
|
)
|
|
|> redirect(to: ~p"/sign-in")
|
|
end
|
|
|
|
# Handle Assent invalid response errors (configuration or malformed responses)
|
|
defp handle_oidc_failure(conn, %Assent.InvalidResponseError{} = _err) do
|
|
# Logging already done safely in failure/3 via log_failure_safely/2
|
|
# No need to log again here to avoid duplicate logs
|
|
|
|
conn
|
|
|> put_flash(
|
|
:error,
|
|
gettext("Authentication configuration error. Please contact the administrator.")
|
|
)
|
|
|> redirect(to: ~p"/sign-in")
|
|
end
|
|
|
|
# Catch-all clause for any other error types
|
|
defp handle_oidc_failure(conn, _reason) do
|
|
# Logging already done safely in failure/3 via log_failure_safely/2
|
|
# No need to log again here to avoid duplicate logs
|
|
|
|
conn
|
|
|> put_flash(:error, gettext("Unable to authenticate with OIDC. Please try again."))
|
|
|> redirect(to: ~p"/sign-in")
|
|
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.
|
|
""")
|
|
|
|
conn
|
|
|> put_flash(:error, message)
|
|
|> redirect(to: ~p"/sign-in")
|
|
else
|
|
conn
|
|
|> put_flash(:error, gettext("Authentication failed. Please try again."))
|
|
|> redirect(to: ~p"/sign-in")
|
|
end
|
|
end
|
|
|
|
defp handle_authentication_failed(conn, _other) do
|
|
conn
|
|
|> put_flash(:error, gettext("Authentication failed. Please try again."))
|
|
|> redirect(to: ~p"/sign-in")
|
|
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)
|
|
|
|
conn
|
|
|> put_flash(:error, error_message)
|
|
|> redirect(to: ~p"/sign-in")
|
|
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
|
|
|
|
# Extract safe metadata from Assent errors for logging
|
|
# Never logs sensitive data: no tokens, secrets, or full request URLs
|
|
# Returns keyword list for Logger.warning/2
|
|
defp safe_assent_meta(%{request_url: url} = err) when is_binary(url) do
|
|
[
|
|
request_url: redact_url(url),
|
|
http_adapter: Map.get(err, :http_adapter)
|
|
]
|
|
|> Enum.filter(fn {_key, value} -> not is_nil(value) end)
|
|
end
|
|
|
|
# Handle InvalidResponseError which has :response field (HTTPResponse struct)
|
|
defp safe_assent_meta(%{response: %{status: status} = response} = err) do
|
|
[
|
|
status: status,
|
|
http_adapter: Map.get(response, :http_adapter) || Map.get(err, :http_adapter)
|
|
]
|
|
|> Enum.filter(fn {_key, value} -> not is_nil(value) end)
|
|
end
|
|
|
|
defp safe_assent_meta(err) do
|
|
# Only extract safe, simple fields
|
|
[
|
|
http_adapter: Map.get(err, :http_adapter)
|
|
]
|
|
|> Enum.filter(fn {_key, value} -> not is_nil(value) end)
|
|
end
|
|
|
|
# Redact URL to only show scheme and host, hiding path, query, and fragments
|
|
defp redact_url(url) when is_binary(url) do
|
|
case URI.parse(url) do
|
|
%URI{scheme: scheme, host: host} when not is_nil(scheme) and not is_nil(host) ->
|
|
"#{scheme}://#{host}"
|
|
|
|
_ ->
|
|
"[redacted]"
|
|
end
|
|
end
|
|
|
|
defp redact_url(_), do: "[redacted]"
|
|
|
|
def sign_out(conn, _params) do
|
|
return_to = get_session(conn, :return_to) || ~p"/"
|
|
|
|
conn
|
|
|> clear_session(:mv)
|
|
|> put_flash(:success, gettext("You are now signed out"))
|
|
|> redirect(to: return_to)
|
|
end
|
|
end
|