Persist login type preference (SSO/standard) (#5520)

* First pass: store login preference

* Only set login preference if SSO is used

* Change mock DNS to use port 5354 and `domain_id` for parameter

* Make login forms use flash message for error passing

---------

Co-authored-by: Adrian Gruntkowski <adrian.gruntkowski@gmail.com>
This commit is contained in:
Adam Rutkowski 2025-06-23 12:28:27 +02:00 committed by GitHub
parent a2ed1e04b1
commit e56baeb272
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 208 additions and 101 deletions

View File

@ -94,12 +94,12 @@ sso-stop:
docker remove idp
generate-corefile:
$(call require, integration_id)
integration_id=$(integration_id) envsubst < $(PWD)/extra/fixture/Corefile.template > $(PWD)/extra/fixture/Corefile.gen.$(integration_id)
$(call require, domain_id)
domain_id=$(domain_id) envsubst < $(PWD)/extra/fixture/Corefile.template > $(PWD)/extra/fixture/Corefile.gen.$(domain_id)
mock-dns: generate-corefile
$(call require, integration_id)
docker run --rm -p 5353:53/udp -v $(PWD)/extra/fixture/Corefile.gen.$(integration_id):/Corefile coredns/coredns:latest -conf Corefile
$(call require, domain_id)
docker run --rm -p 5354:53/udp -v $(PWD)/extra/fixture/Corefile.gen.$(domain_id):/Corefile coredns/coredns:latest -conf Corefile
loadtest-server:
@echo "Ensure your OTP installation is built with --enable-lock-counter"

View File

@ -15,7 +15,7 @@ SHOW_CITIES=true
PADDLE_VENDOR_AUTH_CODE=895e20d4efaec0575bb857f44b183217b332d9592e76e69b8a
PADDLE_VENDOR_ID=3942
SSO_ENABLED=true
SSO_VERIFICATION_NAMESERVERS=0.0.0.0:5353
SSO_VERIFICATION_NAMESERVERS=0.0.0.0:5354
GOOGLE_CLIENT_ID=875387135161-l8tp53dpt7fdhdg9m1pc3vl42si95rh0.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=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

View File

@ -1,7 +1,7 @@
. {
bind 0.0.0.0
template IN TXT plausible.test {
answer "{{ .Name }} 60 IN TXT \"plausible-sso-verification=${integration_id}\""
answer "{{ .Name }} 60 IN TXT \"plausible-sso-verification=${domain_id}\""
fallthrough
}
log

View File

@ -4,6 +4,7 @@ defmodule PlausibleWeb.SSOController do
require Logger
alias Plausible.Auth.SSO
alias PlausibleWeb.LoginPreference
alias PlausibleWeb.Router.Helpers, as: Routes
@ -11,7 +12,16 @@ defmodule PlausibleWeb.SSOController do
[:owner] when action in [:sso_settings]
def login_form(conn, params) do
render(conn, "login_form.html", error: params["error"])
login_preference = LoginPreference.get(conn)
error = Phoenix.Flash.get(conn.assigns.flash, :login_error)
case {login_preference, params["prefer"], error} do
{nil, nil, nil} ->
redirect(conn, to: Routes.auth_path(conn, :login_form, return_to: params["return_to"]))
_ ->
render(conn, "login_form.html")
end
end
def login(conn, %{"email" => email} = params) do
@ -29,7 +39,9 @@ defmodule PlausibleWeb.SSOController do
)
{:error, :not_found} ->
render(conn, "login_form.html", error: "Wrong email.")
conn
|> put_flash(:login_error, "Wrong email.")
|> redirect(to: Routes.sso_path(conn, :login_form))
end
end

View File

@ -48,12 +48,10 @@ defmodule PlausibleWeb.SSO.FakeSAMLAdapter do
PlausibleWeb.UserAuth.log_in_user(conn, identity, params["return_to"])
{:error, :not_found} ->
Phoenix.Controller.redirect(conn,
to:
Routes.sso_path(conn, :login_form,
error: "Wrong email.",
return_to: params["return_to"]
)
conn
|> Phoenix.Controller.put_flash(:login_error, "Wrong email.")
|> Phoenix.Controller.redirect(
to: Routes.sso_path(conn, :login_form, return_to: params["return_to"])
)
end
end

View File

@ -50,12 +50,10 @@ defmodule PlausibleWeb.SSO.RealSAMLAdapter do
|> Phoenix.Controller.redirect(external: url)
{:error, :not_found} ->
Phoenix.Controller.redirect(conn,
to:
Routes.sso_path(conn, :login_form,
error: "Wrong email.",
return_to: return_to
)
conn
|> Phoenix.Controller.put_flash(:login_error, "Wrong email.")
|> Phoenix.Controller.redirect(
to: Routes.sso_path(conn, :login_form, return_to: return_to)
)
end
end
@ -72,9 +70,9 @@ defmodule PlausibleWeb.SSO.RealSAMLAdapter do
|> consume(integration_id, cookie, saml_response, relay_state)
{:error, :session_expired} ->
Phoenix.Controller.redirect(conn,
to: Routes.sso_path(conn, :login_form, error: "Session expired.")
)
conn
|> Phoenix.Controller.put_flash(:login_error, "Session expired.")
|> Phoenix.Controller.redirect(to: Routes.sso_path(conn, :login_form))
end
end
@ -105,12 +103,10 @@ defmodule PlausibleWeb.SSO.RealSAMLAdapter do
PlausibleWeb.UserAuth.log_in_user(conn, identity, cookie.return_to)
else
{:error, reason} ->
Phoenix.Controller.redirect(conn,
to:
Routes.sso_path(conn, :login_form,
error: error_by_reason(reason),
return_to: cookie.return_to
)
conn
|> Phoenix.Controller.put_flash(:login_error, error_by_reason(reason))
|> Phoenix.Controller.redirect(
to: Routes.sso_path(conn, :login_form, return_to: cookie.return_to)
)
end
end

View File

@ -19,8 +19,8 @@
/>
</div>
<%= if @conn.assigns[:error] do %>
<div class="text-red-500 mt-4">{@conn.assigns[:error]}</div>
<%= if login_error = Phoenix.Flash.get(@flash, :login_error) do %>
<div class="text-red-500 mt-4">{login_error}</div>
<% end %>
<.input type="hidden" field={f[:return_to]} />
@ -33,7 +33,10 @@
<:item>
Have a standard account?
<.styled_link href={
Routes.auth_path(@conn, :login_form, return_to: @conn.params["return_to"])
Routes.auth_path(@conn, :login_form,
return_to: @conn.params["return_to"],
prefer: "manual"
)
}>
Log in here
</.styled_link>

View File

@ -1,11 +1,13 @@
defmodule PlausibleWeb.AuthController do
use PlausibleWeb, :controller
use Plausible.Repo
use Plausible
alias Plausible.Auth
alias Plausible.Teams
alias PlausibleWeb.TwoFactor
alias PlausibleWeb.UserAuth
alias PlausibleWeb.LoginPreference
require Logger
@ -227,8 +229,27 @@ defmodule PlausibleWeb.AuthController do
|> redirect(to: Routes.auth_path(conn, :login_form))
end
def login_form(conn, _params) do
render(conn, "login_form.html")
on_ee do
def login_form(conn, params) do
login_preference = LoginPreference.get(conn)
error = Phoenix.Flash.get(conn.assigns.flash, :login_error)
case {login_preference, params["prefer"], error} do
{"sso", nil, nil} ->
if Plausible.sso_enabled?() do
redirect(conn, to: Routes.sso_path(conn, :login_form, return_to: params["return_to"]))
else
render(conn, "login_form.html")
end
_ ->
render(conn, "login_form.html")
end
end
else
def login_form(conn, _params) do
render(conn, "login_form.html")
end
end
def login(conn, %{"user" => params}) do
@ -268,18 +289,24 @@ defmodule PlausibleWeb.AuthController do
params["return_to"]
end
UserAuth.log_in_user(conn, user, redirect_path)
conn
|> LoginPreference.clear()
|> UserAuth.log_in_user(user, redirect_path)
else
{:error, :wrong_password} ->
maybe_log_failed_login_attempts("wrong password for #{email}")
render(conn, "login_form.html", error: "Wrong email or password. Please try again.")
conn
|> put_flash(:login_error, "Wrong email or password. Please try again.")
|> render("login_form.html")
{:error, :user_not_found} ->
maybe_log_failed_login_attempts("user not found for #{email}")
Plausible.Auth.Password.dummy_calculation()
render(conn, "login_form.html", error: "Wrong email or password. Please try again.")
conn
|> put_flash(:login_error, "Wrong email or password. Please try again.")
|> render("login_form.html")
{:error, {:rate_limit, _}} ->
maybe_log_failed_login_attempts("too many login attempts for #{email}")

View File

@ -0,0 +1,40 @@
defmodule PlausibleWeb.LoginPreference do
@moduledoc """
Functions for managing user login preference cookies.
This module handles storing and retrieving the user's preferred login method
(standard or SSO) to provide a better user experience by showing their
preferred option first.
"""
@cookie_name "login_preference"
@cookie_max_age 60 * 60 * 24 * 365
@spec set_sso(Plug.Conn.t()) :: Plug.Conn.t()
def set_sso(conn) do
secure_cookie = PlausibleWeb.Endpoint.secure_cookie?()
Plug.Conn.put_resp_cookie(conn, @cookie_name, "sso",
http_only: true,
secure: secure_cookie,
max_age: @cookie_max_age,
same_site: "Lax"
)
end
@spec clear(Plug.Conn.t()) :: Plug.Conn.t()
def clear(conn) do
Plug.Conn.delete_resp_cookie(conn, @cookie_name)
end
@spec get(Plug.Conn.t()) :: String.t() | nil
def get(conn) do
case Plug.Conn.fetch_cookies(conn) do
%{cookies: %{@cookie_name => "sso"}} ->
"sso"
_ ->
nil
end
end
end

View File

@ -29,8 +29,8 @@
/>
</div>
<%= if @conn.assigns[:error] do %>
<div class="text-red-500 mt-4">{@conn.assigns[:error]}</div>
<%= if login_error = Phoenix.Flash.get(@flash, :login_error) do %>
<div class="text-red-500 mt-4">{login_error}</div>
<% end %>
<.input type="hidden" field={f[:return_to]} />
@ -53,7 +53,10 @@
<%= on_ee do %>
Have a Single Sign-on account?
<.styled_link href={
Routes.sso_path(@conn, :login_form, return_to: @conn.params["return_to"])
Routes.sso_path(@conn, :login_form,
return_to: @conn.params["return_to"],
prefer: "manual"
)
}>
Sign in here
</.styled_link>

View File

@ -44,15 +44,16 @@ defmodule PlausibleWeb.UserAuth do
conn
|> set_user_token(session.token)
|> Plug.Conn.put_session("current_team_id", team.identifier)
|> PlausibleWeb.LoginPreference.set_sso()
|> set_logged_in_cookie()
|> Phoenix.Controller.redirect(to: redirect_to)
{:error, :integration_not_found} ->
conn
|> log_out_user()
|> Phoenix.Controller.put_flash(:login_error, "Wrong email.")
|> Phoenix.Controller.redirect(
to:
Routes.sso_path(conn, :login_form, error: "Wrong email.", return_to: redirect_path)
to: Routes.sso_path(conn, :login_form, return_to: redirect_path)
)
{:error, :over_limit} ->
@ -60,8 +61,9 @@ defmodule PlausibleWeb.UserAuth do
conn
|> log_out_user()
|> Phoenix.Controller.put_flash(:login_error, error)
|> Phoenix.Controller.redirect(
to: Routes.sso_path(conn, :login_form, error: error, return_to: redirect_path)
to: Routes.sso_path(conn, :login_form, return_to: redirect_path)
)
{:error, reason, team, user}

View File

@ -488,6 +488,20 @@ defmodule PlausibleWeb.AuthControllerTest do
assert input_value == "/dummy.site"
end
@tag :ee_only
test "redirects to sso login if preferred", %{conn: conn} do
conn = PlausibleWeb.LoginPreference.set_sso(conn)
conn = get(conn, "/login?return_to=foo")
assert redirected_to(conn, 302) == "/sso/login?return_to=foo"
end
@tag :ee_only
test "keeps standard login form if preference manually overridden", %{conn: conn} do
conn = PlausibleWeb.LoginPreference.set_sso(conn)
conn = get(conn, "/login?prefer=manual")
assert html_response(conn, 200) =~ "Enter your account credentials"
end
end
describe "POST /login" do

View File

@ -223,8 +223,9 @@ defmodule PlausibleWeb.SSOControllerSyncTest do
)
)
assert redirected_to(conn, 302) ==
Routes.sso_path(conn, :login_form, error: "Wrong email.", return_to: "/sites")
assert redirected_to(conn, 302) == Routes.sso_path(conn, :login_form, return_to: "/sites")
assert Phoenix.Flash.get(conn.assigns.flash, :login_error) == "Wrong email."
end
end
@ -341,8 +342,9 @@ defmodule PlausibleWeb.SSOControllerSyncTest do
conn = post(conn, Routes.sso_path(conn, :saml_consume, Ecto.UUID.generate()), params)
assert redirected_to(conn, 302) ==
Routes.sso_path(conn, :login_form, error: "Wrong email.", return_to: "/sites")
assert redirected_to(conn, 302) == Routes.sso_path(conn, :login_form, return_to: "/sites")
assert Phoenix.Flash.get(conn.assigns.flash, :login_error) == "Wrong email."
end
test "redirects with error on mismatch of RelayState", %{
@ -356,11 +358,10 @@ defmodule PlausibleWeb.SSOControllerSyncTest do
conn = post(conn, Routes.sso_path(conn, :saml_consume, integration.identifier), params)
assert redirected_to(conn, 302) ==
Routes.sso_path(conn, :login_form,
error: "Authentication failed (reason: :invalid_relay_state).",
return_to: "/sites"
)
assert redirected_to(conn, 302) == Routes.sso_path(conn, :login_form, return_to: "/sites")
assert Phoenix.Flash.get(conn.assigns.flash, :login_error) ==
"Authentication failed (reason: :invalid_relay_state)."
end
test "redirects with error on missing relay state", %{
@ -371,11 +372,10 @@ defmodule PlausibleWeb.SSOControllerSyncTest do
conn = post(conn, Routes.sso_path(conn, :saml_consume, integration.identifier), params)
assert redirected_to(conn, 302) ==
Routes.sso_path(conn, :login_form,
error: "Authentication failed (reason: :invalid_relay_state).",
return_to: "/sites"
)
assert redirected_to(conn, 302) == Routes.sso_path(conn, :login_form, return_to: "/sites")
assert Phoenix.Flash.get(conn.assigns.flash, :login_error) ==
"Authentication failed (reason: :invalid_relay_state)."
end
test "redirects with error on malformed assertion", %{
@ -390,11 +390,10 @@ defmodule PlausibleWeb.SSOControllerSyncTest do
conn = post(conn, Routes.sso_path(conn, :saml_consume, integration.identifier), params)
assert redirected_to(conn, 302) ==
Routes.sso_path(conn, :login_form,
error: "Authentication failed (reason: :base64_decoding_failed).",
return_to: "/sites"
)
assert redirected_to(conn, 302) == Routes.sso_path(conn, :login_form, return_to: "/sites")
assert Phoenix.Flash.get(conn.assigns.flash, :login_error) ==
"Authentication failed (reason: :base64_decoding_failed)."
end
test "redirects with error on malformed certificate in config (should not happen)", %{
@ -417,11 +416,10 @@ defmodule PlausibleWeb.SSOControllerSyncTest do
conn = post(conn, Routes.sso_path(conn, :saml_consume, integration.identifier), params)
assert redirected_to(conn, 302) ==
Routes.sso_path(conn, :login_form,
error: "Authentication failed (reason: :malformed_certificate).",
return_to: "/sites"
)
assert redirected_to(conn, 302) == Routes.sso_path(conn, :login_form, return_to: "/sites")
assert Phoenix.Flash.get(conn.assigns.flash, :login_error) ==
"Authentication failed (reason: :malformed_certificate)."
end
test "redirects with error on mismatched certificate in config", %{
@ -438,11 +436,10 @@ defmodule PlausibleWeb.SSOControllerSyncTest do
conn = post(conn, Routes.sso_path(conn, :saml_consume, integration.identifier), params)
assert redirected_to(conn, 302) ==
Routes.sso_path(conn, :login_form,
error: "Authentication failed (reason: :digest_verification_failed).",
return_to: "/sites"
)
assert redirected_to(conn, 302) == Routes.sso_path(conn, :login_form, return_to: "/sites")
assert Phoenix.Flash.get(conn.assigns.flash, :login_error) ==
"Authentication failed (reason: :digest_verification_failed)."
end
test "redirects with error on missing email attribute in assertion", %{
@ -457,11 +454,10 @@ defmodule PlausibleWeb.SSOControllerSyncTest do
conn = post(conn, Routes.sso_path(conn, :saml_consume, integration.identifier), params)
assert redirected_to(conn, 302) ==
Routes.sso_path(conn, :login_form,
error: "Authentication failed (reason: :missing_email_attribute).",
return_to: "/sites"
)
assert redirected_to(conn, 302) == Routes.sso_path(conn, :login_form, return_to: "/sites")
assert Phoenix.Flash.get(conn.assigns.flash, :login_error) ==
"Authentication failed (reason: :missing_email_attribute)."
end
test "redirects with error on invalid email attribute in assertion", %{
@ -476,11 +472,10 @@ defmodule PlausibleWeb.SSOControllerSyncTest do
conn = post(conn, Routes.sso_path(conn, :saml_consume, integration.identifier), params)
assert redirected_to(conn, 302) ==
Routes.sso_path(conn, :login_form,
error: "Authentication failed (reason: :invalid_email_attribute).",
return_to: "/sites"
)
assert redirected_to(conn, 302) == Routes.sso_path(conn, :login_form, return_to: "/sites")
assert Phoenix.Flash.get(conn.assigns.flash, :login_error) ==
"Authentication failed (reason: :invalid_email_attribute)."
end
test "redirects with error on missing name attributes in assertion", %{
@ -495,11 +490,10 @@ defmodule PlausibleWeb.SSOControllerSyncTest do
conn = post(conn, Routes.sso_path(conn, :saml_consume, integration.identifier), params)
assert redirected_to(conn, 302) ==
Routes.sso_path(conn, :login_form,
error: "Authentication failed (reason: :missing_name_attributes).",
return_to: "/sites"
)
assert redirected_to(conn, 302) == Routes.sso_path(conn, :login_form, return_to: "/sites")
assert Phoenix.Flash.get(conn.assigns.flash, :login_error) ==
"Authentication failed (reason: :missing_name_attributes)."
end
end

View File

@ -24,7 +24,7 @@ defmodule PlausibleWeb.SSOControllerTest do
describe "login_form/2" do
test "renders login view", %{conn: conn} do
conn = get(conn, Routes.sso_path(conn, :login_form))
conn = get(conn, Routes.sso_path(conn, :login_form, prefer: "sso"))
assert html = html_response(conn, 200)
@ -34,10 +34,27 @@ defmodule PlausibleWeb.SSOControllerTest do
end
test "passes return_to parameter to form", %{conn: conn} do
conn = get(conn, Routes.sso_path(conn, :login_form, return_to: "/sites", prefer: "sso"))
assert html = html_response(conn, 200)
assert text_of_attr(html, "input[name=return_to]", "value") == "/sites"
end
test "renders error if provided in login_error flash message", %{conn: conn} do
conn =
conn
|> init_session()
|> fetch_session()
|> fetch_flash()
|> put_flash(:login_error, "Wrong email.")
conn = get(conn, Routes.sso_path(conn, :login_form, return_to: "/sites"))
assert html = html_response(conn, 200)
assert html =~ "Wrong email."
assert element_exists?(html, "input[name=email]")
assert text_of_attr(html, "input[name=return_to]", "value") == "/sites"
end
end
@ -83,11 +100,9 @@ defmodule PlausibleWeb.SSOControllerTest do
"return_to" => "/sites"
})
assert html = html_response(conn, 200)
assert redirected_to(conn, 302) == Routes.sso_path(conn, :login_form)
assert html =~ "Wrong email."
assert element_exists?(html, "input[name=email]")
assert text_of_attr(html, "input[name=return_to]", "value") == "/sites"
assert Phoenix.Flash.get(conn.assigns.flash, :login_error) == "Wrong email."
end
end
@ -177,8 +192,9 @@ defmodule PlausibleWeb.SSOControllerTest do
"return_to" => "/sites"
})
assert redirected_to(conn, 302) ==
Routes.sso_path(conn, :login_form, error: "Wrong email.", return_to: "/sites")
assert redirected_to(conn, 302) == Routes.sso_path(conn, :login_form, return_to: "/sites")
assert Phoenix.Flash.get(conn.assigns.flash, :login_error) == "Wrong email."
end
end

View File

@ -140,12 +140,14 @@ defmodule PlausibleWeb.UserAuthTest do
conn =
conn
|> init_session()
|> fetch_flash()
|> UserAuth.log_in_user(identity)
assert %{sessions: []} = user |> Repo.reload!() |> Repo.preload(:sessions)
assert redirected_to(conn, 302) ==
Routes.sso_path(conn, :login_form, error: "Wrong email.", return_to: "")
assert redirected_to(conn, 302) == Routes.sso_path(conn, :login_form, return_to: "")
assert Phoenix.Flash.get(conn.assigns.flash, :login_error) == "Wrong email."
assert conn.private[:plug_session_info] == :renew
refute get_session(conn, :user_token)
@ -170,15 +172,15 @@ defmodule PlausibleWeb.UserAuthTest do
conn =
conn
|> init_session()
|> fetch_flash()
|> UserAuth.log_in_user(identity)
assert %{sessions: []} = user |> Repo.reload!() |> Repo.preload(:sessions)
assert redirected_to(conn, 302) ==
Routes.sso_path(conn, :login_form,
error: "Team can't accept more members. Please contact the owner.",
return_to: ""
)
assert redirected_to(conn, 302) == Routes.sso_path(conn, :login_form, return_to: "")
assert Phoenix.Flash.get(conn.assigns.flash, :login_error) ==
"Team can't accept more members. Please contact the owner."
assert conn.private[:plug_session_info] == :renew
refute get_session(conn, :user_token)