Spec-Zone.ru › Phoenix

Области

Исходный код

Область — это структура данных, используемая для хранения информации о текущем запросе или сеансе, например о текущем пользователе, его организации или компании, разрешениях и так далее. Представьте область как контейнер с информацией, которая нужна большинству страниц вашего приложения. В области также можно хранить метаданные запроса, например IP-адреса.

Области играют важную роль в обеспечении безопасности. OWASP указывает «Нарушение контроля доступа» в качестве одного из 10 главных рисков безопасности. Большая часть данных приложения является приватной для пользователя, команды или организации. Операции CRUD с базой данных должны быть корректно ограничены текущим пользователем, командой или организацией. Генераторы Phoenix, такие как mix phx.gen.html, mix phx.gen.json и mix phx.gen.live, автоматически используют ваши пользовательские области.

Области гибкие. В приложении может быть несколько областей, и при вызове генератора можно выбрать нужную. При запуске mix phx.gen.auth область будет создана автоматически, но вы также можете добавить свою.

В этом руководстве вы узнаете:

  • Как mix phx.gen.auth создает для вас область
  • Как генераторы, такие как mix phx.gen.context, используют области для обеспечения безопасности
  • Как определить собственную область с нуля и какие параметры доступны
  • Как дополнить встроенную область дополнительными областями

phx.gen.auth

Задача mix phx.gen.auth создаст область по умолчанию. Эта область связывает сгенерированные ресурсы с текущим аутентифицированным пользователем. Посмотрим, как это работает:

$ mix phx.gen.auth Accounts User users

Код области одинаков для вариантов генератора --live и --no-live.

В сгенерированном файле области lib/my_app/accounts/scope.ex видно, что в нем определена структура с единственным полем user, а также функция for_user/1, которая при передаче структуры User возвращает новую %Scope{} для этого пользователя.

defmodule MyApp.Accounts.Scope do
  alias MyApp.Accounts.User

  defstruct user: nil

  def for_user(%User{} = user) do
    %__MODULE__{user: user}
  end

  def for_user(nil), do: nil
end

Область автоматически извлекается модулем plug fetch_current_scope_for_user, подключенным к конвейеру :browser:

# router.ex
...
pipeline :browser do
  ...
  plug :fetch_current_scope_for_user
end
# user_auth.ex
def fetch_current_scope_for_user(conn, _opts) do
  {user_token, conn} = ensure_user_token(conn)
  user = user_token && Accounts.get_user_by_session_token(user_token)
  assign(conn, :current_scope, Scope.for_user(user))
end

Аналогично, для LiveView существует предопределенный хук mount_current_scope, который гарантирует доступность области:

# user_auth.ex
def on_mount(:mount_current_scope, _params, session, socket) do
  {:cont, mount_current_scope(socket, session)}
end

defp mount_current_scope(socket, session) do
  Phoenix.Component.assign_new(socket, :current_scope, fn ->
    user =
      if user_token = session["user_token"] do
        Accounts.get_user_by_session_token(user_token)
      end

    Scope.for_user(user)
  end)
end

Интеграция областей в генераторы Phoenix

Если в конфигурации приложения определена область по умолчанию, генераторы будут по умолчанию создавать ресурсы с ограниченной областью. Сгенерированные LiveView / контроллеры будут автоматически передавать область функциям контекста. mix phx.gen.auth автоматически задает свою область в качестве области по умолчанию, если область по умолчанию еще не определена:

# config/config.exs
config :my_app, :scopes,
  user: [
    default: true,
    ...
  ]

В следующем разделе мы рассмотрим отдельные параметры.

Теперь посмотрим на код, который генерируется после установки области по умолчанию. В качестве примера возьмем mix phx.gen.live, но идеи и общий код будут похожи и для mix phx.gen.html и mix phx.gen.json:

$ mix phx.gen.live Blog Post posts title:string body:text

