60
46

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

RailsのAPI開発で使える!JWTを理解して認証機能を実装する!

Last updated at Posted at 2021-03-06

背景

以前、vue.js × Rails APIでのSPA開発に挑戦しました。その際、認証にJWTを使ったのですが、ネットの記事を参考に実装したために仕組みやコードを完全に理解できていませんでした。そのためJWTの認証について改めて記事にしてまとめたいと思いました。railsでvue,reactなどと連携させてSPA開発したい方は参考になるかと思います。

#目次

  1. JWTについて
  2. JWTで認証をする流れ
  3. 実際のrailsコード解説
  4. vue.jsとの連携

1. JWTについて

###■JWTとはなにか
JWTとはJSON Web Tokenの略で、JSON形式で表されたトークンです。著名・暗号化によってセキュアな通信ができます。

###■JWTの中身
JWTは下記のうようにピリオド区切りで3つの要素に分かれています。

{①ヘッダー要素}.{②ペイロード要素}.{③署名要素}
  • ①のヘッダー要素は、データの型やルールを指定します。
  • ②のペイロード要素には、属性情報が入ります。例えばuser_idやemailやtokenの有効期限などです。
  • ③の著名要素は、改ざんがされていないか確認するための情報です。

以上がざっくりとして説明です。アプリケーション実装のためには上記の大枠の把握だけで問題ないです。もっと詳しく知りたい場合は下記の参考記事を参照ください。
JWT(JSON Web Token)の「仕組み」と「注意点」


2. JWTで認証をする流れ

###■図による説明
名称未設定.png

ざっくりですが説明です。

  • まずはフロント側からログインフォームからユーザー名・メールアドレス・パスワードなどログインに必要な情報を送ります。
  • その情報をバックエンドで受け取り、登録しているユーザー名とパスワードが一致していた場合に、tokenを発行してレスポンスします。発行されたtokenは秘密鍵で暗号化してJWTとして送られます。
  • そのtokenをlocalstorageに保存して、常に使える状態にします。vue.jsであればvuex, react.jsであればreduxに保存します。ログインしてないとできないリクエストは、このtokenをヘッダーにのせてリクエストします。
  • バックエンド側はヘッダー情報をもとに認証・認可を行い、リクエストを返します。
  • 帰ってきたリクエストをフロント側で表示等します。

上記の流れを理解すれば、バックエンド側の実装も理解しやすくなります。すなわちバックエンド側はjwtのtoken発行、そのルーティングとモデルの設定をすればよいのです。では早速具体的なコードを見ていきましょう。


実際のコードrails解説

###■gemのインストール

gemfile

gem 'active_model_serializers'
gem 'jwt'
  • jwtに関してはそのままjwtというgemがあります。
  • active_model_serializersはレスポンスを簡単にそしてきれいにjson形式に整形してくれるgemです。railsでAPI開発をする際はわりかし頻繁に使われます。

###■ルーティングの設定

config/routes.rb
Rails.application.routes.draw do
  root 'home#index'
  namespace :api do
    resources :users, only: %i[create]
    resource :session, only: %i[create destroy]
  end
end
  • 基本的にapi開発はapiやv1をurlにつけて管理することが多いです。そうすることでバージョンの更新がしやすくなるからです。
  • 今回はuserの登録はusers、usersの情報をもとに認証を行うのがsessionsの役割です。

ルーティングのnamespaceなどの指定に関しては下記記事を参照してください。
Railsのroutingにおけるscope / namespace / module の違い

###■モデルの設定

xxx_create_users.rb
class CreateUsers < ActiveRecord::Migration[6.0]
  def change
    create_table :users do |t|
      t.string :name, null: false
      t.string :email, null: false
      t.string :password_digest, null: false

      t.timestamps
    end
    add_index :users, :email, unique: true
  end
end
models/user.rb
class User < ApplicationRecord
  has_secure_password
  validates :name, presence: true
  validates :email, presence: true, uniqueness: true
  validates :password_digest, presence: true
end
  • 特に難しいことはないですが、簡単に説明するとuser登録にはname,email,passwordが必要で、ログインにはemail,passwordが必要です。passwordはセキュリティの関係上,password_digestで暗号化して保存しています。

###■コントローラの設定

app/contorollers/application_controller.rb
class ApplicationController < ActionController::Base
  protect_from_forgery with: :null_session
  class AuthenticationError < StandardError; end

  rescue_from ActiveRecord::RecordInvalid, with: :render_422
  rescue_from AuthenticationError, with: :not_authenticated

  def authenticate
    raise AuthenticationError unless current_user
  end

  def current_user
    @current_user ||= Jwt::UserAuthenticator.call(request.headers)
  end

  private

  def render_422(exception)
    render json: { error: { messages: exception.record.errors.full_messages } }, status: :unprocessable_entity
  end

  def not_authenticated
    render json: { error: { messages: ['please login'] } }, status: :unauthorized
  end
