Help us understand the problem. What is going on with this article?

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

はじめに

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

記事内の方針の項にも書いたけど、複雑な事をしたいなら素直に devise_token_auth を使ったほうが良さそうだ。

前提条件

検証環境は以下の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.2
BuildVersion:   18C54
$ ruby -v
ruby 2.5.3p105 (2018-10-18 revision 65156) [x86_64-darwin18]
$ rails -v
Rails 5.2.1.1

また、ベースのプロジェクトとして、Rails 5 APIモード + Grape + Grape::Entityで作るWeb API - Qiita で作成したRails + Grape製のWeb APIを利用する。

方針

  • Rails標準の has_secure_token を利用。
  • Userモデル を追加し、そこに token カラムを追加する。
    • メールアドレスとパスワードによる認証も追加する。
    • Rails標準の has_secure_password を利用。
  • かんたんなトークンベース認証を実現するので、トークンに有効期限を設けたり、1ユーザが複数のトークンを持つといった複雑なことはしない。

Userモデル

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

has_secure_passwordhas_secure_token を使うために、 bcrypt-ruby gemのインストールが必要だ。

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

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

その後、bundle install。

$ bundle install

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

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

$ rails g model User email:string name:string password_digest:string token:string

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

# 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_passwordhas_secure_token の追記をした後、各種バリデーションの処理を記述する。

# 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のマイグレーションを行う。

$ rails db:migrate

rails consoleで動作確認をする。

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 を新規作成し、 signinsignup を作成する。

# 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.rbV1::Users をマウントするのを忘れずに。

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

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

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

# 新規登録成功
$ 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"
}
# ログイン成功
$ 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_userauthenticate! だ。 current_user はログイン中のユーザのインスタンスを返す。また、 authenticate!current_user がnilのときに401エラーを返す。

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

# 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 を渡してやれば良い。

# 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は、先程のログイン処理の時に手に入れたものを使う。

# 成功
$ 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等)に保存
  • 以後、そのトークンを利用して通信を行う。
Why do not you register as a user and use Qiita more conveniently?
  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
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  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