Merge branch 'main' into feature/428_export_groups
This commit is contained in:
commit
01d901a61d
11 changed files with 461 additions and 44 deletions
|
|
@ -84,7 +84,7 @@ steps:
|
||||||
# Fetch dependencies
|
# Fetch dependencies
|
||||||
- mix deps.get
|
- mix deps.get
|
||||||
# Run fast tests (excludes slow/performance and UI tests)
|
# Run fast tests (excludes slow/performance and UI tests)
|
||||||
- mix test --exclude slow --exclude ui
|
- mix test --exclude slow --exclude ui --max-cases 2
|
||||||
|
|
||||||
- name: rebuild-cache
|
- name: rebuild-cache
|
||||||
image: drillster/drone-volume-cache
|
image: drillster/drone-volume-cache
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
@plugin "../vendor/daisyui-theme" {
|
@plugin "../vendor/daisyui-theme" {
|
||||||
name: "dark";
|
name: "dark";
|
||||||
default: false;
|
default: false;
|
||||||
prefersdark: true;
|
prefersdark: false;
|
||||||
color-scheme: "dark";
|
color-scheme: "dark";
|
||||||
--color-base-100: oklch(30.33% 0.016 252.42);
|
--color-base-100: oklch(30.33% 0.016 252.42);
|
||||||
--color-base-200: oklch(25.26% 0.014 253.1);
|
--color-base-200: oklch(25.26% 0.014 253.1);
|
||||||
|
|
|
||||||
|
|
@ -45,4 +45,11 @@ defmodule MvWeb.AuthOverrides do
|
||||||
Gettext.gettext(MvWeb.Gettext, "or")
|
Gettext.gettext(MvWeb.Gettext, "or")
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Hide AshAuthentication's Flash component since we use flash_group in root layout
|
||||||
|
# This prevents duplicate flash messages
|
||||||
|
override AshAuthentication.Phoenix.Components.Flash do
|
||||||
|
set :message_class_info, "hidden"
|
||||||
|
set :message_class_error, "hidden"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -15,24 +15,98 @@
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
(() => {
|
(() => {
|
||||||
const setTheme = (theme) => {
|
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
if (theme === "system") {
|
const systemTheme = () => (mq.matches ? "dark" : "light");
|
||||||
localStorage.removeItem("phx:theme");
|
|
||||||
document.documentElement.removeAttribute("data-theme");
|
// Single source of truth:
|
||||||
} else {
|
// - localStorage["phx:theme"] = "light" | "dark" (explicit override)
|
||||||
localStorage.setItem("phx:theme", theme);
|
// - missing key => "system"
|
||||||
document.documentElement.setAttribute("data-theme", theme);
|
const storedTheme = () => localStorage.getItem("phx:theme") || "system";
|
||||||
}
|
|
||||||
|
const effectiveTheme = (t) => (t === "system" ? systemTheme() : t);
|
||||||
|
|
||||||
|
const applyThemeNow = (t) => {
|
||||||
|
document.documentElement.setAttribute("data-theme", effectiveTheme(t));
|
||||||
};
|
};
|
||||||
if (!document.documentElement.hasAttribute("data-theme")) {
|
|
||||||
setTheme(localStorage.getItem("phx:theme") || "system");
|
const syncToggle = () => {
|
||||||
}
|
const eff = effectiveTheme(storedTheme());
|
||||||
window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "system"));
|
document.querySelectorAll("[data-theme-toggle]").forEach((el) => {
|
||||||
|
el.checked = eff === "dark";
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setTheme = (t) => {
|
||||||
|
if (t === "system") localStorage.removeItem("phx:theme");
|
||||||
|
else localStorage.setItem("phx:theme", t);
|
||||||
|
|
||||||
|
applyThemeNow(t);
|
||||||
|
syncToggle(); // if toggle exists already
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1) Apply theme ASAP to match system on first paint
|
||||||
|
applyThemeNow(storedTheme());
|
||||||
|
|
||||||
|
// 2) Sync toggle once DOM is ready (fixes initial "light" toggle)
|
||||||
|
document.addEventListener("DOMContentLoaded", syncToggle);
|
||||||
|
|
||||||
|
// 3) If toggle appears later (LiveView render), sync immediately
|
||||||
|
const obs = new MutationObserver(() => {
|
||||||
|
if (document.querySelector("[data-theme-toggle]")) syncToggle();
|
||||||
|
});
|
||||||
|
obs.observe(document.documentElement, { childList: true, subtree: true });
|
||||||
|
|
||||||
window.addEventListener("phx:set-theme", ({ detail: { theme } }) => setTheme(theme));
|
window.addEventListener("phx:set-theme", ({ detail: { theme } }) => setTheme(theme));
|
||||||
|
window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "system"));
|
||||||
|
|
||||||
|
mq.addEventListener("change", () => {
|
||||||
|
if (localStorage.getItem("phx:theme") === null) {
|
||||||
|
applyThemeNow("system");
|
||||||
|
syncToggle();
|
||||||
|
}
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div
|
||||||
|
id="flash-group-root"
|
||||||
|
aria-live="polite"
|
||||||
|
class="z-50 flex flex-col gap-2 toast toast-top toast-end"
|
||||||
|
>
|
||||||
|
<.flash id="flash-success-root" kind={:success} flash={@flash} />
|
||||||
|
<.flash id="flash-warning-root" kind={:warning} flash={@flash} />
|
||||||
|
<.flash id="flash-info-root" kind={:info} flash={@flash} />
|
||||||
|
<.flash id="flash-error-root" kind={:error} flash={@flash} />
|
||||||
|
|
||||||
|
<.flash
|
||||||
|
id="client-error-root"
|
||||||
|
kind={:error}
|
||||||
|
title={gettext("We can't find the internet")}
|
||||||
|
phx-disconnected={
|
||||||
|
show(".phx-client-error #client-error-root") |> JS.remove_attribute("hidden")
|
||||||
|
}
|
||||||
|
phx-connected={hide("#client-error-root") |> JS.set_attribute({"hidden", ""})}
|
||||||
|
hidden
|
||||||
|
>
|
||||||
|
{gettext("Attempting to reconnect")}
|
||||||
|
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
|
||||||
|
</.flash>
|
||||||
|
|
||||||
|
<.flash
|
||||||
|
id="server-error-root"
|
||||||
|
kind={:error}
|
||||||
|
title={gettext("Something went wrong!")}
|
||||||
|
phx-disconnected={
|
||||||
|
show(".phx-server-error #server-error-root") |> JS.remove_attribute("hidden")
|
||||||
|
}
|
||||||
|
phx-connected={hide("#server-error-root") |> JS.set_attribute({"hidden", ""})}
|
||||||
|
hidden
|
||||||
|
>
|
||||||
|
{gettext("Attempting to reconnect")}
|
||||||
|
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
|
||||||
|
</.flash>
|
||||||
|
</div>
|
||||||
{@inner_content}
|
{@inner_content}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -248,12 +248,17 @@ defmodule MvWeb.Layouts.Sidebar do
|
||||||
aria-label={gettext("Toggle dark mode")}
|
aria-label={gettext("Toggle dark mode")}
|
||||||
>
|
>
|
||||||
<.icon name="hero-sun" class="size-5" aria-hidden="true" />
|
<.icon name="hero-sun" class="size-5" aria-hidden="true" />
|
||||||
<input
|
<div id="theme-toggle" phx-update="ignore">
|
||||||
type="checkbox"
|
<input
|
||||||
value="dark"
|
id="theme-toggle-input"
|
||||||
class="toggle toggle-sm theme-controller focus:outline-none"
|
type="checkbox"
|
||||||
aria-label={gettext("Toggle dark mode")}
|
class="toggle toggle-sm focus:outline-none"
|
||||||
/>
|
data-theme-toggle
|
||||||
|
onchange="window.dispatchEvent(new CustomEvent('phx:set-theme',{detail:{theme:this.checked?'dark':'light'}}))"
|
||||||
|
aria-label={gettext("Toggle dark mode")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<.icon name="hero-moon" class="size-5" aria-hidden="true" />
|
<.icon name="hero-moon" class="size-5" aria-hidden="true" />
|
||||||
</label>
|
</label>
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -45,9 +45,7 @@ defmodule MvWeb.AuthController do
|
||||||
- Generic authentication failures
|
- Generic authentication failures
|
||||||
"""
|
"""
|
||||||
def failure(conn, activity, reason) do
|
def failure(conn, activity, reason) do
|
||||||
Logger.warning(
|
log_failure_safely(activity, reason)
|
||||||
"Authentication failure - Activity: #{inspect(activity)}, Reason: #{inspect(reason)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
case {activity, reason} do
|
case {activity, reason} do
|
||||||
{{:rauthy, _action}, reason} ->
|
{{:rauthy, _action}, reason} ->
|
||||||
|
|
@ -57,10 +55,70 @@ defmodule MvWeb.AuthController do
|
||||||
handle_authentication_failed(conn, caused_by)
|
handle_authentication_failed(conn, caused_by)
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
redirect_with_error(conn, gettext("Incorrect email or password"))
|
conn
|
||||||
|
|> put_flash(:error, gettext("Incorrect email or password"))
|
||||||
|
|> redirect(to: ~p"/sign-in")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Log authentication failures safely, avoiding sensitive data for {:rauthy, _} activities
|
||||||
|
defp log_failure_safely({:rauthy, _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 rauthy 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-rauthy 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 Rauthy (OIDC) authentication failures
|
# Handle all Rauthy (OIDC) authentication failures
|
||||||
defp handle_rauthy_failure(conn, %Ash.Error.Invalid{errors: errors}) do
|
defp handle_rauthy_failure(conn, %Ash.Error.Invalid{errors: errors}) do
|
||||||
handle_oidc_email_collision(conn, errors)
|
handle_oidc_email_collision(conn, errors)
|
||||||
|
|
@ -74,14 +132,46 @@ defmodule MvWeb.AuthController do
|
||||||
handle_oidc_email_collision(conn, errors)
|
handle_oidc_email_collision(conn, errors)
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
redirect_with_error(conn, gettext("Unable to authenticate with OIDC. Please try again."))
|
conn
|
||||||
|
|> put_flash(:error, gettext("Unable to authenticate with OIDC. Please try again."))
|
||||||
|
|> redirect(to: ~p"/sign-in")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Handle Assent server unreachable errors (network/connectivity issues)
|
||||||
|
defp handle_rauthy_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_rauthy_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
|
# Catch-all clause for any other error types
|
||||||
defp handle_rauthy_failure(conn, reason) do
|
defp handle_rauthy_failure(conn, _reason) do
|
||||||
Logger.warning("Unhandled Rauthy failure reason: #{inspect(reason)}")
|
# Logging already done safely in failure/3 via log_failure_safely/2
|
||||||
redirect_with_error(conn, gettext("Unable to authenticate with OIDC. Please try again."))
|
# 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
|
end
|
||||||
|
|
||||||
# Handle generic AuthenticationFailed errors
|
# Handle generic AuthenticationFailed errors
|
||||||
|
|
@ -93,14 +183,20 @@ defmodule MvWeb.AuthController do
|
||||||
You can confirm your account using the link we sent to you, or by resetting your password.
|
You can confirm your account using the link we sent to you, or by resetting your password.
|
||||||
""")
|
""")
|
||||||
|
|
||||||
redirect_with_error(conn, message)
|
conn
|
||||||
|
|> put_flash(:error, message)
|
||||||
|
|> redirect(to: ~p"/sign-in")
|
||||||
else
|
else
|
||||||
redirect_with_error(conn, gettext("Authentication failed. Please try again."))
|
conn
|
||||||
|
|> put_flash(:error, gettext("Authentication failed. Please try again."))
|
||||||
|
|> redirect(to: ~p"/sign-in")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp handle_authentication_failed(conn, _other) do
|
defp handle_authentication_failed(conn, _other) do
|
||||||
redirect_with_error(conn, gettext("Authentication failed. Please try again."))
|
conn
|
||||||
|
|> put_flash(:error, gettext("Authentication failed. Please try again."))
|
||||||
|
|> redirect(to: ~p"/sign-in")
|
||||||
end
|
end
|
||||||
|
|
||||||
# Handle OIDC email collision - user needs to verify password to link accounts
|
# Handle OIDC email collision - user needs to verify password to link accounts
|
||||||
|
|
@ -112,7 +208,10 @@ defmodule MvWeb.AuthController do
|
||||||
nil ->
|
nil ->
|
||||||
# Check if it's a "different OIDC account" error or email uniqueness error
|
# Check if it's a "different OIDC account" error or email uniqueness error
|
||||||
error_message = extract_meaningful_error_message(errors)
|
error_message = extract_meaningful_error_message(errors)
|
||||||
redirect_with_error(conn, error_message)
|
|
||||||
|
conn
|
||||||
|
|> put_flash(:error, error_message)
|
||||||
|
|> redirect(to: ~p"/sign-in")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -177,13 +276,47 @@ defmodule MvWeb.AuthController do
|
||||||
|> redirect(to: ~p"/auth/link-oidc-account")
|
|> redirect(to: ~p"/auth/link-oidc-account")
|
||||||
end
|
end
|
||||||
|
|
||||||
# Generic error redirect helper
|
# Extract safe metadata from Assent errors for logging
|
||||||
defp redirect_with_error(conn, message) do
|
# Never logs sensitive data: no tokens, secrets, or full request URLs
|
||||||
conn
|
# Returns keyword list for Logger.warning/2
|
||||||
|> put_flash(:error, message)
|
defp safe_assent_meta(%{request_url: url} = err) when is_binary(url) do
|
||||||
|> redirect(to: ~p"/sign-in")
|
[
|
||||||
|
request_url: redact_url(url),
|
||||||
|
http_adapter: Map.get(err, :http_adapter)
|
||||||
|
]
|
||||||
|
|> Enum.filter(fn {_key, value} -> not is_nil(value) end)
|
||||||
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
|
def sign_out(conn, _params) do
|
||||||
return_to = get_session(conn, :return_to) || ~p"/"
|
return_to = get_session(conn, :return_to) || ~p"/"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ msgid "Are you sure?"
|
||||||
msgstr "Bist du sicher?"
|
msgstr "Bist du sicher?"
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts.ex
|
#: lib/mv_web/components/layouts.ex
|
||||||
|
#: lib/mv_web/components/layouts/root.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Attempting to reconnect"
|
msgid "Attempting to reconnect"
|
||||||
msgstr "Verbindung wird wiederhergestellt"
|
msgstr "Verbindung wird wiederhergestellt"
|
||||||
|
|
@ -115,11 +116,13 @@ msgid "Show"
|
||||||
msgstr "Anzeigen"
|
msgstr "Anzeigen"
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts.ex
|
#: lib/mv_web/components/layouts.ex
|
||||||
|
#: lib/mv_web/components/layouts/root.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Something went wrong!"
|
msgid "Something went wrong!"
|
||||||
msgstr "Etwas ist schiefgelaufen!"
|
msgstr "Etwas ist schiefgelaufen!"
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts.ex
|
#: lib/mv_web/components/layouts.ex
|
||||||
|
#: lib/mv_web/components/layouts/root.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "We can't find the internet"
|
msgid "We can't find the internet"
|
||||||
msgstr "Keine Internetverbindung gefunden"
|
msgstr "Keine Internetverbindung gefunden"
|
||||||
|
|
@ -582,6 +585,16 @@ msgstr "Ein Konto mit dieser E-Mail existiert bereits. Bitte verifiziere dein Pa
|
||||||
msgid "Unable to authenticate with OIDC. Please try again."
|
msgid "Unable to authenticate with OIDC. Please try again."
|
||||||
msgstr "OIDC-Authentifizierung fehlgeschlagen. Bitte versuche es erneut."
|
msgstr "OIDC-Authentifizierung fehlgeschlagen. Bitte versuche es erneut."
|
||||||
|
|
||||||
|
#: lib/mv_web/controllers/auth_controller.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "The authentication server is currently unavailable. Please try again later."
|
||||||
|
msgstr "Der Authentifizierungsserver ist derzeit nicht erreichbar. Bitte versuche es später erneut."
|
||||||
|
|
||||||
|
#: lib/mv_web/controllers/auth_controller.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Authentication configuration error. Please contact the administrator."
|
||||||
|
msgstr "Authentifizierungskonfigurationsfehler. Bitte kontaktiere den Administrator."
|
||||||
|
|
||||||
#: lib/mv_web/controllers/auth_controller.ex
|
#: lib/mv_web/controllers/auth_controller.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Unable to sign in. Please try again."
|
msgid "Unable to sign in. Please try again."
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ msgid "Are you sure?"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts.ex
|
#: lib/mv_web/components/layouts.ex
|
||||||
|
#: lib/mv_web/components/layouts/root.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Attempting to reconnect"
|
msgid "Attempting to reconnect"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -116,11 +117,13 @@ msgid "Show"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts.ex
|
#: lib/mv_web/components/layouts.ex
|
||||||
|
#: lib/mv_web/components/layouts/root.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Something went wrong!"
|
msgid "Something went wrong!"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts.ex
|
#: lib/mv_web/components/layouts.ex
|
||||||
|
#: lib/mv_web/components/layouts/root.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "We can't find the internet"
|
msgid "We can't find the internet"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -583,6 +586,16 @@ msgstr ""
|
||||||
msgid "Unable to authenticate with OIDC. Please try again."
|
msgid "Unable to authenticate with OIDC. Please try again."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/controllers/auth_controller.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "The authentication server is currently unavailable. Please try again later."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/controllers/auth_controller.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Authentication configuration error. Please contact the administrator."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/controllers/auth_controller.ex
|
#: lib/mv_web/controllers/auth_controller.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Unable to sign in. Please try again."
|
msgid "Unable to sign in. Please try again."
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ msgid "Are you sure?"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts.ex
|
#: lib/mv_web/components/layouts.ex
|
||||||
|
#: lib/mv_web/components/layouts/root.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Attempting to reconnect"
|
msgid "Attempting to reconnect"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -116,11 +117,13 @@ msgid "Show"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts.ex
|
#: lib/mv_web/components/layouts.ex
|
||||||
|
#: lib/mv_web/components/layouts/root.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Something went wrong!"
|
msgid "Something went wrong!"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts.ex
|
#: lib/mv_web/components/layouts.ex
|
||||||
|
#: lib/mv_web/components/layouts/root.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "We can't find the internet"
|
msgid "We can't find the internet"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -583,6 +586,16 @@ msgstr ""
|
||||||
msgid "Unable to authenticate with OIDC. Please try again."
|
msgid "Unable to authenticate with OIDC. Please try again."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/controllers/auth_controller.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "The authentication server is currently unavailable. Please try again later."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/controllers/auth_controller.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Authentication configuration error. Please contact the administrator."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/controllers/auth_controller.ex
|
#: lib/mv_web/controllers/auth_controller.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Unable to sign in. Please try again."
|
msgid "Unable to sign in. Please try again."
|
||||||
|
|
|
||||||
|
|
@ -132,7 +132,7 @@ defmodule MvWeb.Layouts.SidebarTest do
|
||||||
refute html =~ ~s(role="menuitem")
|
refute html =~ ~s(role="menuitem")
|
||||||
|
|
||||||
# Footer section should not be rendered
|
# Footer section should not be rendered
|
||||||
refute html =~ "theme-controller"
|
refute html =~ "data-theme-toggle"
|
||||||
refute html =~ "locale-select"
|
refute html =~ "locale-select"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -253,8 +253,8 @@ defmodule MvWeb.Layouts.SidebarTest do
|
||||||
# Check for language selector form
|
# Check for language selector form
|
||||||
assert html =~ ~s(action="/set_locale")
|
assert html =~ ~s(action="/set_locale")
|
||||||
|
|
||||||
# Check for theme toggle
|
# Check for theme toggle (using data attribute instead of class)
|
||||||
assert has_class?(html, "theme-controller")
|
assert html =~ "data-theme-toggle"
|
||||||
|
|
||||||
# Check for user menu/avatar
|
# Check for user menu/avatar
|
||||||
assert has_class?(html, "avatar")
|
assert has_class?(html, "avatar")
|
||||||
|
|
@ -536,7 +536,7 @@ defmodule MvWeb.Layouts.SidebarTest do
|
||||||
assert html =~ ~s(role="group")
|
assert html =~ ~s(role="group")
|
||||||
|
|
||||||
# Footer section
|
# Footer section
|
||||||
assert html =~ "theme-controller"
|
assert html =~ "data-theme-toggle"
|
||||||
assert html =~ ~s(action="/set_locale")
|
assert html =~ ~s(action="/set_locale")
|
||||||
|
|
||||||
# Check that critical navigation exists (at least /members)
|
# Check that critical navigation exists (at least /members)
|
||||||
|
|
@ -694,8 +694,8 @@ defmodule MvWeb.Layouts.SidebarTest do
|
||||||
test "renders theme toggle" do
|
test "renders theme toggle" do
|
||||||
html = render_sidebar(authenticated_assigns())
|
html = render_sidebar(authenticated_assigns())
|
||||||
|
|
||||||
# Toggle is always visible
|
# Toggle is always visible (using data attribute instead of class)
|
||||||
assert has_class?(html, "theme-controller")
|
assert html =~ "data-theme-toggle"
|
||||||
assert html =~ "hero-sun"
|
assert html =~ "hero-sun"
|
||||||
assert html =~ "hero-moon"
|
assert html =~ "hero-moon"
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,15 @@ defmodule MvWeb.AuthControllerTest do
|
||||||
use MvWeb.ConnCase, async: true
|
use MvWeb.ConnCase, async: true
|
||||||
import Phoenix.LiveViewTest
|
import Phoenix.LiveViewTest
|
||||||
import Phoenix.ConnTest
|
import Phoenix.ConnTest
|
||||||
|
import ExUnit.CaptureLog
|
||||||
|
|
||||||
# Helper to create an unauthenticated conn (preserves sandbox metadata)
|
# Helper to create an unauthenticated conn (preserves sandbox metadata)
|
||||||
defp build_unauthenticated_conn(authenticated_conn) do
|
defp build_unauthenticated_conn(authenticated_conn) do
|
||||||
# Create new conn but preserve sandbox metadata for database access
|
# Create new conn but preserve sandbox metadata for database access
|
||||||
new_conn = build_conn()
|
new_conn =
|
||||||
|
build_conn()
|
||||||
|
|> init_test_session(%{})
|
||||||
|
|> fetch_flash()
|
||||||
|
|
||||||
# Copy sandbox metadata from authenticated conn
|
# Copy sandbox metadata from authenticated conn
|
||||||
if authenticated_conn.private[:ecto_sandbox] do
|
if authenticated_conn.private[:ecto_sandbox] do
|
||||||
|
|
@ -248,4 +252,159 @@ defmodule MvWeb.AuthControllerTest do
|
||||||
|
|
||||||
assert to =~ "/auth/user/password/sign_in_with_token"
|
assert to =~ "/auth/user/password/sign_in_with_token"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# OIDC/Rauthy error handling tests
|
||||||
|
describe "handle_rauthy_failure/2" do
|
||||||
|
test "Assent.ServerUnreachableError redirects to sign-in with error flash", %{
|
||||||
|
conn: authenticated_conn
|
||||||
|
} do
|
||||||
|
conn = build_unauthenticated_conn(authenticated_conn)
|
||||||
|
# Create a mock Assent.ServerUnreachableError struct with required fields
|
||||||
|
error = %Assent.ServerUnreachableError{
|
||||||
|
http_adapter: Assent.HTTPAdapter.Finch,
|
||||||
|
request_url: "https://auth.example.com/callback?token=secret123",
|
||||||
|
reason: %Mint.TransportError{reason: :econnrefused}
|
||||||
|
}
|
||||||
|
|
||||||
|
conn = MvWeb.AuthController.failure(conn, {:rauthy, :callback}, error)
|
||||||
|
|
||||||
|
assert redirected_to(conn) == ~p"/sign-in"
|
||||||
|
|
||||||
|
assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
|
||||||
|
"The authentication server is currently unavailable. Please try again later."
|
||||||
|
end
|
||||||
|
|
||||||
|
test "Assent.InvalidResponseError redirects to sign-in with error flash", %{
|
||||||
|
conn: authenticated_conn
|
||||||
|
} do
|
||||||
|
conn = build_unauthenticated_conn(authenticated_conn)
|
||||||
|
# Create a mock Assent.InvalidResponseError struct with required field
|
||||||
|
# InvalidResponseError only has :response field (HTTPResponse struct)
|
||||||
|
error = %Assent.InvalidResponseError{
|
||||||
|
response: %Assent.HTTPAdapter.HTTPResponse{
|
||||||
|
status: 400,
|
||||||
|
headers: [],
|
||||||
|
body: "invalid_request"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
conn = MvWeb.AuthController.failure(conn, {:rauthy, :callback}, error)
|
||||||
|
|
||||||
|
assert redirected_to(conn) == ~p"/sign-in"
|
||||||
|
|
||||||
|
assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
|
||||||
|
"Authentication configuration error. Please contact the administrator."
|
||||||
|
end
|
||||||
|
|
||||||
|
test "unknown reason triggers catch-all and redirects to sign-in with error flash", %{
|
||||||
|
conn: authenticated_conn
|
||||||
|
} do
|
||||||
|
conn = build_unauthenticated_conn(authenticated_conn)
|
||||||
|
unknown_reason = :oops
|
||||||
|
|
||||||
|
conn = MvWeb.AuthController.failure(conn, {:rauthy, :callback}, unknown_reason)
|
||||||
|
|
||||||
|
assert redirected_to(conn) == ~p"/sign-in"
|
||||||
|
|
||||||
|
assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
|
||||||
|
"Unable to authenticate with OIDC. Please try again."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Logging security tests - ensure no sensitive data is logged
|
||||||
|
describe "failure/3 logging security" do
|
||||||
|
test "does not log full URL with query params for Assent.ServerUnreachableError", %{
|
||||||
|
conn: authenticated_conn
|
||||||
|
} do
|
||||||
|
conn = build_unauthenticated_conn(authenticated_conn)
|
||||||
|
|
||||||
|
error = %Assent.ServerUnreachableError{
|
||||||
|
http_adapter: Assent.HTTPAdapter.Finch,
|
||||||
|
request_url: "https://auth.example.com/callback?token=secret123&code=abc456",
|
||||||
|
reason: %Mint.TransportError{reason: :econnrefused}
|
||||||
|
}
|
||||||
|
|
||||||
|
log =
|
||||||
|
capture_log(fn ->
|
||||||
|
MvWeb.AuthController.failure(conn, {:rauthy, :callback}, error)
|
||||||
|
end)
|
||||||
|
|
||||||
|
# Should log redacted URL (only scheme and host)
|
||||||
|
assert log =~ "https://auth.example.com"
|
||||||
|
# Should NOT log query parameters or tokens
|
||||||
|
refute log =~ "token=secret123"
|
||||||
|
refute log =~ "code=abc456"
|
||||||
|
refute log =~ "callback?token"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not log sensitive data for Assent.InvalidResponseError", %{
|
||||||
|
conn: authenticated_conn
|
||||||
|
} do
|
||||||
|
conn = build_unauthenticated_conn(authenticated_conn)
|
||||||
|
|
||||||
|
error = %Assent.InvalidResponseError{
|
||||||
|
response: %Assent.HTTPAdapter.HTTPResponse{
|
||||||
|
status: 400,
|
||||||
|
headers: [],
|
||||||
|
body: "invalid_request"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log =
|
||||||
|
capture_log(fn ->
|
||||||
|
MvWeb.AuthController.failure(conn, {:rauthy, :callback}, error)
|
||||||
|
end)
|
||||||
|
|
||||||
|
# Should log error type but not full error details
|
||||||
|
assert log =~ "Authentication failure"
|
||||||
|
assert log =~ "rauthy"
|
||||||
|
# Should not log full error struct with inspect
|
||||||
|
refute log =~ "Assent.InvalidResponseError"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not log full reason for unknown rauthy errors", %{
|
||||||
|
conn: authenticated_conn
|
||||||
|
} do
|
||||||
|
conn = build_unauthenticated_conn(authenticated_conn)
|
||||||
|
# Simulate an error that might contain sensitive data
|
||||||
|
error_with_sensitive_data = %{
|
||||||
|
token: "secret_token_123",
|
||||||
|
url: "https://example.com/callback?access_token=abc123",
|
||||||
|
error: :something_went_wrong
|
||||||
|
}
|
||||||
|
|
||||||
|
log =
|
||||||
|
capture_log(fn ->
|
||||||
|
MvWeb.AuthController.failure(conn, {:rauthy, :callback}, error_with_sensitive_data)
|
||||||
|
end)
|
||||||
|
|
||||||
|
# Should log error type but not full error details
|
||||||
|
assert log =~ "Authentication failure"
|
||||||
|
assert log =~ "rauthy"
|
||||||
|
# Should NOT log sensitive data
|
||||||
|
refute log =~ "secret_token_123"
|
||||||
|
refute log =~ "access_token=abc123"
|
||||||
|
refute log =~ "callback?access_token"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "logs full reason for non-rauthy activities (password auth)", %{
|
||||||
|
conn: authenticated_conn
|
||||||
|
} do
|
||||||
|
conn = build_unauthenticated_conn(authenticated_conn)
|
||||||
|
|
||||||
|
reason = %AshAuthentication.Errors.AuthenticationFailed{
|
||||||
|
caused_by: %Ash.Error.Forbidden{errors: []}
|
||||||
|
}
|
||||||
|
|
||||||
|
log =
|
||||||
|
capture_log(fn ->
|
||||||
|
MvWeb.AuthController.failure(conn, {:password, :sign_in}, reason)
|
||||||
|
end)
|
||||||
|
|
||||||
|
# For non-rauthy activities, full reason is safe to log
|
||||||
|
assert log =~ "Authentication failure"
|
||||||
|
assert log =~ "password"
|
||||||
|
assert log =~ "AuthenticationFailed"
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue