Improve oidc only mode #474
19 changed files with 330 additions and 43 deletions
|
|
@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Improved OIDC-only mode** – Admin can enable “Only OIDC sign-in” in settings; when enabled, direct registration is disabled and sign-in page redirects to OIDC when configured.
|
||||||
|
- **Success toast auto-dismiss** – Success flash messages (e.g. “Settings saved”) hide automatically after 5 seconds instead of requiring the user to close them.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Unauthenticated access** – Users who are not logged in are redirected to sign-in without showing a “no permission” message; the message is only shown to logged-in users who lack access.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- **SMTP configuration** – Repaired so that both port 587 (TLS/STARTTLS) and 465 (SSL) work correctly.
|
- **SMTP configuration** – Repaired so that both port 587 (TLS/STARTTLS) and 465 (SSL) work correctly.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,25 @@ Hooks.FocusRestore = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FlashAutoDismiss: after a delay, clear the flash so the toast hides without user clicking X (e.g. success toasts)
|
||||||
|
Hooks.FlashAutoDismiss = {
|
||||||
|
mounted() {
|
||||||
|
const ms = this.el.dataset.autoClearMs
|
||||||
|
if (!ms) return
|
||||||
|
const delay = parseInt(ms, 10)
|
||||||
|
if (delay > 0) {
|
||||||
|
this.timer = setTimeout(() => {
|
||||||
|
const key = this.el.dataset.clearFlashKey || "success"
|
||||||
|
this.pushEvent("lv:clear-flash", {key})
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
destroyed() {
|
||||||
|
if (this.timer) clearTimeout(this.timer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TabListKeydown hook: WCAG tab pattern — prevent default for ArrowLeft/ArrowRight so the server can handle tab switch (roving tabindex)
|
// TabListKeydown hook: WCAG tab pattern — prevent default for ArrowLeft/ArrowRight so the server can handle tab switch (roving tabindex)
|
||||||
Hooks.TabListKeydown = {
|
Hooks.TabListKeydown = {
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|
|
||||||
|
|
@ -362,6 +362,12 @@ defmodule Mv.Accounts.User do
|
||||||
# Authorization Policies
|
# Authorization Policies
|
||||||
# Order matters: Most specific policies first, then general permission check
|
# Order matters: Most specific policies first, then general permission check
|
||||||
policies do
|
policies do
|
||||||
|
# When OIDC-only is active, password sign-in is forbidden (SSO only).
|
||||||
|
policy action(:sign_in_with_password) do
|
||||||
|
forbid_if Mv.Authorization.Checks.OidcOnlyActive
|
||||||
|
authorize_if always()
|
||||||
|
end
|
||||||
|
|
||||||
# AshAuthentication bypass (registration/login without actor)
|
# AshAuthentication bypass (registration/login without actor)
|
||||||
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
|
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
|
||||||
description "Allow AshAuthentication internal operations (registration, login)"
|
description "Allow AshAuthentication internal operations (registration, login)"
|
||||||
|
|
@ -409,6 +415,10 @@ defmodule Mv.Accounts.User do
|
||||||
validate {Mv.Accounts.User.Validations.RegistrationEnabled, []},
|
validate {Mv.Accounts.User.Validations.RegistrationEnabled, []},
|
||||||
where: [action_is(:register_with_password)]
|
where: [action_is(:register_with_password)]
|
||||||
|
|
||||||
|
# Block password registration when OIDC-only mode is active
|
||||||
|
validate {Mv.Accounts.User.Validations.OidcOnlyBlocksPasswordRegistration, []},
|
||||||
|
where: [action_is(:register_with_password)]
|
||||||
|
|
||||||
# Email uniqueness check for all actions that change the email attribute
|
# Email uniqueness check for all actions that change the email attribute
|
||||||
# Validates that user email is not already used by another (unlinked) member
|
# Validates that user email is not already used by another (unlinked) member
|
||||||
validate Mv.Accounts.User.Validations.EmailNotUsedByOtherMember
|
validate Mv.Accounts.User.Validations.EmailNotUsedByOtherMember
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
defmodule Mv.Accounts.User.Validations.OidcOnlyBlocksPasswordRegistration do
|
||||||
|
@moduledoc """
|
||||||
|
Validation that blocks direct registration (register_with_password) when
|
||||||
|
OIDC-only mode is active. In OIDC-only mode, sign-in and registration are
|
||||||
|
only allowed via OIDC (SSO).
|
||||||
|
"""
|
||||||
|
use Ash.Resource.Validation
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def init(opts), do: {:ok, opts}
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def validate(_changeset, _opts, _context) do
|
||||||
|
if Mv.Config.oidc_only?() do
|
||||||
|
{:error,
|
||||||
|
field: :base,
|
||||||
|
message:
|
||||||
|
Gettext.dgettext(
|
||||||
|
MvWeb.Gettext,
|
||||||
|
"default",
|
||||||
|
"Registration with password is disabled when only OIDC sign-in is active."
|
||||||
|
)}
|
||||||
|
else
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
16
lib/mv/authorization/checks/oidc_only_active.ex
Normal file
16
lib/mv/authorization/checks/oidc_only_active.ex
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
defmodule Mv.Authorization.Checks.OidcOnlyActive do
|
||||||
|
@moduledoc """
|
||||||
|
Policy check: true when OIDC-only mode is active (Config.oidc_only?()).
|
||||||
|
|
||||||
|
Used to forbid password sign-in when only OIDC (SSO) sign-in is allowed.
|
||||||
|
"""
|
||||||
|
use Ash.Policy.SimpleCheck
|
||||||
|
|
||||||
|
alias Mv.Config
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def describe(_opts), do: "OIDC-only mode is active"
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def match?(_actor, _context, _opts), do: Config.oidc_only?()
|
||||||
|
end
|
||||||
|
|
@ -63,6 +63,11 @@ defmodule MvWeb.CoreComponents do
|
||||||
values: [:info, :error, :success, :warning],
|
values: [:info, :error, :success, :warning],
|
||||||
doc: "used for styling and flash lookup"
|
doc: "used for styling and flash lookup"
|
||||||
|
|
||||||
|
attr :auto_clear_ms, :integer,
|
||||||
|
default: nil,
|
||||||
|
doc:
|
||||||
|
"when set, flash is auto-dismissed after this many milliseconds (e.g. 5000 for success toasts)"
|
||||||
|
|
||||||
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
|
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
|
||||||
|
|
||||||
slot :inner_block, doc: "the optional inner block that renders the flash message"
|
slot :inner_block, doc: "the optional inner block that renders the flash message"
|
||||||
|
|
@ -74,6 +79,9 @@ defmodule MvWeb.CoreComponents do
|
||||||
<div
|
<div
|
||||||
:if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
|
:if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
|
||||||
id={@id}
|
id={@id}
|
||||||
|
phx-hook={@auto_clear_ms && "FlashAutoDismiss"}
|
||||||
|
data-auto-clear-ms={@auto_clear_ms}
|
||||||
|
data-clear-flash-key={@auto_clear_ms && @kind}
|
||||||
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
|
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
|
||||||
role="alert"
|
role="alert"
|
||||||
class="pointer-events-auto"
|
class="pointer-events-auto"
|
||||||
|
|
|
||||||
|
|
@ -171,7 +171,7 @@ defmodule MvWeb.Layouts do
|
||||||
</label>
|
</label>
|
||||||
<span class="font-bold">{@club_name}</span>
|
<span class="font-bold">{@club_name}</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Main Content (shared between mobile and desktop) -->
|
<!-- Main Content (shared between mobile and desktop) -->
|
||||||
<main class="px-4 py-8 sm:px-6 lg:px-8">
|
<main class="px-4 py-8 sm:px-6 lg:px-8">
|
||||||
<div class="mx-auto space-y-4 max-full">
|
<div class="mx-auto space-y-4 max-full">
|
||||||
|
|
@ -265,7 +265,7 @@ defmodule MvWeb.Layouts do
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
class="z-50 toast toast-bottom toast-end flex flex-col gap-2 pointer-events-none"
|
class="z-50 toast toast-bottom toast-end flex flex-col gap-2 pointer-events-none"
|
||||||
>
|
>
|
||||||
<.flash kind={:success} flash={@flash} />
|
<.flash kind={:success} flash={@flash} auto_clear_ms={5000} />
|
||||||
<.flash kind={:warning} flash={@flash} />
|
<.flash kind={:warning} flash={@flash} />
|
||||||
<.flash kind={:info} flash={@flash} />
|
<.flash kind={:info} flash={@flash} />
|
||||||
<.flash kind={:error} flash={@flash} />
|
<.flash kind={:error} flash={@flash} />
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
class="z-50 flex flex-col gap-2 toast toast-bottom toast-end"
|
class="z-50 flex flex-col gap-2 toast toast-bottom toast-end"
|
||||||
>
|
>
|
||||||
<.flash id="flash-success-root" kind={:success} flash={@flash} />
|
<.flash id="flash-success-root" kind={:success} flash={@flash} auto_clear_ms={5000} />
|
||||||
<.flash id="flash-warning-root" kind={:warning} flash={@flash} />
|
<.flash id="flash-warning-root" kind={:warning} flash={@flash} />
|
||||||
<.flash id="flash-info-root" kind={:info} flash={@flash} />
|
<.flash id="flash-info-root" kind={:info} flash={@flash} />
|
||||||
<.flash id="flash-error-root" kind={:error} flash={@flash} />
|
<.flash id="flash-error-root" kind={:error} flash={@flash} />
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,23 @@ defmodule MvWeb.AuthController do
|
||||||
use AshAuthentication.Phoenix.Controller
|
use AshAuthentication.Phoenix.Controller
|
||||||
|
|
||||||
alias Mv.Accounts.User.Errors.PasswordVerificationRequired
|
alias Mv.Accounts.User.Errors.PasswordVerificationRequired
|
||||||
|
alias Mv.Config
|
||||||
|
|
||||||
def success(conn, activity, user, _token) do
|
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: ~p"/sign-in")
|
||||||
|
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"/"
|
return_to = get_session(conn, :return_to) || ~p"/"
|
||||||
|
|
||||||
message =
|
message =
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
## Events
|
## Events
|
||||||
- `validate` / `save` - Club settings form
|
- `validate` / `save` - Club settings form
|
||||||
- `toggle_registration_enabled` - Enable/disable direct registration (/register)
|
- `toggle_registration_enabled` - Enable/disable direct registration (/register)
|
||||||
|
- `toggle_oidc_only` - Enable/disable OIDC-only sign-in (immediate, outside OIDC form)
|
||||||
- `toggle_join_form_enabled` - Enable/disable the join form
|
- `toggle_join_form_enabled` - Enable/disable the join form
|
||||||
- `add_join_form_field` / `remove_join_form_field` - Manage join form fields
|
- `add_join_form_field` / `remove_join_form_field` - Manage join form fields
|
||||||
- `toggle_join_form_field_required` - Toggle required flag per field
|
- `toggle_join_form_field_required` - Toggle required flag per field
|
||||||
|
|
@ -80,6 +81,7 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
|> assign(:oidc_admin_group_name_env_set, Mv.Config.oidc_admin_group_name_env_set?())
|
|> assign(:oidc_admin_group_name_env_set, Mv.Config.oidc_admin_group_name_env_set?())
|
||||||
|> assign(:oidc_groups_claim_env_set, Mv.Config.oidc_groups_claim_env_set?())
|
|> assign(:oidc_groups_claim_env_set, Mv.Config.oidc_groups_claim_env_set?())
|
||||||
|> assign(:oidc_only_env_set, Mv.Config.oidc_only_env_set?())
|
|> assign(:oidc_only_env_set, Mv.Config.oidc_only_env_set?())
|
||||||
|
|> assign(:oidc_only, Mv.Config.oidc_only?())
|
||||||
|> assign(:oidc_configured, Mv.Config.oidc_configured?())
|
|> assign(:oidc_configured, Mv.Config.oidc_configured?())
|
||||||
|> assign(:oidc_client_secret_set, Mv.Config.oidc_client_secret_set?())
|
|> assign(:oidc_client_secret_set, Mv.Config.oidc_client_secret_set?())
|
||||||
|> assign(:registration_enabled, settings.registration_enabled != false)
|
|> assign(:registration_enabled, settings.registration_enabled != false)
|
||||||
|
|
@ -625,11 +627,30 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
class="checkbox checkbox-sm"
|
class="checkbox checkbox-sm"
|
||||||
checked={@registration_enabled}
|
checked={@registration_enabled}
|
||||||
phx-click="toggle_registration_enabled"
|
phx-click="toggle_registration_enabled"
|
||||||
|
disabled={@oidc_only}
|
||||||
aria-label={gettext("Allow direct registration (/register)")}
|
aria-label={gettext("Allow direct registration (/register)")}
|
||||||
/>
|
/>
|
||||||
<label for="registration-enabled-checkbox" class="cursor-pointer font-medium">
|
<label
|
||||||
|
for="registration-enabled-checkbox"
|
||||||
|
class={
|
||||||
|
if @oidc_only, do: "cursor-not-allowed opacity-70", else: "cursor-pointer font-medium"
|
||||||
|
}
|
||||||
|
>
|
||||||
{gettext("Allow direct registration (/register)")}
|
{gettext("Allow direct registration (/register)")}
|
||||||
</label>
|
</label>
|
||||||
|
<%= if @oidc_only do %>
|
||||||
|
<.tooltip
|
||||||
|
content={gettext("Only OIDC sign-in is active. This option is disabled.")}
|
||||||
|
position="top"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
data-testid="oidc-only-registration-hint"
|
||||||
|
class="cursor-help text-base-content/70"
|
||||||
|
>
|
||||||
|
ⓘ
|
||||||
|
</span>
|
||||||
|
</.tooltip>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 class="font-medium mb-3">{gettext("OIDC (Single Sign-On)")}</h3>
|
<h3 class="font-medium mb-3">{gettext("OIDC (Single Sign-On)")}</h3>
|
||||||
|
|
@ -638,6 +659,38 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
{gettext("Some values are set via environment variables. Those fields are read-only.")}
|
{gettext("Some values are set via environment variables. Those fields are read-only.")}
|
||||||
</p>
|
</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="oidc-only-checkbox"
|
||||||
|
data-testid="oidc-only-checkbox"
|
||||||
|
class="checkbox checkbox-sm"
|
||||||
|
checked={@oidc_only}
|
||||||
|
phx-click="toggle_oidc_only"
|
||||||
|
disabled={@oidc_only_env_set or not @oidc_configured}
|
||||||
|
aria-label={gettext("Only OIDC sign-in (hide password login)")}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
for="oidc-only-checkbox"
|
||||||
|
class={
|
||||||
|
if @oidc_only_env_set or not @oidc_configured,
|
||||||
|
do: "cursor-not-allowed opacity-70",
|
||||||
|
else: "cursor-pointer font-medium"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{if @oidc_only_env_set do
|
||||||
|
gettext("Only OIDC sign-in (hide password login)") <>
|
||||||
|
" (" <> gettext("From OIDC_ONLY") <> ")"
|
||||||
|
else
|
||||||
|
gettext("Only OIDC sign-in (hide password login)")
|
||||||
|
end}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="label-text-alt text-base-content/70 mb-4">
|
||||||
|
{gettext(
|
||||||
|
"When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
<.form for={@form} id="oidc-form" phx-change="validate" phx-submit="save">
|
<.form for={@form} id="oidc-form" phx-change="validate" phx-submit="save">
|
||||||
<div class="grid gap-4">
|
<div class="grid gap-4">
|
||||||
<.input
|
<.input
|
||||||
|
|
@ -744,27 +797,6 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div class="form-control">
|
|
||||||
<.input
|
|
||||||
field={@form[:oidc_only]}
|
|
||||||
type="checkbox"
|
|
||||||
class="checkbox checkbox-sm"
|
|
||||||
disabled={@oidc_only_env_set or not @oidc_configured}
|
|
||||||
label={
|
|
||||||
if @oidc_only_env_set do
|
|
||||||
gettext("Only OIDC sign-in (hide password login)") <>
|
|
||||||
" (" <> gettext("From OIDC_ONLY") <> ")"
|
|
||||||
else
|
|
||||||
gettext("Only OIDC sign-in (hide password login)")
|
|
||||||
end
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<p class="label-text-alt text-base-content/70 mt-1">
|
|
||||||
{gettext(
|
|
||||||
"When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button."
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<.button
|
<.button
|
||||||
:if={
|
:if={
|
||||||
|
|
@ -880,6 +912,7 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
|> assign(:registration_enabled, fresh_settings.registration_enabled != false)
|
|> assign(:registration_enabled, fresh_settings.registration_enabled != false)
|
||||||
|> assign(:vereinfacht_api_key_set, present?(fresh_settings.vereinfacht_api_key))
|
|> assign(:vereinfacht_api_key_set, present?(fresh_settings.vereinfacht_api_key))
|
||||||
|> assign(:oidc_client_secret_set, Mv.Config.oidc_client_secret_set?())
|
|> assign(:oidc_client_secret_set, Mv.Config.oidc_client_secret_set?())
|
||||||
|
|> assign(:oidc_only, Mv.Config.oidc_only?())
|
||||||
|> assign(:oidc_configured, Mv.Config.oidc_configured?())
|
|> assign(:oidc_configured, Mv.Config.oidc_configured?())
|
||||||
|> assign(:smtp_configured, Mv.Config.smtp_configured?())
|
|> assign(:smtp_configured, Mv.Config.smtp_configured?())
|
||||||
|> assign(:smtp_password_set, present?(Mv.Config.smtp_password()))
|
|> assign(:smtp_password_set, present?(Mv.Config.smtp_password()))
|
||||||
|
|
@ -916,19 +949,53 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("toggle_registration_enabled", _params, socket) do
|
def handle_event("toggle_registration_enabled", _params, socket) do
|
||||||
settings = socket.assigns.settings
|
if Mv.Config.oidc_only?() do
|
||||||
new_value = not socket.assigns.registration_enabled
|
{:noreply, socket}
|
||||||
|
else
|
||||||
|
settings = socket.assigns.settings
|
||||||
|
new_value = not socket.assigns.registration_enabled
|
||||||
|
|
||||||
case Membership.update_settings(settings, %{registration_enabled: new_value}) do
|
case Membership.update_settings(settings, %{registration_enabled: new_value}) do
|
||||||
{:ok, updated_settings} ->
|
{:ok, updated_settings} ->
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(:settings, updated_settings)
|
|> assign(:settings, updated_settings)
|
||||||
|> assign(:registration_enabled, updated_settings.registration_enabled != false)
|
|> assign(:registration_enabled, updated_settings.registration_enabled != false)
|
||||||
|> assign_form()}
|
|> assign_form()}
|
||||||
|
|
||||||
{:error, _} ->
|
{:error, _} ->
|
||||||
{:noreply, put_flash(socket, :error, gettext("Failed to update setting."))}
|
{:noreply, put_flash(socket, :error, gettext("Failed to update setting."))}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("toggle_oidc_only", _params, socket) do
|
||||||
|
if socket.assigns.oidc_only_env_set do
|
||||||
|
{:noreply, socket}
|
||||||
|
else
|
||||||
|
settings = socket.assigns.settings
|
||||||
|
new_value = not socket.assigns.oidc_only
|
||||||
|
|
||||||
|
# When enabling OIDC-only, also disable direct registration; when disabling, only change oidc_only.
|
||||||
|
params =
|
||||||
|
if new_value,
|
||||||
|
do: %{oidc_only: true, registration_enabled: false},
|
||||||
|
else: %{oidc_only: false}
|
||||||
|
|
||||||
|
case Membership.update_settings(settings, params) do
|
||||||
|
{:ok, updated_settings} ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:settings, updated_settings)
|
||||||
|
|> assign(:oidc_only, updated_settings.oidc_only == true)
|
||||||
|
|> assign(:registration_enabled, updated_settings.registration_enabled != false)
|
||||||
|
|> assign_form()
|
||||||
|
|> put_flash(:success, gettext("Settings updated successfully"))}
|
||||||
|
|
||||||
|
{:error, _} ->
|
||||||
|
{:noreply, put_flash(socket, :error, gettext("Failed to update setting."))}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ defmodule MvWeb.LiveHelpers do
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> Phoenix.LiveView.put_flash(:error, "You don't have permission to access this page.")
|
|> maybe_put_access_denied_flash(user)
|
||||||
|> Phoenix.LiveView.push_navigate(to: redirect_to)
|
|> Phoenix.LiveView.push_navigate(to: redirect_to)
|
||||||
|
|
||||||
{:halt, socket}
|
{:halt, socket}
|
||||||
|
|
@ -82,6 +82,13 @@ defmodule MvWeb.LiveHelpers do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Only show "no permission" when user is logged in; unauthenticated users are redirected to sign-in without flash.
|
||||||
|
defp maybe_put_access_denied_flash(socket, nil), do: socket
|
||||||
|
|
||||||
|
defp maybe_put_access_denied_flash(socket, _user) do
|
||||||
|
Phoenix.LiveView.put_flash(socket, :error, "You don't have permission to access this page.")
|
||||||
|
end
|
||||||
|
|
||||||
defp ensure_user_role_loaded(socket) do
|
defp ensure_user_role_loaded(socket) do
|
||||||
user = socket.assigns[:current_user]
|
user = socket.assigns[:current_user]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ defmodule MvWeb.Plugs.CheckPagePermission do
|
||||||
conn
|
conn
|
||||||
|> fetch_session()
|
|> fetch_session()
|
||||||
|> fetch_flash()
|
|> fetch_flash()
|
||||||
|> put_flash(:error, "You don't have permission to access this page.")
|
|> maybe_put_access_denied_flash(user)
|
||||||
|> redirect(to: redirect_to)
|
|> redirect(to: redirect_to)
|
||||||
|> halt()
|
|> halt()
|
||||||
end
|
end
|
||||||
|
|
@ -75,6 +75,13 @@ defmodule MvWeb.Plugs.CheckPagePermission do
|
||||||
|
|
||||||
defp redirect_target(user), do: redirect_target_for_user(user)
|
defp redirect_target(user), do: redirect_target_for_user(user)
|
||||||
|
|
||||||
|
# Only set "no permission" flash when user is logged in; unauthenticated users get redirect only, no flash.
|
||||||
|
defp maybe_put_access_denied_flash(conn, nil), do: conn
|
||||||
|
|
||||||
|
defp maybe_put_access_denied_flash(conn, _user) do
|
||||||
|
put_flash(conn, :error, "You don't have permission to access this page.")
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns true if the path is public (no auth/permission check).
|
Returns true if the path is public (no auth/permission check).
|
||||||
Used by LiveView hook to skip redirect on sign-in etc.
|
Used by LiveView hook to skip redirect on sign-in etc.
|
||||||
|
|
|
||||||
61
lib/mv_web/plugs/oidc_only_sign_in_redirect.ex
Normal file
61
lib/mv_web/plugs/oidc_only_sign_in_redirect.ex
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
defmodule MvWeb.Plugs.OidcOnlySignInRedirect do
|
||||||
|
@moduledoc """
|
||||||
|
When OIDC-only mode is active:
|
||||||
|
- GET /sign-in redirects to the OIDC flow when OIDC is configured (sign-in page skipped).
|
||||||
|
- GET /auth/user/password/sign_in_with_token is rejected (redirect to /sign-in with error)
|
||||||
|
so password sign-in cannot complete.
|
||||||
|
"""
|
||||||
|
import Plug.Conn
|
||||||
|
import Phoenix.Controller
|
||||||
|
|
||||||
|
alias Mv.Config
|
||||||
|
|
||||||
|
def init(opts), do: opts
|
||||||
|
|
||||||
|
def call(conn, _opts) do
|
||||||
|
conn
|
||||||
|
|> maybe_redirect_sign_in_to_oidc()
|
||||||
|
|> maybe_reject_password_token_sign_in()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_redirect_sign_in_to_oidc(conn) do
|
||||||
|
if conn.request_path == "/sign-in" and conn.method == "GET" do
|
||||||
|
if Config.oidc_only?() and Config.oidc_configured?() do
|
||||||
|
conn
|
||||||
|
|> redirect(to: "/auth/user/oidc")
|
||||||
|
|> halt()
|
||||||
|
else
|
||||||
|
conn
|
||||||
|
end
|
||||||
|
else
|
||||||
|
conn
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_reject_password_token_sign_in(conn) do
|
||||||
|
if conn.halted, do: conn, else: reject_password_token_sign_in_if_applicable(conn)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp reject_password_token_sign_in_if_applicable(conn) do
|
||||||
|
path = conn.request_path
|
||||||
|
|
||||||
|
password_token_path? =
|
||||||
|
path =~ ~r|/auth/user/password/sign_in_with_token| and conn.method == "GET"
|
||||||
|
|
||||||
|
if password_token_path? and Config.oidc_only?() do
|
||||||
|
message =
|
||||||
|
Gettext.dgettext(
|
||||||
|
MvWeb.Gettext,
|
||||||
|
"default",
|
||||||
|
"Only sign-in via Single Sign-On (SSO) is allowed."
|
||||||
|
)
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> put_flash(:error, message)
|
||||||
|
|> redirect(to: "/sign-in")
|
||||||
|
|> halt()
|
||||||
|
else
|
||||||
|
conn
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -18,6 +18,7 @@ defmodule MvWeb.Router do
|
||||||
plug MvWeb.Plugs.CheckPagePermission
|
plug MvWeb.Plugs.CheckPagePermission
|
||||||
plug MvWeb.Plugs.JoinFormEnabled
|
plug MvWeb.Plugs.JoinFormEnabled
|
||||||
plug MvWeb.Plugs.RegistrationEnabled
|
plug MvWeb.Plugs.RegistrationEnabled
|
||||||
|
plug MvWeb.Plugs.OidcOnlySignInRedirect
|
||||||
end
|
end
|
||||||
|
|
||||||
pipeline :api do
|
pipeline :api do
|
||||||
|
|
|
||||||
|
|
@ -3854,7 +3854,7 @@ msgstr "Wenn deaktiviert, können sich Nutzer*innen nicht über /register anmeld
|
||||||
#: lib/mv_web/controllers/page_controller.ex
|
#: lib/mv_web/controllers/page_controller.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Home"
|
msgid "Home"
|
||||||
msgstr ""
|
msgstr "Startseite"
|
||||||
|
|
||||||
#: lib/mv_web/controllers/join_confirm_controller.ex
|
#: lib/mv_web/controllers/join_confirm_controller.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
|
@ -3895,3 +3895,18 @@ msgstr "Rolle %{name}"
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "User %{email}"
|
msgid "User %{email}"
|
||||||
msgstr "Benutzer*in %{email}"
|
msgstr "Benutzer*in %{email}"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Only OIDC sign-in is active. This option is disabled."
|
||||||
|
msgstr "Nur OIDC-Anmeldung ist aktiv. Diese Option ist deaktiviert."
|
||||||
|
|
||||||
|
#: lib/mv_web/controllers/auth_controller.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Only sign-in via Single Sign-On (SSO) is allowed."
|
||||||
|
msgstr "Nur Anmeldung per Single Sign-On (SSO) ist erlaubt."
|
||||||
|
|
||||||
|
#~ #: lib/accounts/user/validations/oidc_only_blocks_password_registration.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Registration with password is disabled when only OIDC sign-in is active."
|
||||||
|
#~ msgstr "Registrierung mit Passwort ist deaktiviert, wenn nur OIDC-Anmeldung aktiv ist."
|
||||||
|
|
|
||||||
|
|
@ -3895,3 +3895,13 @@ msgstr ""
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "User %{email}"
|
msgid "User %{email}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Only OIDC sign-in is active. This option is disabled."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/controllers/auth_controller.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Only sign-in via Single Sign-On (SSO) is allowed."
|
||||||
|
msgstr ""
|
||||||
|
|
|
||||||
|
|
@ -3895,3 +3895,18 @@ msgstr "Role %{name}"
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "User %{email}"
|
msgid "User %{email}"
|
||||||
msgstr "User %{email}"
|
msgstr "User %{email}"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Only OIDC sign-in is active. This option is disabled."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/controllers/auth_controller.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Only sign-in via Single Sign-On (SSO) is allowed."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/accounts/user/validations/oidc_only_blocks_password_registration.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Registration with password is disabled when only OIDC sign-in is active."
|
||||||
|
#~ msgstr "Registration with password is disabled when only OIDC sign-in is active."
|
||||||
|
|
|
||||||
|
|
@ -318,7 +318,7 @@ defmodule MvWeb.AuthControllerTest do
|
||||||
case result do
|
case result do
|
||||||
{:error, {:redirect, %{to: to}}} ->
|
{:error, {:redirect, %{to: to}}} ->
|
||||||
refute to =~ "sign_in_with_token",
|
refute to =~ "sign_in_with_token",
|
||||||
"Expected password sign-in to be rejected when OIDC-only, got redirect to: #{to}"
|
"Expected password sign-in to be rejected when OIDC-only, got redirect to: #{to}"
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
# LiveView re-rendered (e.g. with flash error) instead of redirecting to success
|
# LiveView re-rendered (e.g. with flash error) instead of redirecting to success
|
||||||
|
|
@ -336,6 +336,7 @@ defmodule MvWeb.AuthControllerTest do
|
||||||
conn: authenticated_conn
|
conn: authenticated_conn
|
||||||
} do
|
} do
|
||||||
{:ok, settings} = Membership.get_settings()
|
{:ok, settings} = Membership.get_settings()
|
||||||
|
|
||||||
prev = %{
|
prev = %{
|
||||||
oidc_only: settings.oidc_only,
|
oidc_only: settings.oidc_only,
|
||||||
oidc_client_id: settings.oidc_client_id,
|
oidc_client_id: settings.oidc_client_id,
|
||||||
|
|
|
||||||
|
|
@ -181,13 +181,14 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "unauthenticated user" do
|
describe "unauthenticated user" do
|
||||||
test "nil current_user is denied and redirected to \"/sign-in\"" do
|
test "nil current_user is denied and redirected to \"/sign-in\" without access-denied flash" do
|
||||||
conn = conn_without_user("/members") |> CheckPagePermission.call([])
|
conn = conn_without_user("/members") |> CheckPagePermission.call([])
|
||||||
|
|
||||||
assert conn.halted
|
assert conn.halted
|
||||||
assert redirected_to(conn) == "/sign-in"
|
assert redirected_to(conn) == "/sign-in"
|
||||||
|
|
||||||
assert Phoenix.Flash.get(conn.assigns[:flash] || %{}, :error) ==
|
# Unauthenticated users are redirected to sign-in only; no "no permission" message.
|
||||||
|
refute Phoenix.Flash.get(conn.assigns[:flash] || %{}, :error) ==
|
||||||
"You don't have permission to access this page."
|
"You don't have permission to access this page."
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue