コードロード

エラー討伐

【Rails×Vue】ログイン機能で使うJWT(JSON Web Token)

過去の実装したが、ロジックを忘れてしまっていたので振り返ってみた。

アプリはRails6.0.3.6とVue2.6.12で実装したものだ。

 

トークンベースの認証とは

トークンベースの認証とセッションを使った認証の違いの確認。

Cookie認証

Cookie認証では、ログイン時にWebサーバがクライアントにCookie(SeesionID)を発行し、HTTPレスポンスを利用して送信する。

次回以降クライアントがWebサーバにアクセスした際に、リクエストヘッダに含まれるCookieをサーバが参照して認証を行う。

Token認証

Token認証でも同様に、ログイン時にWebサーバがクライアントにtokenを返す。

しかし、ここではサーバにその情報を保存せず、次回以降のアクセスでは「認証に成功した」Tokenをリクエストヘッダを含めて送っている。

JWTとは

JWTとは電子署名付きのJSON形式の情報のこと。

JWT本体はこのように暗号化されている。

vaG00p1cJleH.iINgR3WTwYbAW.jgFnVAlf0KIC2UiL

これは、下記の3種類の情報を.でつないでBase64エンコードしたものになる。

ヘッダー.ペイロード(データ本体).署名情報

ヘッダー

データの型やルールを指定

{
"typ": "JWT",
"alg": "HS256"
}

ペイロード

属性情報。例えばuser_idやemailやtokenの有効期限など。

{
"email": "example@example.com",
"admin": true,
"body": "1234567890"
}

署名情報

改ざんがされていないか確認するための情報。

ヘッダーとピリオド、ペイロードを連結したものをヘッダーの alg に設定した署名アルゴリズムエンコードしたもの。

例 : jgFnVAlf0KIC2UiL

このJWTは、Cookieと同様にHTTPリクエストのヘッダに載せて送信することで認証に利用される。

JWTのメリット

CORSなどの制約がない

  • Cookieのように異なるドメイン間の通信の拒否する制約にかからない

ステートレス(状態を持たない)のでスケーラブル

  • Cookieの場合はセッションをどこかしらに保存しておく必要があり(ステートフル)、リクエストのたびに毎回DBに問い合わせる必要がある。一方でトークンの場合はそれ自体が認証情報だから、ベットDBに問い合わせる必要がなく、当該サーバのみで検証が可能。
  • セッションを使わないことで radis のようなセッションを保持する専用のサーバが不要になる。ユーザーが急増してサーバー増設した場合、セッションを保持する radis などのサーバを増やすことは難しい。それに対し、JWTではアプリケーションサーバそのもので検証ができるため、サーバ増設しても問題ないのでスケーラブル

JWTの注意点

JWTの中身はBase64エンコードされた情報だから、中身が簡単に確認できてしまう。パスワードや機密情報など、公開したくない内容は送らないようにする。

JWTで認証する流れ

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

こちらのサイトから画像を拝借。

https://s3-us-west-2.amazonaws.com/secure.notion-static.com/c6c9d5c3-9411-4c7a-acda-835cae74c37b/Untitled.png

JWT発行の流れ

  1. フロント側のログインフォームから、メールアドレスやパスワードなどのログインに必要な情報を送る。
  2. その情報をバックエンドで受け取り、登録している情報と一致しているか検証する。 sorcery の authenticate メソッドを使う。https://rubydoc.info/gems/sorcery/Sorcery/Model/ClassMethods#authenticate-instance_method
  3. 一致していれば、ユーザーIDと有効期限をペイロードとしてtokenを発行する。発行されたtokenは秘密鍵で暗号化してJWTとして送られる。認証キーは secret_key_base がよい。
  4. そのtokenをlocalstorageに保存して、常に使える状態にする。vue.jsであればvuexに保存する。ログインしていないとできないリクエストは、このtokenをヘッダーに載せてリクエストする。

認証tokenを検証するときの流れ

  1. クライアントからはHTTPのBearerヘッダーにtokenを設定してリクエストを投げる
  2. サーバ側はBearerヘッダーの中身を解析し、tokenを取得する
  3. jwt の decode メソッドを利用してtokenの復号を試みる。
  4. 復号できていたら user_id が取得できるので、その id を使ってUserテーブルから対象のユーザーを取得し、それを current_user として扱う。

実装

下記の2つのgemを使って進める。

gem 'sorcery'
gem 'jwt'

application_controllerの設定

