diff --git a/next.config.js b/next.config.js index db8574540..818cb72c0 100644 --- a/next.config.js +++ b/next.config.js @@ -31,10 +31,6 @@ const nextConfig = { }, async rewrites() { return [ - { - source: '/service-api/:url*', - destination: `${baseURL}/api/:url*`, - }, { source: '/aladin-api', has: [{ type: 'query', key: 'QueryType', value: '(?.*)' }], @@ -70,6 +66,9 @@ const nextConfig = { }, ], }, + experimental: { + serverActions: true, + }, }; module.exports = nextConfig; diff --git a/package.json b/package.json index fb1780fd4..f6f057db2 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@types/react-dom": "18.0.10", "axios": "^1.3.4", "colorthief": "^2.4.0", + "jose": "^5.5.0", "next": "13.4.7", "react": "18.2.0", "react-dom": "18.2.0", diff --git a/src/apis/core/axios.ts b/src/apis/core/axios.ts index 5f5a8b4a5..42712d642 100644 --- a/src/apis/core/axios.ts +++ b/src/apis/core/axios.ts @@ -1,15 +1,15 @@ import axios, { CreateAxiosDefaults, InternalAxiosRequestConfig } from 'axios'; import { AuthRefreshIgnoredError } from '@/types/customError'; -import { ACCESS_TOKEN_STORAGE_KEY, SERVICE_ERROR_MESSAGE } from '@/constants'; +import { SERVICE_ERROR_MESSAGE, SESSION_COOKIES_KEYS } from '@/constants'; import { isAuthFailedError, isAuthRefreshError, isAxiosErrorWithCustomCode, } from '@/utils/helpers'; -import webStorage from '@/utils/storage'; +import { deleteAuthSession, setAuthSession } from '@/server/session'; +import { deleteCookie } from '@/utils/cookie'; -const storage = webStorage(ACCESS_TOKEN_STORAGE_KEY); const options: CreateAxiosDefaults = { baseURL: process.env.NEXT_HOST, headers: { @@ -25,11 +25,6 @@ export const publicApi = axios.create({ const requestHandler = (config: InternalAxiosRequestConfig) => { const { data, method } = config; - const accessToken = storage.get(); - - if (accessToken) { - setAxiosAuthHeader(config, accessToken); - } if (!data && (method === 'get' || method === 'delete')) { config.data = {}; @@ -51,7 +46,7 @@ const responseHandler = async (error: unknown) => { } if (isAuthFailedError(code)) { - removeToken(); + await removeToken(); } } else { console.error('예상하지 못한 오류가 발생했어요.\n', error); @@ -63,12 +58,10 @@ const responseHandler = async (error: unknown) => { const silentRefresh = async (originRequest: InternalAxiosRequestConfig) => { try { const newToken = await updateToken(); - storage.set(newToken); - setAxiosAuthHeader(originRequest, newToken); - + await setAuthSession(newToken); return await publicApi(originRequest); } catch (error) { - removeToken(); + await removeToken(); return Promise.reject(error); } }; @@ -93,15 +86,9 @@ const updateToken = () => .finally(() => (isTokenRefreshing = false)); }); -const removeToken = () => { - storage.remove(); -}; - -const setAxiosAuthHeader = ( - config: InternalAxiosRequestConfig, - token: string -) => { - config.headers['Authorization'] = `Bearers ${token}`; +const removeToken = async () => { + SESSION_COOKIES_KEYS.map(key => deleteCookie(key)); + await deleteAuthSession(); }; publicApi.interceptors.request.use(requestHandler); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a29f23abc..f8951353b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,8 +3,10 @@ import type { Metadata } from 'next'; import { appleSplashScreens } from '@/constants/metadata'; import GoogleAnalytics from '@/components/common/GoogleAnalytics'; -import ContextProvider from '@/components/common/ContextProvider'; import AuthFailedErrorBoundary from '@/components/common/AuthFailedErrorBoundary'; +import PWAServiceWorkerProvider from '@/components/common/PWAServiceWorkerProvider'; +import ReactQueryProvider from '@/components/common/ReactQueryProvider'; +import ToastProvider from '@/components/common/Toast/ToastProvider'; import Layout from '@/components/layout/Layout'; import { LineSeedKR } from '@/styles/font'; @@ -41,11 +43,15 @@ const RootLayout = ({ children }: { children: React.ReactNode }) => { - - - {children} - - + + + + + {children} + + + + ); diff --git a/src/app/login/redirect/page.tsx b/src/app/login/redirect/page.tsx index 3432803d5..76d1aa2b5 100644 --- a/src/app/login/redirect/page.tsx +++ b/src/app/login/redirect/page.tsx @@ -1,53 +1,6 @@ -'use client'; - -import { notFound, useRouter, useSearchParams } from 'next/navigation'; -import { useCallback, useEffect } from 'react'; - -import { setAuth } from '@/utils/helpers'; -import userAPI from '@/apis/user'; - import Loading from '@/components/common/Loading'; const RedirectPage = () => { - const router = useRouter(); - const searchParams = useSearchParams(); - - const accessToken = searchParams.get('access_token'); - - if (!accessToken) { - notFound(); - } - - const checkSavedAdditionalInfo = useCallback(async () => { - try { - const isSavedAdditionalInfo = await userAPI.getMyProfile().then( - ({ - data: { - job: { jobName, jobGroupName }, - nickname, - }, - }) => !!(nickname && jobGroupName && jobName) - ); - - if (!isSavedAdditionalInfo) { - router.replace('/profile/me/add'); - } - - router.replace('/bookarchive'); - } catch { - router.replace('/not-found'); - } - }, [router]); - - useEffect(() => { - const hasAccessToken = !!accessToken; - - if (hasAccessToken) { - accessToken && setAuth(accessToken); - checkSavedAdditionalInfo(); - } - }, [accessToken, checkSavedAdditionalInfo]); - return ; }; diff --git a/src/app/profile/me/page.tsx b/src/app/profile/me/page.tsx index 879cd3809..237cae1bf 100644 --- a/src/app/profile/me/page.tsx +++ b/src/app/profile/me/page.tsx @@ -7,8 +7,10 @@ import { useQueryClient } from '@tanstack/react-query'; import userAPI from '@/apis/user'; import userKeys from '@/queries/user/key'; -import { checkAuthentication, removeAuth } from '@/utils/helpers'; -import { KAKAO_LOGIN_URL } from '@/constants'; +import { deleteAuthSession } from '@/server/session'; +import { deleteCookie } from '@/utils/cookie'; +import { checkAuthentication } from '@/utils/helpers'; +import { KAKAO_LOGIN_URL, SESSION_COOKIES_KEYS } from '@/constants'; import { IconArrowRight } from '@public/icons'; import SSRSafeSuspense from '@/components/common/SSRSafeSuspense'; @@ -86,10 +88,14 @@ const MyProfileForAuth = () => { const router = useRouter(); const handleLogoutButtonClick = async () => { - await userAPI.logout(); - removeAuth(); - queryClient.removeQueries({ queryKey: userKeys.me(), exact: true }); - router.refresh(); + try { + await userAPI.logout(); + await deleteAuthSession(); + } finally { + SESSION_COOKIES_KEYS.map(key => deleteCookie(key)); + queryClient.removeQueries({ queryKey: userKeys.me(), exact: true }); + router.refresh(); + } }; return ( diff --git a/src/app/profile/redirect/route.ts b/src/app/profile/redirect/route.ts new file mode 100644 index 000000000..4b68d74c6 --- /dev/null +++ b/src/app/profile/redirect/route.ts @@ -0,0 +1,102 @@ +import { cookies } from 'next/headers'; +import { redirect } from 'next/navigation'; + +import { COOKIE_KEYS, SEARCH_PARAMS_KEYS } from '@/constants'; +import { isAuthRefreshError } from '@/utils/helpers'; +import { createQueryString } from '@/utils/url'; +import { getOrigin } from '@/lib/request/getOrigin'; +import { + getAuthSession, + setProfileSession, + deleteAuthSession, +} from '@/server/session'; + +const REDIRECT_SEARCH_KEY = SEARCH_PARAMS_KEYS.REDIRECT_PATHNAME; + +interface RetryRequest extends Request { + retried?: boolean; // fetch 재시도 시 true로 설정 +} + +// GET /profile/redirect +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const destination = searchParams.get(REDIRECT_SEARCH_KEY); + + const accessToken = cookies().get(COOKIE_KEYS.ACCESS_TOKEN); + + if (accessToken) { + const _request: RetryRequest = request.clone(); + _request.retried = false; + + let response: Response; + + try { + response = await fetchMyProfile(_request, accessToken.value); + } catch (error) { + console.log('Caught error, redirect to root!\n', error); + await deleteAuthSession(); + return redirect('/'); + } + + const data = await response.json(); + const hasProfile = Boolean(data?.nickname && data?.job?.jobGroupName); + + await setProfileSession(hasProfile); + + if (!hasProfile) { + const search = createQueryString({ + ...(destination && { [REDIRECT_SEARCH_KEY]: destination }), + }); + redirect(`/profile/me/add${search}`); + } else if (destination) { + redirect(`${destination}`); + } else { + redirect('/bookarchive'); + } + } + + return redirect('/'); +} + +/* + * 내 프로필 조회 + */ +const fetchMyProfile = async ( + request: RetryRequest, + token: string +): Promise => { + const origin = getOrigin(new URL(request.url), request.headers); + const response = await fetch(`${origin}/service-api/users/me`, { + headers: { + Accept: '*/*', + 'Content-Type': 'application/json', + Authorization: `Bearers ${token}`, + }, + }); + + if (!response.ok) { + // fetch 1번만 재시도 + if (!request.retried) { + const error = await response.json(); + const code = error.code || ''; + + if (isAuthRefreshError(code)) { + // 새로운 auth 세션 쿠키 설정 + const newToken = await getAuthSession(); + if (!newToken) { + throw Error('Failed to get access token'); + } + + // 재시도 flag 설정 + request.retried = true; + + // 재요청 + return fetchMyProfile(request, newToken); + } + } + + throw Error('Failed to get my profile', { cause: response }); + } + + return response; +}; diff --git a/src/components/common/AuthFailedErrorBoundary.tsx b/src/components/common/AuthFailedErrorBoundary.tsx index efd5ce15f..f507131d7 100644 --- a/src/components/common/AuthFailedErrorBoundary.tsx +++ b/src/components/common/AuthFailedErrorBoundary.tsx @@ -3,10 +3,8 @@ import { useEffect } from 'react'; import { QueryErrorResetBoundary } from '@tanstack/react-query'; import { ErrorBoundary, FallbackProps } from 'react-error-boundary'; - import useToast from '@/components/common/Toast/useToast'; -import { isAuthFailedError, isAxiosErrorWithCustomCode } from '@/utils/helpers'; -import Loading from '@/components/common/Loading'; +import QueryErrorBoundaryFallback from '@/components/common/QueryErrorBoundaryFallback'; const AuthFailedErrorBoundary = ({ children, @@ -30,14 +28,8 @@ const AuthFailedFallback = ({ error, resetErrorBoundary }: FallbackProps) => { const { show: showToast } = useToast(); useEffect(() => { - if ( - isAxiosErrorWithCustomCode(error) && - isAuthFailedError(error.response.data.code) - ) { - showToast({ message: '다시 로그인 해주세요' }); - resetErrorBoundary(); - } - }, [error, resetErrorBoundary, showToast]); + showToast({ message: '다시 로그인 해주세요' }); + }, [error, showToast]); - return ; + return ; }; diff --git a/src/components/common/ContextProvider.tsx b/src/components/common/ContextProvider.tsx deleted file mode 100644 index f9dce64ee..000000000 --- a/src/components/common/ContextProvider.tsx +++ /dev/null @@ -1,20 +0,0 @@ -'use client'; - -import { ReactNode } from 'react'; - -import PWAServiceWorkerProvider from '@/components/common/PWAServiceWorkerProvider'; -import ReactQueryProvider from '@/components/common/ReactQueryProvider'; - -import ToastProvider from '@/components/common/Toast/ToastProvider'; - -const ContextProvider = ({ children }: { children: ReactNode }) => { - return ( - - - {children} - - - ); -}; - -export default ContextProvider; diff --git a/src/components/common/QueryErrorBoundaryFallback.tsx b/src/components/common/QueryErrorBoundaryFallback.tsx index 5146aecec..a457f27d1 100644 --- a/src/components/common/QueryErrorBoundaryFallback.tsx +++ b/src/components/common/QueryErrorBoundaryFallback.tsx @@ -9,7 +9,7 @@ const QueryErrorBoundaryFallback = ({

