SSO Domain Validation chain: dns_txt, url, meta_tag (#5414)
* Implement SSO Domain validation chain
* Use iolists 🆒
* Use aliases
* Update moduledoc
* Update test/plausible/auth/sso/domain/validation_test.exs
Co-authored-by: Adrian Gruntkowski <adrian.gruntkowski@gmail.com>
* Update test/plausible/auth/sso/domain/validation_test.exs
Co-authored-by: Adrian Gruntkowski <adrian.gruntkowski@gmail.com>
* Update test/plausible/auth/sso/domain/validation_test.exs
Co-authored-by: Adrian Gruntkowski <adrian.gruntkowski@gmail.com>
* Match non-empty list for meta tag check
---------
Co-authored-by: Adrian Gruntkowski <adrian.gruntkowski@gmail.com>
This commit is contained in:
parent
7ccaebfee7
commit
6040bed54b
|
|
@ -17,12 +17,14 @@ defmodule Plausible.Auth.SSO.Domain do
|
|||
|
||||
alias Plausible.Auth.SSO
|
||||
|
||||
@validation_methods [:dns_txt, :url, :meta_tag]
|
||||
|
||||
@type t() :: %__MODULE__{}
|
||||
|
||||
@validation_methods [:dns_txt, :url, :meta_tag]
|
||||
@type validation_method() :: unquote(Enum.reduce(@validation_methods, &{:|, [], [&1, &2]}))
|
||||
|
||||
@spec validation_methods() :: list(validation_method())
|
||||
def validation_methods(), do: @validation_methods
|
||||
|
||||
schema "sso_domains" do
|
||||
field :identifier, Ecto.UUID
|
||||
field :domain, :string
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
defmodule Plausible.Auth.SSO.Domain.Validation do
|
||||
@moduledoc """
|
||||
SSO domain validation chain
|
||||
|
||||
1. DNS TXT `{domain}` record lookup.
|
||||
Successful expectation contains `plausible-sso-verification={domain-identifier}` record.
|
||||
|
||||
2. HTTP GET lookup at `https://{domain}/plausible-sso-verification`
|
||||
Successful expectation contains `{domain-identifier}` in the body.
|
||||
|
||||
3. META tag lookup at `https://{domain}`
|
||||
Successful expectation contains:
|
||||
|
||||
```html
|
||||
<meta name="plausible-sso-verification" content="{domain-identifier}">
|
||||
```
|
||||
|
||||
in the body of `text/html` type.
|
||||
"""
|
||||
|
||||
alias Plausible.Auth.SSO.Domain
|
||||
require Domain
|
||||
|
||||
@prefix "plausible-sso-verification"
|
||||
|
||||
@spec run(String.t(), String.t(), Keyword.t()) ::
|
||||
{:ok, Domain.validation_method()} | {:error, :invalid}
|
||||
def run(sso_domain, domain_identifier, opts \\ []) do
|
||||
available_methods = Domain.validation_methods()
|
||||
methods = Keyword.get(opts, :methods, available_methods)
|
||||
true = Enum.all?(methods, &(&1 in available_methods))
|
||||
|
||||
Enum.reduce_while(methods, {:error, :invalid}, fn method, acc ->
|
||||
case apply(__MODULE__, method, [sso_domain, domain_identifier, opts]) do
|
||||
true -> {:halt, {:ok, method}}
|
||||
false -> {:cont, acc}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@spec url(String.t(), String.t(), Keyword.t()) :: boolean()
|
||||
def url(sso_domain, domain_identifier, opts \\ []) do
|
||||
url_override = Keyword.get(opts, :url_override)
|
||||
resp = run_request(url_override || "https://" <> Path.join(sso_domain, @prefix))
|
||||
|
||||
case resp do
|
||||
%Req.Response{body: body}
|
||||
when is_binary(body) ->
|
||||
String.trim(body) == domain_identifier
|
||||
|
||||
_ ->
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
@spec meta_tag(String.t(), String.t(), Keyword.t()) :: boolean()
|
||||
def meta_tag(sso_domain, domain_identifier, opts \\ []) do
|
||||
url_override = Keyword.get(opts, :url_override)
|
||||
|
||||
with %Req.Response{body: body} = response when is_binary(body) <-
|
||||
run_request(url_override || "https://#{sso_domain}"),
|
||||
true <- html?(response),
|
||||
{:ok, html} <- Floki.parse_document(body),
|
||||
[_ | _] <- Floki.find(html, ~s|meta[name="#{@prefix}"][content="#{domain_identifier}"]|) do
|
||||
true
|
||||
else
|
||||
_ ->
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
@spec dns_txt(String.t(), String.t()) :: boolean()
|
||||
def dns_txt(sso_domain, domain_identifier, opts \\ []) do
|
||||
record_value = to_charlist("#{@prefix}=#{domain_identifier}")
|
||||
|
||||
timeout = Keyword.get(opts, :timeout, 5_000)
|
||||
nameservers = Keyword.get(opts, :nameservers, [])
|
||||
opts = [timeout: timeout, nameservers: nameservers]
|
||||
|
||||
sso_domain
|
||||
|> to_charlist()
|
||||
|> :inet_res.lookup(:in, :txt, opts, timeout)
|
||||
|> Enum.find_value(false, fn
|
||||
[^record_value] -> true
|
||||
_ -> false
|
||||
end)
|
||||
end
|
||||
|
||||
defp html?(%Req.Response{headers: headers}) do
|
||||
headers
|
||||
|> Map.get("content-type", "")
|
||||
|> List.wrap()
|
||||
|> List.first()
|
||||
|> String.contains?("text/html")
|
||||
end
|
||||
|
||||
defp run_request(base_url) do
|
||||
fetch_body_opts = Application.get_env(:plausible, __MODULE__)[:req_opts] || []
|
||||
|
||||
opts =
|
||||
Keyword.merge(
|
||||
[
|
||||
base_url: base_url,
|
||||
max_redirects: 4,
|
||||
max_retries: 3,
|
||||
retry_log_level: :warning
|
||||
],
|
||||
fetch_body_opts
|
||||
)
|
||||
|
||||
{_req, resp} = opts |> Req.new() |> Req.Request.run_request()
|
||||
resp
|
||||
end
|
||||
|
||||
@after_compile __MODULE__
|
||||
def __after_compile__(_env, _bytecode) do
|
||||
available_methods = Domain.validation_methods()
|
||||
|
||||
exported_funs =
|
||||
:functions
|
||||
|> __MODULE__.__info__()
|
||||
|> Enum.map(&elem(&1, 0))
|
||||
|
||||
Enum.each(
|
||||
available_methods,
|
||||
fn method ->
|
||||
if method not in exported_funs do
|
||||
raise "#{method} must be implemented in #{__MODULE__}"
|
||||
end
|
||||
end
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -19,11 +19,20 @@ defmodule Plausible.Auth.SSO.Domains do
|
|||
@spec verify(SSO.Domain.t(), Keyword.t()) :: SSO.Domain.t()
|
||||
def verify(sso_domain, opts \\ []) do
|
||||
skip_checks? = Keyword.get(opts, :skip_checks?, false)
|
||||
verification_opts = Keyword.get(opts, :verification_opts, [])
|
||||
now = Keyword.get(opts, :now, NaiveDateTime.utc_now(:second))
|
||||
|
||||
if skip_checks? do
|
||||
mark_valid(sso_domain, :dns_txt, now)
|
||||
else
|
||||
case SSO.Domain.Validation.run(sso_domain.domain, sso_domain.identifier, verification_opts) do
|
||||
{:ok, step} ->
|
||||
mark_valid(sso_domain, step, now)
|
||||
|
||||
{:error, :invalid} ->
|
||||
mark_invalid(sso_domain, now)
|
||||
end
|
||||
|
||||
mark_invalid(sso_domain, now)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,153 @@
|
|||
defmodule Plausible.Auth.SSO.Domain.ValidationTest do
|
||||
use Plausible.DataCase, async: true
|
||||
use Plausible
|
||||
|
||||
@moduletag :ee_only
|
||||
|
||||
on_ee do
|
||||
use Plausible.Teams.Test
|
||||
|
||||
alias Plasusible.Test.Support.DNSServer
|
||||
alias Plausible.Auth.SSO.Domain.Validation
|
||||
alias Plug.Conn
|
||||
|
||||
setup do
|
||||
team = new_site().team
|
||||
bypass = Bypass.open()
|
||||
|
||||
{:ok, team: team, bypass: bypass}
|
||||
end
|
||||
|
||||
describe "individual checks" do
|
||||
test "dns_txt" do
|
||||
{:ok, port} = DNSServer.start("plausible-sso-verification=ex4mpl3")
|
||||
|
||||
refute Validation.dns_txt("example.com", "failing-identifier",
|
||||
nameservers: [{{0, 0, 0, 0}, port}]
|
||||
)
|
||||
|
||||
assert Validation.dns_txt("example.com", "ex4mpl3", nameservers: [{{0, 0, 0, 0}, port}])
|
||||
end
|
||||
|
||||
test "url", %{bypass: bypass} do
|
||||
Bypass.expect(bypass, "GET", "/test", fn conn ->
|
||||
Conn.resp(conn, 200, "ex4mpl3")
|
||||
end)
|
||||
|
||||
refute Validation.url("example.com", "failing-identifier",
|
||||
url_override: "http://localhost:#{bypass.port}/test"
|
||||
)
|
||||
|
||||
assert Validation.url("example.com", "ex4mpl3",
|
||||
url_override: "http://localhost:#{bypass.port}/test"
|
||||
)
|
||||
end
|
||||
|
||||
test "meta_tag", %{bypass: bypass} do
|
||||
Bypass.expect(bypass, "GET", "/test", fn conn ->
|
||||
conn
|
||||
|> Conn.put_resp_header("content-type", "text/html")
|
||||
|> Conn.resp(200, """
|
||||
<html>
|
||||
<meta name="plausible-sso-verification" content="ex4mpl3"/>
|
||||
</html>
|
||||
""")
|
||||
end)
|
||||
|
||||
refute Validation.meta_tag("example.com", "failing-identifier",
|
||||
url_override: "http://localhost:#{bypass.port}/test"
|
||||
)
|
||||
|
||||
assert Validation.meta_tag("example.com", "ex4mpl3",
|
||||
url_override: "http://localhost:#{bypass.port}/test"
|
||||
)
|
||||
end
|
||||
|
||||
test "meta-tag fails on non-html", %{bypass: bypass} do
|
||||
Bypass.expect_once(bypass, "GET", "/test", fn conn ->
|
||||
Conn.resp(conn, 200, """
|
||||
<html>
|
||||
<meta name="plausible-sso-verification" content="ex4mpl3"/>
|
||||
</html>
|
||||
""")
|
||||
end)
|
||||
|
||||
refute Validation.meta_tag("example.com", "ex4mpl3",
|
||||
url_override: "http://localhost:#{bypass.port}/test"
|
||||
)
|
||||
end
|
||||
|
||||
test "meta-tag fails on parse failure", %{bypass: bypass} do
|
||||
Bypass.expect_once(bypass, "GET", "/test", fn conn ->
|
||||
conn
|
||||
|> Conn.put_resp_header("content-type", "text/html")
|
||||
|> Conn.resp(200, """
|
||||
meta name="plausible-sso-verification" content="ex4mpl3
|
||||
""")
|
||||
end)
|
||||
|
||||
refute Validation.meta_tag("example.com", "ex4mpl3",
|
||||
url_override: "http://localhost:#{bypass.port}/test"
|
||||
)
|
||||
end
|
||||
|
||||
test "meta_tag succeeds in case of multiple matches", %{bypass: bypass} do
|
||||
Bypass.expect(bypass, "GET", "/test", fn conn ->
|
||||
conn
|
||||
|> Conn.put_resp_header("content-type", "text/html")
|
||||
|> Conn.resp(200, """
|
||||
<html>
|
||||
<meta name="plausible-sso-verification" content="ex4mpl3"/>
|
||||
<meta name="plausible-sso-verification" content="ex4mpl3"/>
|
||||
</html>
|
||||
""")
|
||||
end)
|
||||
|
||||
assert Validation.meta_tag("example.com", "ex4mpl3",
|
||||
url_override: "http://localhost:#{bypass.port}/test"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe "all methods" do
|
||||
test "DNS matches, no HTTP endpoint is ever called", %{bypass: bypass} do
|
||||
{:ok, dns_port} = DNSServer.start("plausible-sso-verification=ex4mpl3")
|
||||
|
||||
Bypass.stub(bypass, "GET", "/", fn _conn -> raise "should never be called" end)
|
||||
|
||||
assert {:ok, :dns_txt} =
|
||||
Validation.run("example.com", "ex4mpl3",
|
||||
url_override: "http://localhost:#{bypass.port}/",
|
||||
nameservers: [{{0, 0, 0, 0}, dns_port}]
|
||||
)
|
||||
end
|
||||
|
||||
test "DNS fails to match, url check succeeds", %{bypass: bypass} do
|
||||
Bypass.expect_once(bypass, "GET", "/", fn conn ->
|
||||
Conn.resp(conn, 200, "ex4mpl3")
|
||||
end)
|
||||
|
||||
assert {:ok, :url} =
|
||||
Validation.run("example.com", "ex4mpl3",
|
||||
url_override: "http://localhost:#{bypass.port}/"
|
||||
)
|
||||
end
|
||||
|
||||
test "DNS and url checks fail to match, meta tag check succeeds", %{bypass: bypass} do
|
||||
Bypass.expect(bypass, "GET", "/", fn conn ->
|
||||
conn
|
||||
|> Conn.put_resp_header("content-type", "text/html")
|
||||
|> Conn.resp(
|
||||
200,
|
||||
"<html><meta name=\"plausible-sso-verification\" content=\"ex4mpl3\"/></html>"
|
||||
)
|
||||
end)
|
||||
|
||||
assert {:ok, :meta_tag} =
|
||||
Validation.run("example.com", "ex4mpl3",
|
||||
url_override: "http://localhost:#{bypass.port}/"
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -2,6 +2,8 @@ defmodule Plausible.Auth.SSO.DomainsTest do
|
|||
use Plausible.DataCase, async: true
|
||||
use Plausible
|
||||
|
||||
@moduletag :ee_only
|
||||
|
||||
on_ee do
|
||||
use Plausible.Teams.Test
|
||||
|
||||
|
|
@ -117,7 +119,7 @@ defmodule Plausible.Auth.SSO.DomainsTest do
|
|||
domain = generate_domain()
|
||||
{:ok, sso_domain} = SSO.Domains.add(integration, domain)
|
||||
|
||||
invalid_domain = SSO.Domains.verify(sso_domain)
|
||||
invalid_domain = SSO.Domains.verify(sso_domain, verification_opts: [methods: []])
|
||||
|
||||
assert invalid_domain.id == sso_domain.id
|
||||
refute invalid_domain.validated_via
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
defmodule Plasusible.Test.Support.DNSServer do
|
||||
@moduledoc """
|
||||
A simple DNS server that responds to TXT queries with fixed sample values.
|
||||
"""
|
||||
|
||||
def start(fixed_response) do
|
||||
{:ok, socket} = :gen_udp.open(0, [:binary, active: true, ip: {0, 0, 0, 0}])
|
||||
{:ok, port} = :inet.port(socket)
|
||||
child = spawn(fn -> loop(socket, fixed_response) end)
|
||||
:ok = :gen_udp.controlling_process(socket, child)
|
||||
{:ok, port}
|
||||
end
|
||||
|
||||
defp loop(socket, fixed_response) do
|
||||
receive do
|
||||
{:udp, _socket, client_ip, client_port, query} ->
|
||||
response = build_response(query, fixed_response)
|
||||
:gen_udp.send(socket, client_ip, client_port, response)
|
||||
loop(socket, fixed_response)
|
||||
end
|
||||
end
|
||||
|
||||
defp build_response(query, response) do
|
||||
<<transaction_id::16, _flags::16, qdcount::16, _rest::binary>> = query
|
||||
|
||||
header = <<
|
||||
# Transaction ID
|
||||
transaction_id::16,
|
||||
# Flags: Standard query response, no error
|
||||
0b10000100_00000000::16,
|
||||
# Questions: Echo the number of questions
|
||||
qdcount::16,
|
||||
# Answer RRs: 1
|
||||
1::16,
|
||||
# Authority RRs: 0
|
||||
0::16,
|
||||
# Additional RRs: 0
|
||||
0::16
|
||||
>>
|
||||
|
||||
<<_header::binary-size(12), question::binary>> = query
|
||||
txt_data = encode_txt_data(response)
|
||||
|
||||
answer = <<
|
||||
# Name: Pointer to the question
|
||||
0xC00C::16,
|
||||
# Type: TXT
|
||||
16::16,
|
||||
# Class: IN (Internet)
|
||||
1::16,
|
||||
# TTL: 60 seconds
|
||||
60::32,
|
||||
byte_size(txt_data)::16,
|
||||
txt_data::binary
|
||||
>>
|
||||
|
||||
[header, question, answer]
|
||||
end
|
||||
|
||||
defp encode_txt_data(txt_value) do
|
||||
<<byte_size(txt_value)::8, txt_value::binary>>
|
||||
end
|
||||
end
|
||||
Loading…
Reference in New Issue