본문 바로가기

7/16 넥스터즈 3주차 (1) : 인증 구현

3주차때에는 소셜 로그인 구현 중 네이버 로그인을 구현하였다.

 

Next.js의 API 서버를 마치 Api Gateway처럼 생각하고 BE와 Server to Server로 구현할 계획이다.

네이버 아이디로 로그인 구현(이하 네아로)의 OAuth2.0 구현은 Gateway에서 할 수있을듯 해서 Gateway에서 구현하였다.

 

개요

회원가입 플로우 다이어그램

 

위 다이어그램에서 볼 수 있듯이 로그인 시에만 인증서버(여기서는 네이버)를 거치고, 그 이후에는 BE에서 생성한 자체 토큰(jwt)으로 검증한다.

 

시나리오

시나리오 1. 최초 로그인 (a.k.a 회원가입)

1. 사용자가 '네이버 로그인'을 클릭

2. 정보제공 동의를 받는 인증 화면으로 이동하여 정보 제공에 동의한다.

3. redirectURL인 /bridge/naver 로(이하 브릿지 화면) 이동된다.

4. 브릿지 화면의 getIntialProps(SSR) 에서 토큰 발급 및 프로필 정보 조회 API를 호출한다.

5. 조회한 프로필 정보를 BE로 넘겨서 유저 DataBase에 저장한다.

6. BE에서 jwt 토큰을 생성 후, FE로 전달한다.

7. FE에서 전달받은 jwt토큰을 브라우저 Cookie에 저장한다.

 

시나리오 2. 재로그인

이미 가입된 사용자가 재로그인 할때

 

1. 사용자가 '네이버 로그인'을 클릭

2. 이전에 정보제공에 동의하였으므로 바로 redirectURL로 이동

3. 브릿지 화면의 getIntialProps(SSR) 에서 토큰 발급 및 프로필 정보 조회 API를 호출한다.

4. 조회한 프로필 정보를 BE로 넘겨서 유저 DataBase에서 조회한다.

5. 존재하는 유저이면, BE에서 jwt 토큰을 생성 후, FE로 전달한다.

(만약 우리 DB에 저장한 프로필정보와 인증서버에서 받은 프로필 정보가 상이하면 업데이트)

6. FE에서 전달받은 jwt토큰을 브라우저 Cookie에 저장한다.

 

시나리오 3. 재접속

로그인한 사용자가 브라우저를 껐다가 다시 접속했을 때

 

1. 사용자가 브라우저에서 우리 서비스로 재접속

2. 브라우저 Cookie에 저장된 토큰이 있는지 검색한다.

3. 토큰이 없다면(만료되었다면) 로그인 화면으로 리다이렉트한다.

4. 토큰이 유효하다면 접속하고자 하는 페이지를 로드한다.

 


구현 상세

1. 네이버 인증화면 URL 이동

 

interface ApiRequest extends NextApiRequest {
query: {
[key: string]: string
}
}
const routes = async (
req: ApiRequest,
res: NextApiResponse<Response<AuthorizeResponse>>,
) => {
try {
const {redirect_uri} = req.query
const state = generateToken()
const {CLIENT_ID} = NAVER
const body: AuthorizeRequest = {
redirect_uri,
client_id: CLIENT_ID,
response_type: 'code',
state,
}
const {client_id, response_type} = body
res.status(200).json({
code: RESPONSE.NORMAL,
message: '네이버 로그인 필요',
result: {
redirectUrl: `https://nid.naver.com/oauth2.0/authorize?redirect_uri=${redirect_uri}&client_id=${client_id}&response_type=${response_type}&state=${state}`,
},
})
} catch (e) {
res.status(500).json({
code: RESPONSE.ERROR,
message: '네이버 로그인 실패',
result: {
redirectUrl: '',
},
})
}
}
export default routes
view raw authorize.ts hosted with ❤ by GitHub
function Login() {
const handleLogin = async (source: string) => {
const res = await withAxios<AuthorizeResponse>({
url: '/login/naver/authorize',
method: 'get',
params: {
redirect_uri: encodeURIComponent(
`${HOST_URL}${ROUTES.LOGIN.BRIDGE}/${source}`,
),
},
})
const {result} = res.data
const {redirectUrl} = result
window.location.replace(redirectUrl)
}
return (
<Button onClick={() => handleLogin('naver')}>
네이버로 로그인
</Button>
)
}
export default Login
view raw index.tsx hosted with ❤ by GitHub

 

