diff --git a/package-lock.json b/package-lock.json index ead0bee8..80c14530 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,8 +18,8 @@ "dayjs": "^1.11.10", "framer-motion": "^11.0.8", "fullcalendar": "^6.1.11", - "js-cookie": "^3.0.5", "next": "14.1.1", + "nookies": "^2.5.2", "openai": "^4.29.0", "react": "^18", "react-daum-postcode": "^3.1.3", @@ -42,7 +42,6 @@ "@storybook/nextjs": "^7.6.17", "@storybook/react": "^7.6.17", "@storybook/test": "^7.6.17", - "@types/js-cookie": "^3.0.6", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", @@ -6572,12 +6571,6 @@ "@types/istanbul-lib-report": "*" } }, - "node_modules/@types/js-cookie": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", - "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==", - "dev": true - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -9413,15 +9406,6 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, - "node_modules/cookie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", @@ -12056,6 +12040,15 @@ "node": ">= 0.10.0" } }, + "node_modules/express/node_modules/cookie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", + "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/express/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -14432,14 +14425,6 @@ "jiti": "bin/jiti.js" } }, - "node_modules/js-cookie": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", - "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", - "engines": { - "node": ">=14" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -15720,6 +15705,23 @@ "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", "dev": true }, + "node_modules/nookies": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/nookies/-/nookies-2.5.2.tgz", + "integrity": "sha512-x0TRSaosAEonNKyCrShoUaJ5rrT5KHRNZ5DwPCuizjgrnkpE5DRf3VL7AyyQin4htict92X1EQ7ejDbaHDVdYA==", + "dependencies": { + "cookie": "^0.4.1", + "set-cookie-parser": "^2.4.6" + } + }, + "node_modules/nookies/node_modules/cookie": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", + "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/normalize-package-data": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", @@ -18313,6 +18315,11 @@ "node": ">= 0.8.0" } }, + "node_modules/set-cookie-parser": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz", + "integrity": "sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==" + }, "node_modules/set-function-length": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.1.tgz", diff --git a/package.json b/package.json index 8390bc4f..c42383b1 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,8 @@ "dayjs": "^1.11.10", "framer-motion": "^11.0.8", "fullcalendar": "^6.1.11", - "js-cookie": "^3.0.5", "next": "14.1.1", + "nookies": "^2.5.2", "openai": "^4.29.0", "react": "^18", "react-daum-postcode": "^3.1.3", @@ -46,7 +46,6 @@ "@storybook/nextjs": "^7.6.17", "@storybook/react": "^7.6.17", "@storybook/test": "^7.6.17", - "@types/js-cookie": "^3.0.6", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", diff --git a/src/apis/auth.ts b/src/apis/auth.ts index 5ddbf046..50f4e04b 100644 --- a/src/apis/auth.ts +++ b/src/apis/auth.ts @@ -3,9 +3,26 @@ import { AUTH_API, LOGIN_API, TOKENS_API } from '@/constants'; import { Account } from '@/types'; import instance from './axios'; +import ssrInstance from './ssrInstance'; export const Auth = { + /** + * 로그인 + * @param value + * @returns 액세스토큰, 리프레시토큰, 유저정보 + */ signin: (value: Account) => instance.post(`${AUTH_API}${LOGIN_API}`, value), - renewToken: () => instance.post(`${AUTH_API}${TOKENS_API}`), + /** + * 토큰 갱신 + * @param isCsr CSR인지 아닌지 + * @returns 새로운 액세스토큰, 리프레시토큰 + */ + renewToken: (type: 'CSR' | 'SSR') => { + if (type === 'CSR') { + return instance.post(`${AUTH_API}${TOKENS_API}`); + } else { + return ssrInstance.post(`${AUTH_API}${TOKENS_API}`); + } + }, }; diff --git a/src/apis/axios.ts b/src/apis/axios.ts index 3b1cb5c6..a07fc65f 100644 --- a/src/apis/axios.ts +++ b/src/apis/axios.ts @@ -1,7 +1,7 @@ import axios from 'axios'; -import Cookies from 'js-cookie'; +import { parseCookies } from 'nookies'; -import { ACCESS_TOKEN_EXPIRED_TIME, REFRESH_TOKEN_EXPIRED_TIME } from '@/constants'; +import { setAuthCookie } from '@/utils'; import { Auth } from './auth'; @@ -11,27 +11,20 @@ const instance = axios.create({ headers: { 'Content-Type': 'application/json', }, - withCredentials: false, }); instance.interceptors.request.use( async (config) => { - const accessToken = Cookies.get('accessToken'); - const refreshToken = Cookies.get('refreshToken'); + const cookies = parseCookies(); + const accessToken = cookies?.accessToken; + const refreshToken = cookies?.refreshToken; + if (!accessToken && refreshToken) { + config.headers['Authorization'] = `Bearer ${refreshToken}`; try { - const res = await Auth.renewToken(); + const res = await Auth.renewToken('CSR'); const { accessToken, refreshToken } = res.data; - Cookies.set('accessToken', accessToken, { - expires: ACCESS_TOKEN_EXPIRED_TIME, - secure: true, - sameSite: 'strict', - }); - Cookies.set('refreshToken', refreshToken, { - expires: REFRESH_TOKEN_EXPIRED_TIME, - secure: true, - sameSite: 'strict', - }); + setAuthCookie(null, accessToken, refreshToken); } catch (error) { return Promise.reject(error); } @@ -43,8 +36,10 @@ instance.interceptors.request.use( }, (error) => Promise.reject(error), ); + instance.interceptors.response.use( (response) => response, (error) => Promise.reject(error), ); + export default instance; diff --git a/src/apis/ssrInstance.ts b/src/apis/ssrInstance.ts new file mode 100644 index 00000000..c03baad9 --- /dev/null +++ b/src/apis/ssrInstance.ts @@ -0,0 +1,11 @@ +import axios from 'axios'; + +const ssrInstance = axios.create({ + baseURL: process.env.NEXT_PUBLIC_BASE_URL, + timeout: 5000, + headers: { + 'Content-Type': 'application/json', + }, +}); + +export default ssrInstance; diff --git a/src/components/auth/SigninForm/index.tsx b/src/components/auth/SigninForm/index.tsx index 679e5b68..de7e9137 100644 --- a/src/components/auth/SigninForm/index.tsx +++ b/src/components/auth/SigninForm/index.tsx @@ -1,29 +1,39 @@ import Link from 'next/link'; import { zodResolver } from '@hookform/resolvers/zod'; +import { AxiosError } from 'axios'; import classNames from 'classnames/bind'; import { FormProvider, useForm } from 'react-hook-form'; import { z } from 'zod'; -import { ERROR_MESSAGE, PAGE_PATHS, REGEX } from '@/constants'; +import { API_ERROR_MESSAGE, ERROR_MESSAGE, PAGE_PATHS, REGEX } from '@/constants'; +import { redirectToPage } from '@/utils'; import AuthInputField from '@/components/auth/AuthInputField'; import { BaseButton } from '@/components/commons/buttons'; +import { ConfirmModal, ModalButton } from '@/components/commons/modals'; +import useSignin from '@/hooks/useSignin'; +import useToggleButton from '@/hooks/useToggleButton'; + +import { Account } from '@/types'; import styles from './SigninForm.module.scss'; const cx = classNames.bind(styles); +const SigninSchema = z.object({ + email: z.string().min(1, { message: ERROR_MESSAGE.email.min }).email({ + message: ERROR_MESSAGE.email.regex, + }), + password: z + .string() + .min(8, { message: ERROR_MESSAGE.password.min }) + .regex(REGEX.password, ERROR_MESSAGE.password.regex), +}); + const SigninForm = () => { - const SigninSchema = z.object({ - email: z.string().min(1, { message: ERROR_MESSAGE.email.min }).email({ - message: ERROR_MESSAGE.email.regex, - }), - password: z - .string() - .min(8, { message: ERROR_MESSAGE.password.min }) - .regex(REGEX.password, ERROR_MESSAGE.password.regex), - }); + const { isVisible: is404Visible, handleToggleClick: toggle404Click } = useToggleButton(); + const { isVisible: is400Visible, handleToggleClick: toggle400Click } = useToggleButton(); const methods = useForm({ mode: 'all', @@ -34,6 +44,23 @@ const SigninForm = () => { formState: { isValid }, } = methods; + const { mutate } = useSignin(); + + const onSubmit = (formData: object) => { + mutate(formData as Account, { + onSuccess: () => { + redirectToPage(PAGE_PATHS.mainList); + }, + onError: (error) => { + if ((error as AxiosError)?.response?.status === 404) { + toggle404Click(); + } else if ((error as AxiosError)?.response?.status === 400) { + toggle400Click(); + } + }, + }); + }; + return (
@@ -45,7 +72,7 @@ const SigninForm = () => {
-
+
회원가입 정보 등록 @@ -56,7 +83,7 @@ const SigninForm = () => { maxLength={15} placeholder='Type your password' /> - + Match Now
@@ -69,6 +96,32 @@ const SigninForm = () => { + + 확인 + + } + warning + /> + + 확인 + + } + warning + />
); }; diff --git a/src/constants/auth.ts b/src/constants/auth.ts index 3e8ddde5..b85b75b6 100644 --- a/src/constants/auth.ts +++ b/src/constants/auth.ts @@ -1,2 +1,10 @@ -export const ACCESS_TOKEN_EXPIRED_TIME = new Date(new Date().getTime() + 25 * 60 * 1000); -export const REFRESH_TOKEN_EXPIRED_TIME = 13; +/** + * 25분 + * SS * MM + */ +export const ACCESS_TOKEN_EXPIRED_TIME = 60 * 25; +/** + * 13일 + * SS * MM * HH * DD + */ +export const REFRESH_TOKEN_EXPIRED_TIME = 60 * 60 * 24 * 13; diff --git a/src/constants/inputValidation.ts b/src/constants/inputValidation.ts index 7600efdd..5f15ef8d 100644 --- a/src/constants/inputValidation.ts +++ b/src/constants/inputValidation.ts @@ -32,3 +32,10 @@ export const REGEX = { password: /^(?=.*[a-zA-Z])(?=.*\d)[a-zA-Z\d]{8,15}$/, textarea: /\n/g, }; + +export const API_ERROR_MESSAGE = { + signin: { + 404: '회원가입을 해주세요', + 400: '이메일과 비밀번호를 확인해주세요', + }, +}; diff --git a/src/constants/pagePaths.ts b/src/constants/pagePaths.ts index cee25e8d..e24aa1c8 100644 --- a/src/constants/pagePaths.ts +++ b/src/constants/pagePaths.ts @@ -3,4 +3,5 @@ export const PAGE_PATHS = { mypage: '/mypage', signup: '/signup', signin: '/signin', + mainList: '/league-of-legends', }; diff --git a/src/hooks/useSignin.ts b/src/hooks/useSignin.ts index cf338e1e..640e7235 100644 --- a/src/hooks/useSignin.ts +++ b/src/hooks/useSignin.ts @@ -1,28 +1,18 @@ import { useMutation } from '@tanstack/react-query'; -import Cookies from 'js-cookie'; import { Auth } from '@/apis/auth'; -import { ACCESS_TOKEN_EXPIRED_TIME, REFRESH_TOKEN_EXPIRED_TIME } from '@/constants'; +import { setAuthCookie } from '@/utils'; import useUserStore from '@/stores/useUserStore'; import { Account } from '@/types'; -const useSignin = (value: Account) => { +const useSignin = () => { const { error, mutate } = useMutation({ - mutationFn: () => Auth.signin(value), + mutationFn: (value: Account) => Auth.signin(value), onSuccess(data) { const { accessToken, refreshToken, user } = data.data; - Cookies.set('accessToken', accessToken, { - expires: ACCESS_TOKEN_EXPIRED_TIME, - secure: true, - sameSite: 'strict', - }); - Cookies.set('refreshToken', refreshToken, { - expires: REFRESH_TOKEN_EXPIRED_TIME, - secure: true, - sameSite: 'strict', - }); + setAuthCookie(null, accessToken, refreshToken); useUserStore.setState({ user: user }); }, }); diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 4bfca925..962900a4 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -1,6 +1,6 @@ import type { AppProps } from 'next/app'; -import { QueryClientProvider } from '@tanstack/react-query'; +import { HydrationBoundary, QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import Modal from 'react-modal'; @@ -21,12 +21,14 @@ export default function App({ Component, pageProps }: AppProps) { return ( - - - - - - + + + + + + + + ); } diff --git a/src/pages/account/index.tsx b/src/pages/account/index.tsx index 23505832..6445067b 100644 --- a/src/pages/account/index.tsx +++ b/src/pages/account/index.tsx @@ -1,6 +1,24 @@ +import { GetServerSidePropsContext } from 'next'; + +import { PAGE_PATHS } from '@/constants'; +import { getAuthCookie, requiresLogin, setAuthCookie } from '@/utils'; + import AccountForm from '@/components/AccountForm'; import Layout from '@/components/layout/Layout'; +export async function getServerSideProps(context: GetServerSidePropsContext) { + const { accessToken, refreshToken } = getAuthCookie(context); + + const res = await requiresLogin(context, accessToken, refreshToken, PAGE_PATHS.mainList); + if (res) { + const { newAccessToken, newRefreshToken } = res; + setAuthCookie(context, newAccessToken, newRefreshToken); + } + + return { + props: {}, + }; +} const AccountPage = () => { return ; }; diff --git a/src/pages/signin/index.tsx b/src/pages/signin/index.tsx index 26420807..f3378b17 100644 --- a/src/pages/signin/index.tsx +++ b/src/pages/signin/index.tsx @@ -1,5 +1,26 @@ +import { GetServerSidePropsContext } from 'next'; + +import { getAuthCookie } from '@/utils'; + import SigninForm from '@/components/auth/SigninForm'; +export function getServerSideProps(context: GetServerSidePropsContext) { + const { accessToken } = getAuthCookie(context); + + if (accessToken) { + return { + redirect: { + destination: `/league-of-legends`, + permanent: false, + }, + }; + } + + return { + props: {}, + }; +} + const SigninPage = () => { return ; }; diff --git a/src/utils/checkAuth.ts b/src/utils/checkAuth.ts new file mode 100644 index 00000000..4b60f8bd --- /dev/null +++ b/src/utils/checkAuth.ts @@ -0,0 +1,58 @@ +import { GetServerSidePropsContext } from 'next'; + +import { Auth } from '@/apis/auth'; +import ssrInstance from '@/apis/ssrInstance'; + +/** + * 비로그인 상태로 페이지 접근 시 원하는 주소로 리다이렉트 + * @param context + * @param accessToken + * @param refreshToken + * @param url + * @returns + */ + +export type TokenResponse = { + newAccessToken: string; + newRefreshToken: string; +}; + +export const requiresLogin = async ( + context: GetServerSidePropsContext, + accessToken: string | undefined, + refreshToken: string | undefined, + url: string, +): Promise => { + const { res } = context; + + if (!accessToken && !refreshToken) { + res.writeHead(302, { location: url }); + res.end(); + } else if (accessToken === undefined && refreshToken) { + ssrInstance.interceptors.request.use( + (config) => { + config.headers['Authorization'] = `Bearer ${refreshToken}`; + return config; + }, + (error) => { + Promise.reject(error); + }, + ); + try { + const res = await Auth.renewToken('SSR'); + const { accessToken: newAccessToken, refreshToken: newRefreshToken } = res.data; + ssrInstance.interceptors.request.use( + (config) => { + config.headers['Authorization'] = `Bearer ${newAccessToken}`; + return config; + }, + (error) => { + Promise.reject(error); + }, + ); + return { newAccessToken, newRefreshToken }; + } catch (error) { + console.log('renewToken', error); + } + } +}; diff --git a/src/utils/cookieUtils.ts b/src/utils/cookieUtils.ts new file mode 100644 index 00000000..2f22b8cb --- /dev/null +++ b/src/utils/cookieUtils.ts @@ -0,0 +1,27 @@ +import { GetServerSidePropsContext } from 'next'; + +import { parseCookies, setCookie } from 'nookies'; + +import { ACCESS_TOKEN_EXPIRED_TIME, REFRESH_TOKEN_EXPIRED_TIME } from '@/constants'; + +export const setAuthCookie = (context: GetServerSidePropsContext | null, accessToken: string, refreshToken: string) => { + setCookie(context, 'accessToken', accessToken, { + maxAge: ACCESS_TOKEN_EXPIRED_TIME, + path: '/', + secure: true, + sameSite: 'strict', + }); + setCookie(context, 'refreshToken', refreshToken, { + maxAge: REFRESH_TOKEN_EXPIRED_TIME, + path: '/', + secure: true, + sameSite: 'strict', + }); +}; + +export const getAuthCookie = (context: GetServerSidePropsContext) => { + const cookies = parseCookies(context); + const accessToken = cookies?.accessToken; + const refreshToken = cookies?.refreshToken; + return { accessToken, refreshToken }; +}; diff --git a/src/utils/index.ts b/src/utils/index.ts index a7250f3a..03634172 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -9,3 +9,5 @@ export * from './getSortCondition'; export * from './dataMap'; export * from './postFormUtils'; export * from './isValidGameName'; +export * from './checkAuth'; +export * from './cookieUtils'; diff --git a/src/utils/signout.ts b/src/utils/signout.ts index c0b37668..8e38226f 100644 --- a/src/utils/signout.ts +++ b/src/utils/signout.ts @@ -1,9 +1,9 @@ -import Cookies from 'js-cookie'; +import { destroyCookie } from 'nookies'; import useUserStore from '@/stores/useUserStore'; export const signout = () => { - Cookies.remove('accessToken'); - Cookies.remove('refreshToken'); + destroyCookie(null, 'accessToken'); + destroyCookie(null, 'refreshToken'); useUserStore.setState({ user: null }); };