Области
Исходный кодОбласть — это структура данных, используемая для хранения информации о текущем запросе или сеансе, например о текущем пользователе, его организации или компании, разрешениях и так далее. Представьте область как контейнер с информацией, которая нужна большинству страниц вашего приложения. В области также можно хранить метаданные запроса, например 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— имя функции, зарегистрированной как callbacksetupв тестах 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")) ...
© 2014 Chris McCord
Licensed under the MIT License.
https://phoenix.hexdocs.pm/scopes.html