Это создает новый контекст Blog с ресурсом Post. Чтобы область была доступна для LiveView, маршруты в router.ex необходимо добавить в live_session, который обеспечивает аутентификацию пользователя. В данном случае это раздел с подходящим названием required_authenticated_user:

   scope "/", MyAppWeb do
     pipe_through [:browser, :require_authenticated_user]

     live_session :require_authenticated_user,
       on_mount: [{MyAppWeb.UserAuth, :ensure_authenticated}] do
       live "/users/settings", UserLive.Settings, :edit
       live "/users/settings/confirm-email/:token", UserLive.Settings, :confirm_email

+      live "/posts", PostLive.Index, :index
+      live "/posts/new", PostLive.Form, :new
+      live "/posts/:id", PostLive.Show, :show
+      live "/posts/:id/edit", PostLive.Form, :edit
     end

     post "/users/update-password", UserSessionController, :update_password
   end

Хотя в маршрутизаторе есть макрос scope, маршрутизатор scope и current_scope — это отдельные функции со схожим назначением: они ограничивают доступ к частям приложения, но действуют на разных уровнях (одна — на уровне маршрутизатора, другая — на уровне данных).

Теперь посмотрим на сгенерированный LiveView (lib/my_app_web/live/post_live/index.ex):

defmodule MyAppWeb.PostLive.Index do
  use MyAppWeb, :live_view

  alias MyApp.Blog

  ...

  @impl true
  def mount(_params, _session, socket) do
    Blog.subscribe_posts(socket.assigns.current_scope)

    {:ok,
     socket
     |> assign(:page_title, "Listing Posts")
     |> stream(:posts, Blog.list_posts(socket.assigns.current_scope))}
  end

  @impl true
  def handle_event("delete", %{"id" => id}, socket) do
    post = Blog.get_post!(socket.assigns.current_scope, id)
    {:ok, _} = Blog.delete_post(socket.assigns.current_scope, post)

    {:noreply, stream_delete(socket, :posts, post)}
  end

  @impl true
  def handle_info({type, %MyApp.Blog.Post{}}, socket)
      when type in [:created, :updated, :deleted] do
    {:noreply, stream(socket, :posts, Blog.list_posts(socket.assigns.current_scope), reset: true)}
  end
end

Обратите внимание, что каждой функции контекста Blog, которую мы вызываем, передается присваиваемое значение current_scope в качестве первого аргумента. Затем функция list_posts/1 использует эту информацию для корректной фильтрации публикаций:

# lib/my_app/blog.ex
def list_posts(%Scope{} = scope) do
  Repo.all(from post in Post, where: post.user_id == ^scope.user.id)
end

LiveView также подписывается на сообщения PubSub с ограниченной областью и автоматически обновляет отображаемый список при создании публикации, а также при обновлении или удалении существующей, обрабатывая при этом только сообщения для текущей области.

Определение областей

Генераторы Phoenix используют конфигурацию приложения, чтобы определить доступные области. Область задается следующими параметрами:

config :my_app, :scopes,
  user: [
    default: true,
    module: MyApp.Accounts.Scope,
    assign_key: :current_scope,
    access_path: [:user, :id],
    schema_key: :user_id,
    schema_type: :id,
    schema_table: :users,
    test_data_fixture: MyApp.AccountsFixtures,
    test_setup_helper: :register_and_log_in_user
  ]

