Phoenix (Elixir)MiddleTechnical

Как реализовать аутентификацию в приложении Phoenix?

Phoenix аутентификацию реализуют через mix phx.gen.auth (встроенный генератор): создаёт User-схему, сессионные токены, plug-пайплайн и LiveView-формы. Для JWT используют библиотеки Joken или Guardian.

Встроенный генератор mix phx.gen.auth

Начиная с Phoenix 1.6, mix phx.gen.auth генерирует полную систему аутентификации с сессионными токенами, хэшированием паролей через bcrypt, подтверждением email и восстановлением пароля.

# Генерация модуля аутентификации
mix phx.gen.auth Accounts User users

# Применяем миграцию
mix ecto.migrate

Генератор создаёт:

  • lib/myapp/accounts/user.ex — Ecto schema
  • lib/myapp/accounts/user_token.ex — токены сессий
  • lib/myapp_web/user_auth.ex — plug-хелперы
  • lib/myapp_web/controllers/user_session_controller.ex
  • Миграции для таблиц users и users_tokens

Структура User schema

# lib/myapp/accounts/user.ex (сгенерированный)
defmodule Myapp.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset

  schema "users" do
    field :email, :string
    field :password, :string, virtual: true, redact: true
    field :hashed_password, :string, redact: true
    field :confirmed_at, :naive_datetime

    timestamps(type: :utc_datetime)
  end

  def registration_changeset(user, attrs, opts \\ []) do
    user
    |> cast(attrs, [:email, :password])
    |> validate_email(opts)
    |> validate_password(opts)
  end

  defp validate_password(changeset, opts) do
    changeset
    |> validate_required([:password])
    |> validate_length(:password, min: 12, max: 72)
    |> maybe_hash_password(opts)
  end

  defp maybe_hash_password(changeset, opts) do
    hash_password? = Keyword.get(opts, :hash_password, true)
    password = get_change(changeset, :password)

    if hash_password? && password && changeset.valid? do
      changeset
      |> validate_length(:password, max: 72, count: :bytes)
      |> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password))
      |> delete_change(:password)
    else
      changeset
    end
  end
end

Plug-пайплайн аутентификации

# lib/myapp_web/user_auth.ex — ключевые функции
defmodule MyappWeb.UserAuth do
  import Plug.Conn
  import Phoenix.Controller

  alias Myapp.Accounts

  # Создание сессии после логина
  def log_in_user(conn, user, params \\ %{}) do
    token = Accounts.generate_user_session_token(user)
    user_return_to = get_session(conn, :user_return_to)

    conn
    |> renew_session()  # Защита от session fixation
    |> put_token_in_session(token)
    |> redirect(to: user_return_to || ~p"/")
  end

  # Plug для защищённых маршрутов
  def require_authenticated_user(conn, _opts) do
    if conn.assigns[:current_user] do
      conn
    else
      conn
      |> put_flash(:error, "You must log in to access this page.")
      |> maybe_store_return_to()
      |> redirect(to: ~p"/users/log_in")
      |> halt()
    end
  end
end

Подключение plugs в Router

# lib/myapp_web/router.ex
defmodule MyappWeb.Router do
  use MyappWeb, :router

  import MyappWeb.UserAuth

  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 :fetch_current_user  # загружает current_user из сессии
  end

  # Публичные маршруты
  scope "/", MyappWeb do
    pipe_through [:browser, :redirect_if_user_is_authenticated]
    get "/users/log_in", UserSessionController, :new
    post "/users/log_in", UserSessionController, :create
  end

  # Защищённые маршруты
  scope "/", MyappWeb do
    pipe_through [:browser, :require_authenticated_user]
    get "/dashboard", DashboardController, :index
    delete "/users/log_out", UserSessionController, :delete
  end
end

JWT-аутентификация с Joken

# mix.exs
{:joken, "~> 2.6"}

# lib/myapp/auth/jwt.ex
defmodule Myapp.Auth.JWT do
  use Joken.Config

  @impl Joken.Config
  def token_config do
    default_claims(iss: "myapp", aud: "myapp-api")
    |> add_claim("role", nil, &(&1 in ["admin", "user"]))
  end

  def generate_token(user_id, role) do
    claims = %{"sub" => to_string(user_id), "role" => role}
    generate_and_sign!(claims)
  end

  def verify_token(token) do
    verify_and_validate(token)
  end
end

Подводные камни

  • renew_session обязателен: без renew_session() при логине сессия не ротируется, что открывает Session Fixation атаку.
  • bcrypt_elixir требует libsodium: в Docker-образе нужно устанавливать системные зависимости (libsodium-dev) или использовать comeonin с pbkdf2_elixir.
  • Токены не инвалидируются автоматически: phx.gen.auth использует DB-токены, но при компрометации секрета старые токены остаются валидными до срока истечения. Реализуйте log_out_user через удаление токена из БД.
  • LiveView и аутентификация: current_user нужно передавать через on_mount хук в LiveView, иначе защищённые live-роуты будут доступны без авторизации.
  • CSRF в LiveView: LiveView использует WebSocket, не HTTP. CSRF-токен из формы валидируется только при начальном HTTP-запросе; дальнейшие events защищены самим WebSocket-соединением.
  • Secrets в конфиге: JWT-секрет должен быть в runtime.exs, не в config.exs, иначе он вшивается в релиз на этапе компиляции.

Common mistakes

  • Сводить authentication к названию метода без lifecycle и failure path.
  • Игнорировать модель runtime: Phoenix 1.8 работает поверх Plug, Endpoint, Router, Controllers/LiveViews, PubSub и OTP supervision.
  • Не отделять validation, authorization, transaction boundary и business logic.

What the interviewer is testing

  • Объясняет authentication через конкретную точку lifecycle в Phoenix (Elixir).
  • Приводит корректный минимальный пример без вымышленных методов или callbacks.
  • Называет edge cases: пустые значения, ошибки, транзакции, безопасность или concurrency.
  • Идёт от входных данных к БД/кешу/логам, а не предлагает случайные исправления.

Sources

Related topics