概要
アクセストークンの認証フローは理解していても、フロントエンド&バックエンド共に実装しようとすると結構めんどくさいです。
特にフロントにおいて、トークンをどこに保持するか、どのタイミングでリフレッシュさせるか、それらは問題を孕まないのか、考え出すとキリがないです。
環境
主な構成と使用するフレームワークは以下のとおりです。
ログインフロー
フロントからリクエスト
フロントのログインフォームのOnSubmit時に以下の関数によりサーバーサイドにリクエストを投げる。
auth.tsinterface LoginProps {
email: string
password: string
}
export const Login = async (data: LoginProps) => {
try {
const result = await authClient
.post('login', { json: data })
.json<{ token: string }>()
return result
} catch (error) {
return handleError(error)
}
}
サーバーサイドにてログイン認証とトークンの発行
フロントからのリクエストを受け取り、以下の関数を通す。
passportのログイン認証が通った場合、アクセストークンとリフレッシュトークンを発行する。
リフレッシュトークンはユーザーIDとセットでDBに保存&Cookiesにセットする。
アクセストークンはレスポンスとしてフロントに返す。
というふうに書いたが、アクセストークンもCookiesにセットして、フロントにはアクセストークンの有効期限をレスポンスとして返す方が、よりセキュアになりそうなので、後で書き直す。
authController.tsexport const login = async (ctx: Context, next: Next) => {
return passport.authenticate(
'local',
async (err: any, user: any, info: any) => {
if (err || !user) {
ctx.status = 401
ctx.body = { message: info ? info.message : '認証に失敗しました' }
return
}
const accessToken = generateAccessToken(user.id)
const refreshToken = generateRefreshToken(user.id)
await saveRefreshTokenInDB(user.id, refreshToken)
setTokenCookies(ctx, refreshToken)
ctx.body = { token: accessToken }
}
)(ctx, next)
}
アクセストークンの有効期限は短く、リフレッシュトークンの有効期限は長くとっておく。
token.tsimport jwt from 'jsonwebtoken'
export const generateAccessToken = (userId: string) => {
return jwt.sign(
{ id: userId },
process.env.ACCESS_SECRET || 'access-secret',
{
expiresIn: '15m',
}
)
}
export const generateRefreshToken = (userId: string) => {
return jwt.sign(
{ id: userId },
process.env.REFRESH_SECRET || 'refresh-secret',
{
expiresIn: '7d',
}
)
}
export const setTokenCookies = (ctx: Koa.Context, token: string) => {
ctx.cookies.set('token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
})
}
フロントにてアクセストークンの保存
サーバーサイドから受け取ったアクセストークンをZustandで作成したストア(メモリ)に保存する。
localStorageやsessionStorageには保存しない。
authStore.tsimport { isTokenExpired } from '@/lib/auth'
import { create } from 'zustand'
interface AuthState {
isAuthenticated: boolean
token: string | null
login: (token: string) => void
logout: () => void
}
export const useAuthStore = create<AuthState>((set) => ({
isAuthenticated: false,
token: null,
login: (token: string) => {
set({ isAuthenticated: !isTokenExpired(token), token })
},
logout: () => {
set({ isAuthenticated: false, token: null })
},
}))
フロントからのリクエストヘッダにアクセストークンをセットする
後者はbeforeRecestにて、ストアからトークン情報を取得して、リクエストヘッダにトークンをセットする処理を記述する。
client.tsimport ky from 'ky'
const createClient = (apiPath?: string) => {
return ky.create({
prefixUrl: `${import.meta.env.VITE_BACKEND_URL}${apiPath}`,
retry: {
limit: 3,
methods: ['get'],
statusCodes: [408, 500, 502, 503, 504],
backoffLimit: 500,
},
timeout: 5000,
credentials: 'include',
})
}
export const authClient = createClient('')
export const apiClient = authClient.extend({
hooks: {
beforeRequest: [
(request) => {
const token = useAuthStore.getState().token
if (token) request.headers.set('Authorization', `Bearer ${token}`)
},
]
}
}
apiClientを使用することにより認証が必要なAPIリクエストに対して、アクセストークンの認証が可能となった。