Add client auth flow (#1868)

Related to
[#588](https://github.com/firezone/product/issues/588#issuecomment-1591730203)
This commit is contained in:
Andrew Dryga
2023-08-07 17:15:35 -05:00
committed by GitHub
parent acb4a0475b
commit b9c11007a4
7 changed files with 339 additions and 79 deletions

View File

@@ -6,7 +6,7 @@ defmodule Web.Auth do
do: ~p"/#{subject.account}/dashboard"
def put_subject_in_session(conn, %Auth.Subject{} = subject) do
{:ok, session_token} = Domain.Auth.create_session_token_from_subject(subject)
{:ok, session_token} = Auth.create_session_token_from_subject(subject)
conn
|> Plug.Conn.put_session(:signed_in_at, DateTime.utc_now())
@@ -15,38 +15,59 @@ defmodule Web.Auth do
end
@doc """
This is a wrapper around `Domain.Auth.sign_in/5` that fails authentication and redirects
to app install instructions for the users that should not have access to the control plane UI.
Redirects the signed in user depending on the actor type.
The account admin users are sent to dashboard or a return path if it's stored in session.
The account users are only expected to authenticate using client apps.
If the platform is known, we direct them to the application through a deep link or an app link;
if not, we guide them to the install instructions accompanied by an error message.
"""
def sign_in(conn, provider, provider_identifier, secret) do
case Domain.Auth.sign_in(
provider,
provider_identifier,
secret,
conn.assigns.user_agent,
conn.remote_ip
) do
{:ok, %Auth.Subject{actor: %{type: :account_admin_user}} = subject} ->
{:ok, subject}
def signed_in_redirect(
conn,
%Auth.Subject{actor: %{type: :account_admin_user}} = subject,
_client_platform,
_client_csrf_token
) do
redirect_to = Plug.Conn.get_session(conn, :user_return_to) || signed_in_path(subject)
{:ok, %Auth.Subject{}} ->
{:error, :invalid_actor_type}
{:error, reason} ->
{:error, reason}
end
conn
|> Web.Auth.renew_session()
|> Web.Auth.put_subject_in_session(subject)
|> Plug.Conn.delete_session(:user_return_to)
|> Phoenix.Controller.redirect(to: redirect_to)
end
def sign_in(conn, provider, payload) do
case Domain.Auth.sign_in(provider, payload, conn.assigns.user_agent, conn.remote_ip) do
{:ok, %Auth.Subject{actor: %{type: :account_admin_user}} = subject} ->
{:ok, subject}
def signed_in_redirect(
conn,
%Auth.Subject{actor: %{type: :account_user}} = subject,
client_platform,
client_csrf_token
) do
platform_redirect_urls =
Domain.Config.fetch_env!(:web, __MODULE__)
|> Keyword.fetch!(:platform_redirect_urls)
{:ok, %Auth.Subject{}} ->
{:error, :invalid_actor_type}
if redirect_to = Map.get(platform_redirect_urls, client_platform) do
{:ok, client_token} = Auth.create_session_token_from_subject(subject)
{:error, reason} ->
{:error, reason}
query =
%{
client_auth_token: client_token,
client_csrf_token: client_csrf_token
}
|> Enum.reject(&is_nil(elem(&1, 1)))
|> URI.encode_query()
conn
|> Phoenix.Controller.redirect(external: "#{redirect_to}?#{query}")
else
conn
|> Phoenix.Controller.put_flash(
:info,
"Please use a client application to access Firezone."
)
|> Phoenix.Controller.redirect(to: ~p"/#{conn.path_params["account_id_or_slug"]}/")
end
end

View File

@@ -22,20 +22,24 @@ defmodule Web.AuthController do
def verify_credentials(conn, %{
"account_id_or_slug" => account_id_or_slug,
"provider_id" => provider_id,
"userpass" => %{
"provider_identifier" => provider_identifier,
"secret" => secret
}
"userpass" =>
%{
"provider_identifier" => provider_identifier,
"secret" => secret
} = form
}) do
with {:ok, provider} <- Domain.Auth.fetch_active_provider_by_id(provider_id),
{:ok, subject} <- Web.Auth.sign_in(conn, provider, provider_identifier, secret) do
redirect_to = get_session(conn, :user_return_to) || Auth.signed_in_path(subject)
conn
|> Web.Auth.renew_session()
|> Web.Auth.put_subject_in_session(subject)
|> delete_session(:user_return_to)
|> redirect(to: redirect_to)
{:ok, subject} <-
Domain.Auth.sign_in(
provider,
provider_identifier,
secret,
conn.assigns.user_agent,
conn.remote_ip
) do
client_platform = form["client_platform"]
client_csrf_token = form["client_csrf_token"]
Web.Auth.signed_in_redirect(conn, subject, client_platform, client_csrf_token)
else
{:error, :not_found} ->
conn
@@ -43,11 +47,6 @@ defmodule Web.AuthController do
|> put_flash(:error, "You can not use this method to sign in.")
|> redirect(to: "/#{account_id_or_slug}/sign_in")
{:error, :invalid_actor_type} ->
conn
|> put_flash(:info, "Please use client application to access Firezone.")
|> redirect(to: ~p"/#{account_id_or_slug}")
{:error, _reason} ->
conn
|> put_flash(:userpass_provider_identifier, String.slice(provider_identifier, 0, 160))
@@ -62,9 +61,10 @@ defmodule Web.AuthController do
def request_magic_link(conn, %{
"account_id_or_slug" => account_id_or_slug,
"provider_id" => provider_id,
"email" => %{
"provider_identifier" => provider_identifier
}
"email" =>
%{
"provider_identifier" => provider_identifier
} = form
}) do
_ =
with {:ok, provider} <- Domain.Auth.fetch_active_provider_by_id(provider_id),
@@ -75,7 +75,10 @@ defmodule Web.AuthController do
|> Web.Mailer.deliver()
end
redirect(conn, to: "/#{account_id_or_slug}/sign_in/providers/email/#{provider_id}")
conn
|> put_session(:client_platform, form["client_platform"])
|> put_session(:client_csrf_token, form["client_csrf_token"])
|> redirect(to: "/#{account_id_or_slug}/sign_in/providers/email/#{provider_id}")
end
@doc """
@@ -89,25 +92,27 @@ defmodule Web.AuthController do
"secret" => secret
}) do
with {:ok, provider} <- Domain.Auth.fetch_active_provider_by_id(provider_id),
{:ok, subject} <- Web.Auth.sign_in(conn, provider, identity_id, secret) do
redirect_to = get_session(conn, :user_return_to) || Auth.signed_in_path(subject)
{:ok, subject} <-
Domain.Auth.sign_in(
provider,
identity_id,
secret,
conn.assigns.user_agent,
conn.remote_ip
) do
client_platform = get_session(conn, :client_platform)
client_csrf_token = get_session(conn, :client_csrf_token)
conn
|> Web.Auth.renew_session()
|> Web.Auth.put_subject_in_session(subject)
|> delete_session(:user_return_to)
|> redirect(to: redirect_to)
|> delete_session(:client_platform)
|> delete_session(:client_csrf_token)
|> Web.Auth.signed_in_redirect(subject, client_platform, client_csrf_token)
else
{:error, :not_found} ->
conn
|> put_flash(:error, "You can not use this method to sign in.")
|> redirect(to: "/#{account_id_or_slug}/sign_in")
{:error, :invalid_actor_type} ->
conn
|> put_flash(:info, "Please use client application to access Firezone.")
|> redirect(to: ~p"/#{account_id_or_slug}")
{:error, _reason} ->
conn
|> put_flash(:error, "The sign in link is invalid or expired.")
@@ -119,11 +124,17 @@ defmodule Web.AuthController do
This controller redirects user to IdP during sign in for authentication while persisting
verification state to prevent various attacks on OpenID Connect.
"""
def redirect_to_idp(conn, %{
"account_id_or_slug" => account_id_or_slug,
"provider_id" => provider_id
}) do
def redirect_to_idp(
conn,
%{
"account_id_or_slug" => account_id_or_slug,
"provider_id" => provider_id
} = params
) do
with {:ok, provider} <- Domain.Auth.fetch_active_provider_by_id(provider_id) do
conn = put_session(conn, :client_platform, params["client_platform"])
conn = put_session(conn, :client_csrf_token, params["client_csrf_token"])
redirect_url =
url(~p"/#{provider.account_id}/sign_in/providers/#{provider.id}/handle_callback")
@@ -165,25 +176,21 @@ defmodule Web.AuthController do
}
with {:ok, provider} <- Domain.Auth.fetch_active_provider_by_id(provider_id),
{:ok, subject} <- Web.Auth.sign_in(conn, provider, payload) do
redirect_to = get_session(conn, :user_return_to) || Auth.signed_in_path(subject)
{:ok, subject} <-
Domain.Auth.sign_in(provider, payload, conn.assigns.user_agent, conn.remote_ip) do
client_platform = get_session(conn, :client_platform)
client_csrf_token = get_session(conn, :client_csrf_token)
conn
|> Web.Auth.renew_session()
|> Web.Auth.put_subject_in_session(subject)
|> delete_session(:user_return_to)
|> redirect(to: redirect_to)
|> delete_session(:client_platform)
|> delete_session(:client_csrf_token)
|> Web.Auth.signed_in_redirect(subject, client_platform, client_csrf_token)
else
{:error, :not_found} ->
conn
|> put_flash(:error, "You can not use this method to sign in.")
|> redirect(to: "/#{account_id_or_slug}/sign_in")
{:error, :invalid_actor_type} ->
conn
|> put_flash(:info, "Please use client application to access Firezone.")
|> redirect(to: ~p"/#{account_id_or_slug}")
{:error, _reason} ->
conn
|> put_flash(:error, "You can not authenticate to this account.")

View File

@@ -2,7 +2,7 @@ defmodule Web.Auth.SignIn do
use Web, {:live_view, layout: {Web.Layouts, :public}}
alias Domain.{Auth, Accounts}
def mount(%{"account_id_or_slug" => account_id_or_slug}, _session, socket) do
def mount(%{"account_id_or_slug" => account_id_or_slug} = params, _session, socket) do
with {:ok, account} <- Accounts.fetch_account_by_id_or_slug(account_id_or_slug),
{:ok, [_ | _] = providers} <- Auth.list_active_providers_for_account(account) do
providers_by_adapter =
@@ -19,6 +19,7 @@ defmodule Web.Auth.SignIn do
{:ok, socket,
temporary_assigns: [
params: Map.take(params, ["client_platform", "client_csrf_token"]),
account: account,
providers_by_adapter: providers_by_adapter,
page_title: "Sign in"
@@ -53,6 +54,7 @@ defmodule Web.Auth.SignIn do
<.providers_group_form
adapter="openid_connect"
providers={@providers_by_adapter[:openid_connect]}
params={@params}
/>
</:item>
@@ -65,6 +67,7 @@ defmodule Web.Auth.SignIn do
adapter="userpass"
provider={List.first(@providers_by_adapter[:userpass])}
flash={@flash}
params={@params}
/>
</:item>
@@ -77,6 +80,7 @@ defmodule Web.Auth.SignIn do
adapter="email"
provider={List.first(@providers_by_adapter[:email])}
flash={@flash}
params={@params}
/>
</:item>
</.intersperse_blocks>
@@ -100,7 +104,7 @@ defmodule Web.Auth.SignIn do
def providers_group_form(%{adapter: "openid_connect"} = assigns) do
~H"""
<div class="space-y-3 items-center">
<.openid_connect_button :for={provider <- @providers} provider={provider} />
<.openid_connect_button :for={provider <- @providers} provider={provider} params={@params} />
</div>
"""
end
@@ -118,6 +122,8 @@ defmodule Web.Auth.SignIn do
id="userpass_form"
phx-update="ignore"
>
<.input :for={{key, value} <- @params} type="hidden" name={key} value={value} />
<.input
field={@userpass_form[:provider_identifier]}
type="text"
@@ -156,6 +162,8 @@ defmodule Web.Auth.SignIn do
id="email_form"
phx-update="ignore"
>
<.input :for={{key, value} <- @params} type="hidden" name={key} value={value} />
<.input
field={@email_form[:provider_identifier]}
type="email"
@@ -175,7 +183,9 @@ defmodule Web.Auth.SignIn do
def openid_connect_button(assigns) do
~H"""
<a href={~p"/#{@provider.account_id}/sign_in/providers/#{@provider.id}/redirect"} class={~w[
<a
href={~p"/#{@provider.account_id}/sign_in/providers/#{@provider}/redirect?#{@params}"}
class={~w[
w-full inline-flex items-center justify-center py-2.5 px-5
bg-white rounded-lg
text-sm font-medium text-gray-900
@@ -184,7 +194,8 @@ defmodule Web.Auth.SignIn do
hover:bg-gray-100 hover:text-gray-900
focus:z-10 focus:ring-4 focus:ring-gray-200
dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400
dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700]}>
dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700]}
>
Log in with <%= @provider.name %>
</a>
"""

View File

@@ -134,7 +134,7 @@ defmodule Web.Acceptance.Auth.UserPassTest do
session
|> password_login_flow(account, identity.provider_identifier, password)
|> assert_path(~p"/#{account}")
|> assert_path(~p"/#{account}/")
end
defp password_login_flow(session, account, username, password) do

View File

@@ -214,6 +214,103 @@ defmodule Web.AuthControllerTest do
assert subject.identity.last_seen_remote_ip.address == {127, 0, 0, 1}
assert subject.identity.last_seen_at
end
test "redirects to the platform link when credentials are valid for account users", %{
conn: conn
} do
account = AccountsFixtures.create_account()
provider = AuthFixtures.create_userpass_provider(account: account)
password = "Firezone1234"
actor =
ActorsFixtures.create_actor(
type: :account_user,
account: account,
provider: provider
)
identity =
AuthFixtures.create_identity(
actor: actor,
account: account,
provider: provider,
provider_virtual_state: %{"password" => password, "password_confirmation" => password}
)
csrf_token = Ecto.UUID.generate()
conn =
conn
|> post(
~p"/#{provider.account_id}/sign_in/providers/#{provider.id}/verify_credentials",
%{
"userpass" => %{
"provider_identifier" => identity.provider_identifier,
"secret" => password,
"client_platform" => "android",
"client_csrf_token" => csrf_token
}
}
)
assert conn.assigns.flash == %{}
assert is_nil(get_session(conn, :user_return_to))
assert redirected_to = redirected_to(conn)
assert redirected_to_uri = URI.parse(redirected_to)
assert redirected_to_uri.scheme == "https"
assert redirected_to_uri.host == "app.firez.one"
assert redirected_to_uri.path == "/handle_client_auth_callback"
assert %{
"client_auth_token" => _token,
"client_csrf_token" => ^csrf_token
} = URI.decode_query(redirected_to_uri.query)
end
test "redirects account users to app install page when mobile platform is invalid", %{
conn: conn
} do
account = AccountsFixtures.create_account()
provider = AuthFixtures.create_userpass_provider(account: account)
password = "Firezone1234"
actor =
ActorsFixtures.create_actor(
type: :account_user,
account: account,
provider: provider
)
identity =
AuthFixtures.create_identity(
actor: actor,
account: account,
provider: provider,
provider_virtual_state: %{"password" => password, "password_confirmation" => password}
)
conn =
conn
|> post(
~p"/#{provider.account_id}/sign_in/providers/#{provider.id}/verify_credentials",
%{
"userpass" => %{
"provider_identifier" => identity.provider_identifier,
"secret" => password,
"client_platform" => "platform"
}
}
)
assert conn.assigns.flash == %{
"info" => "Please use a client application to access Firezone."
}
assert redirected_to(conn) == ~p"/#{account.id}/"
assert is_nil(get_session(conn, :user_return_to))
end
end
describe "request_magic_link/2" do
@@ -247,6 +344,38 @@ defmodule Web.AuthControllerTest do
assert redirected_to(conn) == "/#{account.id}/sign_in/providers/email/#{provider.id}"
end
test "persists client platform name", %{conn: conn} do
account = AccountsFixtures.create_account()
provider = AuthFixtures.create_email_provider(account: account)
identity = AuthFixtures.create_identity(account: account, provider: provider)
conn =
post(
conn,
~p"/#{provider.account_id}/sign_in/providers/#{provider.id}/request_magic_link",
%{
"email" => %{
"provider_identifier" => identity.provider_identifier,
"client_platform" => "platform"
}
}
)
assert_email_sent(fn email ->
assert email.subject == "Firezone Sign In Link"
verify_sign_in_token_path =
"/#{account.id}/sign_in/providers/#{provider.id}/verify_sign_in_token"
assert email.text_body =~ "#{verify_sign_in_token_path}"
assert email.text_body =~ "identity_id=#{identity.id}"
assert email.text_body =~ "secret="
end)
assert redirected_to(conn) == "/#{account.id}/sign_in/providers/email/#{provider.id}"
assert get_session(conn, :client_platform) == "platform"
end
test "does not return error if provider is not found", %{conn: conn} do
account = AccountsFixtures.create_account()
provider_id = Ecto.UUID.generate()
@@ -374,6 +503,32 @@ defmodule Web.AuthControllerTest do
assert redirected_to(conn) == "/#{account.id}/dashboard"
end
test "redirects to the platform link when credentials are valid for account users", %{
conn: conn
} do
account = AccountsFixtures.create_account()
provider = AuthFixtures.create_email_provider(account: account)
identity =
AuthFixtures.create_identity(
actor_default_type: :account_user,
account: account,
provider: provider
)
conn =
conn
|> put_session(:client_platform, "apple")
|> get(~p"/#{account}/sign_in/providers/#{provider}/verify_sign_in_token", %{
"identity_id" => identity.id,
"secret" => identity.provider_virtual_state.sign_in_token
})
assert conn.assigns.flash == %{}
assert redirected_to(conn) =~ "firezone://handle_client_auth_callback?client_auth_token="
assert is_nil(get_session(conn, :client_platform))
end
test "renews the session when credentials are valid", %{conn: conn} do
account = AccountsFixtures.create_account()
provider = AuthFixtures.create_email_provider(account: account)
@@ -455,6 +610,21 @@ defmodule Web.AuthControllerTest do
"state" => state
}
end
test "persists client platform name", %{conn: conn} do
account = AccountsFixtures.create_account()
{provider, _bypass} =
AuthFixtures.start_openid_providers(["google"])
|> AuthFixtures.create_openid_connect_provider(account: account)
conn =
get(conn, ~p"/#{account.id}/sign_in/providers/#{provider.id}/redirect", %{
"client_platform" => "platform"
})
assert get_session(conn, :client_platform) == "platform"
end
end
describe "handle_idp_callback/2" do
@@ -595,6 +765,45 @@ defmodule Web.AuthControllerTest do
assert subject.identity.last_seen_remote_ip.address == {127, 0, 0, 1}
assert subject.identity.last_seen_at
end
test "redirects to the platform link when credentials are valid for account users", %{
account: account,
provider: provider,
bypass: bypass,
conn: conn,
redirected_conn: redirected_conn
} do
identity =
AuthFixtures.create_identity(
actor_default_type: :account_user,
account: account,
provider: provider
)
{token, _claims} = AuthFixtures.generate_openid_connect_token(provider, identity)
AuthFixtures.expect_refresh_token(bypass, %{"id_token" => token})
AuthFixtures.expect_userinfo(bypass)
cookie_key = "fz_auth_state_#{provider.id}"
redirected_conn = fetch_cookies(redirected_conn)
{state, _verifier} = redirected_conn.cookies[cookie_key] |> :erlang.binary_to_term([:safe])
%{value: signed_state} = redirected_conn.resp_cookies[cookie_key]
conn =
conn
|> put_req_cookie(cookie_key, signed_state)
|> put_session(:foo, "bar")
|> put_session(:preferred_locale, "en_US")
|> put_session(:client_platform, "apple")
|> get(~p"/#{account.id}/sign_in/providers/#{provider.id}/handle_callback", %{
"state" => state,
"code" => "MyFakeCode"
})
assert conn.assigns.flash == %{}
assert redirected_to(conn) =~ "firezone://handle_client_auth_callback?client_auth_token="
assert is_nil(get_session(conn, :client_platform))
end
end
describe "sign_out/2" do

View File

@@ -90,6 +90,12 @@ config :web,
external_trusted_proxies: [],
private_clients: [%{__struct__: Postgrex.INET, address: {172, 28, 0, 0}, netmask: 16}]
config :web, Web.Auth,
platform_redirect_urls: %{
"apple" => "firezone://handle_client_auth_callback",
"android" => "https://app.firez.one/handle_client_auth_callback"
}
config :web, Web.Plugs.SecureHeaders,
csp_policy: [
"default-src 'self' 'nonce-${nonce}'",

View File

@@ -81,6 +81,12 @@ if config_env() == :prod do
cookie_signing_salt: compile_config!(:cookie_signing_salt),
cookie_encryption_salt: compile_config!(:cookie_encryption_salt)
config :web, Web.Auth,
platform_redirect_urls: %{
"apple" => "firezone://handle_client_auth_callback",
"android" => "#{external_url_scheme}://#{external_url_host}/handle_client_auth_callback"
}
###############################
##### API #####################
###############################