トークンを使ってAPIの認証機能をつくる【rails】

はじめに

トークンを発行してapiの認証機能を実装する方法について。

構成

  • userモデルに関連づけたApiKeyモデルを作成。

  • user登録時とログイン時に期限つきのトークンを作成。 (userがすでに有効なトークンを持っている場合はそれを使用)

  • レスポンスヘッダーにトークンを入れる。

  • 該当のURLにアクセスする際にトークンで認証する。

ApiKeyモデルを作成

まずはApiKeyモデルを作成します。

$ rails g model ApiKey user:references access_token:string expires_at:datetime
class CreateApiKeys < ActiveRecord::Migration[6.0]
  def change
    create_table :api_keys do |t|
      t.references :user, null: false, foreign_key: true
      t.string :access_token, null: false
      t.datetime :expires_at

      t.timestamps
      t.index :access_token, unique: true
    end
  end
end

access_tokenカラムにNOT NULL制約とインデックスを付与します。

$ rails db:migrate

モデルの関連付け

userモデルに関連付けを指定します。

has_many :api_keys, dependent: :destroy

initializeメソッドを上書き

ApiKeyの初期化時に期限つきのトークンを作成したいので、initializeメソッドを上書きします。

また、有効なトークンだけをとってくるscopeを追加します。

class ApiKey < ApplicationRecord
  belongs_to :user

  validates :access_token, presence: true, uniqueness: true

  scope :valid_expire, -> { where('expires_at > ?', Time.current) }

  def initialize(attributes = {})
    super
    self.access_token = SecureRandom.urlsafe_base64
    self.expires_at = 1.week.from_now
  end
end

トークンに関するメソッドを作成

続いて、userモデルに有効なトークンを返すメソッドを追記します。

  def grant_api_key
    return api_keys.valid_expire.first if api_keys.valid_expire.exists?
# 有効なトークンがあればその中で一番古いトークンを返す。
# なければ新しいトークンを作成。
    api_keys.create
  end

次は継承元のbase_controllerに、レスポンスヘッダーにトークンを入れる処理を書きます。

      def set_token(user)
        api_key = user.grant_api_key
        response.headers['AccessToken'] = api_key.access_token
      end

ユーザー登録時とログイン時にトークンをセットするために、先程定義したset_tokenメソッドを呼び出します。

module Api
  module V1
    class RegistrationsController < BaseController
      def create
        user = User.new(user_params)
        if user.save
          json_string = UserSerializer.new(user).serialized_json
          set_token(user) # 追加
          render json: json_string
        else
          render_400(nil, user.errors.full_messages)
        end
      end

      private

      def user_params
        params.require(:user).permit(:name, :email, :password)
      end
    end
  end
end
module Api
  module V1
    class AuthenticationsController < BaseController
      def create
        user = login(params[:email], params[:password])
        raise ActiveRecord::RecordNotFound unless user

        json_string = UserSerializer.new(user).serialized_json
        set_token(user)  # 追加
        render json: json_string
      end

      private

      def form_authenticity_token; end
    end
  end
end

これでユーザー新規作成時とログイン時に、有効なトークンがなければ発行し、レスポンスヘッダーに入れます。

そして有効なトークンがあれば、それをレスポンスヘッダーにいれる処理が完了しました。

ページアクセスする際の認証

記事一覧ページなどにアクセスする際に、先程のトークンを認証する処理をつくります。

bese_controllerにauthenticateメソッドを追加します。

module Api
  module V1
    class BaseController < ApplicationController
      include Api::ExceptionHandler
      include ActionController::HttpAuthentication::Token::ControllerMethods
      #  authenticate_or_request_with_http_tokenメソッドを使うために必要。

      private

      def set_token(user)
        api_key = user.grant_api_key
        response.headers['AccessToken'] = api_key.access_token
      end

      def authenticate
        authenticate_or_request_with_http_token do |token, _options|
          @_current_user ||= ApiKey.valid_expire.find_by(access_token: token)&.user
        end
      end

      def current_user
        @_current_user  # `_` は直接参照してほしくない変数につける。
      end
    end
  end
end

authenticate_or_request_with_http_tokenHTTPメソッドでトークンの認証をおこないます。

falseの場合はToken: Access denied.を返します。

current_userを使えるようにするためにcurrent_userメソッドも定義しています。

最後に、articles_controllerに先程定義したauthenticateメソッドを、before_actionに設定します。

module Api
  module V1
    class ArticlesController < BaseController
      before_action :authenticate  # 追加

      def index
        articles = Article.all
        json_string = ArticleSerializer.new(articles).serialized_json
        render json: json_string
      end

      def show
        article = Article.find(params[:id])
        options = { include: %i[user 'user.name' 'user.email'] }
        json_string = ArticleSerializer.new(article, options).serialized_json
        render json: json_string
      end
    end
  end
end