end
  • application controllerではエラーに関する処理を記載しています
  • 「protect_from_forgery with:」メソッドは自動でCSRF対策の設定です。「null_session」のオプションはTokenが一致しなかった場合にsessionを空にするというオプションです。
  • 「class AuthenticationError < StandardError; end」はStandardErrorは例外束ねているクラスです。それをAuthenticationErrorへ継承しています。
  • 「rescue_from」は例外の処理です。「rescue_from AuthenticationError, with: :not_authenticated」の意味はAuthenticationErrorが起こった場合に,not_authenticatedメソッドを実行するという意味です。
  • 「rescue_from ActiveRecord::RecordInvalid, with: :render_422」はActiveRecord::RecordInvalid(railsが用意しているバリデーションのエラー)が起こった場合に,render_422をするという意味です。
  • 「authenticate」は現在ログイン中のuserでなければエラーを発生させるメソッドです。raiseはエラーを発生させるメソッドで、currentuserでない場合はAuthenticationErrorを発生させます。
  • 「current_user」は現在ログイン中のuserかどうかを判定するメソッドです。Jwt::UserAuthenticator(この後説明するサービスファイル)で定義したcallメソッドを呼びます。引数にはリクエストのヘッダー情報を送ります。またuser情報が取得できた場合は@current_userに代入され、できない場合はfalseを返します。
  • render_422, not_anthentiatedはjson形式でエラーメッセージとstatusを返すメソッドです。

app/controllers/api/sessions_controller.rb
class Api::SessionsController < ApplicationController
  def create
    user = User.find_by(email: session_params[:email])

    if user&.authenticate(session_params[:password])
      token = Jwt::TokenProvider.call(user_id: user.id)
      render json: ActiveModelSerializers::SerializableResource.new(user, serializer: UserSerializer).as_json.deep_merge(user: { token: token })
    else
      render json: { error: { messages: ['mistake emal or password'] } }, status: :unauthorized
    end
  end

  private

  def session_params
    params.require(:session).permit(:email, :password)
  end
