ここ最近、Elixir、Nerves、Phoenixを使ったIoTデバイスの開発に夢中になってます。しばらく電子工作しながら基礎を学んだあと、autoracex主催者@torifukukaiouさんの記事を参考に自宅の温度と湿度をリアルタイムで監視できるシステムを作りました。
取り敢えずいい感じに動いているので、次はセキュリティ面の強化に取り組もうと思います。今日は簡易的なトークン認証を実装します。
4/3(土) 00:00〜 4/5(月) 23:59開催のautoracex #21での成果です。
English edition
方針
- Plugのパイプラインに追加できるようにする。
- トークンはIExにて手動で生成する。ログインの部分は実装しない。
- APIユーザーはAuthorizationリクエストヘッダーにトークンを入れなければ、アクセスできない。
Phoenix.Token
ありがたいことに、PhoenixにはPhoenix.Token
モジュールにトークン認証に最低限必要な機能が備わっています。Nice!
トークン認証Plug
の実装例
Phoenix.Token
モジュールを使用して、2つのカスタムPlug
を書きました。Programming Phoenixで学んだ内容をベースにしてます。
-
ExampleWeb.API.AuthPlug
- リクエストヘッダーのトークンを認証し、:current_user
をconn
にassign
する。 -
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.Router
でExampleWeb.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.Router
でExampleWeb.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 SchoolにGuardianを使用した実装方法が紹介されてます。
ウエブフックにはそれ用のルールがありx-Hub-Signature-256
またはx-hub-signature
ヘッダーが使用されるとのことです。
以上
Comments