Elixir
Phoenix
guardian
guardianDB

Phoenix/ElixirでAPIのログイン機能を作成する(guardian・guardianDB)

はじめに

PhoenixでAPIを作成する時に、すごく悩ましい問題がこのログイン機能の実装だと思います。自分自身もどうすればいいのか試行錯誤する毎日でした。(今もそうw)

ElixirでもAPIのログイン機能を簡単に実装できるライブラリーがありまして、それがguardianです。

guardianのREADEMEを見ると

Guardian is a token based authentication library for use with Elixir applications.

とありますので、JWTトークンベースの認証ライブラリーであることがわかります。ただこのguardianだけでは弱点があります。

それはトークンの状態をDBに持たないので、トークンの追跡ができません。その弱点を補うためのライブラリがguardianDBです。

今回は色々調べたことを備忘録的に残していきます。

やりたいこと

API概要

Clientからユーザとパスワードがきたら、アクセストークンとリフレッシュトークンを発行します。クライアントがアクセストークンを送ってきて、有効期限内であればリクエストに応じた処理を行います。もし、有効期限切れの場合は401エラーを返します。
その後クライアントからリフレッシュトークンがくれば、アクセストークンとリフレッシュトークンを再発行します。

auth.png

レスポンス設計

各種Jsonフォーマット例
#ログイン認証後のレスポンス(正常)(200)
{ 
"data": {
    "accsess_token": "asasfmiahalsdhfklasdjfklajdf",
    "refresh_token": "flakdspfasjdgpoasgdskoakdfof",
    "expires_in": 1516993875 
  }
}

#ログイン認証後のレスポンス(エラー)(401)
{
"data": {
    "error": "Invalid request"
    "error_description": "Incorrect email or password."
  }
}

#期限切れアクセストークンのレスポンス(401)
{
"data": {
    "error": "Invalid request",
    "error_description": "Access token expired."
  }
}

開発環境

Elixir: 1.5
Phoenix: 1.3
guardian: 1.0.1
guardianDB: 1.1.0

APIのログイン機能を作成

プロジェクトの作成

まずはPhoenixのプロジェクトを作成します。

$ mix phx.new app_ex --no-brunch
$ cd app_ex
$ mix ecto.create

ライブラリのインストール

必要なライブラリをインストールします。

mix.exs
  defp deps do
    [
      # 省略・・・
      {:guardian, "~> 1.0"},
      {:guardian_db, "~> 1.0"},
      {:comeonin, "~> 4.0"},
      {:bcrypt_elixir, "~> 0.12"}
    ]

必要なライブラリを記入したらインストールを実行します。

$ mix deps.get

データベースの準備

guardianDBを使う準備

configファイルにguardianDBを使うための設定を追加します。
各設定を簡単に説明すると、
shema_name:トークン管理するテーブル名
sweep_interval:期限切れのトークンを削除するワーカーの実行間隔(分)

config/config.exs
#省略...
config :guardian, Guardian.DB,
  repo: AppEx.Repo,
  schema_name: "guardian_tokens",
  sweep_interval: 60 # default: 60 minutes

ワーカーを設定します。これによって60分おきに期限切れのトークンが削除される処理が発生します。
「worker(Guardian.DB.Token.SweeperServer, [])」を追加します

lib/app_ex/application.ex
#省略...
  def start(_type, _args) do
    import Supervisor.Spec

    # Define workers and child supervisors to be supervised
    children = [
      # Start the Ecto repository
      supervisor(AppEx.Repo, []),
      # Start the endpoint when the application starts
      supervisor(AppExWeb.Endpoint, []),
      # Start your own worker by calling: AppEx.Worker.start_link(arg1, arg2, arg3)
      worker(Guardian.DB.Token.SweeperServer, []), #ここだけ追加
    ]


トークン管理用テーブルを作成します。

