Files
firezone/elixir/apps/web/lib/web/auth.ex
Brian Manifold d135a8b8eb Add sign-in success page for clients (#3714)
Why:

* On some clients, the web view that is opened to sign-in to Firezone is
left open and ends up getting stuck on the Sign In page with the
liveview loader on the top of the page also stuck and appearing as
though it is waiting for another response. This commit adds a sign-in
success page that is displayed upon successful sign-in and shows a
message to the user that lets them know they can close the window if
needed. If the client device is able to close the web view that was
opened, then the page will either very briefly be shown or will not be
visible at all due to how quickly the redirect happens.
2024-02-21 21:31:11 +00:00

644 lines
20 KiB
Elixir

defmodule Web.Auth do
use Web, :verified_routes
alias Domain.{Auth, Accounts, Tokens}
# This is the cookie which will store recent account ids
# that the user has signed in to.
@recent_accounts_cookie_name "fz_recent_account_ids"
@recent_accounts_cookie_options [
sign: true,
max_age: 365 * 24 * 60 * 60,
same_site: "Lax",
secure: true,
http_only: true
]
@remember_last_account_ids 5
# Session is stored as a list in a cookie so we want to limit numbers
# of items in the list to avoid hitting cookie size limit.
#
# Max cookie size is 4kb. One session is ~460 bytes.
# We also leave space for other cookies.
@remember_last_sessions 6
# Session Management
def put_account_session(%Plug.Conn{} = conn, :client, _account_id, _encoded_fragment) do
conn
end
def put_account_session(%Plug.Conn{} = conn, :browser, account_id, encoded_fragment) do
session = {:browser, account_id, encoded_fragment}
sessions =
Plug.Conn.get_session(conn, :sessions, [])
|> Enum.reject(fn {session_context_type, session_account_id, _encoded_fragment} ->
session_context_type == :browser and session_account_id == account_id
end)
sessions = Enum.take(sessions ++ [session], -1 * @remember_last_sessions)
Plug.Conn.put_session(conn, :sessions, sessions)
end
defp delete_account_session(conn, context_type, account_id) do
sessions =
Plug.Conn.get_session(conn, :sessions, [])
|> Enum.reject(fn {session_context_type, session_account_id, _encoded_fragment} ->
session_context_type == context_type and session_account_id == account_id
end)
Plug.Conn.put_session(conn, :sessions, sessions)
end
# Signing In and Out
@doc """
Returns non-empty parameters that should be persisted during sign in flow.
"""
def take_sign_in_params(params) do
params
|> Map.take(["as", "state", "nonce", "redirect_to"])
|> Map.reject(fn {_key, value} -> value in ["", nil] end)
end
@doc """
Takes sign in parameters returned by `take_sign_in_params/1` and
returns the appropriate auth context type for them.
"""
def fetch_auth_context_type!(%{"as" => "client"}), do: :client
def fetch_auth_context_type!(_params), do: :browser
def fetch_token_nonce!(%{"nonce" => nonce}), do: nonce
def fetch_token_nonce!(_params), do: nil
@doc """
Persists the token in the session and redirects the user depending on the
auth context type.
The browser users are sent to authenticated home or a return path if it's stored in params.
The account users are only expected to authenticate using client apps and are redirected
to the deep link.
"""
def signed_in(
%Plug.Conn{} = conn,
%Auth.Provider{} = provider,
%Auth.Identity{} = identity,
context,
encoded_fragment,
redirect_params
) do
redirect_params = take_sign_in_params(redirect_params)
conn = prepend_recent_account_ids(conn, provider.account_id)
if is_nil(redirect_params["as"]) and identity.actor.type == :account_user do
conn
|> Phoenix.Controller.put_flash(
:error,
"Please use a client application to access Firezone."
)
|> Phoenix.Controller.redirect(to: ~p"/#{conn.path_params["account_id_or_slug"]}")
|> Plug.Conn.halt()
else
conn
|> put_account_session(context.type, provider.account_id, encoded_fragment)
|> signed_in_redirect(identity, context, encoded_fragment, redirect_params)
end
end
defp signed_in_redirect(conn, identity, %Auth.Context{type: :client}, encoded_fragment, %{
"as" => "client",
"nonce" => _nonce,
"state" => state
}) do
query =
%{
fragment: encoded_fragment,
state: state,
actor_name: identity.actor.name,
identity_provider_identifier: identity.provider_identifier
}
|> Enum.reject(&is_nil(elem(&1, 1)))
Phoenix.Controller.redirect(conn,
to: ~p"/#{conn.assigns.account.slug}/sign_in/success?#{query}"
)
end
defp signed_in_redirect(
conn,
_identity,
%Auth.Context{type: :client},
_encoded_fragment,
_params
) do
conn
|> Phoenix.Controller.put_flash(:error, "Please use a client application to access Firezone.")
|> Phoenix.Controller.redirect(to: ~p"/#{conn.path_params["account_id_or_slug"]}")
|> Plug.Conn.halt()
end
defp signed_in_redirect(
conn,
_identity,
%Auth.Context{type: :browser},
_encoded_fragment,
redirect_params
) do
account = conn.assigns.account
redirect_to = signed_in_path(account, redirect_params)
Phoenix.Controller.redirect(conn, to: redirect_to)
end
defp signed_in_path(%Accounts.Account{} = account, %{"redirect_to" => redirect_to})
when is_binary(redirect_to) do
if String.starts_with?(redirect_to, "/#{account.id}") or
String.starts_with?(redirect_to, "/#{account.slug}") do
redirect_to
else
signed_in_path(account)
end
end
defp signed_in_path(%Accounts.Account{} = account, _redirect_params) do
signed_in_path(account)
end
defp signed_in_path(%Accounts.Account{} = account) do
~p"/#{account}/sites"
end
@doc """
Logs the user out.
It clears all session data for safety. See `renew_session/1`.
"""
def sign_out(%Plug.Conn{} = conn, params) do
account_or_slug = Map.get(conn.assigns, :account) || params["account_id_or_slug"]
conn
|> renew_session()
|> sign_out_redirect(account_or_slug, params)
end
defp sign_out_redirect(
%{assigns: %{subject: %Auth.Subject{} = subject}} = conn,
account_or_slug,
params
) do
post_sign_out_url = post_sign_out_url(account_or_slug, params)
{:ok, _identity, redirect_url} = Auth.sign_out(subject, post_sign_out_url)
Phoenix.Controller.redirect(conn, external: redirect_url)
end
defp sign_out_redirect(conn, account_or_slug, params) do
post_sign_out_url = post_sign_out_url(account_or_slug, params)
Phoenix.Controller.redirect(conn, external: post_sign_out_url)
end
defp post_sign_out_url(_account_or_slug, %{"as" => "client", "state" => state}) do
"firezone://handle_client_sign_out_callback?state=#{state}"
end
defp post_sign_out_url(account_or_slug, _params) do
url(~p"/#{account_or_slug}")
end
@doc """
This function renews the session ID to avoid fixation attacks
and erases the session token from the sessions list.
"""
def renew_session(%Plug.Conn{} = conn) do
preferred_locale = Plug.Conn.get_session(conn, :preferred_locale)
account_id = if Map.get(conn.assigns, :account), do: conn.assigns.account.id
sessions =
Plug.Conn.get_session(conn, :sessions, [])
|> Enum.reject(fn {_, session_account_id, _} ->
session_account_id == account_id
end)
|> Enum.take(-1 * @remember_last_sessions)
conn
|> Plug.Conn.configure_session(renew: true)
|> Plug.Conn.clear_session()
|> Plug.Conn.put_session(:preferred_locale, preferred_locale)
|> Plug.Conn.put_session(:sessions, sessions)
end
###########################
## Controller Helpers
###########################
def list_recent_account_ids(conn) do
conn = Plug.Conn.fetch_cookies(conn, signed: [@recent_accounts_cookie_name])
if recent_account_ids = Map.get(conn.cookies, @recent_accounts_cookie_name) do
{:ok, :erlang.binary_to_term(recent_account_ids, [:safe]), conn}
else
{:ok, [], conn}
end
end
defp prepend_recent_account_ids(conn, account_id) do
update_recent_account_ids(conn, fn recent_account_ids ->
[account_id] ++ recent_account_ids
end)
end
def update_recent_account_ids(conn, callback) when is_function(callback, 1) do
{:ok, recent_account_ids, conn} = list_recent_account_ids(conn)
recent_account_ids =
recent_account_ids
|> callback.()
|> Enum.take(@remember_last_account_ids)
|> :erlang.term_to_binary()
Plug.Conn.put_resp_cookie(
conn,
@recent_accounts_cookie_name,
recent_account_ids,
@recent_accounts_cookie_options
)
end
###########################
## Plugs
###########################
@doc """
Fetches the user agent value from headers and assigns it the connection.
"""
def fetch_user_agent(%Plug.Conn{} = conn, _opts) do
case Plug.Conn.get_req_header(conn, "user-agent") do
[user_agent | _] -> Plug.Conn.assign(conn, :user_agent, user_agent)
_ -> conn
end
end
def fetch_account(%Plug.Conn{path_info: [account_id_or_slug | _]} = conn, _opts) do
case Accounts.fetch_account_by_id_or_slug(account_id_or_slug) do
{:ok, account} -> Plug.Conn.assign(conn, :account, account)
_ -> conn
end
end
def fetch_account(%Plug.Conn{} = conn, _opts) do
conn
end
@doc """
Fetches the session token from the session and assigns the subject to the connection.
"""
def fetch_subject(%Plug.Conn{} = conn, _opts) do
params = take_sign_in_params(conn.params)
context_type = fetch_auth_context_type!(params)
context = get_auth_context(conn, context_type)
if account = Map.get(conn.assigns, :account) do
sessions = Plug.Conn.get_session(conn, :sessions, [])
with {:ok, encoded_fragment} <- fetch_token(sessions, account.id, context.type),
{:ok, subject} <- Auth.authenticate(encoded_fragment, context),
true <- subject.account.id == account.id do
conn
|> Plug.Conn.put_session(:live_socket_id, Tokens.socket_id(subject.token_id))
|> Plug.Conn.assign(:subject, subject)
else
{:error, :unauthorized} ->
delete_account_session(conn, context.type, account.id)
_ ->
conn
end
else
conn
end
end
defp fetch_token(sessions, account_id, context_type) do
sessions
|> Enum.find(fn {session_context_type, session_account_id, _encoded_fragment} ->
session_context_type == context_type and session_account_id == account_id
end)
|> case do
{_context_type, _account_id, encoded_fragment} -> {:ok, encoded_fragment}
_ -> :error
end
end
def get_auth_context(%Plug.Conn{} = conn, type) do
{location_region, location_city, {location_lat, location_lon}} =
get_load_balancer_ip_location(conn)
%Auth.Context{
type: type,
user_agent: Map.get(conn.assigns, :user_agent),
remote_ip: conn.remote_ip,
remote_ip_location_region: location_region,
remote_ip_location_city: location_city,
remote_ip_location_lat: location_lat,
remote_ip_location_lon: location_lon
}
end
defp get_load_balancer_ip_location(%Plug.Conn{} = conn) do
location_region =
case Plug.Conn.get_req_header(conn, "x-geo-location-region") do
["" | _] -> nil
[location_region | _] -> location_region
[] -> nil
end
location_city =
case Plug.Conn.get_req_header(conn, "x-geo-location-city") do
["" | _] -> nil
[location_city | _] -> location_city
[] -> nil
end
{location_lat, location_lon} =
case Plug.Conn.get_req_header(conn, "x-geo-location-coordinates") do
["" | _] ->
{nil, nil}
["," | _] ->
{nil, nil}
[coordinates | _] ->
[lat, lon] = String.split(coordinates, ",", parts: 2)
lat = String.to_float(lat)
lon = String.to_float(lon)
{lat, lon}
[] ->
{nil, nil}
end
{location_lat, location_lon} =
Domain.Geo.maybe_put_default_coordinates(location_region, {location_lat, location_lon})
{location_region, location_city, {location_lat, location_lon}}
end
defp get_load_balancer_ip_location(x_headers) do
location_region =
case get_socket_header(x_headers, "x-geo-location-region") do
{"x-geo-location-region", ""} -> nil
{"x-geo-location-region", location_region} -> location_region
_other -> nil
end
location_city =
case get_socket_header(x_headers, "x-geo-location-city") do
{"x-geo-location-city", ""} -> nil
{"x-geo-location-city", location_city} -> location_city
_other -> nil
end
{location_lat, location_lon} =
case get_socket_header(x_headers, "x-geo-location-coordinates") do
{"x-geo-location-coordinates", ""} ->
{nil, nil}
{"x-geo-location-coordinates", coordinates} ->
[lat, lon] = String.split(coordinates, ",", parts: 2)
lat = String.to_float(lat)
lon = String.to_float(lon)
{lat, lon}
_other ->
{nil, nil}
end
{location_lat, location_lon} =
Domain.Geo.maybe_put_default_coordinates(location_region, {location_lat, location_lon})
{location_region, location_city, {location_lat, location_lon}}
end
defp get_socket_header(x_headers, key) do
List.keyfind(x_headers, key, 0)
end
@doc """
Used for routes that require the user to not be authenticated.
"""
def redirect_if_user_is_authenticated(%Plug.Conn{} = conn, _opts) do
if subject = conn.assigns[:subject] do
redirect_params = take_sign_in_params(conn.params)
encoded_fragment = fetch_subject_token!(conn, subject)
identity = %{subject.identity | actor: subject.actor}
conn
|> signed_in_redirect(identity, subject.context, encoded_fragment, redirect_params)
|> Plug.Conn.halt()
else
conn
end
end
defp fetch_subject_token!(conn, %Auth.Subject{} = subject) do
sessions = Plug.Conn.get_session(conn, :sessions, [])
{:ok, encoded_fragment} = fetch_token(sessions, subject.account.id, subject.context.type)
encoded_fragment
end
@doc """
Used for routes that require the user to be authenticated.
This plug will only work if there is an `account_id` in the path params.
"""
def ensure_authenticated(%Plug.Conn{} = conn, _opts) do
if conn.assigns[:subject] do
conn
else
redirect_params = maybe_store_return_to(conn)
conn
|> Phoenix.Controller.put_flash(:error, "You must sign in to access this page.")
|> Phoenix.Controller.redirect(
to: ~p"/#{conn.path_params["account_id_or_slug"]}?#{redirect_params}"
)
|> Plug.Conn.halt()
end
end
@doc """
Used for routes that require the user to be authenticated as a specific kind of actor.
This plug will only work if there is an `account_id` in the path params.
"""
def ensure_authenticated_actor_type(%Plug.Conn{} = conn, type) do
if not is_nil(conn.assigns[:subject]) and conn.assigns[:subject].actor.type == type do
conn
else
conn
|> Web.FallbackController.call({:error, :not_found})
|> Plug.Conn.halt()
end
end
defp maybe_store_return_to(%{method: "GET"} = conn) do
%{"redirect_to" => Phoenix.Controller.current_path(conn)}
end
defp maybe_store_return_to(_conn) do
%{}
end
###########################
## LiveView
###########################
@doc """
Handles mounting and authenticating the actor in LiveViews.
Notice: every protected route should have `account_id` in the path params.
## `on_mount` arguments
* `:mount_subject` - assigns user_agent and subject to the socket assigns based on
session_token, or nil if there's no session_token or no matching user.
* `:ensure_authenticated` - authenticates the user from the session,
and assigns the subject to socket assigns based on session_token.
Redirects to login page if there's no logged user.
* `:redirect_if_user_is_authenticated` - authenticates the user from the session.
Redirects to signed in path if there's a logged user.
* `:mount_account` - takes `account_id` from path params and loads the given account
into the socket assigns using the `subject` mounted via `:mount_subject`. This is useful
because some actions can be performed by superadmin users on behalf of other accounts
so we can't really rely on `subject.account` in a lot of places.
## Examples
Use the `on_mount` lifecycle macro in LiveViews to mount or authenticate
the subject:
defmodule Web.Page do
use Web, :live_view
on_mount {Web.UserAuth, :mount_subject}
...
end
Or use the `live_session` of your router to invoke the on_mount callback:
live_session :authenticated, on_mount: [{Web.UserAuth, :ensure_authenticated}] do
live "/:account_id/profile", ProfileLive, :index
end
"""
def on_mount(:mount_subject, params, session, socket) do
socket = mount_account(socket, params, session)
{:cont, mount_subject(socket, params, session)}
end
def on_mount(:mount_account, params, session, socket) do
{:cont, mount_account(socket, params, session)}
end
def on_mount(:ensure_authenticated, params, session, socket) do
socket = mount_account(socket, params, session)
socket = mount_subject(socket, params, session)
if socket.assigns[:subject] do
{:cont, socket}
else
socket =
socket
|> Phoenix.LiveView.put_flash(:error, "You must sign in to access this page.")
|> Phoenix.LiveView.redirect(to: ~p"/#{params["account_id_or_slug"]}")
{:halt, socket}
end
end
def on_mount(:ensure_account_admin_user_actor, params, session, socket) do
socket = mount_account(socket, params, session)
socket = mount_subject(socket, params, session)
if socket.assigns[:subject].actor.type == :account_admin_user do
{:cont, socket}
else
raise Web.LiveErrors.NotFoundError
end
end
def on_mount(:redirect_if_user_is_authenticated, params, session, socket) do
socket = mount_account(socket, params, session)
socket = mount_subject(socket, params, session)
if socket.assigns[:subject] do
{:halt, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket.assigns.account))}
else
{:cont, socket}
end
end
# TODO: we need to schedule socket expiration for this subject, so that when it expires
# LiveView socket will be disconnected. Otherwise, you can keep using the system as long as
# socket is active extending the session.
defp mount_subject(socket, params, session) do
Phoenix.Component.assign_new(socket, :subject, fn ->
params = take_sign_in_params(params)
context_type = fetch_auth_context_type!(params)
user_agent = Phoenix.LiveView.get_connect_info(socket, :user_agent)
real_ip = real_ip(socket)
x_headers = Phoenix.LiveView.get_connect_info(socket, :x_headers) || []
{location_region, location_city, {location_lat, location_lon}} =
get_load_balancer_ip_location(x_headers)
context = %Auth.Context{
type: context_type,
user_agent: user_agent,
remote_ip: real_ip,
remote_ip_location_region: location_region,
remote_ip_location_city: location_city,
remote_ip_location_lat: location_lat,
remote_ip_location_lon: location_lon
}
sessions = session["sessions"] || []
with account when not is_nil(account) <- Map.get(socket.assigns, :account),
{:ok, encoded_fragment} <- fetch_token(sessions, account.id, context.type),
{:ok, subject} <- Auth.authenticate(encoded_fragment, context),
true <- subject.account.id == account.id do
subject
else
_ -> nil
end
end)
end
defp mount_account(socket, %{"account_id_or_slug" => account_id_or_slug}, _session) do
Phoenix.Component.assign_new(socket, :account, fn ->
with {:ok, account} <-
Accounts.fetch_account_by_id_or_slug(account_id_or_slug) do
account
else
_ -> nil
end
end)
end
def real_ip(socket) do
peer_data = Phoenix.LiveView.get_connect_info(socket, :peer_data)
x_headers = Phoenix.LiveView.get_connect_info(socket, :x_headers)
real_ip =
if is_list(x_headers) and length(x_headers) > 0 do
RemoteIp.from(x_headers, Web.Endpoint.real_ip_opts())
end
real_ip || peer_data.address
end
end