トークン認証フローの実装 - ログイン -

imatomix
2024年11月25日 10:45

概要

アクセストークンの認証フローは理解していても、フロントエンド&バックエンド共に実装しようとすると結構めんどくさいです。
特にフロントにおいて、トークンをどこに保持するか、どのタイミングでリフレッシュさせるか、それらは問題を孕まないのか、考え出すとキリがないです。

環境

主な構成と使用するフレームワークは以下のとおりです。
  • バックエンド: Koa + Typescript + Passport + Prisma
  • データベース: PostgreSQL
  • フロントエンド: React + Typescript + Zustand + Ky

ログインフロー

フロントからリクエスト

フロントのログインフォームのOnSubmit時に以下の関数によりサーバーサイドにリクエストを投げる。
auth.ts
interface 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.ts
export 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) /* リフレッシュトークンをDBに保存&クッキーにセット */ await saveRefreshTokenInDB(user.id, refreshToken) setTokenCookies(ctx, refreshToken) /* アクセストークンをフロントに返す */ ctx.body = { token: accessToken } } )(ctx, next) }
アクセストークンの有効期限は短く、リフレッシュトークンの有効期限は長くとっておく。
token.ts
import 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', } ) } // Cookiesにセット 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.ts
import { 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 }) }, }))

フロントからのリクエストヘッダにアクセストークンをセットする

kyで2種類のクライアントを作成しておく。
  • 認証不要のクライアント
  • 認証が必要な(ヘッダにアクセストークンをセットする)クライアント
後者はbeforeRecestにて、ストアからトークン情報を取得して、リクエストヘッダにトークンをセットする処理を記述する。
client.ts
import 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リクエストに対して、アクセストークンの認証が可能となった。

ただし、このままでは以下の問題がある。
  • フロントをリロードするとストア上のアクセストークン情報が消える。
  • アクセストークンの有効期限が切れると認証が通らなくなる。
次はこれらに対応していく。