$ mix guardian.db.gen.migration
* creating priv/repo/migrations
* creating priv/repo/migrations/20180119201703_guardiandb.exs
$ mix ecto.migrate
[info] == Running AppEx.Repo.Migrations.Guardian.DB.change/0 forward
[info] create table guardian_tokens
[info] == Migrated in 0.0s

Userモデルの作成

今回はemailでの認証とします。

$ mix phx.gen.context Auth User users username:string email:string password:string

usernameとemailをユニークにしておきます。

priv/repo/migrations/20180119202640_create_users.exs
defmodule ApiEx.Repo.Migrations.CreateUsers do
  use Ecto.Migration

  def change do
    create table(:users) do
      add :username, :string
      add :email, :string
      add :password, :string

      timestamps()
    end
    create unique_index(:users, [:email, :username])

  end
end

マイグレーションをします。

$ mix ecto.migrate
[info] == Running AppEx.Repo.Migrations.CreateUsers.change/0 forward
[info] create table users
[info] create index users_email_username_index
[info] == Migrated in 0.0s

これでデータベース側は整いました。

Userモデルのバリデーションの追加

パスワードのハッシュ化やemail、usernameに対してバリデーションを追加します。

app_ex/lib/app_ex/auth/user.ex
defmodule AppEx.Auth.User do
  use Ecto.Schema
  import Ecto.Changeset
  alias AppEx.Auth.User
  alias Comeonin.Bcrypt


  schema "users" do
    field :email, :string
    field :password, :string
    field :username, :string

    timestamps()
  end

  @doc false
  def changeset(%User{} = user, attrs) do
    user
    |> cast(attrs, [:username, :email, :password])
    |> validate_required([:username, :email, :password])
    |> unique_constraint([:email])
    |> unique_constraint([:username])
    |> validate_format(:email, ~r/@/)
    |> validate_length(:password, min: 5)
    |> put_pass_hash()
  end

  defp put_pass_hash(%Ecto.Changeset{valid?: true, changes: %{password: password}} = chgset) do
    change(chgset, password: Bcrypt.hashpwsalt(password))
  end
  defp put_pass_hash(chgset), do: chgset
end

認証を追加

メールアドレスとパスワードで認証を行います。

lib/app_ex/auth/auth.ex
  @doc """
  Authenticate user.
  """
  def authenticate_user(email, plain_text_password) do
    query = from u in User, where: u.email == ^email
    Repo.one(query)
    |> check_password(plain_text_password)
  end

  defp check_password(nil, _), do: {:error, "Incorrect email or password."}
  defp check_password(user, plain_text_password) do
    case Bcrypt.checkpw(plain_text_password, user.password) do
      true -> {:ok, user}
      false -> {:error, "Incorrect email or password."}
    end
  end

トークンを使う機能を実装

guardianのモジュール追加

最低限必要なモジュールを追加します。こちらはguardianのREADMEとほぼ同様な形で書いています。

lib/app_ex/auth/guardian.ex
defmodule AppEx.Auth.Guardian do
  use Guardian, otp_app: :app_ex

  alias AppEx.Auth

  def subject_for_token(resource, _claims) do
    {:ok, to_string(resource.id)}
  end
  def subject_for_token(_, _) do
    {:error, :invalid_resource_id}
  end

  def resource_from_claims(claims) do
    user = claims["sub"]
    |> Auth.get_user!
    {:ok, user}
  end
  def resource_from_claims(_), do: {:error, :invalid_claims}

end

さらにconfigファイルにも追加します。

config/config.exs
config :app_ex, AppEx.Auth.Guardian,
  issuer: "app_ex",
  secret_key: "Secret key. You can use `mix guardian.gen.secret` to get one"

secret_keyに関しては下記のコマンドを実行して作ったキーを各々で記入してください。

$ mix guardian.gen.secret
KeHGlvTlMB+Iw5VtEKakEGg9VejJBAszOaDpkuM1QOjqVqz0RGRltBYXAqmcnvMm

以上で準備が整いました。

トークンを扱う関数の追加

