Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
2
Help us understand the problem. What is going on with this article?
@mnishiguchi

PhoenixのAPIサーバー向け簡易トークン認証

ここ最近、ElixirNervesPhoenixを使ったIoTデバイスの開発に夢中になってます。しばらく電子工作しながら基礎を学んだあと、autoracex主催者@torifukukaiouさんの記事を参考に自宅の温度と湿度をリアルタイムで監視できるシステムを作りました。

取り敢えずいい感じに動いているので、次はセキュリティ面の強化に取り組もうと思います。今日は簡易的なトークン認証を実装します。

4/3(土) 00:00〜 4/5(月) 23:59開催のautoracex #21での成果です。
English edition

hello-nerves-2

方針

  • Plugのパイプラインに追加できるようにする。
  • トークンはIExにて手動で生成する。ログインの部分は実装しない。
  • APIユーザーはAuthorizationリクエストヘッダーにトークンを入れなければ、アクセスできない。

Phoenix.Token

ありがたいことに、PhoenixにはPhoenix.Tokenモジュールにトークン認証に最低限必要な機能が備わっています。Nice!

トークン認証Plugの実装例

Phoenix.Tokenモジュールを使用して、2つのカスタムPlugを書きました。Programming Phoenixで学んだ内容をベースにしてます。

  • ExampleWeb.API.AuthPlug - リクエストヘッダーのトークンを認証し、:current_userconnassignする。
  • ExampleWeb.API.AuthPlug.authenticate_api_user/2 - :current_userの値が存在するか確認。
defmodule ExampleWeb.API.AuthPlug do
  @moduledoc """
  A module plug that verifies the bearer token in the request headers and
  assigns `:current_user`. The authorization header value may look like
  `Bearer xxxxxxx`.
  """

  import Plug.Conn, only: [assign: 3, get_req_header: 2, halt: 1, put_status: 2]
  import Phoenix.Controller, only: [put_view: 2, render: 2]

  @token_salt "api token"

  def init(opts), do: opts

  def call(conn, _opts) do
    conn
    |> get_token()
    |> verify_token()
    |> case do
      {:ok, user_id} -> assign(conn, :current_user, user_id)
      _unauthorized -> assign(conn, :current_user, nil)
    end
  end

  @doc """
  A function plug that ensures that `:current_user` value is present.

  ## Examples

      # in a router pipeline
      pipe_through [:api, :authenticate_api_user]

      # in a controller
      plug :authenticate_api_user when action in [:index, :create]

  """
  def authenticate_api_user(conn, _opts) do
    if Map.get(conn.assigns, :current_user) do
      conn
    else
      render_error(conn)
    end
  end

  defp render_error(conn) do
    conn
    |> put_status(:unauthorized)
    |> put_view(ExampleWeb.ErrorView)
    |> render(:"401")
    # Stop any downstream transformations.
    |> halt()
  end

  @doc """
  Generate a new token for a user id.

  ## Examples

      iex> ExampleWeb.API.AuthPlug.generate_token(123)
      "xxxxxxx"

  """
  def generate_token(user_id) do
    Phoenix.Token.sign(
      ExampleWeb.Endpoint,
      @token_salt,
      user_id
    )
  end

  @doc """
  Verify a user token.

  ## Examples

      iex> ExampleWeb.API.AuthPlug.verify_token("good-token")
      {:ok, 1}

      iex> ExampleWeb.API.AuthPlug.verify_token("bad-token")
      {:error, :invalid}

      iex> ExampleWeb.API.AuthPlug.verify_token("old-token")
      {:error, :expired}

      iex> ExampleWeb.API.AuthPlug.verify_token(nil)
      {:error, :missing}

  """
  @spec verify_token(nil | binary) :: {:error, :expired | :invalid | :missing} | {:ok, any}
  def verify_token(token) do
    one_month = 30 * 24 * 60 * 60

    Phoenix.Token.verify(
      ExampleWeb.Endpoint,
      @token_salt,
      token,
      max_age: one_month
    )
  end

  @spec get_token(Plug.Conn.t()) :: nil | binary
  def get_token(conn) do
    case get_req_header(conn, "authorization") do
      ["Bearer " <> token] -> token
      _ -> nil
    end
  end
end

:current_userを使わないのであれば、シンプルに関数Plugひとつにまとめてもいいかもしれません。