В этом примере область называется user и является областью default, которая автоматически используется при запуске mix phx.gen.schema, mix phx.gen.context, mix phx.gen.live, mix phx.gen.html и mix phx.gen.json. Для области нужен модуль, определяющий структуру, в данном случае MyApp.Accounts.Scope. Эти структуры используются в качестве первого аргумента сгенерированных функций контекста, например list_posts/1.

  • default — логическое значение, указывающее, является ли эта область областью по умолчанию. Можно определить только одну область по умолчанию.

  • module — модуль, определяющий структуру для этой области.

  • assign_key — ключ, по которому структура области присваивается сокету или conn.

  • access_path — список ключей, задающих путь к идентифицирующему полю в структуре области. Генераторы создают код вида where: schema_key == ^scope.user.id.

  • route_prefix — (необязательный параметр) шаблон пути, задающий вложенность ресурсов. Например, /organizations/:org создаст маршруты вида /organizations/:org/posts. Сегмент параметра (:org) в шаблонах и LiveView будет заменен соответствующим значением из области.

  • route_access_path — (необязательный параметр) список ключей, задающих путь к полю, используемому при генерации маршрутов (если задан route_prefix). Это особенно полезно для удобных для пользователя URL-адресов, в которых вместо ID может использоваться slug. Если параметр не указан, по умолчанию используется Enum.drop(scope.access_path, -1) или access_path, если первое значение пустое. Например, если access_path равно [:organization, :id], по умолчанию используется [:organization], при условии, что значение в scope.organization реализует протокол Phoenix.Param.

  • schema_key — внешний ключ, связывающий ресурс с областью. Новые схемы с ограниченной областью создаются с полем внешнего ключа schema_key типа schema_type, указывающим на таблицу schema_table.

  • schema_type — тип поля внешнего ключа в схеме. Обычно это :id или :binary_id.

  • schema_migration_type (необязательный параметр) — тип столбца внешнего ключа в базе данных. Используется для сгенерированной миграции. По умолчанию используется тип внешнего ключа миграции, заданный по умолчанию.

  • schema_table — имя таблицы, на которую указывает внешний ключ.

  • test_data_fixture — модуль, автоматически импортируемый в файл тестов контекста. Он должен содержать функцию NAME_scope_fixture/0, возвращающую уникальную структуру области для тестов контекста, в данном случае user_scope_fixture/0.

  • test_setup_helper — имя функции, зарегистрированной как callback setup в тестах LiveView / контроллеров. Предполагается, что функция импортирована в файл тестов. Обычно это обеспечивается добавлением ее в модуль MyAppWeb.ConnCase.

Хотя mix phx.gen.auth автоматически создает область, области также можно определять вручную. Это может быть полезно, например, при добавлении областей в существующее приложение или при определении областей, не связанных с пользователем.

В этом примере мы реализуем пользовательскую область, отдельную для каждого сеанса. В большинстве реальных приложений это может быть бесполезно, поскольку созданные ресурсы станут недоступны сразу после завершения сеанса, но такой пример поможет понять принцип работы областей. Пример расширения существующей области организациями (командами, компаниями и т. п.) приведен в следующем разделе.

Сначала определим модуль области lib/my_app/scope.ex:

defmodule MyApp.Scope do
  defstruct id: nil

  def for_id(id) do
    %MyApp.Scope{id: id}
  end
end

Затем определим plug в маршрутизаторе, который назначает область каждому запросу:

   pipeline :browser do
     plug :accepts, ["html"]
     plug :fetch_session
     plug :fetch_live_flash
     plug :put_root_layout, html: {MyAppWeb.Layouts, :root}
     plug :protect_from_forgery
     plug :put_secure_browser_headers
+    plug :assign_scope
   end
+
+  defp assign_scope(conn, _opts) do
+    if id = get_session(conn, :scope_id) do
+      assign(conn, :current_scope, MyApp.Scope.for_id(id))
+    else
+      id = System.unique_integer()
+
+      conn
+      |> put_session(:scope_id, id)
+      |> assign(:current_scope, MyApp.Scope.for_id(id))
+    end
+  end

Для тестов также определим модуль фикстур test/support/fixtures/scope_fixtures.ex:

defmodule MyApp.ScopeFixtures do
  alias MyApp.Scope

  def session_scope_fixture(id \\ System.unique_integer()) do
    %Scope{id: id}
  end
end

Затем добавим вспомогательную функцию setup в test/support/conn_case.ex:

defmodule MyAppWeb.ConnCase do
  ...

  def put_scope_in_session(%{conn: conn}) do
    id = System.unique_integer()
    scope = MyApp.ScopeFixtures.session_scope_fixture(id)

    conn =
      conn
      |> Phoenix.ConnTest.init_test_session(%{})
      |> Plug.Conn.put_session(:scope_id, id)

    %{conn: conn, scope: scope}
  end
end

Наконец, настроим область в config/config.exs приложения:

config :my_app, :scopes,
  session: [
    default: true,
    module: MyApp.Scope,
    assign_key: :current_scope,
    access_path: [:id],
    schema_key: :session_id,
    schema_type: :id,
    schema_migration_type: :bigint,
    schema_table: nil,
    test_data_fixture: MyApp.ScopeFixtures,
    test_setup_helper: :put_scope_in_session
  ]