데이터를 불러오는 중 문제가 발생했어요.

); diff --git a/src/components/common/ReactQueryProvider.tsx b/src/components/common/ReactQueryProvider.tsx index 1871e4227..323930376 100644 --- a/src/components/common/ReactQueryProvider.tsx +++ b/src/components/common/ReactQueryProvider.tsx @@ -1,3 +1,5 @@ +'use client'; + import AuthRefreshIgnoredError from '@/types/customError/AuthRefreshIgnoredError'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; diff --git a/src/components/common/Toast/ToastProvider.tsx b/src/components/common/Toast/ToastProvider.tsx index ba6205efc..9a2260e05 100644 --- a/src/components/common/Toast/ToastProvider.tsx +++ b/src/components/common/Toast/ToastProvider.tsx @@ -1,3 +1,5 @@ +'use client'; + import { createContext, ReactNode, useMemo, useState } from 'react'; import type { diff --git a/src/components/profile/info/MyProfileInfoContainer.tsx b/src/components/profile/info/MyProfileInfoContainer.tsx index fe32ae121..86ed6a4b3 100644 --- a/src/components/profile/info/MyProfileInfoContainer.tsx +++ b/src/components/profile/info/MyProfileInfoContainer.tsx @@ -1,24 +1,9 @@ -import { useEffect } from 'react'; -import { usePathname, useRouter } from 'next/navigation'; - import useMyProfileQuery from '@/queries/user/useMyProfileQuery'; import ProfileInfoPresenter from '@/components/profile/info/ProfileInfoPresenter'; const MyProfileContainer = () => { const { data } = useMyProfileQuery(); - const { replace } = useRouter(); - const pathname = usePathname(); - - useEffect(() => { - const { - nickname, - job: { jobGroupName, jobName }, - } = data; - const isSavedAdditionalInfo = !!(nickname && jobGroupName && jobName); - if (!isSavedAdditionalInfo) replace(`${pathname}/add`); - }, [data, pathname, replace]); - return ; }; diff --git a/src/constants/index.ts b/src/constants/index.ts index c8d049316..bc97e2f2b 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -1,4 +1,4 @@ -export * from './storage'; export * from './error'; export * from './groupRadioValues'; export * from './url'; +export * from './key'; diff --git a/src/constants/key.ts b/src/constants/key.ts new file mode 100644 index 000000000..b63833676 --- /dev/null +++ b/src/constants/key.ts @@ -0,0 +1,23 @@ +export const SEARCH_PARAMS_KEYS = { + ACCESS_TOKEN: 'access_token', + REDIRECT_PATHNAME: 'from', +}; + +// prefix 'PUBLIC_' for public cookie +export const COOKIE_KEYS = { + ACCESS_TOKEN: process.env.DADOK_AUTH_SESSION_KEY || '', + REFRESH_TOKEN: 'RefreshToken', + ADDED_PROFILE_FLAG: 'DID_APF', + PUBLIC_USER_ID: 'DID_PUI', +}; + +// 사용자 인증 관련 세션 쿠키 key +export const SESSION_COOKIES_KEYS = [ + COOKIE_KEYS.ACCESS_TOKEN, + COOKIE_KEYS.PUBLIC_USER_ID, + COOKIE_KEYS.ADDED_PROFILE_FLAG, +]; + +export const SECRET_KEYS = { + JWT: process.env.JWT_SECRET_KEY || '', +}; diff --git a/src/constants/storage.ts b/src/constants/storage.ts deleted file mode 100644 index 5e4a263f4..000000000 --- a/src/constants/storage.ts +++ /dev/null @@ -1 +0,0 @@ -export const ACCESS_TOKEN_STORAGE_KEY = 'accessToken'; diff --git a/src/lib/auth/verifyJWT.ts b/src/lib/auth/verifyJWT.ts new file mode 100644 index 000000000..fd64adb94 --- /dev/null +++ b/src/lib/auth/verifyJWT.ts @@ -0,0 +1,14 @@ +import { jwtVerify } from 'jose'; + +import { SECRET_KEYS } from '@/constants'; + +export const verifyJWT = async (token: string) => { + try { + const encoder = new TextEncoder(); + const secretKey = encoder.encode(SECRET_KEYS.JWT); + const { payload } = await jwtVerify(token, secretKey); + return payload; + } catch { + return false; + } +}; diff --git a/src/lib/request/getOrigin.ts b/src/lib/request/getOrigin.ts new file mode 100644 index 000000000..b82b2c41c --- /dev/null +++ b/src/lib/request/getOrigin.ts @@ -0,0 +1,23 @@ +/** + * host property를 가지는 객체(ex. URL)와 + * protocol 관련 헤더(x-forwarded-proto)를 추출하기 위한 header 객체를 전달받아 + * origin 문자열을 생셩하여 반환합니다. + * - 문자열을 생성할 수 없으면 환경변수에 저장된 값을 전달합니다. + * @param parsed host property를 가지는 객체 + * @param headers protocol을 추출하기 위한 header 객체 + */ +export const getOrigin = ( + parsed: { host: string | null }, + headers: Headers +) => { + const protocol = headers.get('x-forwarded-proto') ?? 'http'; + const envHost = process.env.NEXT_HOST; + + if (parsed.host) { + return `${protocol}://${parsed.host}`; + } else if (envHost) { + return `${envHost}`; + } else { + return ''; + } +}; diff --git a/src/middleware.ts b/src/middleware.ts index c004d8761..7cd6f3058 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,19 +1,147 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; +import { + COOKIE_KEYS, + SEARCH_PARAMS_KEYS, + SESSION_COOKIES_KEYS, +} from '@/constants'; +import { createQueryString } from '@/utils/url'; +import { verifyJWT } from '@/lib/auth/verifyJWT'; + +// cookie constants +const SESSION_AUTH_KEY = COOKIE_KEYS.ACCESS_TOKEN; +const SESSION_PUBLIC_UID_KEY = COOKIE_KEYS.PUBLIC_USER_ID; +const REFRESH_TOKEN_KEY = COOKIE_KEYS.REFRESH_TOKEN; + +// search parameter constants +const ACCESS_TOKEN_KEY = SEARCH_PARAMS_KEYS.ACCESS_TOKEN; +const REDIRECT_SEARCH_KEY = SEARCH_PARAMS_KEYS.REDIRECT_PATHNAME; + +// Authorization Header를 포함하지 않는 API +const EXCLUDE_AUTH_HEADER_API = ['/api/auth/token']; + +// 추가 프로필 등록이 반드시 필요한 path +const NEED_PROFILE_PATHS = ['/bookarchive', '/profile/me', '/profile/me/edit']; + export const config = { - matcher: ['/'], + matcher: [ + /* + * Match all request paths except for the ones starting with: + * - api (API routes) + * - _next/static, images, icons (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + * - pwaServiceWorker, manifest (pwa related files) + */ + '/((?!api|_next/static|_next/image|favicon.ico|images|icons|pwaServiceWorker|manifest).*)', + ], }; export async function middleware(request: NextRequest) { - if (request.nextUrl.pathname.match('/')) { - /** - * '/' 로 접근하는 경우, 아래 조건에 따라 redirect - * cookie에 RefreshToken이 존재하면 /bookarchive - * cookie에 RefreshToken이 없으면 /login - */ + // accessToken을 담고 있는 세션 쿠키 + const authSession = request.cookies.get(SESSION_AUTH_KEY); + const uidSession = request.cookies.get(SESSION_PUBLIC_UID_KEY); + /* + * Server API Proxy + */ + if (request.nextUrl.pathname.startsWith('/service-api/')) { + const { search } = request.nextUrl; + const pathname = request.nextUrl.pathname.replace('/service-api', '/api'); + const destination = `${process.env.NEXT_PUBLIC_API_URL}${pathname}${search}`; + + const headers = new Headers(request.headers); + + // Authorization header 추가 + if ( + authSession && + uidSession && + !EXCLUDE_AUTH_HEADER_API.includes(pathname) + ) { + headers.set('Authorization', `Bearers ${authSession.value}`); + } + + const response = NextResponse.rewrite(destination, { + request: { headers }, + }); + + if (pathname === '/api/auth/logout') { + SESSION_COOKIES_KEYS.map(key => response.cookies.delete(key)); + } + + return response; + } + + /* + * 로그인 상태이면서 + * 'NEED_PROFILE_PATHS'에 포함되어 있는 경우, + * 추가 프로필 등록 페이지로 리다이렉션 + */ + if ( + authSession && + uidSession && + NEED_PROFILE_PATHS.includes(request.nextUrl.pathname) + ) { + // 프로필이 등록되었는지 여부를 저장하고 있는 세션 쿠키 + const profileSession = request.cookies.get(COOKIE_KEYS.ADDED_PROFILE_FLAG); + + // 프로필 세션 쿠기가 없거나 프로필이 등록되어 있지 않으면, + // '/profile/redirect?from=[현재_요청_pathname]'로 리다이렉션 + if (!profileSession || !JSON.parse(profileSession.value)) { + const destination = new URL('/profile/redirect', request.url); + const search = createQueryString({ + [REDIRECT_SEARCH_KEY]: request.nextUrl.pathname, + }); + + return NextResponse.redirect(`${destination}${search}`); + } + + // accessToken이 유효하고, 프로필도 등록되어 있으면 응답 재개 + return NextResponse.next(); + } + + /* + * oAuth redirect uri 요청이 들어오면, + * '/profile/redirect'로 리다이렉션 + */ + if (request.nextUrl.pathname.startsWith('/login/redirect')) { + const token = request.nextUrl.searchParams.get(ACCESS_TOKEN_KEY) ?? ''; + const jwtPayload = await verifyJWT(token); + + const redirectParam = request.nextUrl.searchParams.get(REDIRECT_SEARCH_KEY); + + const destination = new URL('/profile/redirect', request.url); + const search = createQueryString({ + ...(redirectParam && { [REDIRECT_SEARCH_KEY]: redirectParam }), + }); + + const response = NextResponse.redirect(`${destination}${search}`); + + // 인증 관련 세션 쿠키 추가 + if (jwtPayload) { + response.cookies.set(SESSION_AUTH_KEY, token, { + httpOnly: true, + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + }); + + if (jwtPayload.id) { + response.cookies.set(SESSION_PUBLIC_UID_KEY, `${jwtPayload.id}`, { + sameSite: 'lax', + }); + } + } + + return response; + } - if (request.cookies.has('RefreshToken')) { + /* + * '/' 로 접근하는 경우, 아래 조건에 따라 리다이렉션 + * cookie에 RefreshToken이 존재하면 /bookarchive + * cookie에 RefreshToken이 없으면 /login + */ + if (request.nextUrl.pathname.match(/^\/$/)) { + if (request.cookies.has(REFRESH_TOKEN_KEY)) { request.nextUrl.pathname = '/bookarchive'; } else { request.nextUrl.pathname = '/login'; diff --git a/src/server/session.ts b/src/server/session.ts new file mode 100644 index 000000000..68672c41b --- /dev/null +++ b/src/server/session.ts @@ -0,0 +1,81 @@ +'use server'; + +import { cookies, headers } from 'next/headers'; + +import { COOKIE_KEYS, SESSION_COOKIES_KEYS } from '@/constants'; +import { verifyJWT } from '@/lib/auth/verifyJWT'; +import { getOrigin } from '@/lib/request/getOrigin'; + +const SESSION_KEY = COOKIE_KEYS.ACCESS_TOKEN; +const SESSION_PUBLIC_UID_KEY = COOKIE_KEYS.PUBLIC_USER_ID; +const SESSION_ADDED_PROFILE_KEY = COOKIE_KEYS.ADDED_PROFILE_FLAG; + +/* + * 새로운 accessToken 발급 받은 후, 세션 쿠키 갱신 + */ +export async function getAuthSession() { + const refreshToken = cookies().get(COOKIE_KEYS.REFRESH_TOKEN); + + if (!refreshToken) { + return null; + } + + const host = headers().get('host'); + const origin = getOrigin({ host }, headers()); + + const response = await fetch(`${origin}/service-api/auth/token`, { + method: 'POST', + headers: { + Cookie: `${refreshToken.name}=${refreshToken.value};`, + }, + }); + + const data: { accessToken?: string } = await response.json(); + + if (!response.ok || !data || !data.accessToken) { + return null; + } + + await setAuthSession(data.accessToken); + + return data.accessToken; +} + +/* + * accessToken 유효성 검사 후, accessToken과 uid 세션 쿠키에 삽입 + */ +export async function setAuthSession(token: string) { + const payload = await verifyJWT(token); + + if (payload) { + cookies().set(SESSION_KEY, token, { + httpOnly: true, + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + }); + + // 클라이언트에서 접근 가능하도록 public하게 설정 + if (payload.id) { + cookies().set(SESSION_PUBLIC_UID_KEY, `${payload.id}`, { + sameSite: 'lax', + }); + } + } +} + +/* + * auth 관련 세션 쿠키 모두 제거 + */ +export async function deleteAuthSession() { + SESSION_COOKIES_KEYS.map(key => cookies().delete(key)); +} + +/* + * 추가 프로필이 등록되었는지 여부를 저장하는 세션 쿠키 설정 + */ +export async function setProfileSession(value: boolean) { + cookies().set(SESSION_ADDED_PROFILE_KEY, JSON.stringify(value), { + httpOnly: true, + sameSite: 'lax', + }); +} diff --git a/src/utils/cookie.ts b/src/utils/cookie.ts new file mode 100644 index 000000000..092f606c3 --- /dev/null +++ b/src/utils/cookie.ts @@ -0,0 +1,55 @@ +import isClient from '@/utils/isClient'; + +export const getCookie = (key: string) => { + if (!isClient()) return; + + const matches = document.cookie.match( + new RegExp( + '(?:^|; )' + key.replace(/([.$?*|{}()[\]\\/+^])/g, '\\$1') + '=([^;]*)' + ) + ); + + return matches ? decodeURIComponent(matches[1]) : undefined; +}; + +interface CookieSetOptions { + path?: string; + expires?: Date | string; + 'max-age'?: number; + domain?: string; + secure?: boolean; + httpOnly?: boolean; + sameSite?: boolean | 'none' | 'lax' | 'strict'; +} + +const setCookie = ( + name: string, + value: string, + options: CookieSetOptions = {} +) => { + options = { + path: '/', + ...options, + }; + + if (options.expires instanceof Date) { + options.expires = options.expires.toUTCString(); + } + + let updatedCookie = + encodeURIComponent(name) + '=' + encodeURIComponent(value); + + for (const optionKey in options) { + updatedCookie += '; ' + optionKey; + const optionValue = options[optionKey as keyof CookieSetOptions]; + if (optionValue !== true) { + updatedCookie += '=' + optionValue; + } + } + + document.cookie = updatedCookie; +}; + +export const deleteCookie = (name: string) => { + setCookie(name, '', { 'max-age': -1 }); +}; diff --git a/src/utils/helpers/auth.ts b/src/utils/helpers/auth.ts index 0c7bc2df3..5b8ad28c7 100644 --- a/src/utils/helpers/auth.ts +++ b/src/utils/helpers/auth.ts @@ -1,19 +1,9 @@ -import { ACCESS_TOKEN_STORAGE_KEY } from '@/constants/index'; -import webStorage from '@/utils/storage'; - -const storage = webStorage(ACCESS_TOKEN_STORAGE_KEY); +import { COOKIE_KEYS } from '@/constants'; +import { getCookie } from '@/utils/cookie'; const checkAuthentication = () => { - const accessToken = storage.get(); + const accessToken = getCookie(COOKIE_KEYS.PUBLIC_USER_ID); return !!accessToken; }; -const setAuth = (newToken: string) => { - storage.set(newToken); -}; - -const removeAuth = () => { - storage.remove(); -}; - -export { checkAuthentication, setAuth, removeAuth }; +export { checkAuthentication }; diff --git a/src/utils/url.ts b/src/utils/url.ts new file mode 100644 index 000000000..9f2b4b7ec --- /dev/null +++ b/src/utils/url.ts @@ -0,0 +1,6 @@ +export const createQueryString = ( + params: URLSearchParams | Record +) => { + const searchParams = new URLSearchParams(params); + return Array.from(searchParams).length ? `?${searchParams}` : ''; +}; diff --git a/yarn.lock b/yarn.lock index 0db7f2816..94561c1d4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8054,6 +8054,11 @@ jiti@^1.18.2: resolved "https://registry.npmjs.org/jiti/-/jiti-1.19.1.tgz" integrity sha512-oVhqoRDaBXf7sjkll95LHVS6Myyyb1zaunVwk4Z0+WPSW4gjS0pl01zYKHScTuyEhQsFxV5L4DR5r+YqSyqyyg== +jose@^5.5.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/jose/-/jose-5.5.0.tgz#ec2a834606325d797851532733b14f1d5a40ee25" + integrity sha512-DUPr/1kYXbuqYpkCj9r66+B4SGCKXCLQ5ZbKCgmn4sJveJqcwNqWtAR56u4KPmpXjrmBO2uNuLdEAEiqIhFNBg== + jpeg-js@^0.4.1: version "0.4.4" resolved "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz"