Skip to content

Commit

Permalink
feat: 로그인 페이지 권한 검사 미들웨어 구현
Browse files Browse the repository at this point in the history
  • Loading branch information
cobocho committed Jun 11, 2024
1 parent dd0e1c3 commit 69461cc
Show file tree
Hide file tree
Showing 10 changed files with 117 additions and 105 deletions.
5 changes: 4 additions & 1 deletion apps/web/src/app/(beforeLogin)/auth/token/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ const AuthCallbackPage = ({
})

const checkUserRegistered = async () => {
const userInfo = await userInfoService.getUserInfo()
const userInfo = await userInfoService.getUserInfo({
access,
refresh,
})
const isRegistered = userInfo.result.status === UserStatus.Registered

if (isRegistered) {
Expand Down
26 changes: 2 additions & 24 deletions apps/web/src/app/(beforeLogin)/login/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,9 @@
import React, { PropsWithChildren } from 'react'
import { userInfoService } from '@vook-client/api'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { UserStatus } from 'node_modules/@vook-client/api/src/services/useUserInfoQuery/model'

import { loginLayout } from './layout.css'

const Layout = async ({ children }: PropsWithChildren) => {
const cookieStore = cookies()

const accessToken = cookieStore.get('access')?.value
const refreshToken = cookieStore.get('access')?.value

if (!accessToken || !refreshToken) {
return <div className={loginLayout}>{children}</div>
}

const userInfo = await userInfoService.getUserInfo({
access: accessToken || '',
refresh: refreshToken || '',
})

if (userInfo.result.status === UserStatus.SocialLoginCompleted) {
redirect('/signup')
}

return null
const Layout = ({ children }: PropsWithChildren) => {
return <div className={loginLayout}>{children}</div>
}

export default Layout
2 changes: 1 addition & 1 deletion apps/web/src/app/(onboarding)/onboarding/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { OnBoardingProvider } from './_context/useOnboarding'
const Layout = async ({ children }: PropsWithChildren) => {
const cookieStore = cookies()
const accessToken = cookieStore.get('access')?.value
const refreshToken = cookieStore.get('access')?.value
const refreshToken = cookieStore.get('refresh')?.value

if (!accessToken && !refreshToken) {
redirect('/login')
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Metadata } from 'next'
import '@/styles/reset.css'
import '@vook-client/design-system/style.css'

import { Metadata } from 'next'

import ReactQueryProvider from '@/providers/ReactQueryProvider'
import { pretendard } from '@/styles/fonts'

Expand Down
10 changes: 9 additions & 1 deletion apps/web/src/components/SignUpForm/SignUpForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { zodResolver } from '@hookform/resolvers/zod'
import { ChangeEventHandler } from 'react'
import { useRouter } from 'next/navigation'
import z from 'zod'
import Cookies from 'js-cookie'

import {
checkboxGroup,
Expand All @@ -26,7 +27,14 @@ const signUpSchema = z.object({
})

export const SignUpForm = () => {
const userInfoQuery = useUserInfoQuery()
const access = Cookies.get('access')
const refresh = Cookies.get('refresh')

const userInfoQuery = useUserInfoQuery({
access: access || '',
refresh: refresh || '',
})

const { register, handleSubmit, setValue, watch, formState } = useForm({
resolver: zodResolver(signUpSchema),
defaultValues: {
Expand Down
101 changes: 63 additions & 38 deletions apps/web/src/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,60 +1,85 @@
import { NextRequest, NextResponse } from 'next/server'
import {
UserInfoResponse,
UserStatus,
} from 'node_modules/@vook-client/api/src/services/useUserInfoQuery/model'

const beforeLoginMatcher = (pathname: string) => {
const beforeLoginPaths = ['/login', '/signup']
return beforeLoginPaths.some((path) => pathname.includes(path))
}
import { UserInfoResponse, UserStatus } from 'node_modules/@vook-client/api/'

const beforeLoginMiddleware = async (req: NextRequest) => {
const accessToken = req.cookies.get('access')
const refreshToken = req.cookies.get('refresh')
/**
* NOTE
* 1. 액세스 토큰과 리프레시 토큰이 모두 없는 경우 => 로그인 페이지로 리다이렉트
* 2. 액세스 토큰만 있는 경우 => 로그인 페이지로 리다이렉트
* 3. 리프레시 토큰만 있는 경우 => 리프레시 토큰으로 액세스 토큰을 갱신
* 4. 액세스 토큰과 리프레시 토큰이 모두 있는 경우 => 액세스 토큰이 유효한지 확인
*/

// 토큰이 모두 없는 경우
if (!accessToken && !refreshToken) {
return
}
const onlyRegisteredMatch = ['/']

if (refreshToken) {
// TODO: 리프레시 토큰을 이용해 엑세스 토큰 재발급
}
const isOnlyRegistered = async (
req: NextRequest,
finalResponse: NextResponse,
) => {
const accessToken = req.cookies.get('access')?.value
const refreshToken = req.cookies.get('refresh')?.value

if ((accessToken && !refreshToken) || !accessToken || !refreshToken) {
return
// 1 & 2
if ((!accessToken && !refreshToken) || (accessToken && !refreshToken)) {
return NextResponse.redirect(`${process.env.NEXT_PUBLIC_DOMAIN}/login`)
}

try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/user/info`, {
// 3
if (!accessToken && refreshToken) {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/refresh`, {
headers: {
Authorization: accessToken.value,
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Refresh-Authorization': refreshToken,
},
})

if (res.status === 401) {
if (
res.ok &&
res.headers.get('Authorization') &&
res.headers.get('X-Refresh-Authorization')
) {
finalResponse.cookies.set('access', res.headers.get('Authorization')!)
finalResponse.cookies.set(
'refresh',
res.headers.get('X-Refresh-Authorization')!,
)
} else {
return NextResponse.redirect(`${process.env.NEXT_PUBLIC_DOMAIN}/login`)
}
}

if (res.ok && res.status === 200) {
const userInfo = (await res.json()) as UserInfoResponse
const userInfoRes = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/user/info`,
{
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
Authorization:
finalResponse.cookies.get('access')?.value || accessToken || '',
},
},
)

if (userInfoRes.ok) {
const userInfo = (await userInfoRes.json()) as UserInfoResponse

if (
[UserStatus.SocialLoginCompleted, UserStatus.Registered].includes(
userInfo.result.status,
)
) {
return NextResponse.redirect(`${process.env.NEXT_PUBLIC_DOMAIN}/`)
}
// 회원가입이 완료되지 않은 경우
if (userInfo.result.status === UserStatus.SocialLoginCompleted) {
return NextResponse.redirect(`${process.env.NEXT_PUBLIC_DOMAIN}/login`)
} else {
return finalResponse
}
} catch {
} else {
return NextResponse.redirect(`${process.env.NEXT_PUBLIC_DOMAIN}/login`)
}
}

// eslint-disable-next-line @typescript-eslint/require-await
export async function middleware(req: NextRequest) {
if (beforeLoginMatcher(req.nextUrl.pathname)) {
return beforeLoginMiddleware(req)
const response = NextResponse.next()

if (onlyRegisteredMatch.includes(req.nextUrl.pathname)) {
await isOnlyRegistered(req, response)
}

return response
}
2 changes: 2 additions & 0 deletions packages/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export {
export { searchService } from './services/useSearchQuery/searchService'
export type { SignUpDTO } from './services/useSignUpMutation/model'
export { useSignUpMutation } from './services/useSignUpMutation/queries'
export type { UserInfoResponse } from './services/useUserInfoQuery/model'
export { UserStatus } from './services/useUserInfoQuery/model'
export {
useUserInfoQuery,
useUserInfoSuspenseQuery,
Expand Down
44 changes: 24 additions & 20 deletions packages/api/src/lib/fetcher.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import Cookies from 'js-cookie'

import { Tokens } from '../shared/type'

const API_URL =
process.env.NEXT_PUBLIC_API_URL ||
process.env.PLASMO_PUBLIC_API_URL ||
Expand All @@ -8,14 +10,17 @@ const API_URL =
export class Fetcher {
private baseUrl: string

private unAuthorizedHandler: VoidFunction | null = null
private tokenRefresher = (tokens: Tokens) => {
Cookies.set('access', tokens.access)
Cookies.set('refresh', tokens.refresh)
}

public constructor(baseUrl: string) {
this.baseUrl = baseUrl
}

public setUnAuthorizedHandler(handler: VoidFunction) {
this.unAuthorizedHandler = handler
public setTokenRefresher(handler: (tokens: Tokens) => void) {
this.tokenRefresher = handler
}

private async request<ResponseType>(
Expand Down Expand Up @@ -66,35 +71,34 @@ export class Fetcher {
options?: RequestInit,
) => {
try {
if (!options?.headers) {
return this.unAuthorizedHandler?.()
}

const response = await fetch(`${API_URL}/auth/refresh`, options)

const newAccessToken = response.headers.get('Authorization')
const newRefreshToken = response.headers.get('Authorization')
const newRefreshToken = response.headers.get('X-Refresh-Authorization')

if (!newAccessToken || !newRefreshToken) {
if (this.unAuthorizedHandler) {
return this.unAuthorizedHandler?.()
}

throw new Error('토큰 갱신에 실패하였습니다.')
}

Cookies.set('access', newAccessToken)
Cookies.set('refresh', newRefreshToken)
this.tokenRefresher({
access: newAccessToken,
refresh: newRefreshToken,
})

return this.request<ResponseType>(url, options)
return this.request<ResponseType>(url, {
...options,
headers: {
...options?.headers,
Authorization: newAccessToken,
'X-Refresh-Authorization': newRefreshToken,
},
})
} catch (err) {
// eslint-disable-next-line no-console
console.error(err)
if (this.unAuthorizedHandler) {
return this.unAuthorizedHandler?.()
}
if (location) {
location.href = '/login'

if (global.location) {
global.location.href = '/login'
}
}
}
Expand Down
12 changes: 7 additions & 5 deletions packages/api/src/services/useUserInfoQuery/queries.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,33 @@
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'

import { CustomQueryOptions } from '../../shared/type'
import { CustomQueryOptions, Tokens } from '../../shared/type'

import { userInfoService } from './userInfoService'
import { UserInfoResponse } from './model'

export const userInfoQueryOptions = {
getUserInfo: () => ({
getUserInfo: (token: Tokens) => ({
queryKey: [],
queryFn: () => userInfoService.getUserInfo(),
queryFn: () => userInfoService.getUserInfo(token),
}),
}

export const useUserInfoQuery = (
token: Tokens,
queryOptions: CustomQueryOptions<UserInfoResponse> = {},
) => {
return useQuery<UserInfoResponse>({
...userInfoQueryOptions.getUserInfo(),
...userInfoQueryOptions.getUserInfo(token),
...queryOptions,
})
}

export const useUserInfoSuspenseQuery = (
token: Tokens,
queryOptions: CustomQueryOptions<UserInfoResponse> = {},
) => {
return useSuspenseQuery<UserInfoResponse>({
...userInfoQueryOptions.getUserInfo(),
...userInfoQueryOptions.getUserInfo(token),
...queryOptions,
})
}
17 changes: 3 additions & 14 deletions packages/api/src/services/useUserInfoQuery/userInfoService.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,14 @@
import Cookies from 'js-cookie'

import { baseFetcher } from '../..'
import { Tokens } from '../../shared/type'

import { UserInfoResponse } from './model'

export const userInfoService = {
async getUserInfo(tokens?: Tokens) {
if (!tokens) {
return baseFetcher.get<UserInfoResponse>('/user/info', {
headers: {
Authorization: Cookies.get('access') || '',
'X-Refresh-Token': Cookies.get('refresh') || '',
},
})
}

async getUserInfo(tokens: Tokens) {
return baseFetcher.get<UserInfoResponse>('/user/info', {
headers: {
Authorization: tokens.access || '',
'X-Refresh-Token': tokens.refresh || '',
Authorization: tokens.access,
'X-Refresh-Authorization': tokens.refresh,
},
})
},
Expand Down

0 comments on commit 69461cc

Please sign in to comment.