defmodule ExampleWeb.API.AuthPlug do

  ...

  def authenticate_api_user(conn, _opts) do
    conn
    |> get_token()
    |> verify_token()
    |> case do
      {:ok, _user_id} -> conn
      _unauthorized -> render_error(conn)
    end
  end

  ...

トークン認証Plugの使用例

関数のプラグauthenticate_api_user/2は使用前にどこかでimportする必要があります。2つのシナリオが考えられます。

A: routerパイプラインで使用

このパターンはパイプラインにある全てのコントローラに対して影響を及ぼしたい場合に便利です。関数のプラグをExampleWebモジュールのrouter関数のquoteブロック内でimportします。

 defmodule ExampleWeb do

   ...

   def router do
     quote do
       use Phoenix.Router

       import Plug.Conn
       import Phoenix.Controller
       import Phoenix.LiveView.Router
+      import ExampleWeb.API.AuthPlug, only: [authenticate_api_user: 2]
     end
   end

ExampleWeb.RouterExampleWeb.API.AuthPlug:authenticate_api_userの両方ともパイプラインに追加します。

 defmodule ExampleWeb.Router do
   use ExampleWeb, :router

   pipeline :api do
     plug :accepts, ["json"]
+    plug ExampleWeb.API.AuthPlug
   end

   scope "/api", ExampleWeb do
-    pipe_through [:api]
+    pipe_through [:api, :authenticate_api_user]

     resources "/measurements", API.Environment.MeasurementController, only: [:index,  :show, :create]
   end

B: 特定のコントローラアクションで使用

このパターンはある特定のコントローラアクションに対して影響を及ぼしたい場合に便利です。
関数のプラグをExampleWebモジュールのcontroller関数のquoteブロック内でimportします。または、各コントローラファイルにて明示的にimportすることもできます。

 defmodule ExampleWeb do

   ...

   def controller do
     quote do
       use Phoenix.Controller, namespace: ExampleWeb

       import Plug.Conn
       import ExampleWeb.Gettext
+      import ExampleWeb.API.AuthPlug, only: [authenticate_api_user: 2]
       alias ExampleWeb.Router.Helpers, as: Routes
     end
   end

まずExampleWeb.RouterExampleWeb.API.AuthPlugをパイプラインに追加します。

 defmodule ExampleWeb.Router do
   use ExampleWeb, :router

   pipeline :api do
     plug :accepts, ["json"]
+    plug ExampleWeb.API.AuthPlug
   end

その後、コントローラ内で特定のアクションに対して使用します。

 defmodule ExampleWeb.API.MeasurementController do
   use ExampleWeb, :controller

   alias Example.Measurement

   action_fallback ExampleWeb.API.FallbackController
+
+  plug :authenticate_api_user when action in [:create]
+

クイックテスト

IExを開きトークンを生成。

iex> ExampleWeb.API.AuthPlug.generate_token(1)
"SFMyNTY.g2gDbQAAAAVoZWxsb24GABaFup54AWIAAVGA.R3AjaixW4edvVLSQjQqr9LcTieqSV1ivfBltWBZt0x0"

そのトークンをヘッダーに含めて、APIにアクセスしてみる。

❯ curl -X POST \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer SFMyNTY.g2gDbQAAAAVoZWxsb24GABaFup54AWIAAVGA.R3AjaixW4edvVLSQjQqr9LcTieqSV1ivfBltWBZt0x0" \
  -d '{"measurement": {"temperature_c": "23.5"}}' \
  http://localhost:4000/api/measurements
{"data":{"id":37,"temperature_c":23.5}}

❯ curl -X POST \
  -H "Content-Type: application/json" \
  -d '{"measurement": {"temperature_c": "23.5" }}' \
  http://localhost:4000/api/measurements
"Unauthorized"

注意点

公式ドキュメントによると、SECRET_KEY_BASEがトークン生成に使用されているとのことですので、production用のトークンはproductionサーバーのIexで生成する必要があります。

もっとちゃんとしたトークン認証を実装するには、JWTを使うのが一般的のようです。 Elixir SchoolGuardianを使用した実装方法が紹介されてます。

ウエブフックにはそれ用のルールがありx-Hub-Signature-256またはx-hub-signatureヘッダーが使用されるとのことです。

以上

2
Help us understand the problem. What is going on with this article?
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
mnishiguchi
Software Engineer in Washington DC

Comments

No comments
Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account Login
2
Help us understand the problem. What is going on with this article?