Rails 5 APIモード + GrapeによるAPIにかんたんなトークンベース認証の機構を追加する

## はじめに

Rails 5 APIモード + Grapeで実装された既存のWeb APIに、かんたんなトークンベース認証の機構を追加するサンプル。
なお、今回の記事でベースとする既存のWeb APIは、先日書いた [Rails 5 APIモード + Grape + Grape::Entityで作るWeb API - Qiita](https://qiita.com/mktakuya/items/117f1563c2b7e8a6e69c) で作成したものとする。

記事内の方針の項にも書いたけど、複雑な事をしたいなら素直に [devise_token_auth](https://github.com/lynndylanhurley/devise_token_auth) を使ったほうが良さそうだ。


## 前提条件

検証環境は以下の通り。

```shell-session
$ sw_vers
ProductName:	Mac OS X
ProductVersion:	10.14.2
BuildVersion:	18C54
```

```shell-session
$ ruby -v
ruby 2.5.3p105 (2018-10-18 revision 65156) [x86_64-darwin18]
```

```shell-session
$ rails -v
Rails 5.2.1.1
```

また、ベースのプロジェクトとして、[Rails 5 APIモード + Grape + Grape::Entityで作るWeb API - Qiita](https://qiita.com/mktakuya/items/117f1563c2b7e8a6e69c) で作成したRails + Grape製のWeb APIを利用する。


## 方針

- Rails標準の `has_secure_token` を利用。
- `Userモデル` を追加し、そこに `token` カラムを追加する。
  - メールアドレスとパスワードによる認証も追加する。
  - Rails標準の `has_secure_password` を利用。
- かんたんなトークンベース認証を実現するので、トークンに有効期限を設けたり、1ユーザが複数のトークンを持つといった複雑なことはしない。
  - 複雑な事をしたいなら、 [devise_token_auth](https://github.com/lynndylanhurley/devise_token_auth) 等を使うほうが早そう。
    - 参考: [Devise + Grape で認証付きAPIの実装 - Qiita](https://qiita.com/travelist/items/ec0b08a9a19cbe9ec78b)


## Userモデル

### Gemfileの編集、bcrypt gemのインストール

`has_secure_password` や `has_secure_token` を使うために、 [bcrypt-ruby](https://github.com/codahale/bcrypt-ruby) gemのインストールが必要だ。

`Gemfile` を開き、17行目付近にある `bcrypt` のコメントアウトを外す。

```ruby
# Gemfile
# Use ActiveModel has_secure_password
gem 'bcrypt', '~> 3.1.7'
```

その後、bundle install。

```shell-session
$ bundle install
```


### Userモデルの生成と動作確認

次に、Userモデルを生成する。

```shell-session
$ rails g model User email:string name:string password_digest:string token:string
```

生成された `db/migrate/20181203043642_create_users.rb` を編集する。

```ruby 
# db/migrate/20181203043642_create_users.rb
class CreateUsers < ActiveRecord::Migration[5.2]
  def change
    create_table :users do |t|
      t.string :email, null: false
      t.string :password_digest, null: false
      t.string :name, null: false
      t.string :token

      t.timestamps
    end
    add_index :users, :email, unique: true
    add_index :users, :token, unique: true
  end
end
```

* `token` 以外の各カラムにNOT NULL制約を追加
    * `token` カラムにはNOT NULL制約を付加しない [^1]
* `email` カラムにUNIQUE制約を追加
* `token` カラムにUNIQUE制約を追加

次に、 `app/models/user.rb` を編集する。 `has_secure_password` と `has_secure_token` の追記をした後、各種バリデーションの処理を記述する。

```ruby
# app/models/user.rb
class User < ApplicationRecord
  has_secure_password
  has_secure_token

  validates :email, presence: true
  validates :email, uniqueness: true
  validates :name, presence: true
  validates :password_digest, presence: true
  validates :token, uniqueness: true
end
```

DBのマイグレーションを行う。

```shell-session
$ rails db:migrate
```

rails consoleで動作確認をする。

```ruby
user = User.new(name: 'mktakuya', email: 'mktakuya@example.com', password: 'password')
user.save
#=> true

user.token
#=> "何らかの文字列"

user.authenticate('password')
#=> #<User id: 1, name: "mktakuya", ... >

user.authenticate('wrong_password')
#=> false
```


## 認証まわりの実装

### ログインと新規登録

まずは、メールアドレスとパスワードによるログインと、新規会員登録の仕組みを作る。

`app/api/v1/users.rb` を新規作成し、 `signin` と `signup` を作成する。

```ruby
# app/api/v1/users.rb
module V1
  class Users < Grape::API
    resources :users do
      desc 'signin'
      params do
        requires :email, type: String
        requires :password, type: String
      end
      post '/signin' do
        @user = User.find_by(email: params[:email])
        if @user.authenticate(params[:password])
          @user
        else
          error!('Unauthorized. Invalid email or password.', 401)
        end
      end

      desc 'signup'
      params do
        requires :email, type: String
        requires :password, type: String
        requires :name, type: String
      end
      post '/signup' do
        @user = User.new(declared(params))

        if @user.save
          @user
        else
          @user.errors.full_messages
        end
      end
    end
  end
end
```

`root.rb` で `V1::Users` をマウントするのを忘れずに。

```ruby
# app/api/v1/root.rb
module V1
  class Root < Grape::API
    # 省略

    mount V1::Users
    mount V1::Authors
    mount V1::Books
  end
end
```

curlコマンドでテストしてみる。以後の通信では、レスポンスで降ってきたtokenを利用して通信を行う。

```shell-session
# 新規登録成功
$ curl -X POST -H 'Content-Type: application/json' http://localhost:3000/v1/users/signup -d '{"email": "hello@example.com", "password": "password", "name": "hello"}'
{
  "id": 1,
  "email": "hello@example.com",
  "password_digest": "何らかの文字列",
  "name": "hello",
  "token": "何らかの文字列",
  "created_at": "2018-12-21T09:02:08.779Z",
  "updated_at": "2018-12-21T09:02:08.779Z"
}
```

```shell-session
# ログイン成功
$ curl -X POST -H 'Content-Type: application/json' http://localhost:3000/v1/users/signin -d '{"email": "hello@example.com", "password": "password"}'
{
  "id": 1,
  "email": "hello@example.com",
  "password_digest": "$2a$10$0Jj6J2x4ui1ZmvMcQvNUB.SoDjrpJTZSq8LaJ8YGKIsXZlchj5f.K",
  "name": "hello",
  "token": "hff2Lpo8AXNhRHykrTw7rCZe",
  "created_at": "2018-12-21T09:02:08.779Z",
  "updated_at": "2018-12-21T09:02:08.779Z"
}
```

ログイン成功時に `password_digest` やら `*_at` やらが降ってきて不穏だけどそれはこの記事の本質ではないのでよしとする。ちゃんとやりたいなら、 `UserWithTokenEntity` などを作って、必要な情報とtokenのみ返す感じでしてやれば良い。


### 認証ヘルパーの実装

認証ヘルパーの実装を行う。今回実装するのは2つ。 `current_user` と `authenticate!` だ。 `current_user` はログイン中のユーザのインスタンスを返す。また、 `authenticate!` は `current_user` がnilのときに401エラーを返す。

ふつう、こういったヘルパーの類は `helpers` などという名前空間を切って記述するが、今回は簡単のため `app/api/v1/root.rb` 内に helpersブロックで記述する。あとあとヘルパーが増えてきて見通しが悪くなってきたら、 [公式ドキュメントのHelpersの項目](https://github.com/ruby-grape/grape#helpers) を参考に名前空間を切ってファイルを分けてやれば良い。

```ruby
# app/api/v1/root.rb
module V1
  class Root < Grape::API
    version :v1
    format :json

    TOKEN_PREFIX = /^Bearer\s/ # Tokenは "Bearer " というPrefixをもつ
    TOKEN_REGEX = /^Bearer\s(.+)/ # Tokenは "Bearer 何らかの文字列" という形式である
    helpers do
      def authenticate!
        error!('Unauthorized. Invalid or expired token.', 401) unless current_user
      end

      def current_user
        return nil unless headers['Authorization'] && headers['Authorization'].match(TOKEN_REGEX)
        token = headers['Authorization'].gsub(TOKEN_PREFIX, '')
        User.find_by(token: token)
      end
    end

    mount V1::Users
    mount V1::Authors
    mount V1::Books
  end
end
```


### 使ってみる

認証付きのAPIの例として、著者（Authors）の作成は認証済みのユーザじゃないと出来ないというように変更してみようと思う。といっても簡単で、単に該当箇所の処理の最初で `authenticate!` ヘルパーを呼び出してやれば良い。また今回は扱わないが、 `Author` に誰かこの`Author` を追加したか （`created_by`） といった情報を追加するとなったら、 `current_user` を渡してやれば良い。

```ruby
# app/api/v1/authors.rb
module V1
  class Authors < Grape::API
    resources :authors do
      desc 'Create an author'
      params do
        requires :name, type: String
      end
      post '/' do
        authenticate! # ← 追加

        @author = Author.new(name: params[:name])

        if @author.save
          status 201
          present @author, with: V1::Entities::AuthorEntity
        else
          status 400
          present @author.errors
        end
      end
    end
  end
end
```

curlでテスト。tokenは、先程のログイン処理の時に手に入れたものを使う。

```shell-session
# 成功
$ curl -X POST -H 'Content-Type: application/json' -H 'Authorization: Bearer hff2Lpo8AXNhRHykrTw7rCZe' http://localhost:3000/v1/authors -d '{"name": "水嶋ヒロ"}'
{
  "id": 25,
  "name": "水嶋ヒロ"
}

# 間違ったトークンを設定
$ curl -X POST -H 'Content-Type: application/json' -H 'Authorization: Bearer hikakin-tv-everyday' http://localhost:3000/v1/authors -d '{"name": "三島由紀夫"}'
{
  "error": "Unauthorized. Invalid or expired token."
}

# トークン設定無し
$ curl -X POST -H 'Content-Type: application/json' http://localhost:3000/v1/authors -d '{"name": "ひかわ博一"}'
{
  "error": "Unauthorized. Invalid or expired token."
}
```

## おわりに

以上で、Rails 5 APIモード + GrapeによるAPIにかんたんなトークンベース認証の機構を追加することができた。

実際にモバイルアプリ等から利用する際は、以下のような感じになるだろう。

- メールアドレス・パスワードによる認証でトークンを取得し、アプリ内の領域（UserDefaults等）に保存
- 以後、そのトークンを利用して通信を行う。


[^1]: [Rails5のhas_secure_tokenを使う場合はvalidationをかけてはいけない！ - 地方でリモートワーク](http://www.tom08.net/entry/2017/07/06/225859)