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] -> issue = to_string(reason) <> "_noforce" conn |> log_out_user() |> Phoenix.Controller.redirect( to: Routes.sso_path(conn, :provision_issue, issue: issue) ) 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} | {:error, :session_expired, Auth.UserSession.t()} 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} {:error, :expired, user_session} -> {:error, :session_expired, user_session} 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