app/controllers/application_controller.rb

class ApplicationController < ActionController::Base
  include Api::UserAuthenticator
  protect_from_forgery with: :null_session
end
  • protect_from_forgery with: メソッドは自動でCSRF対策の設定。
  • null_sessionのオプションはTokenが一致しなかった場合にsessionを空にするというオプションです。
  • null_session で、protect_form_forgery で使用される CSRF Token がリクエスト元と一致しなかった場合に例外を投げるんじゃなくてセッションを空にするという動作になる。すると、セッション処理なんて必要ない API 機能が問題なく使える。
  • 認証処理はコントローラのスリムさを保つために別クラスに切り出す。 include Api::UserAuthenticator

Rails5 でAPIに対するアクションに対してCSRFを無効にする

外部からPOSTできない?RailsのCSRF対策をまとめてみた – Qiita

ログイン情報をサーバー側で受け取る

app/controllers/api/v1/sessions_controller.rb

class Api::V1::SessionsController < ApplicationController
  def create
    user = User.authenticate(params[:email], params[:password])

    if user
      token = user.create_tokens

      render json: { token: token }
    else
      head :unauthorized
    end
  end
end
  • 送られてきたemailとpasswordをauthenticateメソッドで一致しているか確認
  • 一致していれば、tokenを発行して token という変数に格納する。それをJSONで返す。
  • 一致していなければヘッダに unauthorized を返す。
  • create_tokensメソッドは app/models/conserns/jwt_token.rb で設定する。

tokenを発行する

app/models/conserns/jwt_token.rb

module JwtToken
  extend ActiveSupport::Concern

  # JWTをデコードする(発行したトークンの中身を確認)
  class_methods do
    def decode(token)
      JWT.decode token, Rails.application.secret_key_base
    end
  end

  def create_tokens
    payload = { user_id: id }
    issue_token(payload.merge(exp: Time.current.to_i + 1.month))
  end

  private

  # JWTを発行する
  def issue_token(payload)
    JWT.encode payload, Rails.application.secret_key_base
  end
end
  • issue_token(paylaod) メソッドで、 JWT.encode payload, Rails.application.secret_key_base で、ペイロードを encode している。
  • create_tokensメソッドで、payloadを設定している。ペイロードにはユーザーIDを入れている。また、mergeメソッドで、有効期限をpayloadに結合させている。
  • つまり、 create_token メソッドで、ユーザーIDと有効期限をペイロードとしてtokenを発行できる。

tokenを認証する&tokenをdocodeしてuser_idを取得する

app/javascript/store/modules/users.js

import axios from "../../plugins/axios";

const state = {
  authUser: null,
};

const getters = {
  authUser: (state) => state.authUser,
};

const mutations = {
  setUser: (state, user) => {
    state.authUser = user;
  },
};

const actions = {
  async loginUser({ commit }, user) {
    // ログイン
    const sessionsResponse = await axios.post("/v1/sessions", user);
    localStorage.auth_token = sessionsResponse.data.token;
    axios.defaults.headers.common[
      "Authorization"
    ] = `Bearer ${localStorage.auth_token}`;
    // ログインユーザー情報の取得
    const userResponse = await axios.get("/v1/users/me");
    commit("setUser", userResponse.data);
  },
  logoutUser({ commit }) {
    // ログアウト
    localStorage.removeItem("auth_token");
    axios.defaults.headers.common["Authorization"] = "";
    commit("setUser", null);
  },

  async fetchAuthUser({ commit, state }) {
    if (!localStorage.auth_token) return null;
    if (state.authUser) return state.authUser;

    const userResponse = await axios.get("/v1/users/me").catch((err) => {
      return null;
    });
    if (!userResponse) return null;

    const authUser = userResponse.data;
    if (authUser) {
      commit("setUser", authUser);
      return authUser;
    } else {
      commit("setUser", null);
      return null;
    }
  }
};

export default {
  namespaced: true,
  state,
  getters,
  mutations,
  actions,
};

loginUserアクションに注目

  • const sessionsResponse = await axios.post("/v1/sessions", user) この処理はつまり、Rails側にユーザーのログイン情報を送っているということ。axiosにてRailsのここに情報を渡している。そして create_tokens でtokenをVue側で受け取り、tokenを sessionsResponse という定数に定義している。