end 
  • 「user = User.find ・・・」でusersカラムから、リクエストされてきたemail情報をもとに特定のuserを見つけます。
  • 「user&.authenticate」はuserがnilでない場合にautheicateを実行するという意味です。(ぼっち演算子)
  • 「token = JWT」でTokenProviderというサービスファイルで定義したcallメソッドを呼び出し、引数であるuser_idをもとにトークンを取得しています。
  • 次に処理内容を「render json」でjson形式でレスポンスしているのですが、少し長いので分解して解説します。「ActiveModelSerializers::SerializableResource.new(user, serializer: UserSerializer)」の箇所は新しくserializerインスタンスを作成しています。普通であればserializerファイルを作っていればこんな記述をしなくてよいのですが,sessionの中身はuserモデルと同じなので、user情報とuserのserializerを引数にとり、その情報をもとにserializerインスタンスを作成しているのです。「.as_json」はserializerの形式を指定しています。「.deep_merge(user: { token: token })」はuserserializerにuserのもっているtoken情報を結合するよという意味です。「.merge」はコントローラ内でもよく使うと思うのですが、ハッシュの中にハッシュがあるような場合は「.deep_merge」を使う必要があります。
  • その他の「render json: {error~」や「sessionparams」の定義は難しくないので大丈夫だと思います。

app/controllers/api/users_controller.rb
class Api::UsersController < ApplicationController
  def create
    user = User.new(user_params)
    user.save!
    render json: user, serializer: UserSerializer
  end

  private

  def user_params
    params.require(:user).permit(:name, :email, :password, :password_confirmation)
  end
end
  • こちらも簡単ですので説明省略します。

###■サービスの設定

app/services/jwt/user_authenticator.rb
module Jwt::UserAuthenticator
  extend self

  def call(request_headers)
    @request_headers = request_headers
    begin
      payload, = Jwt::TokenDecryptor.call(token)
      User.find(payload['user_id'])
    rescue StandardError
      nil
    end
  end

  private
  def token
    @request_headers['Authorization'].split(' ').last
  end
end
  • 「extend self」は、レシーバが「module Jwt::UserAuthenticator」そのものであり、module内で定義したメソッドが「Jwt::UserAuthenticator.メソッド名」として使えるようになるという意味です。
  • ちなみにここで定義されたcallメソッドはapplication.rbのcurrentuserメソッドで呼ばれました。引数としてrequestのヘッダー情報が渡されています。
  • 「begin ~ rescue ~ end」は例外処理です。begin内でエラーが起こりそうなアクション、resucueがエラーが起こったときのアクションです。「rescue StandardError」としてあるのは、特定のエラーを指定しています。つまりStandard errorが発生したときにnilを返す、今回でいうとcurrent_userがnilになります。ちなみにStandardErrorはプログラムで発生するよくあるエラーがまとまっているクラスです。明記しなくてもデフォルトで設定されるかも。。
  • 「payload,= Jwt::TokenDecryptor.call(token)」はサービスのTokenDecryptorファイルにあるcallメソッドを呼び出し、payloadに格納しています。また「payload,」は間違えではありません。「payload,_」と書くことも多いですが、このアンダーバーにヘッダー情報が格納されます。実際にこのコードではtokenを暗号化しているのですが、その際にpayloadの属性情報と一緒に、ヘッダー情報を返します。
  • 「User.find...」は取得したpayloadのuser_idの情報をもとにuserを探します。そしてapplicationコントローラのcurrent_userに代入されます。
  • プライベートメソッドのtokenには「request_headers['Authorization'].split(' ').last」と書いてあり、ヘッダーのauthrizationのtokenのみを取得しています。request_headersの中身は「Authorization: Bearer tokentokentoken...」となっているので、tokenだけを取得するには上記のようにします。

app/services/jwt/token_decryptor.rb
module Jwt::TokenDecryptor
  extend self

  def call(token)
    decrypt(token)
  end

  private

  def decrypt(token)
    JWT.decode(token, Rails.application.credentials.secret_key_base)
  rescue StandardError
    raise InvalidTokenError
  end
end
class InvalidTokenError < StandardError; end
  • プライペートメソッドのdecryptは、引数のtokenをもとに復号化しています。復号にはrailsのrailsの秘密鍵が必要になるので第2引数で指定しています。
  • つまりcallは復号化のメソッドです。「services/jwt/user_authenticator.rb」で特定のuserを探すために暗号化されたtokenを複合しているのです。
  • resucue以下は例外処理です。StandardErrorが起こった際にraiseでInvalidTokenErrorという自分で定義したエラーを起こしています。InvalidTokenErrorはStandardErrorを継承しています。

app/services/jwt/token_provider.rb
module Jwt::TokenProvider
  extend self

  def call(payload)
    issue_token(payload)
  end

  private
  def issue_token(payload)
    JWT.encode(payload, Rails.application.credentials.secret_key_base)
  end
end
  • 「issue_token」メソッドは、引数のpayload(今回でいうとuser_idのこと)をもとに暗号化しています。暗号化するにはrailsの秘密鍵が必要になるので第2引数で指定しています。
  • つまりcallは暗号化のメソッドです。これはsessionコントローラから呼ばれるメソッドですが、user_idをもとにtokenを暗号化してレスポンスします。

以上で終わりです。あとはpostmanなどで正常にリクエストできるか確かめるだけです。
ファイル構造に沿って流れを確認すると、ログイン時はログインリクエストが送られる→ルーティングでsessionコントローラに振り分けられる→リクエストのemailとpassword情報が正しければ暗号化してレスポンスするという流れです。またログイン中じゃないとできないアクションは、リクエストが送られる→ルーティングで該当のコントローラに振り分けられる→コントローラ内でcurrent_userが呼ばれる→リクエストを暗号化し特定のuserを探す→userがいた場合はcurrent_userに格納するという流れです。


4. vue.jsとの連携(補足)

■vuexでlocalhostに保存する。
vueの細かい動きは説明しませんが、vuexにjwtのtoken情報を保存するコードを説明します。localhostに保存するのがセキュリティ上どうなのかというところは正直わかりません。
vuexの基本に関しては前回記事を書いたのでそれを参照ください。
【Rails × VueでSPA開発】Vue Router・Vuexを学ぶ

:store.vue
import axios from 'axios'
const state = {
    currentUser: null,
};

const getters = {
    currentUser: state => state.currentUser,
};

const mutations = {
    SET_CURRENT_USER: (state, user) => {
        state.currentUser = user;
        localStorage.setItem('currentUser', JSON.stringify(user))
        axios.defaults.headers.common['Authorization'] = `Bearer ${user.token}`
    },
    CLEAR_CURRENT_USER: () => {
        state.currentUser = null
        localStorage.removeItem('currentUser')
        location.reload()
    }
};

const actions = {
    async login({ commit }, sessionParams) {
        const res = await axios.post(`/api/session`, sessionParams)
        commit("SET_CURRENT_USER", res.data.user);
    },

    logout({ commit }) {
        commit("CLEAR_CURRENT_USER");
    },
};

export default {
    namespaced: true,
    state,
    mutations,
    actions,
    getters
}; 
  • actionsでrailsのsessionsにリクエストを送り、レスポンスをresに格納します。そしてcommitしてmutationsのSET_CURRENT_USERに渡します。
  • そしてSET_CURRENT_USERでstateのcurrentuserに値を渡し、それを「localStorage.setItem('currentUser', JSON.stringify(user))」でlocalstorageに保存します。「axios.defaults.headers.common['Authorization'] = Bearer ${user.token}」でaxiosのデフォルトの通信にlocalstrageに保存した情報をのせて、認証します。
  • CLEAR_CURRENT_USERはログアウトのメソッドです。localstorageにあるユーザー情報を消せば、認証できなくなるのでログアウトという意味になります。

#まとめ
いままでrailsの認証はdeviseに乗っかっていったので裏で何が起こっているかわからなかったですが、jwtを実装したことで裏の動きがわかるようになりました。Rails APIは他にもdevise_token_authやfirebaseAuthを利用して認証する方法が考えられますが、認証のベースがわかっていればなんとかなると思ってます。またjwtもまだ奥が深そうなので勉強したいと思います。とくにセキュリティに関しては無知ですので、、、


60
46
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
60
46

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?