236 lines
7.1 KiB
Elixir
236 lines
7.1 KiB
Elixir
defmodule PlausibleWeb.SSO.RealSAMLAdapter do
|
|
@moduledoc """
|
|
Real implementation of SAML authentication interface.
|
|
"""
|
|
alias Plausible.Auth.SSO
|
|
alias SimpleXml.XmlNode
|
|
|
|
alias PlausibleWeb.Router.Helpers, as: Routes
|
|
|
|
@deflate "urn:oasis:names:tc:SAML:2.0:bindings:URL-Encoding:DEFLATE"
|
|
|
|
@cookie_name "session_saml"
|
|
@cookie_seconds 10 * 60
|
|
|
|
def signin(conn, %{"integration_id" => integration_id} = params) do
|
|
email = params["email"]
|
|
return_to = params["return_to"]
|
|
|
|
case SSO.get_integration(integration_id) do
|
|
{:ok, integration} ->
|
|
sp_entity_id = SSO.SAMLConfig.entity_id(integration)
|
|
relay_state = gen_id()
|
|
id = "saml_flow_#{gen_id()}"
|
|
|
|
auth_xml = generate_auth_request(sp_entity_id, id, DateTime.utc_now())
|
|
|
|
params = %{
|
|
"SAMLEncoding" => @deflate,
|
|
"SAMLRequest" => Base.encode64(:zlib.zip(auth_xml)),
|
|
"RelayState" => relay_state,
|
|
"login_hint" => email
|
|
}
|
|
|
|
url = %URI{} = URI.parse(integration.config.idp_signin_url)
|
|
|
|
query_string =
|
|
(url.query || "")
|
|
|> URI.decode_query()
|
|
|> Map.merge(params)
|
|
|> URI.encode_query()
|
|
|
|
url = URI.to_string(%{url | query: query_string})
|
|
|
|
conn
|
|
|> Plug.Conn.configure_session(renew: true)
|
|
|> set_cookie(
|
|
relay_state: relay_state,
|
|
return_to: return_to
|
|
)
|
|
|> Phoenix.Controller.redirect(external: url)
|
|
|
|
{:error, :not_found} ->
|
|
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
|
|
|
|
def consume(conn, _params) do
|
|
integration_id = conn.path_params["integration_id"]
|
|
saml_response = conn.body_params["SAMLResponse"]
|
|
relay_state = conn.body_params["RelayState"] |> safe_decode_www_form()
|
|
|
|
case get_cookie(conn) do
|
|
{:ok, cookie} ->
|
|
conn
|
|
|> clear_cookie()
|
|
|> consume(integration_id, cookie, saml_response, relay_state)
|
|
|
|
{:error, :session_expired} ->
|
|
conn
|
|
|> Phoenix.Controller.put_flash(:login_error, "Session expired.")
|
|
|> Phoenix.Controller.redirect(to: Routes.sso_path(conn, :login_form))
|
|
end
|
|
end
|
|
|
|
@verify_opts if Mix.env() == :test, do: [skip_time_conditions?: true], else: []
|
|
|
|
defp consume(conn, integration_id, cookie, saml_response, relay_state) do
|
|
with {:ok, integration} <- SSO.get_integration(integration_id),
|
|
:ok <- validate_authresp(cookie, relay_state),
|
|
{:ok, {root, assertion}} <- SimpleSaml.parse_response(saml_response),
|
|
{:ok, cert} <- convert_pem_cert(integration.config.idp_cert_pem),
|
|
public_key = X509.Certificate.public_key(cert),
|
|
:ok <-
|
|
SimpleSaml.verify_and_validate_response(root, assertion, public_key, @verify_opts),
|
|
{:ok, attributes} <- extract_attributes(root) do
|
|
session_timeout_minutes = integration.team.policy.sso_session_timeout_minutes
|
|
|
|
expires_at =
|
|
NaiveDateTime.add(NaiveDateTime.utc_now(:second), session_timeout_minutes, :minute)
|
|
|
|
identity =
|
|
%SSO.Identity{
|
|
id: assertion.name_id,
|
|
name: name_from_attributes(attributes),
|
|
email: attributes.email,
|
|
expires_at: expires_at
|
|
}
|
|
|
|
PlausibleWeb.UserAuth.log_in_user(conn, identity, cookie.return_to)
|
|
else
|
|
{:error, reason} ->
|
|
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
|
|
|
|
defp error_by_reason(:not_found), do: "Wrong email."
|
|
defp error_by_reason(reason), do: "Authentication failed (reason: #{inspect(reason)})."
|
|
|
|
defp convert_pem_cert(cert) do
|
|
case X509.Certificate.from_pem(cert) do
|
|
{:ok, cert} -> {:ok, cert}
|
|
{:error, _} -> {:error, :malformed_certificate}
|
|
end
|
|
end
|
|
|
|
defp name_from_attributes(attributes) do
|
|
[attributes.first_name, attributes.last_name]
|
|
|> Enum.reject(&is_nil/1)
|
|
|> Enum.join(" ")
|
|
|> String.trim()
|
|
end
|
|
|
|
defp extract_attributes(root_node) do
|
|
with {:ok, assertion_node} <- XmlNode.first_child(root_node, ~r/.*:?Assertion$/),
|
|
{:ok, attributes_node} <-
|
|
XmlNode.first_child(assertion_node, ~r/.*:?AttributeStatement$/),
|
|
{:ok, attribute_nodes} <- XmlNode.children(attributes_node) do
|
|
found = get_attributes(attribute_nodes)
|
|
|
|
attributes = %{
|
|
email: String.trim(found["email"] || ""),
|
|
first_name: String.trim(found["first_name"] || ""),
|
|
last_name: String.trim(found["last_name"] || "")
|
|
}
|
|
|
|
cond do
|
|
attributes.email == "" ->
|
|
{:error, :missing_email_attribute}
|
|
|
|
# very rudimentary way to check if the attribute is at least email-like
|
|
not String.contains?(attributes.email, "@") or String.length(attributes.email) < 3 ->
|
|
{:error, :invalid_email_attribute}
|
|
|
|
attributes.first_name == "" and attributes.last_name == "" ->
|
|
{:error, :missing_name_attributes}
|
|
|
|
true ->
|
|
{:ok, attributes}
|
|
end
|
|
end
|
|
end
|
|
|
|
defp get_attributes(nodes) do
|
|
Enum.reduce(nodes, %{}, fn node, attributes ->
|
|
with {:ok, name} <- XmlNode.attribute(node, "Name"),
|
|
{:ok, value_node} <- XmlNode.first_child(node, ~r/.*:?AttributeValue$/),
|
|
{:ok, value} <- XmlNode.text(value_node) do
|
|
Map.put(attributes, name, value)
|
|
else
|
|
_ ->
|
|
attributes
|
|
end
|
|
end)
|
|
end
|
|
|
|
defp safe_decode_www_form(nil), do: ""
|
|
defp safe_decode_www_form(data), do: URI.decode_www_form(data)
|
|
|
|
defp generate_auth_request(issuer_id, id, timestamp) do
|
|
XmlBuilder.generate(
|
|
{:"samlp:AuthnRequest",
|
|
[
|
|
"xmlns:samlp": "urn:oasis:names:tc:SAML:2.0:protocol",
|
|
ID: id,
|
|
Version: "2.0",
|
|
IssueInstant: DateTime.to_iso8601(timestamp)
|
|
], [{:"saml:Issuer", ["xmlns:saml": "urn:oasis:names:tc:SAML:2.0:assertion"], issuer_id}]}
|
|
)
|
|
end
|
|
|
|
defp validate_authresp(%{relay_state: relay_state}, relay_state)
|
|
when byte_size(relay_state) == 32 do
|
|
:ok
|
|
end
|
|
|
|
defp validate_authresp(_, _), do: {:error, :invalid_relay_state}
|
|
|
|
defp gen_id() do
|
|
24 |> :crypto.strong_rand_bytes() |> Base.url_encode64()
|
|
end
|
|
|
|
@doc false
|
|
def set_cookie(conn, attrs) do
|
|
attrs = %{
|
|
relay_state: Keyword.fetch!(attrs, :relay_state),
|
|
return_to: Keyword.fetch!(attrs, :return_to)
|
|
}
|
|
|
|
Plug.Conn.put_resp_cookie(conn, @cookie_name, attrs,
|
|
domain: conn.private.phoenix_endpoint.host(),
|
|
secure: true,
|
|
encrypt: true,
|
|
max_age: @cookie_seconds,
|
|
same_site: "None"
|
|
)
|
|
end
|
|
|
|
defp get_cookie(conn) do
|
|
conn = Plug.Conn.fetch_cookies(conn, encrypted: [@cookie_name])
|
|
|
|
if cookie = conn.cookies[@cookie_name] do
|
|
{:ok, cookie}
|
|
else
|
|
{:error, :session_expired}
|
|
end
|
|
end
|
|
|
|
defp clear_cookie(conn) do
|
|
Plug.Conn.delete_resp_cookie(conn, @cookie_name,
|
|
domain: conn.private.phoenix_endpoint.host(),
|
|
secure: true,
|
|
encrypt: true,
|
|
max_age: @cookie_seconds,
|
|
same_site: "None"
|
|
)
|
|
end
|
|
end
|