【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本体はこのように暗号化されている。
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の場合はセッションをどこかしらに保存しておく必要があり(ステートフル)、リクエストのたびに毎回DBに問い合わせる必要がある。一方でトークンの場合はそれ自体が認証情報だから、ベットDBに問い合わせる必要がなく、当該サーバのみで検証が可能。
- セッションを使わないことで
radis
のようなセッションを保持する専用のサーバが不要になる。ユーザーが急増してサーバー増設した場合、セッションを保持するradis
などのサーバを増やすことは難しい。それに対し、JWTではアプリケーションサーバそのもので検証ができるため、サーバ増設しても問題ないのでスケーラブル
JWTの注意点
JWTの中身はBase64エンコードされた情報だから、中身が簡単に確認できてしまう。パスワードや機密情報など、公開したくない内容は送らないようにする。
JWTで認証する流れ
RailsのAPI開発で使える!JWTを理解して認証機能を実装する! – Qiita
こちらのサイトから画像を拝借。
JWT発行の流れ
- フロント側のログインフォームから、メールアドレスやパスワードなどのログインに必要な情報を送る。
- その情報をバックエンドで受け取り、登録している情報と一致しているか検証する。
sorcery
のauthenticate
メソッドを使う。https://rubydoc.info/gems/sorcery/Sorcery/Model/ClassMethods#authenticate-instance_method - 一致していれば、ユーザーIDと有効期限をペイロードとしてtokenを発行する。発行されたtokenは秘密鍵で暗号化してJWTとして送られる。認証キーは
secret_key_base
がよい。 - そのtokenをlocalstorageに保存して、常に使える状態にする。vue.jsであればvuexに保存する。ログインしていないとできないリクエストは、このtokenをヘッダーに載せてリクエストする。
認証tokenを検証するときの流れ
- クライアントからはHTTPのBearerヘッダーにtokenを設定してリクエストを投げる
- サーバ側はBearerヘッダーの中身を解析し、tokenを取得する
jwt
のdecode
メソッドを利用してtokenの復号を試みる。- 復号できていたら
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に設定し、そのレスポンスを返却する。
こうすることでリロード時もログイン情報が保持される。