mirror of
https://github.com/plausible/analytics.git
synced 2024-12-29 04:22:34 +03:00
373d4dd665
* Turn `Plausible.Auth.UserSession` into full schema * Implement token based sessions and use them as default * Ignore expired user sessions during retrieval from DB * Implement plug bumping user session last used and timeout timestamps * Implement Oban worker removing expired user sessions with grace period * Implement legacy session conversion on touch, when applicable * Update `UserAuth` moduledoc * Extend `UserAuth` tests to account for db-backed session tokens * Update CHANGELOG * Add tests for `UserSessionTouch` plug * Add test for `CleanUserSessions` worker * Add logging of legacy session retrievals * Use single update permitting stale records when touching user session * Don't fetch session and user for external API endpoints (/api/event too) * Refactor `Users.with_subscription/1` and expose helper query * Skip fetching session in legacy `SessionTimeoutPlug` * Rely on user session assign from `AuthContext` in `SentryContext` * Silence legacy session warnings in `UserSessionTouchTest` * Rely on session assign from `AuthPlug` in `SuperAdminOnlyPlug` * Change `UserAuth` to get session, user and last subscription in one go * Avoid refetching user session in `AuthorizeSiteAccess` plug * Fix code formatting * Refactor `UserAuth.get_user_token/1` (h/t @aerosol) * Remove bogus empty opts from `scope` declarations in router * Only touch session once an hour and keep `user.last_seen` in sync * Bring back logging of legacy token use
269 lines
7.6 KiB
Elixir
269 lines
7.6 KiB
Elixir
defmodule PlausibleWeb.UserAuth do
|
|
@moduledoc """
|
|
Functions for user session management.
|
|
|
|
In it's current shape, both current (legacy) and soon to be implemented (new)
|
|
user sessions are supported side by side.
|
|
|
|
The legacy token is still accepted from the session cookie. Once 14 days
|
|
pass (the current time window for which session cookie is valid without
|
|
any activity), the legacy cookies won't be accepted anymore (legacy token
|
|
retrieval is tracked with logging) and the logic will be cleaned of branching
|
|
for legacy session.
|
|
"""
|
|
|
|
import Ecto.Query, only: [from: 2]
|
|
|
|
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()) :: Auth.UserSession.t()
|
|
def touch_user_session(%{token: nil} = user_session) do
|
|
# NOTE: Legacy token sessions can't be touched.
|
|
user_session
|
|
end
|
|
|
|
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
|
|
|
|
@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
|
|
|
|
@spec convert_legacy_session(Plug.Conn.t()) :: Plug.Conn.t()
|
|
def convert_legacy_session(conn) do
|
|
current_user = conn.assigns[:current_user]
|
|
|
|
if current_user && Plug.Conn.get_session(conn, :current_user_id) do
|
|
{token, user_session} = create_user_session(conn, current_user)
|
|
|
|
conn
|
|
|> put_token_in_session(token)
|
|
|> Plug.Conn.delete_session(:current_user_id)
|
|
|> Plug.Conn.assign(:current_user_session, user_session)
|
|
else
|
|
conn
|
|
end
|
|
end
|
|
|
|
defp get_session_by_token({:legacy, user_id}) do
|
|
case Plausible.Users.with_subscription(user_id) do
|
|
%Auth.User{} = user ->
|
|
{:ok, %Auth.UserSession{user_id: user.id, user: user}}
|
|
|
|
nil ->
|
|
{:error, :session_not_found}
|
|
end
|
|
end
|
|
|
|
defp get_session_by_token({:new, 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, {:new, token}) do
|
|
conn
|
|
|> Plug.Conn.put_session(:user_token, token)
|
|
|> Plug.Conn.put_session(:live_socket_id, "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, {:new, token}}
|
|
end
|
|
|
|
defp get_user_token(%{"current_user_id" => user_id}) when is_integer(user_id) do
|
|
Logger.warning("Legacy user session detected (user: #{user_id})")
|
|
{:ok, {:legacy, user_id}}
|
|
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!()
|
|
|
|
{{:new, user_session.token}, user_session}
|
|
end
|
|
|
|
defp remove_user_session({:legacy, _}), do: :ok
|
|
|
|
defp remove_user_session({:new, 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
|