dh_demo

DreamHanks demo project
git clone git://git.lair.cx/dh_demo
Log | Files | Refs | README

commit 8ee33f32bdcc3942ea1ebd5bd7027251cb5bc556
parent 23ade9a98a518077da528d7352787c7117b93e60
Author: Yongbin Kim <iam@yongbin.kim>
Date:   Thu, 19 Jan 2023 10:41:24 +0900

feat: AccessToken 자동으로 갱신하도록 수정

Signed-off-by: Yongbin Kim <iam@yongbin.kim>

Diffstat:
Acomponents/contexts/TokenContext.tsx | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alib/env.ts | 7+++++++
Apages/api/auth/refresh.ts | 33+++++++++++++++++++++++++++++++++
Mpages/api/auth/token.ts | 34++++++++++++++++++++++++++--------
4 files changed, 153 insertions(+), 8 deletions(-)

diff --git a/components/contexts/TokenContext.tsx b/components/contexts/TokenContext.tsx @@ -0,0 +1,87 @@ +import { getAccessTokenCookieName } from '@/lib/env' +import { decode, JwtPayload } from 'jsonwebtoken' +import { createContext, ReactNode, useCallback, useEffect, useRef, useState } from 'react' + +const regexCookie = /^([^=]*)=([^;]*)(?:;\s*|$)/ + +const TOKEN_REFRESH_BEFORE = 60 * 1000 // 만료 1분 전 갱신 + +export interface AccessTokenPayload extends JwtPayload { + tid?: string + uid?: number +} + +const TokenContext = createContext<AccessTokenPayload | null>(null) + +export function TokenProvider ({ children }: { children: ReactNode }) { + const [accessToken, setAccessToken] = useState<AccessTokenPayload | null>(null) + const [checkCounter, setCheckCounter] = useState(0) + + // 최초 로딩시 토큰 있는지 체크 + useEffect(() => { + // 토큰 체크 + const cookie = getCookie(getAccessTokenCookieName()) + const accessToken = cookie != null + ? decode(cookie) as AccessTokenPayload + : null + + setAccessToken(accessToken ?? {}) + }, [checkCounter]) + + // 토큰 갱신 + useEffect(() => { + // 아직 토큰이 존재하는지 체크하지 않았으면 생략 + if (accessToken == null) { + return + } + + const refresh = () => { + console.log('TokenContext: refresh token') + + fetch('/api/auth/refresh', { + method: 'POST' + }).then(res => { + // 갱신하지 못했을 때에는 무시 + if (res.status !== 200) { + return + } + + // 토큰 갱신 + setCheckCounter(v => v + 1) + }).catch(err => { + console.error('TokenContext: fetch failed:', err) + }) + } + + // 토큰이 없거나 만료되었을 때 갱신 + const exp = (accessToken.exp ?? 0) * 1000 - (Date.now() + TOKEN_REFRESH_BEFORE) + if (exp <= 0) { + refresh() + } else { + const timer = setTimeout(refresh, exp) + return () => clearTimeout(timer) + } + }, [accessToken, accessToken?.exp]) + + return ( + <TokenContext.Provider value={accessToken}> + {children} + </TokenContext.Provider> + ) +} + +function getCookie (name: string) { + name = name.toLowerCase() + + let cookie = document.cookie + for (;;) { + let caps = regexCookie.exec(cookie) + if (caps == null) { + return null + } + if (caps[1].toLowerCase() === name) { + return caps[2] + } + cookie = cookie.substring(caps[0].length) + } +} diff --git a/lib/env.ts b/lib/env.ts @@ -0,0 +1,7 @@ +export function getAccessTokenCookieName() { + return `${process.env.WIKI_JWT_COOKIE_PREFIX ?? 'wiki_jwt_'}access_token` +} + +export function getRefreshTokenCookieName() { + return `${process.env.WIKI_JWT_COOKIE_PREFIX ?? 'wiki_jwt_'}refresh_token` +} diff --git a/pages/api/auth/refresh.ts b/pages/api/auth/refresh.ts @@ -0,0 +1,33 @@ +import { ERR_METHOD_NOT_ALLOWED, ERR_UNAUTHORIZED } from '@/lib/apierror' +import { getRefreshTokenCookieName } from '@/lib/env' +import { verifyToken } from '@/lib/security/token' +import { signAndSendToken } from '@/pages/api/auth/token' +import { NextApiRequest, NextApiResponse } from 'next' + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + if (req.method !== 'POST') { + res.status(405).json(ERR_METHOD_NOT_ALLOWED) + return + } + + const refreshToken = req.cookies[getRefreshTokenCookieName()] + if (refreshToken == null) { + res.status(401).json(ERR_UNAUTHORIZED) + return + } + + const token = await verifyToken(refreshToken) + if (token == null) { + res.status(401).json(ERR_UNAUTHORIZED) + return + } + + const { tid, uid } = token as { tid: string, uid: number } + + // TODO: Revoke old token + + signAndSendToken(res, uid) +} diff --git a/pages/api/auth/token.ts b/pages/api/auth/token.ts @@ -1,12 +1,17 @@ import { ApiError } from '@/lib/apierror' +import { getAccessTokenCookieName, getRefreshTokenCookieName } from '@/lib/env' import { getLoginInfoViaEmail } from '@/lib/models/login_info' import { comparePasswordHash } from '@/lib/security/password' -import { signToken } from '@/lib/security/token' +import { REFRESH_TOKEN_EXPIRES_IN, signToken, TOKEN_EXPIRES_IN } from '@/lib/security/token' import type { NextApiRequest, NextApiResponse } from 'next' -type TokenResponse = { - accessToken: string - refreshToken: string +export interface TokenRequest { + email: string + password: string +} + +export interface TokenResponse { + expires: number } const ERR_INVALID_CREDENTIALS: ApiError = { @@ -27,7 +32,7 @@ export default async function handler ( } // 사용자 정보를 가져옴 - const { email, password } = req.body as { email: string; password: string } + const { email, password } = req.body as TokenRequest const loginInfo = await getLoginInfoViaEmail(email) if (loginInfo == null) { @@ -41,8 +46,21 @@ export default async function handler ( } // JWT 토큰 발급 - const tokenSecret = process.env.TOKEN_SECRET ?? 'dangerously_insecure_s3cr3t' - const [ accessToken, refreshToken, tokenId ] = signToken(loginInfo, tokenSecret) + signAndSendToken(res, loginInfo.id) +} + +export function signAndSendToken ( + res: NextApiResponse<TokenResponse>, + uid: number, +) { + const [accessToken, refreshToken, tokenId] = signToken(uid) + + res.setHeader('Set-Cookie', [ + `${getAccessTokenCookieName()}=${accessToken}; Path=/; SameSite=Strict; Max-Age=${TOKEN_EXPIRES_IN / 1000}`, + `${getRefreshTokenCookieName()}=${refreshToken}; HttpOnly; Path=/; SameSite=Strict; Max-Age=${REFRESH_TOKEN_EXPIRES_IN / 1000}`, + ]) - res.status(200).json({ accessToken, refreshToken }) + res.status(200).json({ + expires: Date.now() + TOKEN_EXPIRES_IN, + }) }