lib/openagents_web/user_auth.ex

58e6347eeb72 · 10 KB

defmodule OpenAgentsWeb.UserAuth do
  @moduledoc "Session and LiveView authorization for active OpenAgents users."

  use OpenAgentsWeb, :verified_routes

  import Plug.Conn

  alias OpenAgents.Accounts
  alias OpenAgents.DeviceAuthorizations

  @session_key "user_id"

  # Read back by `OpenAgentsWeb.AuthController.callback/2`. Both ends go
  # through `DeviceAuthorizations.cast_user_code/1`, so nothing but a code this
  # application mints ever reaches it or leaves it.
  @device_session_key "device_user_code"

  # The path is written out because a route pattern cannot be a `~p` sigil.
  # `OpenAgentsWeb.RouteAuthority` classifies the same literal.
  @device_path "/device"

  @doc "The session key carrying a device authorization across the sign-in."
  def device_session_key, do: @device_session_key

  def put_no_store(conn, _options), do: put_resp_header(conn, "cache-control", "no-store")

  def fetch_current_user(conn, _options) do
    with user_id when is_binary(user_id) <- get_session(conn, @session_key),
         {:ok, user} <- Accounts.get_active_user(user_id) do
      Plug.Conn.assign(conn, :current_user, user)
    else
      _missing_or_inactive ->
        conn
        |> delete_session(@session_key)
        |> Plug.Conn.assign(:current_user, nil)
    end
  end

  def require_authenticated_user(
        %{assigns: %{current_user: %{status: "active"}}} = conn,
        _options
      ),
      do: conn

  def require_authenticated_user(conn, _options) do
    {conn, sign_in_path} = remember_device_authorization(conn)

    conn
    |> put_resp_header("cache-control", "no-store")
    |> Phoenix.Controller.redirect(to: sign_in_path)
    |> halt()
  end

  # `/device` is the one authenticated route a reader reaches before they have
  # a session here: a terminal sent them, and the code it printed is in the
  # link. Bouncing them to the public root and forgetting the code turns one
  # intent into two errands — sign in, then go and find the code again — which
  # is what issue #129 is about.
  #
  # So the code is remembered in the session on the way out.
  # `OpenAgentsWeb.AuthController.callback/2` reads it back after the OAuth
  # round trip and returns the reader to the approval with the code already in
  # hand, so approving is the next click. Carrying it in the session rather
  # than in the sign-in form means every sign-in control returns them —
  # the landing page's and the command bar's alike — instead of only the one
  # that was threaded with the code.
  #
  # It rides in the URL as well, but only so the landing page can say what the
  # sign-in is for. The session is the authority; the parameter is display.
  #
  # Only a code travels, never a path. `cast_user_code/1` admits exactly the
  # shape this application mints, so a crafted `?user_code=` carrying a host, a
  # path, a newline, or markup is refused here and the reader lands on the
  # public root exactly as they did before. This is the only place in the
  # sign-out path that builds a redirect from anything the browser sent, and it
  # builds one of two constants either way.
  defp remember_device_authorization(%{request_path: @device_path} = conn) do
    conn = Plug.Conn.fetch_query_params(conn)

    case DeviceAuthorizations.cast_user_code(conn.query_params["user_code"]) do
      {:ok, code} ->
        {put_session(conn, @device_session_key, code), ~p"/?user_code=#{code}"}

      # Reaching the device page without a usable code says the reader is not
      # mid-flow with the one we may have remembered earlier. Drop it rather
      # than let it decide where a later sign-in lands.
      :error ->
        {delete_session(conn, @device_session_key), ~p"/"}
    end
  end

  defp remember_device_authorization(conn), do: {conn, ~p"/"}

  def require_admin_user(conn, _options) do
    if Accounts.admin?(conn.assigns[:current_user]) do
      conn
    else
      conn
      |> put_resp_header("cache-control", "no-store")
      |> Phoenix.Controller.redirect(to: ~p"/")
      |> halt()
    end
  end

  # Which sections the reader has collapsed, so the first paint already agrees
  # with them. See `OpenAgentsWeb.Plugs.SidebarSections`.
  defp assign_sidebar_sections(socket, session) do
    Phoenix.Component.assign(
      socket,
      :sidebar_sections,
      OpenAgentsWeb.Plugs.SidebarSections.from_session(session)
    )
  end

  # The scope is the user, plus the answers the layout needs on every render
  # and must not re-ask for. Resolved once here, at mount.
  #
  # It deliberately does not depend on the page's params. It used to carry the
  # repository the sidebar's Issues and Projects rows pointed at, read from
  # `:owner`/`:repo` when the route had them, so a global nav row silently
  # retargeted as you browsed. Those rows now address `/issues` and
  # `/projects`, which mean the same thing on every page.
  defp scope(user) do
    %{
      user
      | agent_surfaces?: OpenAgents.Conversations.user_has_messages?(user),
        unread_notifications: OpenAgents.Notifications.unread_count(user)
    }
  end

  # The unread count is the one thing on the scope that goes stale while the
  # reader sits still: a comment lands, or they mark something read, and the
  # sidebar badge is wrong until the next navigation. So the count is refreshed
  # in place rather than only at mount.
  #
  # It lives here rather than in each LiveView because the sidebar renders
  # inside `Layouts.app/1` on every page, and threading a second assign through
  # every call site would put the badge's correctness in the hands of whoever
  # writes the next surface. One subscription per connected session, one
  # aggregate per event addressed to that account, and nothing on the render
  # path asks the database at all.
  #
  # The topic is keyed by the session's own user id, never by anything the
  # browser sent, so this widens what the scope carries without widening whose
  # data it can reach.
  defp watch_unread_notifications(socket, user) do
    if Phoenix.LiveView.connected?(socket) do
      :ok = OpenAgents.Notifications.subscribe_unread(user)

      Phoenix.LiveView.attach_hook(
        socket,
        :unread_notifications,
        :handle_info,
        &recount_unread/2
      )
    else
      socket
    end
  end

  defp recount_unread({:unread_notifications_changed, _user_id}, socket) do
    case socket.assigns[:current_user] do
      %Accounts.User{} = user ->
        {:halt,
         Phoenix.Component.assign(
           socket,
           :current_scope,
           %{
             socket.assigns.current_scope
             | unread_notifications: OpenAgents.Notifications.unread_count(user)
           }
         )}

      _signed_out ->
        {:halt, socket}
    end
  end

  defp recount_unread(_message, socket), do: {:cont, socket}

  def on_mount(:mount_current_user, _params, session, socket) do
    socket = assign_sidebar_sections(socket, session)

    with user_id when is_binary(user_id) <- session[@session_key],
         {:ok, user} <- Accounts.get_active_user(user_id) do
      {:cont,
       socket
       |> Phoenix.Component.assign(:current_user, user)
       |> Phoenix.Component.assign(:current_scope, scope(user))
       |> watch_unread_notifications(user)}
    else
      _missing_or_inactive ->
        {:cont,
         socket
         |> Phoenix.Component.assign(:current_user, nil)
         |> Phoenix.Component.assign(:current_scope, nil)}
    end
  end

  def on_mount(:ensure_authenticated, _params, session, socket) do
    socket = assign_sidebar_sections(socket, session)

    with user_id when is_binary(user_id) <- session[@session_key],
         {:ok, user} <- Accounts.get_active_user(user_id) do
      {:cont,
       socket
       |> Phoenix.Component.assign(:current_user, user)
       |> Phoenix.Component.assign(:current_scope, scope(user))
       |> watch_unread_notifications(user)
       |> Phoenix.LiveView.attach_hook(
         :active_user_guard,
         :handle_event,
         &ensure_active_event/3
       )}
    else
      _missing_or_inactive ->
        {:halt,
         socket
         |> Phoenix.LiveView.put_flash(:error, "Log in with GitHub to continue.")
         |> Phoenix.LiveView.redirect(to: ~p"/")}
    end
  end

  def on_mount(:ensure_admin, _params, _session, socket) do
    with %{id: user_id} <- socket.assigns[:current_user],
         {:ok, user} <- Accounts.get_active_user(user_id),
         true <- Accounts.admin?(user) do
      {:cont,
       socket
       |> Phoenix.Component.assign(:current_user, user)
       |> Phoenix.LiveView.attach_hook(:admin_guard, :handle_event, &ensure_admin_event/3)}
    else
      _not_an_operator ->
        {:halt, Phoenix.LiveView.redirect(socket, to: ~p"/")}
    end
  end

  defp ensure_active_event(_event, _params, socket) do
    case Accounts.get_active_user(socket.assigns.current_user.id) do
      {:ok, user} ->
        {:cont, Phoenix.Component.assign(socket, :current_user, user)}

      {:error, _inactive_or_missing} ->
        {:halt,
         socket
         |> Phoenix.LiveView.put_flash(:error, "This session is no longer active.")
         |> Phoenix.LiveView.redirect(to: ~p"/")}
    end
  end

  defp ensure_admin_event(_event, _params, socket) do
    with {:ok, user} <- Accounts.get_active_user(socket.assigns.current_user.id),
         true <- Accounts.admin?(user) do
      {:cont, Phoenix.Component.assign(socket, :current_user, user)}
    else
      _not_an_operator -> {:halt, Phoenix.LiveView.redirect(socket, to: ~p"/")}
    end
  end

  def require_authenticated_api_user(
        %{assigns: %{current_user: %{status: "active"}}} = conn,
        _options
      ),
      do: conn

  def require_authenticated_api_user(conn, _options) do
    conn
    |> put_status(:unauthorized)
    |> put_resp_header("cache-control", "no-store")
    |> Phoenix.Controller.json(%{error: "authentication_required"})
    |> halt()
  end

  def require_operator_api_user(conn, _options) do
    if Accounts.admin?(conn.assigns[:current_user]) do
      conn
    else
      conn
      |> put_status(:forbidden)
      |> put_resp_header("cache-control", "no-store")
      |> Phoenix.Controller.json(%{error: "operator_required"})
      |> halt()
    end
  end
end