Если задать для schema_table значение nil, сгенерированные ресурсы не будут иметь внешнего ключа для области. Вместо этого они будут содержать обычный столбец bigint, непосредственно хранящий идентификатор области.

Теперь можно сгенерировать новый ресурс, например с помощью phx.gen.html:

$ mix phx.gen.html Post posts title:string

Если теперь перейти на http://localhost:4000/posts и создать публикацию, вы увидите, что она доступна только в текущем сеансе. Если открыть приватное окно браузера и перейти по тому же URL, ранее созданная публикация не будет видна. Аналогично, если создать новую публикацию в приватном окне, она не будет видна в другом окне. Если скопировать URL публикации, созданной в одном сеансе, и открыть его в другом, возникнет ошибка Ecto.NoResultsError, которая автоматически преобразуется в ответ 404, если параметр debug_errors отключен.

Расширение областей

Предположим, что вы использовали mix phx.gen.auth для создания области, связанной с пользователями. Но теперь вы также создаете новую сущность organization, участниками которой могут быть пользователи:

defmodule MyApp.Accounts.Organization do
  use Ecto.Schema
  import Ecto.Changeset

  @derive {Phoenix.Param, key: :slug}
  schema "organizations" do
    field :name, :string
    field :slug, :string
    ...

    many_to_many :users, MyApp.Accounts.User, join_through: "organizations_users"

    timestamps(type: :utc_datetime)
  end
end

Сначала изменим структуру области, добавив в нее организацию:

 defmodule MyApp.Accounts.Scope do
   alias MyApp.Accounts.User
   alias MyApp.Accounts.Organization

-  defstruct user: nil
+  defstruct user: nil, organization: nil

   def for_user(%User{} = user) do
     %__MODULE__{user: user}
   end

   def for_user(nil), do: nil
+
+  def put_organization(%__MODULE__{} = scope, %Organization{} = organization) do
+    %{scope | organization: organization}
+  end
 end

Предположим также, что текущая организация указана в пути URL, например http://localhost:4000/organizations/foo/posts. Тогда изменим маршрутизатор, чтобы извлекать организацию из пути и присваивать ее области:

  # router.ex
  pipeline :browser do
    ...
    plug :fetch_current_scope_for_user
+   plug :assign_org_to_scope
  end
# user_auth.ex
def assign_org_to_scope(conn, _opts) do
  current_scope = conn.assigns.current_scope
  if slug = conn.params["org"] do
    org = MyApp.Accounts.get_organization_by_slug!(current_scope, slug)
    assign(conn, :current_scope, MyApp.Accounts.Scope.put_organization(current_scope, org))
  else
    conn
  end
end

Для LiveView также потребуется добавить новый хук :on_mount и указать его в параметре on_mount для live_session в маршрутизаторе:

  # router.ex
  scope "/", MyAppWeb do
    pipe_through [:browser]

    live_session :current_user,
      on_mount: [
        {MyAppWeb.UserAuth, :mount_current_scope},
+       {MyAppWeb.UserAuth, :assign_org_to_scope}
      ] do
      ...
    end
  end
# user_auth.ex
def on_mount(:assign_org_to_scope, %{"org" => slug}, _session, socket) do
  socket =
    case socket.assigns.current_scope do
      %{organization: nil} = scope ->
        org = MyApp.Accounts.get_organization_by_slug!(socket.assigns.current_scope, slug)
        Phoenix.Component.assign(socket, :current_scope, Scope.put_organization(scope, org))

      _ ->
        socket
    end

  {:cont, socket}
end

def on_mount(:assign_org_to_scope, _params, _session, socket), do: {:cont, socket}

Таким образом, если маршрут определен как live /organizations/:org/posts, plug assign_org_to_scope извлечет организацию из пути и присвоит ее области. В этом коде предполагается, что get_organization_by_slug!/2 вызывает исключение Ecto.NoResultsError, которое автоматически преобразуется в 404. Однако ошибку можно обработать явно, например, показать сообщение об ошибке и перенаправить пользователя на другую страницу, например на панель управления. Функция get_organization_by_slug!/2 также должна учитывать текущую область и отбирать только те организации, к которым у пользователя есть доступ.

