defmodule Dialup.Auth do @moduledoc """ Authentication helpers for Dialup applications. Dialup separates two session concepts: - `dialup_session` — connection ID for `UserSessionProcess` (anonymous until enriched) - `_dialup_user_token` — opaque auth token resolved to `current_user` Configure an accounts module on the app or plug: use Dialup, auth_accounts: MyApp.Accounts plugs: [{Dialup.Auth.Plug, accounts: MyApp.Accounts}] """ @user_token_cookie "_dialup_user_token" @csrf_cookie "_dialup_csrf" @doc "Cookie name for the opaque user session token." def user_token_cookie, do: @user_token_cookie @doc "Cookie name for CSRF double-submit." def csrf_cookie, do: @csrf_cookie @doc "Returns the configured accounts module for an app, if any." @spec accounts_module(atom()) :: module() | nil def accounts_module(app_module) when is_atom(app_module) do if function_exported?(app_module, :__auth_accounts__, 0) do app_module.__auth_accounts__() end end @doc "Resolves the current user from conn assigns (set by Dialup.Auth.Plug)." @spec current_user(Plug.Conn.t()) :: map() | struct() | nil def current_user(%Plug.Conn{assigns: %{dialup_current_user: user}}), do: user def current_user(%Plug.Conn{}), do: nil @doc "Fetches the current user from assigns or cookies." @spec fetch_current_user(Plug.Conn.t(), module()) :: {Plug.Conn.t(), map() | struct() | nil} def fetch_current_user(conn, accounts_module) do conn = Plug.Conn.fetch_cookies(conn) user = current_user(conn) || user_from_cookies(conn.cookies, accounts_module) {Plug.Conn.assign(conn, :dialup_current_user, user), user} end @doc "Returns the opaque auth session token from cookies, if present." @spec session_token_from_cookies(map()) :: binary() | nil def session_token_from_cookies(cookies) when is_map(cookies) do Map.get(cookies, @user_token_cookie) end @doc "Resolves a user from request cookies using the accounts module." @spec user_from_cookies(map(), module()) :: map() | struct() | nil def user_from_cookies(cookies, accounts_module) when is_map(cookies) do case session_token_from_cookies(cookies) do nil -> nil token -> accounts_module.get_user_by_session_token(token) end end @doc """ Resolves the current user from plug assigns or cookies. Matches HTTP page rendering: prefers `dialup_current_user` from `Dialup.Auth.Plug`, then falls back to cookie resolution when `auth_accounts` is configured. """ @spec resolve_conn_user(Plug.Conn.t(), atom()) :: map() | struct() | nil def resolve_conn_user(conn, app_module) do case current_user(conn) do nil -> conn = Plug.Conn.fetch_cookies(conn) case accounts_module(app_module) do nil -> nil accounts -> user_from_cookies(conn.cookies, accounts) end user -> user end end @doc "Returns the opaque auth session token from a connection's cookies." @spec session_token(Plug.Conn.t()) :: binary() | nil def session_token(conn) do conn = Plug.Conn.fetch_cookies(conn) session_token_from_cookies(conn.cookies) end @doc "Seeds layout session with `current_user` before layout.mount runs." @spec initial_session(map() | struct() | nil) :: map() def initial_session(nil), do: %{} def initial_session(user), do: %{current_user: user} @doc "Merges auth context into an existing session map." @spec build_layout_session(map(), map()) :: map() def build_layout_session(session, %{current_user: user}) when not is_nil(user) do Map.put(session, :current_user, user) end def build_layout_session(session, _auth_context), do: session @doc "Projects a user for Dialup session / agent_state (no secrets)." @spec public_user(map() | struct() | nil) :: map() | nil def public_user(nil), do: nil def public_user(user) do id = Map.get(user, :id) || Map.get(user, "id") email = Map.get(user, :email) || Map.get(user, "email") role = Map.get(user, :role) || Map.get(user, "role") || :member %{id: id, email: email, role: role} end @doc "Returns cookie options aligned with the app's `:secure_cookies` setting." @spec cookie_opts(Plug.Conn.t()) :: keyword() def cookie_opts(conn) do app = conn.private[:dialup_app] [http_only: true, same_site: "Lax", secure: secure_cookie?(conn, app)] end @doc "Returns a CSRF token for forms, setting the cookie when needed." @spec csrf_token(Plug.Conn.t()) :: {Plug.Conn.t(), binary()} def csrf_token(conn) do conn = Plug.Conn.fetch_cookies(conn) case conn.cookies[@csrf_cookie] do token when is_binary(token) and token != "" -> {conn, token} _ -> token = Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false) conn = Plug.Conn.put_resp_cookie(conn, @csrf_cookie, token, cookie_opts(conn)) {conn, token} end end @doc "Returns a same-origin relative path or `/` when the target is unsafe." @spec safe_redirect_path(term()) :: binary() def safe_redirect_path(path) when is_binary(path) do if safe_local_redirect?(path), do: path, else: "/" end def safe_redirect_path(_path), do: "/" @doc "Validates a CSRF token from form params against the cookie." @spec valid_csrf?(Plug.Conn.t(), map()) :: boolean() def valid_csrf?(conn, params) when is_map(params) do conn = Plug.Conn.fetch_cookies(conn) cookie = conn.cookies[@csrf_cookie] param = Map.get(params, "csrf_token") || Map.get(params, :csrf_token) is_binary(cookie) and is_binary(param) and cookie != "" and Plug.Crypto.secure_compare(cookie, param) end @doc "Logs a user in by issuing the auth cookie and rotating dialup_session." @spec log_in_user(Plug.Conn.t(), map() | struct(), module()) :: Plug.Conn.t() def log_in_user(conn, user, accounts_module) do token = accounts_module.generate_user_session_token(user) opts = Keyword.merge(cookie_opts(conn), max_age: Dialup.Auth.SessionToken.ttl(:session)) conn |> Plug.Conn.put_resp_cookie(@user_token_cookie, token, opts) |> Plug.Conn.assign(:dialup_current_user, user) |> sync_connection_sessions(user, auth_session_token: token) end @doc "Logs a user out, clearing the auth cookie and rotating dialup_session." @spec log_out_user(Plug.Conn.t(), module()) :: Plug.Conn.t() def log_out_user(conn, accounts_module) do conn = Plug.Conn.fetch_cookies(conn) opts = cookie_opts(conn) old_session_id = conn.cookies["dialup_session"] tokens_to_revoke = [conn.cookies[@user_token_cookie]] |> then(fn tokens -> if is_binary(old_session_id) and old_session_id != "" do tokens ++ Dialup.UserSessionProcess.live_auth_session_tokens(old_session_id) else tokens end end) |> Enum.reject(&is_nil/1) |> Enum.uniq() |> Enum.each(&accounts_module.delete_session_token/1) conn |> Plug.Conn.delete_resp_cookie(@user_token_cookie, opts) |> Plug.Conn.assign(:dialup_current_user, nil) |> sync_connection_sessions(nil) end @doc "Halts with a redirect when no user is present." @spec require_authenticated_user(Plug.Conn.t(), binary()) :: Plug.Conn.t() def require_authenticated_user(conn, login_path \\ "/log_in") do if current_user(conn) do conn else conn |> Plug.Conn.put_resp_header("location", safe_redirect_path(login_path)) |> Plug.Conn.send_resp(302, "") |> Plug.Conn.halt() end end @doc "Rotates the anonymous connection session cookie (session fixation mitigation)." @spec rotate_dialup_session(Plug.Conn.t()) :: Plug.Conn.t() def rotate_dialup_session(conn) do Plug.Conn.put_resp_cookie( conn, "dialup_session", new_connection_session_id(), cookie_opts(conn) ) end @doc false def sync_connection_sessions(conn, auth_user, opts \\ []) do conn = Plug.Conn.fetch_cookies(conn) old_session_id = conn.cookies["dialup_session"] if is_binary(old_session_id) and old_session_id != "" do broadcast_opts = case Keyword.fetch(opts, :auth_session_token) do {:ok, token} -> [auth_session_token: token] :error -> [] end Dialup.UserSessionProcess.broadcast_auth_user(old_session_id, auth_user, broadcast_opts) end new_session_id = new_connection_session_id() conn = Plug.Conn.put_resp_cookie(conn, "dialup_session", new_session_id, cookie_opts(conn)) if is_binary(old_session_id) and old_session_id != "" do Dialup.UserSessionProcess.rotate_connection_session_id(old_session_id, new_session_id) end conn end defp new_connection_session_id do :crypto.strong_rand_bytes(16) |> Base.url_encode64(padding: false) end defp secure_cookie?(conn, app_module) do case secure_cookies_mode(app_module) do true -> true false -> false :auto -> https_request?(conn) _ -> https_request?(conn) end end defp secure_cookies_mode(nil), do: :auto defp secure_cookies_mode(app_module) do if function_exported?(app_module, :__secure_cookies__, 0), do: app_module.__secure_cookies__(), else: :auto end defp https_request?(conn) do conn.scheme == :https or forwarded_proto(conn) == "https" end defp safe_local_redirect?(path) do local_path?(path) and local_path?(fully_decode(path)) rescue ArgumentError -> false end defp fully_decode(path, remaining \\ 5) defp fully_decode(path, 0), do: path defp fully_decode(path, remaining) do decoded = URI.decode(path) if decoded == path do path else fully_decode(decoded, remaining - 1) end end defp local_path?(path) do String.starts_with?(path, "/") and not String.starts_with?(path, "//") and not String.contains?(path, "\\") and not String.contains?(path, "://") and not String.match?(path, ~r/[\r\n\x00]/) and not dot_dot_segment?(path) end defp dot_dot_segment?(path) do path |> Path.split() |> Enum.any?(&(&1 == "..")) end defp forwarded_proto(conn) do conn |> Plug.Conn.get_req_header("x-forwarded-proto") |> List.first() |> case do nil -> nil value -> value |> String.split(",") |> List.first() |> String.trim() |> String.downcase() end end end