環境
ruby 3.1.0
Rails 7.0.4
vue@3.2.41
Vuex@4.1.0
初めに
個人開発でSPAのログイン機能を実装した時のメモです。
理解不足な面も多いので、指摘いただけると嬉しいです。
ログイン状態は、Railsであればセッションで管理することができます。
セッションによる認証はサーバー側で行うため、
JSなどフロント側ではあまり気にしなくて良かったのですが、
Vue.jsのようなSPAの場合はフロント側でも制御が必要になります。
そのログイン状態の管理をトークンベースの認証で実装しようと思います。
以下仕様
・ログインはパスワードとメールアドレスで実行する
・ログイン後のページは「ログイン状態」でないと遷移できない
・ログイン画面は、「ログイン状態」では遷移できない
・ログイン後、リロードしてもログイン状態が保持される
・ログアウトすると、ログイン画面に遷移する
トークンベースの認証とは
セッションを使ったログイン(認証)との違いをざっくり整理したいと思います。
セッションを使ったログイン
セッションを使ったログインでは、SessionのデータをRedisなどのサーバーに保持し、
サーバーが発行したSessionId(Cookie)
をHTTPレスポンスのヘッダーに埋め込み
クライアントに送信します。次回以降、クライアントがWebサーバーにアクセスした際に、
リクエストヘッダに含まれるSessionId
を利用してサーバーからデータを参照して認証を行います。
トークンベースのログイン
トークンベースの認証では、ログイン時にWEBサーバーがクライアントにtoken
を返します。
セッションと違うのは、サーバにその情報を保存しないことです。
次回以降のクラインアントからのアクセスでは、「認証に成功した」tokenを
リクエストヘッダに含めてサーバー側に送信します。
その後、サーバー側で受け取ったトークンを解析して、認証を行います。
Web開発において「Webトークン」は、 JSON Web Token (JWT) のことが多いそうです。
トークンのメリット
・token
情報を保持する専用のサーバーが不要。
・存在そのものが情報なので、DBに対しリクエストを投げる必要がない。
トークンのデメリット
・Base64Url
エンコードされた情報なので、復元が容易。
・データ本体をもつtoken
はデータサイズが大きいこと。
JWTトークン
公式サイト
JWT(ジョット / JSON Web Token) とは、
・クラインアントサーバー間で通信するため、JSON
オブジェクトとしてエンコードされた情報。
・改竄の防止のため暗号化(ハッシュ)を用いて電子著名がされている。
・以下の三種類の情報を.
で繋いでBase64Url
でエンコードしたもの。
・decode
することで元の内容のJSON
を取得することができる。
ヘッダー.ペイロード(データ本体).署名情報
// encoded 例
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
ヘッダー
ヘッダーには、JWT
に関する情報(署名アルゴリズムとtoken
の種類)部分
// decoded 例
{
"typ": "JWT",
"alg": "HS256"
}
ペイロード
アプリケーションによって異なる部分。好きな情報を詰めることができる。
// decoded 例
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}
著名情報
暗号化アルゴリズムによって生成される文字列。
途中でデータが改竄されたどうかを検証するために用いる。
// encoded 例
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
your-256-bit-secret
) secret base64 encoded
Vuex
アプリのデータを管理するための簡易的なDBで、
ログイン時に認証されたユーザーの情報を保存するために使用。
簡単な説明はこちら
※Vuex
のstore
はリロードで情報が吹き飛ぶのでリロード対策は必要。
JWTの発行、検証について
トークンの発行と復元方法
こちらの記事がめちゃくちゃ参考になりました。
ペイロードの作成
ペイロードにはクレーム(予約語)を指定します。
クレームとはオブジェクトを識別するための値で、予約クレームが予め用意されています。
予約クレーム一覧 - JWT
ただ、user_id: xxx
とオリジナルで織り込むことも可能ですが、
他アプリケーションとの衝突を避けるために予約クレームを使用することが推奨されています。
今回は、トークンの有効期限(exp
)とuser_id
を指定します。
> payload = {user_id: 1, exp: (DateTime.current + 7.days).to_i }
=> {:user_id=>1, :exp=>1675566620}
トークン発行(エンコード)
トークン発行には、署名時に使用する鍵が必要です。
鍵がないと、トークンが盗まれた後、簡単に復元されてしまうからです。
鍵には非公開鍵であるRails
のシークレットキーを使用します。
また、トークン生成には JWT.encode メソッドを使用します。
> token = JWT.encode(payload, Rails.application.credentials.secret_key_base)
=> "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJleHAiOjE2NzU1NjY2MjB9.bnYKsb-siG45XWe3nKsqLHo0b3WpeSTzY8BWhNuZHwQ"
トークン復元(デコード)
トークンの復元でも非公開鍵であるRails
のシークレットキーを使用します。
また、トークン生成には JWT.decode
メソッドを使用します。
> decoded_info = JWT.decode(token, Rails.application.credentials.secret_key_base)
=> [{"user_id"=>1, "exp"=>1676170634}, {"alg"=>"HS256"}]
> decoded_info[0]['user_id']
=> 1
alg
... algorithm
署名アルゴリズム
デフォルトでHS256
が指定されています。
ログイン時のトークン取得と認証済みユーザーをstore登録するまで
①ログイン画面からからメールアドレス、パスワードをcontroller
にPOST
②パスワードの認証を行う
③認証が通れば、user_id
と有効期限をペイロードとしてJWTトークン
を作成する。
④取得したトークンを返却する
⑤受け取ったトークンは LocalStrage
にも保存する
⑥トークンをheader
に埋めて、contoller
にPOST
⑦リクエストの中のトークンを取得
⑧トークンをdecode
することでユーザーID
を取得
⑨ID
からユーザー情報を取得
⑩ユーザー情報をクライアント側に返却
⑪受け取ったユーザー情報はAuthUser(認証済みユーザー)
として情報を保持する
ログイン時、サーバー側でのチェック事項
・リクエストのheader
にあるtoken
を取り出す。
・token
を復元し、得られたID
からユーザー情報を取得。
・ユーザー情報が取得できない場合、権限のないリクエストであると見なす。
内部で例外が発生した場合はhead
メソッドによって、
本文(body)のないヘッダーのみのレスポンスをブラウザに返却します。
Railsガイド headでヘッダのみのレスポンスを生成する
以下のような実装イメージ
def xxx
# ヘッダーからトークンを取得
header = request.headers['Authorization']
# トリミング
token = header.gsub('Bearer ', '')
# トークンが存在しない場合は、権限のないリクエスト
return head :unauthorized unless token
# トークンを解析し、ユーザー情報を取得
payload = JWT.decode(token, Rails.application.credentials.secret_key_base)[0]
user = User.find_by(id: payload['user_id'])
# ユーザーが見つからない場合は、権限のないリクエスト
return head :unauthorized unless user
# ユーザー返却
render json: user
end
ログイン後のトークン管理
デフォルト設定
・token
取得後に、header
情報に認証情報を埋め込む
以下のように書いておくと毎度設定する必要がないので便利
設定のデフォルト 公式ドキュメント
axios.defaults.headers.common['Authorization'] = `Bearer ${ token }`;
リロード対策も工夫が必要です。
リロード時に、ローカルストレージからtoken
を取り出して、header
に詰めるようにします。
if (localStorage.token){
axiosInstance.defaults.headers.common['Authorization'] = `Bearer ${localStorage.token}`;
}
ページ遷移/リロード時もログイン状態を保持
・ローカルストレージにtoken
がなければ、ログインページに遷移(ログインしていないと見なす)。
・store
に認証済みユーザーがあるかチェック。
・store
に認証済みユーザーがない場合、サーバに問い合わせ、ユーザー情報をstore
にセット。
・遷移先がトークンを必要としている かつ 認証済みユーザーがない場合、ログインページに遷移。
・遷移先がトークンを必要としていない かつ 認証済みユーザーがある場合、ルートパスに遷移。
・そうでない場合、ページを正常に遷移。
遷移先がトークンを必要としているかどうか のmeta
情報はvue-router
でセットできる。
ルートメタフィールド 公式ドキュメント
const router = new VueRouter({
routes: [
{
path: '/foo',
component: Foo,
// メタフィールド
meta: { tokenRequired: true }
}
]
})
ではどうやって遷移先のメタ情報を確認できるのか。
公式ドキュメントによると
ルートにマッチした全てのルートレコードは route.matched 配列として $route オブジェクト上で (また、ナビゲーションガード上のルートオブジェクトでも) アクセス可能になります。
また、ルートオブジェクトは以下の3種類が存在します。
ナビゲーションガード 公式ドキュメント
to
: 遷移先のページ
from
: 今いるページ
next();
: 処理を続行する
これらの情報を画面遷移の前に確認して、ログインページに飛ばすかの判定をすれば良さそう。
以下のようなイメージ
const router = new VueRouter({ ... })
router.beforeEach((to, from, next) => {
// 認証済みユーザー取得
const AuthUser = fetchAuthUser();
// 遷移先がトークンを必要としている かつ 認証済みユーザーがない場合はログイン画面へ
if (to.matched.some(record => record.meta.tokenRequired) && !AuthUser) {
next({ name: 'login' });
// 遷移先がトークンを必要としていない かつ 認証済みユーザーがある
} else if (to.matched.some(record => !record.meta.tokenRequired) && AuthUser) {
next({ name: 'main' });
} else {
// 正常に遷移
next();
}
})
ログアウト処理
ログインボタンのクリックイベントで以下の処理をすれば良さそう。
・ローカルストレージのtoken
を削除する
・ヘッダーのtoken
を削除する
・ログイン画面を表示させる
ログイン状態はローカルストレージのtoken
とheader
にあるtoken
の2重管理になっており、これらの整合性を担保するロジックが必要です。つまり、ログイン時にはローカルストレージとheader
の両方にtoken
をセットして、反対にログアウト時にはセットした両者のtoken
を削除しなければなりません。
「片方にはあるけど、片方にない」みたいな実装はNGです。
実装
初めに
jwt
はgem
で用意されているので、Gemfile
に以下を追加してbundle-install
gem 'jwt'
Vuex
は、vuex.esm-browser.jsから拝借
著者はimportmap
を使用しているので、pinコマンド
でダウンロードします。
その後、以下を追記して準備完了。
pin "vuex", to: "vuex.esm-browser.js"
ログイン画面
Vuex
のmapActions
で、store
で定義したaction
の呼び出しを簡易的にしています。
入力したパスワードとメールアドレスを引数に渡しています。
import { mapActions } from 'vuex'
const loginMain = {
template:
`
<div class="login">
<p class="middle-title">ログイン</p>
<!--メールアドレス-->
<div class="form">
<input type="email"
class="form-item"
placeholder="メールアドレス"
v-model="form_attributes.email.value">
</div>
<!--パスワード-->
<div class="form">
<input type="password"
class="form-item"
placeholder="パスワード"
v-model="form_attributes.password.value">
</div>
<!--ログインボタン-->
<div class="btn">
<a class="blue-btn login-btn"
@click="login">
ログイン</a>
</div>
</div>
`,
data() {
return {
form_attributes: {
email: {
value: '',
},
password: {
value: '',
},
}
}
},
methods: {
...mapActions([
'loginUser',
]),
// ログイン
async login() {
// storeのactionからログインする
await this.loginUser(this.form_attributes);
// 画面遷移
this.$router.push({ name: 'main' });
},
}
}
export default loginMain
storeの準備
トークンの認証にはBearer
認証を使用します。
Bearer
認証はHTTPのAuthorization
ヘッダーにスキームとして指定でき,
Authorization: Bearer <token>
のようにして指定します.
import { createStore } from 'vuex'
import Axios from "../plugins/axios";
const store = createStore({
state () {
authUser: null
},
getters: {
authUser: state => state.authUser
},
mutations: {
// 認証済みユーザー情報をセット
setAuthUser (state, AuthUser) {
state.authUser = AuthUser
}
},
actions: {
// ログイン処理
async loginUser (context, params) {
// ログイン情報をPOST 図1.①
const sessionsResponse = await Axios.post('login', {login_params: params});
// ローカルストレージにtokenを埋める 図1.⑤
localStorage.setItem('token', sessionsResponse.data.token);
// リクエストのヘッダーにtokenを埋める 図1.⑤
Axios.defaults.headers.common['Authorization'] = `Bearer ${sessionsResponse.data.token}`;
// 認証済みユーザーを取得する 図1.⑥
const AuthUserResponse = await Axios.get('fetch_auth_user');
const AuthUser = AuthUserResponse.data;
// mutationを呼び出す 図1.⑪
context.commit('setAuthUser', AuthUser);
},
// 認証済みユーザー情報を取得する
async fetchAuthUser({ commit, state }) {
// ローカルストレージにトークンがない場合は処理終了
if (!localStorage.token) return null;
// storeに認証済みユーザーがある場合はそれを返却
if (state.authUser) return state.authUser;
// ない場合は取得する(リロード対策)
const AuthUserResponse = await Axios.get('fetch_auth_user');
// 取得できない場合は処理終了
if (!AuthUserResponse.data) return null;
// 取得できたらstoreに保存する
const AuthUser = AuthUserResponse.data;
if (AuthUser) {
commit('setAuthUser', AuthUser)
return AuthUser;
} else {
commit('setAuthUser', null)
return null;
}
},
// ログアウト処理
logoutUser(context) {
// ローカルストレージのトークンを削除する
localStorage.removeItem('token');
// ヘッダーのトークンを削除する
Axios.defaults.headers.common['Authorization'] = '';
// storeの認証済みユーザーを削除する
context.commit('setAuthUser', null);
},
}
})
export default store
routesの追加
Rails.application.routes.draw do
root to: 'home#index'
namespace :api do
# 略
# 以下を追加
# ログイン
post 'login' => 'sessions#create'
post 'logout' => 'sessions#delete'
# 認証済みユーザー取得
get 'fetch_auth_user' => 'sessions#fetch_auth_user'
end
end
サーバー側の処理諸々
以下のメソッドを追加しました。
・ログイン時にtokenを生成し、tokenを返却するメソッド
・認証済みユーザーを取得して返却するメソッド
class Api::SessionsController < ApplicationController
protect_from_forgery with: :null_session
wrap_parameters format: []
# ログイン時にtokenを生成し、tokenを返却
def create
email = login_params['email']['value']
password = login_params['password']['value']
user = User.find_by(email: email)
# 図1.②
# ユーザーが見つからない場合は処理を終了
return head :unauthorized unless user.present?
# パスワードが一致しない場合も処理終了
return head :unauthorized unless is_authenticated(user, password)
# ペイロード指定
payload = {user_id: user.id, exp: (DateTime.current + 7.days).to_i }
# トークン生成 図1.③
token = JWT.encode(payload, Rails.application.credentials.secret_key_base)
# トークン返却 図1.④
render json: { token: token }
end
# 認証済みユーザーを取得して返却する
def fetch_auth_user
# ヘッダー情報を取得 図1.⑦
header = request.headers['Authorization']
# トリミング
token = header.gsub('Bearer ', '')
# デコード 図1.⑧
payload = JWT.decode(token, Rails.application.credentials.secret_key_base)[0]
# ユーザー情報を取得 図1.⑨
user = User.find_by(id: payload['user_id'])
# 認証済みユーザーを返却 図1.⑩
render json: user
end
private
# ストロングパラメーター
def login_params
params.require(:login_params).permit(email: {}, password: {})
end
# パスワードが正しいか認証する
def is_authenticated(user, raw_password)
user.present? &&
user.hashed_password.present? &&
raw_password.present? &&
BCrypt::Password.new(user.hashed_password) == raw_password
end
end
ルーティングにメタ情報を持たせる + 設定諸々
以下の内容を追加しました。
・ルーティングにメタ情報を持たせる
・画面遷移前にtoken存在チェック
import * as VueRouter from "vue-router";
import store from '../store/index'
import MainIndex from "./../controllers/pages/main/index" //ルート先のview
import LoginMain from "./../controllers/pages/login/index"
// ルーティングにメタ情報を持たせる
const routes = [{
path: '/',
name: 'main',
component: MainIndex,
meta: { tokenRequired: true }
}, {
path: '/login',
name: 'login',
component: LoginMain,
meta: { tokenRequired: false }
},
];
const router = VueRouter.createRouter({
history: VueRouter.createWebHashHistory(), routes, // short for `routes: routes`
});
// 画面遷移前にtoken存在チェック
router.beforeEach((to, from, next) => {
store.dispatch('fetchAuthUser').then((AuthUser) => {
// 遷移先がトークンを必要としている かつ 認証済みユーザーがない場合はログイン画面へ
if (to.matched.some(record => record.meta.tokenRequired) && !AuthUser) {
next({ name: 'login' });
// 遷移先がトークンを必要としていない かつ 認証済みユーザーがある
} else if (to.matched.some(record => !record.meta.tokenRequired) && AuthUser) {
next({ name: 'main' });
} else {
// 正常に遷移
next();
}
})
})
export default router
リロード対策
以下を追加しました。
・リロードのタイミングでheader
にtoken
を仕込む
これでログイン後にリロードしても、header
のtoken
情報を維持することができます。
import Axios from "axios";
const axiosInstance = Axios.create({
baseURL: 'api'
})
// 以下を追加
// リロード対策
if (localStorage.token){
axiosInstance.defaults.headers.common['Authorization'] = `Bearer ${localStorage.token}`;
}
export default axiosInstance
ログアウト画面
ヘッダーの「ログアウトリンク」をクリックすると、ログアウトイベントを発火
import { mapActions } from "vuex";
const headerComp = {
template:
`
<div class="header">
// 略
<div class="header-right">
<ul>
// 略
<a><li class="link-item" @click="logout">ログアウト</li></a>
</ul>
</div>
</div>
`,
methods: {
...mapActions([
`logoutUser`
]),
logout(){
// ログアウト
this.logoutUser();
// 画面遷移
this.$router.push({ name: 'login' });
},
}
}
export default headerComp
動作確認
ログイン後にheaderの要素の出しわけるやギミックや、バリデーション、
データ取得にログインユーザーを結びつけるなどの工程は残っていますが、
無事トークンベースでログイン機能を作ることができました。
備考
今回は生成したトークンをローカルストレージに保存しているので、
XSS対策の脆弱なサイトではトークンが抜き取られ、
第三者にからリクエストされるリスクもあります。
(その対策にシークレットキーを使ったトークン生成を使用)
特にトークンの有効期限が長ければXSSのリスクも高くなります。
トークンの有効期限を短くすれば比較的、リスクは下がるかもしれない。
でも有効期限を短くするとUX面でよろしくない。
ベスプラがまだまだわかっていません。
最後に
ここまで見ていただいてありがとうございます。
セッションしか経験したことがなかったのでトークンでログイン管理するのが新鮮で、
メチャむずいなと感じました。
特にローカルストレージとヘッダーの2重管理になっている分
管理が大変そうな印象です。
参照
・【Rails×Vue】ログイン機能で使うJWT(JSON Web Token)
・トークンベースの認証とは?
・SPAのログイン認証のベストプラクティスがわからなかったのでわりと網羅的に研究してみた〜JWT or Session どっち?〜
・【認証】JWTについての説明書
・JWTとは何か?(ruby-jwtのインストール)
・【Rails】JWTを利用したログインAPIと認証付きAPIの実装