Теперь можно определить новую область в config/config.exs приложения, чтобы генерировать ресурсы, ограниченные организацией:

config :my_app, :scopes,
  user: [
    ...
  ],
  organization: [
    module: MyApp.Accounts.Scope,
    assign_key: :current_scope,
    access_path: [:organization, :id],
    route_prefix: "/organizations/:org",
    route_access_path: [:organization, :slug],
    schema_key: :org_id,
    schema_type: :id,
    schema_table: :organizations,
    test_data_fixture: MyApp.AccountsFixtures,
    test_setup_helper: :register_and_log_in_user_with_org
  ]

Для сгенерированных тестов также потребуется определить фикстуру в test/support/fixtures/accounts_fixtures.ex и расширить test/support/conn_case.ex:

defmodule MyApp.AccountsFixtures do
  ...

  def organization_scope_fixture(scope \\ user_scope_fixture()) do
    org = organization_fixture(scope)
    Scope.put_organization(scope, org)
  end
end
defmodule MyAppWeb.ConnCase do
  ...

  def register_and_log_in_user_with_org(context) do
    %{conn: conn, user: _user, scope: scope} = register_and_log_in_user(context)
    %{conn: conn, scope: MyApp.AccountsFixtures.organization_scope_fixture(scope)}
  end
end

Теперь, когда конфигурация области включает route_prefix, можно создавать ресурсы, ограниченные организацией, а все пути будут автоматически сгенерированы с правильным slug организации:

$ mix phx.gen.live Blog Post posts title:string body:text --scope organization

Этот пример показывает, что области достаточно гибкие и позволяют поддерживать четко определенную структуру данных даже по мере роста приложения.

В большинстве случаев в приложении будет один модуль области, как в этом примере. Но иногда может понадобиться создать новый модуль области, например, чтобы полностью разделить область для пользователей и область администратора, и разрешить вызов функций контекста только одной из них.

Вспомогательные функции областей

При работе с более сложными областями часто полезно создавать вспомогательные функции, которые удобно добавлять в модуль области:

defmodule MyApp.Accounts.Scope do
  alias MyApp.Accounts
  alias MyApp.Accounts.{User, Organization}

  defstruct user: nil, organization: nil

  def for_user(%User{} = user) do
    %__MODULE__{user: user}
  end

  def for_user(nil), do: nil

  def put_organization(%__MODULE__{} = scope, %Organization{} = organization) do
    %{scope | organization: organization}
  end

  def for(opts) when is_list(opts) do
    cond do
      opts[:user] && opts[:org] ->
        user = user(opts[:user])
        org = org(opts[:org])

        user
        |> for_user()
        |> put_organization(org)

      opts[:user] ->
        user = user(opts[:user])
        for_user(user)

      opts[:org] ->
        %__MODULE__{organization: org(opts[:org])}
    end
  end

  defp user(id) when is_integer(id) do
    Accounts.get_user!(id)
  end

  defp user(email) when is_binary(email) do
    Accounts.get_user_by_email(email)
  end

  defp org(id) when is_integer(id) do
    Accounts.get_organization!(id)
  end

  defp org(slug) when is_binary(slug) do
    Accounts.get_organization_by_slug!(slug)
  end
end

Затем можно добавить псевдоним модуля Scope в .iex.exs проекта:

alias MyApp.Accounts.Scope

А при работе с функциями контекста, использующими области, достаточно написать:

iex> MyApp.Blog.list_posts(Scope.for(user: 1, org: "foo"))
...
iex> MyApp.Accounts.list_api_tokens(Scope.for(user: "john@doe.com"))
...
← Предыдущая страница mix phx.gen.auth
Следующая страница → Аутентификация API

Пакет Hex Предпросмотр Hex (текущий файл) Скачать версию ePub

Создано с помощью ExDoc (v0.40.3) для языка программирования Elixir

© 2014 Chris McCord
Licensed under the MIT License.
https://phoenix.hexdocs.pm/scopes.html

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API