Rails でトークン認証 API を 15 分で実装する


下記を作成してみます。SPA のバックエンド側を想定しています。

- `/auth` でログインし token を発行してもらえる。
- `/users` でユーザー一覧を取得できる。
    - ただし token が必要。
    - 更に admin ユーザーのみでき、member ユーザーはアクセス許可がない。

上記を利用したフロント側の記事も書いておりますので宜しければご覧ください。 
[Vue.js で簡単なログイン画面 (トークン認証) を作ってみた](https://qiita.com/kumagaias/items/f3486da415d3024c7ed4)

## User API 作成

rails new で API モードで新規アプリ作成します。

```sh
$ rails new yourappname --api
```

scaffold で User Model と Controller を作ります。
lock_version というカラムを追加すると Rails で楽観ロックを実装してくれます。便利ですね。

```sh
$ cd yourappname
$ rails g scaffold User name:string \
email:string \
role:integer \
password_digest:string \
register_user:integer \
update_user:integer \
lock_version:integer \
activated_at:datetime \
deleted_at:datetime
```

seeds に初期データを書いてみましょう。

```db/seeds.rb
User.create!([
  {
    name: 'admin',
    email: 'admin@example.com',
    role: 'admin',
    password_digest: '$2a$10$HjQH2VBdguACJLyZHoVSs.yBZbwypqY3vUJGnxlWj94rmilWIuWzK',
    register_user: 1,
    update_user: 1,
    lock_version: 0,
    activated_at: '2020-02-03 00:00:00',
    deleted_at: nil,
  },
  {
    name: 'member',
    email: 'member@example.com',
    role: 'member',
    password_digest: '$2a$10$HjQH2VBdguACJLyZHoVSs.yBZbwypqY3vUJGnxlWj94rmilWIuWzK',
    register_user: 1,
    update_user: 1,
    lock_version: 0,
    activated_at: '2020-02-03 00:00:00',
    deleted_at: nil,
  },
])
```

開発用の DB をマイグレーション & 初期データを投入します。

```
$ rails db:migrate && rails db:seed
```

サーバーを起動します。

```sh
$ rails s
```

別ターミナルにて curl で叩くと初期データが返ってきます。
まずは簡単な API ができたことを確認します。:smile:

```sh
# 別ターミナル
$ curl -s http://localhost:3000/users | jq
[
  {
    "id": 1,
    "name": "admin",
    "email": "admin@example.com",
    "role": "admin",
    "password_digest": "$2a$10$HjQH2VBdguACJLyZHoVSs.yBZbwypqY3vUJGnxlWj94rmilWIuWzK",
    "register_user": 1,
    "update_user": 1,
    "lock_version": 0,
    "activated_at": "2020-02-03T00:00:00.000Z",
    "deleted_at": null,
    "created_at": "2020-02-22T04:08:58.769Z",
    "updated_at": "2020-02-22T04:08:58.769Z"
  },
  {
    "id": 2,
    "name": "member",
    "email": "member@example.com",
    "role": "member",
    "password_digest": "$2a$10$HjQH2VBdguACJLyZHoVSs.yBZbwypqY3vUJGnxlWj94rmilWIuWzK",
    "register_user": 1,
    "update_user": 1,
    "lock_version": 0,
    "activated_at": "2020-02-03T00:00:00.000Z",
    "deleted_at": null,
    "created_at": "2020-02-22T04:08:58.775Z",
    "updated_at": "2020-02-22T04:08:58.775Z"
  }
]
```

テストも実行してエラーが無いことも確認しましょう。

```
$ rails t
Running via Spring preloader in process 16858
Run options: --seed 26013

# Running:

.....

Finished in 0.320389s, 15.6060 runs/s, 21.8484 assertions/s.
5 runs, 7 assertions, 0 failures, 0 errors, 0 skips
```


ちなみに jq が入っていない場合は入れると JSON 整形してくれて便利です。

```
$ brew install jq
```

## Gemfile もろもろ

Gemfile は Node.js で言う、package.json 的なファイルだと思います。

下記を追記します。ついでにオススメの gem も。

```Gemfile
gem 'redis-rails' # Redis を扱うための gem
gem 'mock_redis' # Redis のモック。テスト実行時に使用。
gem 'config' # 環境ごとに yml の設定ファイルを作成可能。
gem 'pundit' # 認証周りを REST ベースでシンプルに実装できる。
gem 'paranoia' # 論理削除できる。
```

下記をコメントアウトします。
has_secure_password を使う際に必要です。
これはテーブルに password_digest というカラムを用意すると、Rails がパスワードをハッシュ化してくれます。 

```Gemfile
gem 'bcrypt', '~> 3.1.7'
```

bundle install するとパッケージがインストールされます。

```sh
$ bundle install
```

config をインストールすると rails g config:install が使えます。
環境ごとに yml ファイルができます。便利ですね。

```sh
$ rails g config:install
Running via Spring preloader in process 17408
      create  config/initializers/config.rb
      create  config/settings.yml
      create  config/settings.local.yml
      create  config/settings
      create  config/settings/development.yml
      create  config/settings/production.yml
      create  config/settings/test.yml
      append  .gitignore
```

## Redis 設定

セッション情報を保存するためにインメモリ DB の Redis を使います。
Mac の方は brew でインストール。

```sh
$ brew install redis
```

redis-server で起動できます。簡単でいいですね。

```sh
$ redis-server
```

いったん、全環境共通の settings.yml に url と timeout を追記します。
こうすると例えば、下記の url の値を取り出すには `Settings.session.url` と記述すれば OK です。

```config/settings.yml
session:
  url: redis://localhost:6379
  timeout: 7200
role:
  member: member
  admin: admin
```

ところで Redis 接続時に毎回 Redis.new() を書くのは不便なので、initializers/ 下に redis.rb を作成します。
ついでにテストの時は Redis 立ち上げなくても済むようにしました。

```config/initializers/redis.rb
if Rails.env.test?
  REDIS = MockRedis.new
else
  REDIS = Redis.new(url: Settings.session.url)
end
```

## Auth コントローラー作成

コマンドラインから枠を作ります。

```sh
$ rails g controller auth
```

auth のルーティングを追加します。
個人的にはなるべく resources を使うようにすると綺麗だと思います。

```config/routes.rb
Rails.application.routes.draw do
  resources :users
+  resources :auth, :only => [:create, :destroy]
end
```

セッション作成処理を concerns に切り出してみます。

```app/controllers/concerns/session.rb
module Session
  def self.create(user)
    token = SecureRandom.hex(64)
    REDIS.mapped_hmset(
      token,
      'user_id' => user.id,
      'role' => user.role,
    )
    REDIS.expire(token, Settings.session.timeout)
    return token
  end
end
```

AuthController で先ほど作った Session モジュールを利用します。

```app/controllers/auth_controller.rb
class AuthController < ApplicationController
+  def create
+    user = User.find_by(email: params[:email])
+    token = ''
+    status = :unauthorized
+    if user && user.authenticate(params[:password])
+      token = Session.create(user)
+      status = :created
+    end
+    render json: { token: token }, status: status
+  end
end
```

models/user.rb に下記を追記。
物理削除とパスワードハッシュ化を利用します。
enum で member と admin も定義しておきます。

```app/models/user.rb
class User < ApplicationRecord
+  acts_as_paranoid
+  has_secure_password
+  enum role: { member: 0, admin: 1 }
end
```

動作確認してみましょう。

### ログイン OK

まずは seeds.rb に記述した email と password で /auth を叩きます。
token が返ってきますね:smile:

```sh
$ curl -s \
-X POST http://localhost:3000/auth \
-H "Content-Type: application/json" \
-d '{"email": "admin@example.com", "password": "password1234"}' 

{"token":"ed64bbcf830e4feca9203b0955c5916fda815a23da80da8a7bb62b8c6466dbc34a07e21e9dd96447882fc8b9cb06121c8ee414b79ffedef4892e7197bbe9edd1"}
```

redis-cli で Redis に接続して、トークンが作成されたか確認してみましょう。
おお、できています！:joy:

```sh
$ redis-cli
127.0.0.1:6379> keys *
1) "ed64bbcf830e4feca9203b0955c5916fda815a23da80da8a7bb62b8c6466dbc34a07e21e9dd96447882fc8b9cb06121c8ee414b79ffedef4892e7197bbe9edd1"
```

### ログイン NG

password を適当に変えてみます。
token が空になっていますね。

```sh
$ curl -s -X POST http://localhost:3000/auth \
-H "Content-Type: application/json" \
-d '{"email": "admin@example.com", "password": "wrongpassword"}'

{"token":""}
```

## 認証を設定

Session モジュールにセッション情報を取得する function を追加します。

```app/controllers/concerns/session.rb
module Session

+  def self.get(token)
+    REDIS.hgetall(token)
+  end

  def self.create(user)
    token = SecureRandom.hex(64)
    REDIS.mapped_hmset(
      token,
      'user_id' => user.id,
      'role' => user.role,
    )
    REDIS.expire(token, Settings.session.timeout)
    return token
  end
end
```

続いて基幹コントローラーに手を入れます。

- `authenticate_with_http_token` を利用すると、
リクエストヘッダに 'Authorization: Token hogehoge' がセットされていた場合に、トークン hogehoge を取り出せます。
  - 上記を利用するには `include ActionController::HttpAuthentication::Token::ControllerMethods` する必要があります。
- `before_action :set_session` で、Redis に登録してあるセッション (ここでは user.id と user.role ) をメンバにセットしています。
- `before_action :require_login` で、基本的にセッションがない場合に認証エラーとしています。

```app/controllers/application_controller.rb
class ApplicationController < ActionController::API
+  include ActionController::HttpAuthentication::Token::ControllerMethods
+
+  before_action :set_session
+  before_action :require_login
+
+  @session = {}
+
+  def require_login
+    render json: { error: 'unauthorized' }, status: :unauthorized if @session.empty?
+  end
+
+  private
+    def set_session
+      authenticate_with_http_token do |token, options|
+        @session = Session.get(token)
+      end
+    end
end
```

AuthController に skip_before_action を追加します。
これを追加しないと一生ログインができません・・。

```app/controllers/auth_controller.rb
class AuthController < ApplicationController
+  skip_before_action :require_login, only: [:create]
+
  def create
    user = User.find_by(email: params[:email])
    token = ''
    status = :unauthorized
    if user && user.authenticate(params[:password])
      token = Session.create(user)
      status = :created
    end
    render json: { token: token }, status: status
  end
end
```

### ユーザー一覧 OK

`Authorization: Token xxx` には、先ほど /auth を叩いて得られた token をセットします。
/users の結果が返ってきています。

~~~sh
$ curl -s http://localhost:3000/users \
-H "Content-Type: application/json" \
-H "Authorization: Token ed64bbcf830e4feca9203b0955c5916fda815a23da80da8a7bb62b8c6466dbc34a07e21e9dd96447882fc8b9cb06121c8ee414b79ffedef4892e7197bbe9edd1" \
| jq

[
  {
    "id": 1,
    "name": "admin",
    "email": "admin@example.com",
    "role": 0,
    "password_digest": "$2a$10$HjQH2VBdguACJLyZHoVSs.yBZbwypqY3vUJGnxlWj94rmilWIuWzK",
    "register_user": 1,
    "update_user": 1,
    "lock_version": 0,
    "activated_at": "2020-02-03T00:00:00.000Z",
    "deleted_at": null,
    "created_at": "2020-02-21T14:28:10.627Z",
    "updated_at": "2020-02-21T14:28:10.627Z"
  },
  {
    "id": 2,
    "name": "member",
    "email": "member@example.com",
    "role": 0,
    "password_digest": "$2a$10$HjQH2VBdguACJLyZHoVSs.yBZbwypqY3vUJGnxlWj94rmilWIuWzK",
    "register_user": 1,
    "update_user": 1,
    "lock_version": 0,
    "activated_at": "2020-02-03T00:00:00.000Z",
    "deleted_at": null,
    "created_at": "2020-02-21T14:28:10.635Z",
    "updated_at": "2020-02-21T14:28:10.635Z"
  }
]
~~~

### ユーザー一覧 NG

token を適当なものに変えるとちゃんと認証エラーになりました:relaxed:

~~~sh
$ curl -s http://localhost:3000/users -H "Content-Type: application/json" -H "Authorization: Token wrong_token" | jq

{
  "error": "unauthorized"
}
~~~

## 権限周り設定

Web アプリでは権限は必須と言えます。
Pundit を使うと Rest ベースでシンプルに実装できます。

~~~sh
$ rails g pundit:install
$ rails g pundit:policy user
~~~

まずは application_policy.rb に管理者かどうかの admin? を追加してみます。

~~~app/policies/application_policy.rb
class ApplicationPolicy
  attr_reader :user, :record

  def initialize(user, record)
    @user = user
    @record = record
  end

  def index?
    false
  end

  def show?
    false
  end

  def create?
    false
  end

  def new?
    create?
  end

  def update?
    false
  end

  def edit?
    update?
  end

  def destroy?
    false
  end

+  def admin?
+    @user['role'] == Settings.role.admin
+  end

  class Scope
    attr_reader :user, :scope

    def initialize(user, scope)
      @user = user
      @scope = scope
    end

    def resolve
      scope.all
    end
  end
end
~~~

続いて、user_policy.rb の index に先ほど作った admin? を追加します。

~~~app/policies/user_policy.rb
class UserPolicy < ApplicationPolicy
  class Scope < Scope
    def resolve
      scope.all
    end
  end

+  def index?
+    admin?
+  end
end
~~~

あとは利用側です。
application_controller.rb で Pundit を include し、current_user メソッドを追加します。
また、Pundit の NotAuthorizedError を拾えるように rescue_from を追加します。
(エラー処理は増えてきたら concerns に切り出すと良いと思います。)

~~~app/controllers/application_controller.rb
class ApplicationController < ActionController::API
  include ActionController::HttpAuthentication::Token::ControllerMethods
+  include Pundit

  before_action :set_session
  before_action :require_login

  @session = {}

+  rescue_from Pundit::NotAuthorizedError do |e|
+    render json: { detail: e.message }, status: :unauthorized
+  end
+
  def require_login
    render json: { error: 'unauthorized' }, status: :unauthorized if @session.empty?
  end

+  def current_user
+    @session
+  end

  private
    def set_session
      authenticate_with_http_token do |token, options|
        @session = Session.get(token)
      end
    end
end
~~~

後は users_controller.rb の index に Pundit を追加します。

~~~app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :set_user, only: [:show, :update, :destroy]

  # GET /users
  def index
-    @users = User.all
-
-    render json: @users
+    users = authorize Pundit.policy_scope(@session, User)
+    render json: users
  end

  # GET /users/1
  def show
    render json: @user
  end

  # POST /users
  def create
    @user = User.new(user_params)

    if @user.save
      render json: @user, status: :created, location: @user
    else
      render json: @user.errors, status: :unprocessable_entity
    end
  end

  # PATCH/PUT /users/1
  def update
    if @user.update(user_params)
      render json: @user
    else
      render json: @user.errors, status: :unprocessable_entity
    end
  end

  # DELETE /users/1
  def destroy
    @user.destroy
  end

  private
    # Use callbacks to share common setup or constraints between actions.
    def set_user
      @user = User.find(params[:id])
    end

    # Only allow a trusted parameter "white list" through.
    def user_params
      params.require(:user).permit(:name, :email, :role, :password_digest, :register_user, :update_user, :lock_version, :activated_at, :deleted_at)
    end
end
~~~

さて、動作確認してみましょう。
まずは member ユーザーでログインし、/users を叩きます。
ユーザー一覧が取得できないことを確認します。

~~~sh
$ curl -s -X POST http://localhost:3000/auth -H "Content-Type: application/json" -d '{"email": "member@example.com", "password": "password1234"}'
{"token":"482125111d11c2882cad25b700221a45bc64d8745771f427cb8039337c0e7cda95fc8498f98d6d784781b177afe84699bccb2d62628b1bee3ccee0e96eb9e576"}

$ curl -s http://localhost:3000/users -H "Content-Type: application/json" -H "Authorization: Token 482125111d11c2882cad25b700221a45bc64d8745771f427cb8039337c0e7cda95fc8498f98d6d784781b177afe84699bccb2d62628b1bee3ccee0e96eb9e576"
{"error":"not allowed to index? this User::ActiveRecord_Relation"}
~~~

続いて admin ユーザーでログインし、/users を叩きます。
結果が取得できましたね:smile:

~~~sh
$ curl -s -X POST http://localhost:3000/auth -H "Content-Type: application/json" -d '{"email": "admin@example.com", "password": "password1234"}'
{"token":"a87dee3d5cb1592e3b3b09f78931e26254292f1453ea77a1d491fd949ce508d5ea7e00dec6163fb83ed9c7f5d39b672aa429c770a6d0487952022fc55a493487"}

$ curl -s http://localhost:3000/users -H "Content-Type: application/json" -H "Authorization: Token a87dee3d5cb1592e3b3b09f78931e26254292f1453ea77a1d491fd949ce508d5ea7e00dec6163fb83ed9c7f5d39b672aa429c770a6d0487952022fc55a493487"
[{"id":1,"name":"admin","email":"admin@example.com","role":"admin","password_digest":"$2a$10$HjQH2VBdguACJLyZHoVSs.yBZbwypqY3vUJGnxlWj94rmilWIuWzK","register_user":1,"update_user":1,"lock_version":0,"activated_at":"2020-02-03T00:00:00.000Z","deleted_at":null,"created_at":"2020-02-11T12:14:52.557Z","updated_at":"2020-02-11T12:14:52.557Z"},{"id":2,"name":"member","email":"member@example.com","role":"member","password_digest":"$2a$10$HjQH2VBdguACJLyZHoVSs.yBZbwypqY3vUJGnxlWj94rmilWIuWzK","register_user":1,"update_user":1,"lock_version":0,"activated_at":"2020-02-03T00:00:00.000Z","deleted_at":null,"created_at":"2020-02-11T12:14:52.565Z","updated_at":"2020-02-11T12:14:52.565Z"}]
~~~