2. 브릿지 화면에서 프로필 정보 조회 + Cookie Setting

@todo 주석 부분은 실제 BE API를 호출하지 않는 mock 코드이다.

 

const LoginBridge = ({error, error_description}: LoginBridgeProps) => {
if (error) {
return <>{error_description}</>
}
return null
}
LoginBridge.getInitialProps = async ({req, res, query}: NextPageContext) => {
const {code, state} = query
if (code && state) {
/* access_token 발급 */
const tokenResult = await withAxios<Partial<TokenResponse>>({
url: `/login/naver/token`,
method: 'post',
data: {
code,
state,
grant_type: GrantType.create,
},
})
const {
data: {code: resCode, result},
} = tokenResult
const {error, error_description} = result
if (resCode === RESPONSE.NORMAL) {
const {access_token, expires_in} = result
/* access_token으로 프로필 정보 조회 (내부에 검증 로직 있음) */
const {data: profileData} = await withAxios<ProfileResponse>({
url: '/login/naver/profile',
method: 'POST',
data: {
access_token,
},
})
if (profileData.code === RESPONSE.NORMAL) {
const {
result: {response},
} = profileData
/**
* @todo BE로 프로필 정보 전송 + jwt 받아서 cookie에 저장
*/
const {token, expires_in} = await withAxios({
url: '/login'
method: 'POST',
data: response
})
res.setHeader('Set-Cookie', `letterLogin=${token}; path=/; max-age=${expires_in} HttpOnly`)
if (res && req) {
res!.writeHead(302, {Location: ROUTES.MAIN})
res!.end()
} else {
Router.push(ROUTES.MAIN)
}
}
return {
error: 'INVALID_ACCESS',
error_description: '진입 불가능'
}
}
return {
error,
error_description,
}
}
return {
error: 'AUTHORIZE_ERROR',
error_description: '네이버 로그인 실패',
}
}
export default LoginBridge
view raw bridge.tsx hosted with ❤ by GitHub

 

3. BE에서 프로필 정보 조회 (mocking)

Context API로 구현하였다. (SWR 적용)

 

interface ProfileContextState {
profile: Partial<Profile> | undefined
reset: () => void
error: Error | undefined
}
const ProfileContext = createContext<ProfileContextState>(
{} as ProfileContextState,
)
export const ProfileProvider = ({children}: {children: ReactNode}) => {
const {
data: profile,
error,
mutate: refreshProfile,
} = useRequest<Profile>( // useRequest는 useSWR의 wrapper
{
url: '/profile',
},
{
revalidateOnMount: true,
},
)
const value = useMemo(
() => ({
profile: profile?.result,
reset() {
refreshProfile()
},
error,
}),
[profile, refreshProfile, error],
)
return (
<ProfileContext.Provider value={value}>
{children}
</ProfileContext.Provider>
)
}
export const useProfileContext = () => {
const context = useContext(ProfileContext)
if (!context) {
throw new Error(`You need to wrap UserProvider.`)
}
return context
}

 

4. __app.tsx 의 SSR에서 CSR 이전에 쿠키 검증

 

class App extends App<AppProps> {
static async getInitialProps({
ctx,
Component: {getInitialProps: getComponentIntialProps},
}: AppContext): Promise<AppProps> {
try {
/**
* @todo jwt 존재여부 검사
* jwt가 있으면 메인으로 리다이렉트, 없으면 로그인화면으로 리다이렉트
*/
const {letterLogin} = cookies(ctx)
const {needToCheckCookie, redirectUrl, compare, needLogout} =
needToCheckCookiePath(ctx.pathname)
if (needToCheckCookie) {
if (needLogout(letterLogin)) {
await withAxios({
url: '/logout',
})
}
if (compare(letterLogin)) {
if (ctx.req && ctx.res) {
ctx.res!.writeHead(302, {Location: redirectUrl}) // 로그인으로 리다이렉트, 화면 유지
ctx.res!.end()
} else {
Router.push(redirectUrl)
}
}
}
const props = await (getComponentIntialProps
? getComponentIntialProps(ctx)
: Promise.resolve({}))
const pageProps = {
...props,
token: letterLogin,
}
return {
pageProps,
}
} catch (error) {
return {
pageProps: {error},
}
}
}
render() {
/* */
}
}
view raw _app.tsx hosted with ❤ by GitHub

 

5. 완성 화면

 

참고한 자료들

 


7/18 구글 로그인 추가 (react-google-login)