2.8 KiB · text History b9a6d14
defmodule DiogramosWeb.Api.V1.SessionController do
  @moduledoc """
  Magic-link login for native clients, and revocation of the calling
  device's own token.

  The QR-pairing path (the primary one) never touches this controller —
  the token is minted from an authenticated browser session in
  `UserLive.Settings` and carried to the phone by the QR code.
  """
  use DiogramosWeb, :controller

  alias Diogramos.Accounts

  action_fallback DiogramosWeb.Api.FallbackController

  @doc """
  Sends a magic link if the address belongs to a registered user.

  Always replies 204 — a different answer for unknown addresses would
  turn this into an account-enumeration oracle.
  """
  def request_magic_link(conn, %{"email" => email}) when is_binary(email) do
    if user = Accounts.get_user_by_email(email) do
      # /m/:token rather than the web login path: Android claims that
      # route as an App Link, so tapping the link in the phone's inbox
      # opens the app instead of a browser. Opened anywhere else it
      # redirects into the same web flow.
      Accounts.deliver_login_instructions(user, &url(~p"/m/#{&1}"))
    end

    send_resp(conn, :no_content, "")
  end

  def request_magic_link(conn, _params) do
    conn
    |> put_status(:unprocessable_entity)
    |> put_view(json: DiogramosWeb.Api.ErrorJSON)
    |> render(:error, %{code: "unprocessable", message: "An email is required."})
  end

  @doc """
  Trades a magic-link token for a long-lived API token.

  The magic-link token is single-use: it is deleted on redemption, same as
  the browser login flow.
  """
  def redeem(conn, %{"token" => token} = params) when is_binary(token) do
    case Accounts.login_user_by_magic_link(token) do
      {:ok, {user, _expired_tokens}} ->
        api_token = Accounts.create_api_token(user, %{label: params["label"]})

        conn
        |> put_status(:created)
        |> json(%{token: api_token, user: user_payload(user)})

      _ ->
        conn
        |> put_status(:unauthorized)
        |> put_view(json: DiogramosWeb.Api.ErrorJSON)
        |> render(:error, %{
          code: "unauthenticated",
          message: "That link is invalid or has expired."
        })
    end
  end

  def redeem(conn, _params) do
    conn
    |> put_status(:unprocessable_entity)
    |> put_view(json: DiogramosWeb.Api.ErrorJSON)
    |> render(:error, %{code: "unprocessable", message: "A token is required."})
  end

  @doc "Revokes the token this request authenticated with."
  def delete(conn, _params) do
    user = conn.assigns.current_scope.user

    case Accounts.delete_api_token(user, conn.assigns.current_api_token_id) do
      :ok -> send_resp(conn, :no_content, "")
      error -> error
    end
  end

  defp user_payload(user) do
    %{id: user.id, email: user.email, confirmed_at: user.confirmed_at}
  end
end