mirror of
https://github.com/plausible/analytics.git
synced 2024-12-26 11:02:52 +03:00
6940281d66
* Enable exceptions when revoking all user sessions * Add `User` changeset for changing password * Make button in `2fa_input` component optional * Implement password change from User Settings * Add tests * Fix 2FA modal cancel button formatting * Update changelog * Don't pass redundant params to `render_settings` and clean up code a bit * Render one error per field in password reset form * Refactor inline form 2FA validation --------- Co-authored-by: Adrian Gruntkowski <adrian.gruntkowski@gmail.com>
266 lines
7.1 KiB
Elixir
266 lines
7.1 KiB
Elixir
defmodule PlausibleWeb.UserAuth do
|
|
@moduledoc """
|
|
Functions for user session management.
|
|
"""
|
|
|
|
import Ecto.Query
|
|
|
|
alias Plausible.Auth
|
|
alias Plausible.Repo
|
|
alias PlausibleWeb.TwoFactor
|
|
|
|
alias PlausibleWeb.Router.Helpers, as: Routes
|
|
|
|
require Logger
|
|
|
|
@spec log_in_user(Plug.Conn.t(), Auth.User.t(), String.t() | nil) :: Plug.Conn.t()
|
|
def log_in_user(conn, user, redirect_path \\ nil) do
|
|
login_dest =
|
|
redirect_path || Plug.Conn.get_session(conn, :login_dest) || Routes.site_path(conn, :index)
|
|
|
|
conn
|
|
|> set_user_session(user)
|
|
|> set_logged_in_cookie()
|
|
|> Phoenix.Controller.redirect(external: login_dest)
|
|
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} -> remove_user_session(token)
|
|
{:error, _} -> :pass
|
|
end
|
|
|
|
if live_socket_id = Plug.Conn.get_session(conn, :live_socket_id) do
|
|
PlausibleWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
|
|
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
|
|
get_session_by_token(token)
|
|
end
|
|
end
|
|
|
|
@spec touch_user_session(Auth.UserSession.t(), NaiveDateTime.t()) :: Auth.UserSession.t()
|
|
def touch_user_session(user_session, now \\ NaiveDateTime.utc_now(:second)) do
|
|
if NaiveDateTime.diff(now, user_session.last_used_at, :hour) >= 1 do
|
|
Plausible.Users.bump_last_seen(user_session.user_id, now)
|
|
|
|
user_session
|
|
|> Auth.UserSession.touch_session(now)
|
|
|> Repo.update!(allow_stale: true)
|
|
else
|
|
user_session
|
|
end
|
|
end
|
|
|
|
@spec revoke_user_session(Auth.User.t(), pos_integer()) :: :ok
|
|
def revoke_user_session(user, session_id) do
|
|
{_, tokens} =
|
|
Repo.delete_all(
|
|
from us in Auth.UserSession,
|
|
where: us.user_id == ^user.id and us.id == ^session_id,
|
|
select: us.token
|
|
)
|
|
|
|
case tokens do
|
|
[token] ->
|
|
PlausibleWeb.Endpoint.broadcast(live_socket_id(token), "disconnect", %{})
|
|
|
|
_ ->
|
|
:pass
|
|
end
|
|
|
|
:ok
|
|
end
|
|
|
|
@spec revoke_all_user_sessions(Auth.User.t(), Keyword.t()) :: :ok
|
|
def revoke_all_user_sessions(user, opts \\ []) do
|
|
except = Keyword.get(opts, :except)
|
|
|
|
delete_query = from us in Auth.UserSession, where: us.user_id == ^user.id, select: us.token
|
|
|
|
delete_query =
|
|
if except do
|
|
where(delete_query, [us], us.id != ^except.id)
|
|
else
|
|
delete_query
|
|
end
|
|
|
|
{_count, tokens} = Repo.delete_all(delete_query)
|
|
|
|
Enum.each(tokens, fn token ->
|
|
PlausibleWeb.Endpoint.broadcast(live_socket_id(token), "disconnect", %{})
|
|
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 get_session_by_token(token) do
|
|
now = NaiveDateTime.utc_now(:second)
|
|
|
|
last_subscription_query = Plausible.Users.last_subscription_join_query()
|
|
|
|
token_query =
|
|
from(us in Auth.UserSession,
|
|
inner_join: u in assoc(us, :user),
|
|
as: :user,
|
|
left_lateral_join: s in subquery(last_subscription_query),
|
|
on: true,
|
|
where: us.token == ^token and us.timeout_at > ^now,
|
|
preload: [user: {u, subscription: s}]
|
|
)
|
|
|
|
case Repo.one(token_query) do
|
|
%Auth.UserSession{} = user_session ->
|
|
{:ok, user_session}
|
|
|
|
nil ->
|
|
{:error, :session_not_found}
|
|
end
|
|
end
|
|
|
|
defp set_user_session(conn, user) do
|
|
{token, _} = create_user_session(conn, user)
|
|
|
|
conn
|
|
|> renew_session()
|
|
|> TwoFactor.Session.clear_2fa_user()
|
|
|> put_token_in_session(token)
|
|
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, live_socket_id(token))
|
|
end
|
|
|
|
defp live_socket_id(token) do
|
|
"user_sessions:#{Base.url_encode64(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
|
|
|
|
defp create_user_session(conn, user) do
|
|
device_name = get_device_name(conn)
|
|
|
|
user_session =
|
|
user
|
|
|> Auth.UserSession.new_session(device_name)
|
|
|> Repo.insert!()
|
|
|
|
{user_session.token, user_session}
|
|
end
|
|
|
|
defp remove_user_session(token) do
|
|
Repo.delete_all(from us in Auth.UserSession, where: us.token == ^token)
|
|
:ok
|
|
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
|