下記のプログラムはguardianDBとのやりとりがメインの関数です。guardianDBで用意している主な関数は3つです。

  • after_encode_and_sign:トークンをDBに追加する関数です。
  • on_verify:トークンの確認です。
  • on_revoke:トークンの破棄です。
lib/app_ex/auth/auth_tokens.ex
defmodule AppEx.Auth.AuthTokens do
  use Guardian, otp_app: :app_ex

  def after_encode_and_sign(resource, claims, token) do
    with {:ok, _} <- Guardian.DB.after_encode_and_sign(resource, claims["typ"], claims, token) do
      {:ok, token}
    end
  end

  def on_verify(claims, token) do
    with {:ok, _} <- Guardian.DB.on_verify(claims, token) do
      {:ok, claims}
    end
  end

  def on_revoke(claims, token) do
    with {:ok, _} <- Guardian.DB.on_revoke(claims, token) do
      {:ok, claims}
    end
  end

end

ログイン機能の実装

ルータの追加

バージョン付きAPIを想定して、ルーティングを追加します。

lib/app_ex_web/router.ex
  scope "/api", AppExWeb do
    pipe_through :api
    scope "/v1", V1, as: :v1 do
      post "/login", SessionController, :login
    end
  end

コントローラの追加

コントローラでlogin関数を実装します。ログイン認証が通れば、トークンを発行しかつそのトークンはguardianDBに保存します。リフレッシュトークンとアクセストークンを紐づけるためにリフレッシュトークン側のDBにアクセストークンを格納しています。

lib/app_ex_web/controller/v1/session_controller.ex
defmodule AppExWeb.V1.SessionController do
  use AppExWeb, :controller

  alias AppEx.Auth
  alias AppEx.Auth.Guardian
  alias AppEx.Auth.AuthTokens

  @doc """
  login(create token)
  """
  def login(conn, %{"user"=>%{"email"=>email, "password"=>plain_text_password}}) do
    conn
    |> login_reply(Auth.authenticate_user(email, plain_text_password))
  end

  defp login_reply(conn, {:ok, user}) do
    {:ok, access_token, access_claims, refresh_token, _refresh_claims} = create_token(user)
    response = %{
      access_token: access_token,
      refresh_token: refresh_token,
      expires_in: access_claims["exp"]
    }
    render(conn, "login.json", response: response)
  end

  defp login_reply(conn, {:error, _}) do
    response = %{}
    conn
    |> put_status(:unauthorized)
    |> render("login-error.json", response: response)
  end

  defp create_token(user) do
    {:ok, access_token, access_claims} = Guardian.encode_and_sign(user, %{}, [token_type: "access", ttl: {1, :weeks}])
    {:ok, refresh_token, refresh_claims} = Guardian.encode_and_sign(user, %{access_token: access_token}, [token_type: "refresh", ttl: {4, :weeks}])
    {:ok, _a_token} = AuthTokens.after_encode_and_sign(user, access_claims, access_token)
    {:ok, _r_token} = AuthTokens.after_encode_and_sign(user, refresh_claims, refresh_token)
    {:ok, access_token, access_claims, refresh_token, refresh_claims}
  end

end

ビューの追加

コントローラを追加したら忘れずにビューも追加します。最後に返すJsonデータの形を作ります。

lib/app_ex_web/views/v1/session_view.ex
defmodule AppExWeb.V1.SessionView do
  use AppExWeb, :view
  def render("login.json", %{response: response}) do
    %{data: response}
  end

  def render("login-error.json", %{response: response}) do
    %{data: %{
      error: "Invalid request",
      error_description: "Incorrect email or password."
        }
      } 
  end
end

ログアウト機能の実装

ルータの追加

ログアウト専用のルータを追加します。

lib/app_ex_web/router.ex
  scope "/api", AppExWeb do
    pipe_through :api
    scope "/v1", V1, as: :v1 do
      post "/login", SessionController, :login
      post "/logout", SessionController, :logout
    end
  end

コントローラの追加

