229 lines
6.6 KiB
Elixir
229 lines
6.6 KiB
Elixir
defmodule PlausibleWeb.UserAuth do
|
|
@moduledoc """
|
|
Functions for user session management.
|
|
"""
|
|
|
|
use Plausible
|
|
|
|
alias Plausible.Auth
|
|
alias PlausibleWeb.TwoFactor
|
|
|
|
alias PlausibleWeb.Router.Helpers, as: Routes
|
|
|
|
require Logger
|
|
|
|
on_ee do
|
|
@type login_subject() :: Auth.User.t() | Auth.SSO.Identity.t()
|
|
else
|
|
@type login_subject() :: Auth.User.t()
|
|
end
|
|
|
|
@spec log_in_user(Plug.Conn.t(), login_subject(), String.t() | nil) ::
|
|
Plug.Conn.t()
|
|
def log_in_user(conn, subject, redirect_path \\ nil)
|
|
|
|
def log_in_user(conn, %Auth.User{} = user, redirect_path) do
|
|
redirect_to = login_redirect_path(conn, redirect_path)
|
|
device_name = get_device_name(conn)
|
|
session = Auth.UserSessions.create!(user, device_name)
|
|
|
|
conn
|
|
|> set_user_token(session.token)
|
|
|> set_logged_in_cookie()
|
|
|> Phoenix.Controller.redirect(to: redirect_to)
|
|
end
|
|
|
|
on_ee do
|
|
def log_in_user(conn, %Auth.SSO.Identity{} = identity, redirect_path) do
|
|
case Auth.SSO.provision_user(identity) do
|
|
{:ok, _provisioning_from, team, user} ->
|
|
redirect_to = login_redirect_path(conn, redirect_path)
|
|
device_name = get_device_name(conn)
|
|
session = Auth.UserSessions.create!(user, device_name, timeout_at: identity.expires_at)
|
|
|
|
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, return_to: redirect_path)
|
|
)
|
|
|
|
{:error, :over_limit} ->
|
|
error = "Team can't accept more members. Please contact the owner."
|
|
|
|
conn
|
|
|> log_out_user()
|
|
|> Phoenix.Controller.put_flash(:login_error, error)
|
|
|> Phoenix.Controller.redirect(
|
|
to: Routes.sso_path(conn, :login_form, return_to: redirect_path)
|
|
)
|
|
|
|
{:error, reason, team, user}
|
|
when reason in [:multiple_memberships, :active_personal_team] ->
|
|
redirect_path = Routes.site_path(conn, :index, __team: team.identifier)
|
|
|
|
log_in_user(conn, user, redirect_path)
|
|
end
|
|
end
|
|
end
|
|
|
|
@spec log_out_user(Plug.Conn.t()) :: Plug.Conn.t()
|
|
def log_out_user(conn) do
|
|
case get_user_token(conn) do
|
|
{:ok, token} -> Auth.UserSessions.remove_by_token(token)
|
|
{:error, _} -> :pass
|
|
end
|
|
|
|
if live_socket_id = Plug.Conn.get_session(conn, :live_socket_id) do
|
|
Auth.UserSessions.disconnect_by_token(live_socket_id)
|
|
end
|
|
|
|
conn
|
|
|> renew_session()
|
|
|> clear_logged_in_cookie()
|
|
end
|
|
|
|
@spec get_user_session(Plug.Conn.t() | map()) ::
|
|
{:ok, Auth.UserSession.t()} | {:error, :no_valid_token | :session_not_found}
|
|
def get_user_session(%Plug.Conn{assigns: %{current_user_session: user_session}}) do
|
|
{:ok, user_session}
|
|
end
|
|
|
|
def get_user_session(conn_or_session) do
|
|
with {:ok, token} <- get_user_token(conn_or_session) do
|
|
case Auth.UserSessions.get_by_token(token) do
|
|
{:ok, session} -> {:ok, session}
|
|
{:error, :not_found} -> {:error, :session_not_found}
|
|
end
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Sets the `logged_in` cookie share with the static site for determining
|
|
whether client is authenticated.
|
|
|
|
As it's a separate cookie, there's a chance it might fall out of sync
|
|
with session cookie state due to manual deletion or premature expiration.
|
|
"""
|
|
@spec set_logged_in_cookie(Plug.Conn.t()) :: Plug.Conn.t()
|
|
def set_logged_in_cookie(conn) do
|
|
Plug.Conn.put_resp_cookie(conn, "logged_in", "true",
|
|
http_only: false,
|
|
max_age: 60 * 60 * 24 * 365 * 5000
|
|
)
|
|
end
|
|
|
|
defp set_user_token(conn, token) do
|
|
conn
|
|
|> renew_session()
|
|
|> TwoFactor.Session.clear_2fa_user()
|
|
|> put_token_in_session(token)
|
|
end
|
|
|
|
defp login_redirect_path(conn, redirect_path) do
|
|
if String.starts_with?(redirect_path || "", "/") do
|
|
redirect_path
|
|
else
|
|
Routes.site_path(conn, :index)
|
|
end
|
|
end
|
|
|
|
defp renew_session(conn) do
|
|
Phoenix.Controller.delete_csrf_token()
|
|
|
|
conn
|
|
|> Plug.Conn.configure_session(renew: true)
|
|
|> Plug.Conn.clear_session()
|
|
end
|
|
|
|
defp clear_logged_in_cookie(conn) do
|
|
Plug.Conn.delete_resp_cookie(conn, "logged_in")
|
|
end
|
|
|
|
defp put_token_in_session(conn, token) do
|
|
conn
|
|
|> Plug.Conn.put_session(:user_token, token)
|
|
|> Plug.Conn.put_session(:live_socket_id, Auth.UserSessions.socket_id(token))
|
|
end
|
|
|
|
defp get_user_token(%Plug.Conn{} = conn) do
|
|
conn
|
|
|> Plug.Conn.get_session()
|
|
|> get_user_token()
|
|
end
|
|
|
|
defp get_user_token(%{"user_token" => token}) when is_binary(token) do
|
|
{:ok, token}
|
|
end
|
|
|
|
defp get_user_token(_) do
|
|
{:error, :no_valid_token}
|
|
end
|
|
|
|
@unknown_label "Unknown"
|
|
|
|
defp get_device_name(%Plug.Conn{} = conn) do
|
|
conn
|
|
|> Plug.Conn.get_req_header("user-agent")
|
|
|> List.first()
|
|
|> get_device_name()
|
|
end
|
|
|
|
defp get_device_name(user_agent) when is_binary(user_agent) do
|
|
case UAInspector.parse(user_agent) do
|
|
%UAInspector.Result{client: %UAInspector.Result.Client{name: "Headless Chrome"}} ->
|
|
"Headless Chrome"
|
|
|
|
%UAInspector.Result.Bot{name: name} when is_binary(name) ->
|
|
name
|
|
|
|
%UAInspector.Result{} = ua ->
|
|
browser = browser_name(ua)
|
|
|
|
if os = os_name(ua) do
|
|
browser <> " (#{os})"
|
|
else
|
|
browser
|
|
end
|
|
|
|
_ ->
|
|
@unknown_label
|
|
end
|
|
end
|
|
|
|
defp get_device_name(_), do: @unknown_label
|
|
|
|
defp browser_name(ua) do
|
|
case ua.client do
|
|
:unknown -> @unknown_label
|
|
%UAInspector.Result.Client{name: "Mobile Safari"} -> "Safari"
|
|
%UAInspector.Result.Client{name: "Chrome Mobile"} -> "Chrome"
|
|
%UAInspector.Result.Client{name: "Chrome Mobile iOS"} -> "Chrome"
|
|
%UAInspector.Result.Client{name: "Firefox Mobile"} -> "Firefox"
|
|
%UAInspector.Result.Client{name: "Firefox Mobile iOS"} -> "Firefox"
|
|
%UAInspector.Result.Client{name: "Opera Mobile"} -> "Opera"
|
|
%UAInspector.Result.Client{name: "Opera Mini"} -> "Opera"
|
|
%UAInspector.Result.Client{name: "Opera Mini iOS"} -> "Opera"
|
|
%UAInspector.Result.Client{name: "Yandex Browser Lite"} -> "Yandex Browser"
|
|
%UAInspector.Result.Client{name: "Chrome Webview"} -> "Mobile App"
|
|
%UAInspector.Result.Client{type: "mobile app"} -> "Mobile App"
|
|
client -> client.name || @unknown_label
|
|
end
|
|
end
|
|
|
|
defp os_name(ua) do
|
|
case ua.os do
|
|
:unknown -> nil
|
|
os -> os.name
|
|
end
|
|
end
|
|
end
|