app/controllers/api/v1/sessions_controller.rb

  def create
    user = User.authenticate(params[:email], params[:password])

    if user
      token = user.create_tokens

      render json: { token: token }
    else
      head :unauthorized
    end
  end
  • localStorage.auth_token = sessionsResponse.data.token; で、localStorageにtokenを保存している。
  • そして下記の部分で、tokenをデフォルトヘッダー(Bearerヘッダー)に入れることでapi通信するたびに毎回tokenを設定する必要がなくなるようにしている。認証されたtokenがヘッダに入っている状態。
    axios.defaults.headers.common[
      "Authorization"
    ] = `Bearer ${localStorage.auth_token}`;
  • 最後に下記の部分で、app/controllers/api/v1/users_controller.rbから情報を取得し、 userResponse という定数に定義している。
  • それを setUser というmutationにcommitし、stateに状態を保持させる。
    // ログインユーザー情報の取得
    const userResponse = await axios.get("/v1/users/me");
    commit("setUser", userResponse.data);
  • axios.get("/v1/users/me"); では下記のように current_user をJSONで返す。
class Api::V1::UsersController < ApplicationController
  before_action :authenticate!, only: %i[me]

  def me
    render json: current_user
  end

end
  • current_user の設定は下記とのおり。sorceryをインストールしたら使えるようになるメソッド。

app_controllers/conserns/api/user_authenticator.rb

module Api::UserAuthenticator
  extend ActiveSupport::Concern

  def current_user
    return @current_user if @current_user
    return unless bearer_token

    payload, = User.decode bearer_token
    @current_user ||= User.find_by(id: payload['user_id'])
  end

  def authenticate!
    return if current_user

    head :unauthorized
  end

  def bearer_token
    pattern = /^Bearer /
    header = request.headers['Authorization']

    header.gsub(pattern, '') if header&.match(pattern)
  end
end
  • return unless bearer_token で、 bearer_token メソッドが通れば、次のtokenのdecodeに進む。
  • bearer_token メソッドでは、ヘッダの中のAuthorizationのtokenだけを取得している。 request.headers の中身はtoken以外のものも混ざっているため。
  • payload, = User.decode bearer_token でtokenをdecodeしている。decodeメソッドは下記の通り。

app/models/conserns/jwt_token.rb

module JwtToken
  extend ActiveSupport::Concern

  # JWTをデコードする(発行したトークンの中身を確認)
  class_methods do
    def decode(token)
      JWT.decode token, Rails.application.secret_key_base
    end
  end
end
  • そして @current_user ||= User.find_by(id: payload['user_id']) で、 @current_user に user_id を格納している。

これでtokenの発行、認証まで完成!!!

Vue側の実装追加

今のままだと、application_controllerに設定した protect_from_forgery with: :null_session のせいで、リロードしたらログイン状態が保持されなくなってしまっている。

ブラウザリロードしてもログイン状態が保持されるように設定する。

app/javascript/router/index.js

router.beforeEach((to, from, next) => {
  store.dispatch('users/fetchAuthUser').then((authUser) => {
    if (to.matched.some(record => record.meta.requiredAuth) && !authUser) {
      next({ name: 'LoginIndex' });
    } else {
      next();
    }
  })
});
  • router.beforeEach に処理を書くことでページ遷移時(リロード時も含め)に必ずこの処理が走るようになる。storeに認証済みのユーザーが存在するかを問い合わせ、認証済みユーザーが存在しないかつ遷移先のページが要ログインのページであればログインページに飛ばすという処理をしている。
  • fetchAuthUser は下記の通り。

app/javascript/router/modules/users.js

const state = {
  authUser: null,
};

const getters = {
  authUser: (state) => state.authUser,
};

const mutations = {
  setUser: (state, user) => {
    state.authUser = user;
  },
};

const actions = {
  async fetchAuthUser({ commit, state }) {
    if (!localStorage.auth_token) return null;
    if (state.authUser) return state.authUser;

    const userResponse = await axios.get("/v1/users/me").catch((err) => {
      return null;
    });
    if (!userResponse) return null;

    const authUser = userResponse.data;
    if (authUser) {
      commit("setUser", authUser);
      return authUser;
    } else {
      commit("setUser", null);
      return null;
    }
  },
  • localstorageにtokenが慣れければログインしていないということなので、その時点で処理終了。
  • stateに認証済みユーザーがいればそれを返す。
  • stateに認証済みユーザーがいなければユーザー情報をサーバに問い合わせ、レスポンスをstateに設定し、そのレスポンスを返却する。

こうすることでリロード時もログイン情報が保持される。