ログアウトのロジックを追加します。on_revoke関数でトークンを削除します。
ログアウト処理はリフレッシュトークンを受け取ったら、それとひもづくアクセストークンも削除するようにしています。

lib/app_ex_web/controller/v1/session_controller.ex
  # 省略・・・・
  @doc """
  logout(delete token)
  """
  def logout(conn, %{"refresh_token"=> refresh_token}) do
    logout_reply(conn, confirm_token(refresh_token), refresh_token)
  end

  defp logout_reply(conn, {:ok, claims}, refresh_token) do
    case AuthTokens.on_revoke(claims, refresh_token) do
      {:ok, _} -> 
        with {:ok, access_claims} <- confirm_token(claims["access_token"]) do
          AuthTokens.on_revoke(access_claims, claims["access_token"])
        end
        render(conn, "logout.json", response: %{})
      {:error, _} -> logout_reply(conn, {:error, :revoke_error}, refresh_token)
    end

  end
  defp logout_reply(conn, {:error, _}, _) do
    conn
    |> put_status(:bad_request)
    |> render("logout-error.json", response: %{})
  end

  @doc """
  Confirm token
  """
  def confirm_token(token) do
    case Guardian.decode_and_verify(token) do
      {:ok, claims} ->
        AuthTokens.on_verify(claims, token)
      _ -> {:error, :not_decode_and_verify}
    end
  end

ビューの追加

最後にビューを追加します。

lib/app_ex_web/views/v1/session_view.ex
  #省略・・・
  def render("logout.json", %{response: _}) do
    %{data: %{
      success: "Request success",
      success_description: "Logout."
      }
    }
  end

  def render("logout-error.json", %{response: _}) do
    %{data: %{
      error: "Invalid request",
      error_description: "Can't logout."
      }
    } 
  end

アクセストークンを更新する

ルータの追加

アクセストークンの再発行のルーティングを追加します。

lib/app_ex_web/router.ex
  scope "/api", AppExWeb do
    pipe_through :api
    scope "/v1", V1, as: :v1 do
      post "/login", SessionController, :login
      post "/logout", SessionController, :logout
      post "/refresh_token", SessionController, :refresh_token
    end
  end

コントローラの追加

アクセストークン再発行のロジックを追加します。基本的な動作はclaimsにユーザIDが入っているので、そこからUserを取得、あとはログインと同じ処理なのでログイン関数に渡します。念のためリフレッシュトークンリクエストがきた際のリフレッシュトークンは破棄するようにしました。

lib/app_ex_web/controller/v1/session_controller.ex
  @doc """
  Refresh Token
  """
  def refresh_token(conn, %{"refresh_token"=> refresh_token}) do
    refresh_token_reply(conn, confirm_token(refresh_token), refresh_token)
  end

  defp refresh_token_reply(conn, {:ok, claims}, refresh_token) do
    user = Auth.get_user!(claims["sub"])
    AuthTokens.on_revoke(claims, refresh_token)
    with {:ok, access_claims} <- confirm_token(claims["access_token"]) do
      AuthTokens.on_revoke(access_claims, claims["access_token"])
    end
    login_reply(conn, {:ok, user})
  end

  defp refresh_token_reply(conn, {:error, _}, _) do
    conn
    |> put_status(:bad_request)
    |> render("expired.json", response: %{})
  end

ビューの追加

期限切れ用のビューを追加します。

lib/app_ex_web/views/v1/session_view.ex
  def render("expired.json", _) do
    %{data: %{
      error: "Invalid request",
      error_description: "Access token expired."
      }
    } 
  end

まとめ

とりあえず一通り触ってみたものの、既存の関数でうまくできるところがあるとは思いますが、何せサンプルや文献が少なく結構苦戦しました。もしGuardianを使って認証を作ろうと思っている方がいらっしゃたらぜひ色々情報交換させていただけると助かります。今回作ったソースコードは下記にあげています。
https://github.com/yujikawa/app_ex