背景
以前、vue.js × Rails APIでのSPA開発に挑戦しました。その際、認証にJWTを使ったのですが、ネットの記事を参考に実装したために仕組みやコードを完全に理解できていませんでした。そのためJWTの認証について改めて記事にしてまとめたいと思いました。railsでvue,reactなどと連携させてSPA開発したい方は参考になるかと思います。
#目次
- JWTについて
- JWTで認証をする流れ
- 実際のrailsコード解説
- vue.jsとの連携
1. JWTについて
###■JWTとはなにか
JWTとはJSON Web Tokenの略で、JSON形式で表されたトークンです。著名・暗号化によってセキュアな通信ができます。
###■JWTの中身
JWTは下記のうようにピリオド区切りで3つの要素に分かれています。
{①ヘッダー要素}.{②ペイロード要素}.{③署名要素}
- ①のヘッダー要素は、データの型やルールを指定します。
- ②のペイロード要素には、属性情報が入ります。例えばuser_idやemailやtokenの有効期限などです。
- ③の著名要素は、改ざんがされていないか確認するための情報です。
以上がざっくりとして説明です。アプリケーション実装のためには上記の大枠の把握だけで問題ないです。もっと詳しく知りたい場合は下記の参考記事を参照ください。
JWT(JSON Web Token)の「仕組み」と「注意点」
2. JWTで認証をする流れ
ざっくりですが説明です。
- まずはフロント側からログインフォームからユーザー名・メールアドレス・パスワードなどログインに必要な情報を送ります。
- その情報をバックエンドで受け取り、登録しているユーザー名とパスワードが一致していた場合に、tokenを発行してレスポンスします。発行されたtokenは秘密鍵で暗号化してJWTとして送られます。
- そのtokenをlocalstorageに保存して、常に使える状態にします。vue.jsであればvuex, react.jsであればreduxに保存します。ログインしてないとできないリクエストは、このtokenをヘッダーにのせてリクエストします。
- バックエンド側はヘッダー情報をもとに認証・認可を行い、リクエストを返します。
- 帰ってきたリクエストをフロント側で表示等します。
上記の流れを理解すれば、バックエンド側の実装も理解しやすくなります。すなわちバックエンド側はjwtのtoken発行、そのルーティングとモデルの設定をすればよいのです。では早速具体的なコードを見ていきましょう。
実際のコードrails解説
###■gemのインストール
gem 'active_model_serializers'
gem 'jwt'
- jwtに関してはそのままjwtというgemがあります。
- active_model_serializersはレスポンスを簡単にそしてきれいにjson形式に整形してくれるgemです。railsでAPI開発をする際はわりかし頻繁に使われます。
###■ルーティングの設定
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 の違い
###■モデルの設定
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
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で暗号化して保存しています。
###■コントローラの設定
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を返すメソッドです。
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」の定義は難しくないので大丈夫だと思います。
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
- こちらも簡単ですので説明省略します。
###■サービスの設定
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だけを取得するには上記のようにします。
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を継承しています。
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もまだ奥が深そうなので勉強したいと思います。とくにセキュリティに関しては無知ですので、、、