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:
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,
+ })
}