mitgliederverwaltung/lib/mv_web/controllers/auth_controller.ex
Simon c381b86b5e
All checks were successful
continuous-integration/drone/push Build is passing
Improve oidc only mode (#474)
## Description of the implemented changes
The changes were:
- [x] Bugfixing
- [x] New Feature
- [ ] Breaking Change
- [x] Refactoring

**OIDC-only mode improvements and UX tweaks (success toasts, unauthenticated redirect).**

## What has been changed?

### OIDC-only mode (new feature)
- **Admin settings:** "Only OIDC sign-in" is an immediate toggle at the top of the OIDC section (no save button). Enabling it also turns off "Allow direct registration". When OIDC-only is on, the registration checkbox is disabled and shows a tooltip (DaisyUI `<.tooltip>`).
- **Backend:** Password sign-in is forbidden via Ash policy (`OidcOnlyActive` check). Password registration is blocked via validation `OidcOnlyBlocksPasswordRegistration`. New plug `OidcOnlySignInRedirect`: when OIDC-only and OIDC are configured, GET `/sign-in` redirects to the OIDC flow; GET `/auth/user/password/sign_in_with_token` is rejected with redirect + flash. `AuthController.success/4` also rejects password sign-in when OIDC-only.
- **Tests:** GlobalSettingsLive (OIDC-only UI), AuthController (redirect and password sign-in rejection), User authentication (register_with_password blocked when OIDC-only).

### UX / behaviour (no new feature flag)
- **Success toasts:** Success flash messages auto-dismiss after 5 seconds via JS hook `FlashAutoDismiss` and optional `auto_clear_ms` on `<.flash>` (used for success in root layout and `flash_group`).
- **Unauthenticated users:** Redirect to sign-in without the "You don't have permission to access this page" flash; that message is only shown to logged-in users who lack access. Logic in `LiveHelpers` and `CheckPagePermission` plug; test updated accordingly.

### Other
- Layouts: comment about unprocessed join-request count no longer uses "TODO" (Credo).
- Gettext: German translation for "Home" (Startseite); POT/PO kept in sync.
- CHANGELOG: Unreleased section updated with the above.

## Definition of Done
### Code Quality
- [x] No new technical depths
- [x] Linting passed
- [x] Documentation is added where needed (module docs, comments where non-obvious)

### Accessibility
- [x] New elements are properly defined with html-tags (labels, aria-label on checkboxes)
- [x] Colour contrast follows WCAG criteria (unchanged)
- [x] Aria labels are added when needed (e.g. oidc-only and registration checkboxes)
- [x] Everything is accessible by keyboard (toggles and buttons unchanged)
- [x] Tab-Order is comprehensible
- [x] All interactive elements have a visible focus (existing patterns)

### Testing
- [x] Tests for new code are written (OIDC-only UI, auth controller, user auth; SMTP config builder and mailer)
- [x] All tests pass
- [ ] axe-core dev tools show no critical or major issues (not re-run for this PR; suggest spot-check on settings and sign-in)

## Additional Notes
- **OIDC-only:** When the `OIDC_ONLY` env var is set, the toggle is read-only and shows "(From OIDC_ONLY)". When OIDC is not configured, the toggle is disabled.
- **Invalidation:** Enabling OIDC-only sets `registration_enabled: false` in one update; disabling OIDC-only only updates `oidc_only` (registration left as-is).
- **Review focus:** Plug order in router (OidcOnlySignInRedirect), policy/validation order in User, and that all OIDC-only paths (form, plug, controller) stay consistent.

Reviewed-on: #474
Co-authored-by: Simon <s.thiessen@local-it.org>
Co-committed-by: Simon <s.thiessen@local-it.org>
2026-03-16 19:09:07 +01:00

347 lines
12 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
alias Mv.Config
def success(conn, {:password, :sign_in} = _activity, user, token) do
if Config.oidc_only?() do
conn
|> put_flash(:error, gettext("Only sign-in via Single Sign-On (SSO) is allowed."))
|> redirect(to: sign_in_path_after_oidc_failure())
else
success_continue(conn, {:password, :sign_in}, user, token)
end
end
def success(conn, activity, user, token) do
success_continue(conn, activity, user, token)
end
defp success_continue(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: sign_in_path_after_oidc_failure())
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: sign_in_path_after_oidc_failure())
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: sign_in_path_after_oidc_failure())
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: sign_in_path_after_oidc_failure())
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: sign_in_path_after_oidc_failure())
end
end
# Path used when redirecting to sign-in after an OIDC failure. The query param tells
# OidcOnlySignInRedirect to show the sign-in page instead of redirecting back to OIDC (avoids loop).
defp sign_in_path_after_oidc_failure, do: "/sign-in?oidc_failed=1"
# 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