Next-auth 를 이용한 로그인 구현

terry yoon·2023년 3월 8일
96
post-thumbnail

next-auth 라이브러리를 이용해 로그인을 구현한 내용에 대해 공유합니다. 혹시 보시면서 보안적으로 허술하거나, 부족한 부분이 있다면 언제든 공유해주시면 감사하겠습니다.

Next-Auth 라이브러리를 사용하면 토큰 갱신과 자동 로그인을 구현한 방법에 대해서도 소개한 글이 궁금하다면? 바로가기

1️⃣ Next-Auth

💬 next-auth 란 무엇인가?

next-auth 라이브러리는 next.js 로 구현되어 있는 페이지에서 로그인을 쉽게 구현할 수 있도록 관련 기능을 제공하는 3rd Party 라이브러리이다. (자세한 설명은 next-auth introduction을 참고)

Oauth Provider 제공

해당 라이브러리를 처음 도입하게 된 이유는 Oauth 인증 방식의 로그인 서비스를 쉽게 구현할 수 있도록 Provider를 제공해준다는 점 때문이었다. 로그인을 구현할 당시는 팀 내에서 신규 웹 서비스 도입 테스트 기간이었고, 그만큼 웹 서비스를 MVP 형태로 빠르게 만들 필요가 있었다. 따라서 이런 라이브러리에서 제공하는 편리한 기능을 활용해 로그인과 관련된 핵심 로직에만 신경써도 된다는 점이 마음에 들었다.

안전한 토큰 저장

로그인에서 중요한 점이 바로 보안이다. 로그인을 위해 JWT를 발급 받은 후 브라우저에 안전하게 저장하는 것이 필요했다. 일반적으로 토큰을 저장하는 저장소는 localStorageCookie로 나뉜다. (각각의 장단점은 다음 글 참조)

✅ 로컬 스토리지 저장

로컬스토리지의 경우 간편 하지만 XSS 공격에 취약하다는 단점이 있다.

XSS(Cross Site Scripting attack)는 해커가 사용자의 브라우저 환경에서 악의적인 자바스크립트 코드를 실행하는 경우를 말한다. 로컬 스토리지는 자바스크립트로 접근 가능하므로, 만약 사용자의 브라우저가 악의적인 코드에 노출되게 된다면 해커에게 바로 토큰이 탈취 당한다.

✅ 쿠키에 저장

쿠키에 http-onlySecure 설정을 하면, 자바스크립트로 쿠키에 접근할 수 없기 때문에 XSS 공격에 상대적으로 안전하다. (물론, 위 설정으로 토큰 정보를 볼 수 없지만 XSS 공격으로 HTTP 요청을 위조할 수 있기 때문에 완전히 안전한 건 아니다)

물론 토큰도 단점이 존재한다. 쿠키는 최대 4KB의 용량만 저장할 수 있기 때문에 용량이 큰 토큰의 경우 저장이 불가능할 수 있고, CSRF 공격에 취약할 수 있다.

CSRF 공격은 사용자의 의도와 무관하게 특정 HTTP 요청을 하게 만드는 공격 방법으로, 대표적으로 sns 등에 광고성 글을 올리는 공격 등을 말할 수 있다. 토큰은 HTTP 요청마다 RequestHeader에 담겨 보내지기 때문에, 만약 서버에서 쿠키에 담긴 토큰 정보를 이용해 사용자 권한을 검증한다면 CSRF 공격 시 문제가 된다.

이 때문에 우리 회사에서는 HTTP 요청 시 쿠키에 토큰 정보를 담아 보내지 않고, Autorization 헤더를 별도 세팅하여 보내주고 있다. 물론 다행히
next-auth는 쿠키에 토큰을 저장할 때 Same-site 속성을 기본적으로 Lax로 지정한다.Same-site 속성은 브라우저가 cross-site에 HTTP 요청을 보낼 경우 쿠키에 해당 정보를 담아 보낼 것인지 여부를 알려주는 속성이다. Lax는 해당 정보를 담아 보내지 않는다는 옵션이기 때문에 cross-site로 토큰 정보가 보내지지 않는다.

하지만 API 에서 Authroization 헤더를 사용하는만큼, 쿠키는 XSS로부터 안전한 저장소 역할을 하고 HTTP 요청마다 client에서 쿠키에 저장된 토큰 정보를 받아 헤더를 세팅하여 HTTP 요청을 하는 방식으로 구현했다.

✅ client에서 쿠키에 저장된 토큰을 어떻게?

맞다. http-only 설정을 하면 자바스크립트로 토큰 정보에 접근할 수 없다. 이를 위해서 next-auth는 jwt, session 콜백 함수 등을 이용해 client에서도 토큰 정보 일부를 노출시킬 수 있고, useSession 과 같은 훅을 통해 이를 참조할 수 있다.

💬 next-auth 사용 방법

API 라우트 구성하기

next-auth는 기본적으로 Next js의 동적 API route을 이용해 로그인을 구현한다. 동적 API route는 서버 사이드에서 동작하는 코드로 사용자가 RESTful 하게 요청과 응답을 구성할 수 있는 라우팅 방식이다. 동적 API route는 동적 route를 지원하기 때문에, 다양한 경로의 API 요청을 동적으로 구성할 수 있다.

다음과 같이 pages/api /auth 폴더를 만들고 맨 마지막 auth 폴더 하위에 […nextauth].js 를 만들어 주면 된다

// pages/api/auth/[...nextauth].ts

import NextAuth from "next-auth"
import GithubProvider from "next-auth/providers/github"
export const authOptions = {
  ...
}
export default NextAuth(authOptions)

next-auth 에서 제공하는 signIn/signOut 함수 역시 이 api로 요청을 보내는 것이다. (자세한 사항은 다음 페이지 참고) e.g api/auth/signIn/kakao, api/auth/signOut

All requests to /api/auth/*(signIncallbacksignOut, etc.) will automatically be handled by NextAuth.js.

그 후, client에서 session 정보를 불러올 수 있는 useSession 훅을 사용하기 위해 앱의 가장 최상단(_app.tsx)에 SessionProvider를 심어준다.

// pages/_app.jsx

import { SessionProvider } from "next-auth/react"
export default function App({
  Component,
  pageProps: { session, ...pageProps },
}) {
  return (
    <SessionProvider session={session}>
      <Component {...pageProps} />
    </SessionProvider>
  )
}

Oauth Provider 구성하기

우선 Oauth 프로토콜 방식의 로그인을 구현하기 위해서, next-auth에서 제공하는 Auth Provider를 가져온다. 네이버카카오 Provider 역시 제공된다.

// pages/api/auth/[...nextauth].ts

import NextAuth from "next-auth"
import NaverProvider from "next-auth/providers/naver"
import KakaoProvider from "next-auth/providers/kakao"

export const authOptions = {
  // Configure one or more authentication providers
  providers: [
    NaverProvider({
      clientId: process.env.NAVER_CLIENT_ID,
      clientSecret: process.env.NAVER_SECRET,
    }),
	KakaoProvider({
      clientId: process.env.KAKAO_CLIENT_ID,
      clientSecret: process.env.KAKAO_SECRET,
    }),
  ],
}
export default NextAuth(authOptions)

각 Provider에 입력할 client ID와 Secret 키는 카카오, 네이버 개발자 센터에서 등록한 앱의 ID와 Secret을 넣으면 된다. (카카오의 경우 RestAPI Key를 ID로 넣고, 카카오 로그인 > 보안 탭으로 이동하면 Client Secret 생성이 가능하다)

💡 카카오 개발자센터에서 Client Secret 을 발급하면 기존 앱에서 사용한 카카오 로그인 기능에 문제가 생기지 않을까? 아니다. 기존 로그인이 REST API로 구현되어 있지 않은 이상, Client Secret 발급 유무가 기존 앱의 로그인 기능과는 무관하다. 즉, Client Secret은 REST API를 사용할 경우만 유효한 값이다. (참고)

이 때 주의할 점은 해당 환경 변수는 절대로 외부에 노출 되어서는 안 되기 때문에, Next.js 에서 환경변수를 등록하기 위해 prefix로 붙여주는 NEXT_PUBLIC 을 붙여서는 안 된다. 이 prefix를 붙이는 이유는 해당 환경 변수를 브라우저에 노출하기 위해 사용하는 것이기 때문이다. (관련 문서)

카카오 Redirect URL 설정방법

SNS 로그인을 위해서, 카카오와 네이버 개발자센터에서 로그인에 사용될 redirect URL 설정이 필요하다. 개발 환경과 배포 환경에 따라 도메인 이름을 localhost 또는 배포된 도메인 이름 뒤에 api/auth/callback/naver , api/auth/callback/kakao 를 붙이면 된다.

SNS 로그인을 이용하기 위한 기본 세팅은 이게 끝이다! 정말 간단하다.

Credential Provider 설정하기

next-auth 에서는 3가지의 Provider를 제공한다.

이 중 Credentials는 사용자가 직접 임의의 credentials(로그인에 필요한 정보들)을 구성하여 로그인을 구현할 수 있도록 만들어 주는 provider이다.

// pages/api/auth/[...nextauth].js

import CredentialsProvider from "next-auth/providers/credentials"
...
providers: [
  CredentialsProvider({
	id : 'telephone',
    name: 'Credentials',
    credentials: {
      username: { label: "Username", type: "text", placeholder: "jsmith" },
      password: {  label: "Password", type: "password" }
    },
    async authorize(credentials, req) {
      const res = await fetch("/your/endpoint", {
        method: 'POST',
        body: JSON.stringify(credentials),
        headers: { "Content-Type": "application/json" }
      })
      const user = await res.json()

      // If no error and we have user data, return it
      if (res.ok && user) {
        return user
      }
      // Return null if user data could not be retrieved
      return null
    }
  })
]
...

위의 코드는 next-auth 공식 문서에 있는 예시 코드이다. credentials 프로퍼티를 통해 어떤 입력 정보를 받을지 개발자가 지정할 수 있다. 위의 예시에서는 username과 password 라는 값을 입력받도록 설정되어 있고, 해당 프로퍼티 값을 가진 객체가 authroize 콜백 함수의 첫 번째 인수로 전달된다.

  • credentials 안에 label과 placeholder 값이 있는 이유
    next-auth에서 로그인 페이지를 구현하지 않아도 기본적으로 로그인 페이지가 구현되어 있다. 이 값들은 기본 로그인 페이지에 있는 input 컴포넌트를 구성하는데 필요한 것이기 때문에, 만약 자체 로그인 페이지를 구현한다면 굳이 설정하지 않아도 된다.

authorize 콜백은 credentials 값을 통해 해당 사용자가 로그인이 가능한지 여부를 판단하여 로그인을 제어할 수 있는 함수이다. 만약 해당 사용자가 로그인이 가능하면 User 객체를 반환하고 그렇지 않을 경우 null/false을 반환하면 된다. User 객체의 타입은 다음과 같다.

// type.d.ts
export interface DefaultUser {
    id: string;
    name?: string | null;
    email?: string | null;
    image?: string | null;
}

export interface User extends DefaultUser {
}

DefaultUser를 상속받고 있기 때문에 id 프로퍼티를 필수값으로 넣어야 하고, 이외에 추가하고 싶은 속성을 넣을 수도 있다. 이 User 객체는 아래에서 설명한 SignIn 콜백 함수의 인수 중 user 프로퍼티에 전달된다.

When using the Credentials Provider the user object is the response returned from the authorize callback and … (참고)

환경 변수 설정하기

위에서 말한 client ID와 Secret 외에도 배포 시 다음의 환경 변수 세팅이 필요하다.

NEXTAUTH_SECRET 은 토큰 정보를 encrypt, decrypt 하기 위해 필요하며, NEXTAUTH_URL 은 로그인 기능이 포함된 도메인 주소를 작성하면 된다. 아마 보안 상의 이유로 NEXTAUTH_URL 에 설정된 도메인에서만 로그인 요청이 가능하도록 하는 거 같다.

💬  next-auth로 로그인 구현 방법

회사에서는 SNS 로그인과 휴대폰 인증 로그인을 제공하고 있다.

SNS 로그인

회사의 로그인은 Oauth 프로토콜로 인증한 토큰 값을 그대로 사용하는 것이 아니라, 내부 API 요청을 통해 검증 과정을 한 번 더 거치게 된다. 해당 사용자가 만료, 휴면, 블랙 회원인지 여부를 판단하여 예외 처리를 해주어야 하기 때문에 위에서 설정한 Oauth 설정 + 추가 설정이 필요하다.

SignIn Callback

해당 콜백은 Oauth/Email/Credentials Provider로 로그인 한 후에 실행되어 사용자의 로그인을 제어할 수 있는 콜백 함수이다. 만약 카카오 로그인을 한다고 가정하면, next-auth 에서 제공하는 signIn 함수를 클라이언트에서 실행(e.g signIn(’kakao’)) 하고 카카오 로그인을 한 뒤, 로그인 정보가 signIn 콜백 함수에 전달 된다.

callbacks: {
  async signIn({ user, account, profile, email, credentials }) {
	try {
	    const { meta, data: token } = await snsLogin({ account, user });
		return meta.code === 0 || `signin?errorcode=${meta.code}`
	} catch(error){
		if(error instansceof AxiosError){
			return `signin?errorcode=${error.message}`
		}
	}
  }
}

카카오 로그인이 성공적으로 완료되면 signIn 함수에 로그인 정보가 전달되는데, 이 때 account 에는 provider, providerAccountId, refresh_token, access_token 등의 프로퍼티가 들어 있다. user에는 카카오 로그인 시, 사용자가 정보 제공에 동의한 항목으로 사용자 이메일 등과 같은 정보가 들어있다.

이 정보를 토대로 다시 서버 쪽에 API를 요청하여(await snsLogin({ account, user })) 해당 유저가 로그인 가능한지(휴면, 블랙회원이 아닌지) 여부를 확인한다. 로그인이 가능하다면 콜백 함수의 반환값으로 true를 전달하면 되고, 그렇지 않을 경우 리다이렉트를 위한 URL을 전달할 수 있다.

로그인이 불가능한 상황은 휴면, 만료 또는 신규 회원가입이 필요한 경우인데, 에러에 따른 추가 검증 작업을 클라이언트에서 해야 하므로 queryString에 에러 코드를 전달하여, 클라이언트가 에러에 따른 추가 작업을 진행하도록 했다.(return 'signin?errorcode=${meta.code}')

JWT Callback, Session Callback

공식 문서에 따르면, jwt 콜백은 JWT가 생성되거나(e.g sign in 성공), 업데이트(e.g useSessing 등 클라이언트에서 session에 접근하였을 때)되었을 때 실행된다. 함수의 반환 값은 encrypted 되며, 쿠키에 저장된다.

[JWT 콜백이 실행되는 조건]

Requests to /api/auth/signin/api/auth/session and calls to getSession(),  getServerSession()useSession() will invoke this function, but only if you are using a JWT session. This method is not invoked when you persist sessions in a database.

만약 클라이언트로 token 이나 user 정보를 노출해야 하는 경우, JWT 콜백과 Session 콜백을 함께 사용할 수 있다. jwt 콜백에는 로그인 방식에 따라 다르지만, Oauth 로 로그인할 경우 access_token 정보가 포함된 account 객체가 인수로 전달된다.

// pages/api/auth/[...nextauth].js
...
callbacks: {
  async jwt({ token, account, profile }) { <--- account 내 access_token을 참조 할 수 있다
    if (account) {
      token.accessToken = account.access_token <--- token 객체에 다시 담아서
      token.id = profile.id
    }
    return token <--- 반환해주면, session 콜백으로 전달된다.
  }
}
...

이 토큰 정보를 token 객체에 담아서 내보내면, session 콜백 함수에 전달된다. 물론 모든 token 객체가 전달되지는 않고, 일부만 전달된다고 한다.

By default, only a subset of the token is returned for increased security ( 🔗)

위에서 token 객체에 추가한 값을 client에서 사용할 수 있도록 하기 위해서는, session 콜백에서 명시적으로 프로퍼티 추가를 해야 한다.

If you want to make something available you added to the token (like access_token and user.id from above) via the jwt() callback, you have to explicitly forward it here to make it available to the client.

...
callbacks: {
  async session({ session, token, user }) {
    // Send properties to the client, like an access_token and user id from a provider.
    session.accessToken = token.accessToken <--- 전달받은 token 객체에서 토큰 값을 다시 session 객체에 담고
    session.user.id = token.id
    
    return session <--- 반환해주면, client에서 접근 가능하다. 
  }
}
...
...
const { data : session } = useSession(); 

// 다음과 같이 session 객체 내 accessToken을 참조할 수 있다.
console.log(session.accessToken) 

타입스크립트를 사용한다면 Session 타입을 확장해 주어야 한다. src 파일 하위에 auth.d.ts 파일을 만들면(파일 이름은 .d.ts만 붙여주면 아무거나 상관 없다) 타입을 확장할 수 있다. (src 폴더 하위에 두는 건, src 폴더가 빌드 대상이 되기 때문에 웹팩에게 해당 타입에 대해 알려주는 것이다.)

import NextAuth, { DefaultSession , User } from 'next-auth';

// user 객체에 id와 acceessToken 프로퍼티 타입을 추가함
declare module 'next-auth' {
  interface Session extends DefaultSession {
    user?: {
      id?: string;
    } & DefaultSession['user'];
    accessToken: string;
  }
}

우리 회사 역시 http 요청을 위해 Authorization 헤더를 세팅해야 하므로, client에서 토큰 정보를 참조해야 한다. 다만 jwt 콜백 함수에 전달되는 access_token 값을 그대로 사용하는 것이 아니라, 한 번 더 내부 API에 요청하여 받은 토큰을 사용하기 때문에 위 방법을 우회해야 했다.

callbacks: {
      async signIn({ user, account, credentials }: any) {
        try {
          const { meta, data: token } = await snsLogin({ account, user });
          privateToken = token <--- 내부 API 요청을 통해 받은 token 값을 전역 변수에 저장한다. 

          return (
            meta.code === 0 || `/signin?errorCode=${meta.code}`
          );
        } catch (error) {
           ...
        }
      },
	  async session({ session, token }: any) {
        session.accessToken = privateToken; <--- 해당 전역 변수 값을 session 객체에 저장한다. 

        return session;
      },

이를 우회하기 위해, signIn 함수에서 내부 API를 요청하여 받은 토큰값을 별도의 전역 변수로 저장한 다음 session 콜백 함수에서 accessToken 프로퍼티를 추가하는 방법을 사용했다. (서버사이드 로직은 클라이언트 소스 파일에 포함되지 않아서, 브라우저에서 참조 불가능하다.)

휴대폰 로그인

휴대폰 로그인은 자체적인 인증 시스템이기 때문에 Oauth 대신 Credential Provider를 사용해야 한다. 하지만 휴대폰 인증은 사용자가 휴대폰 번호를 입력하고, 받은 인증번호를 다시 재입력하는 등의 클라이언트 사이드에 많이 의존하는 로그인 방법이다. 따라서 단순히 credentials를 Credential Provider 에 전달하는 방법으로 인증하는 것이 어려웠다.

때문에 클라이언트에서 모든 로그인 프로세스를 진행한 다음 서버로부터 받은 accessToken 값을 마지막에 Credential Provider에 전달하는 방식을 취했다. 아래와 같이 서버로부터 받은 token 값을 signIn 함수에 두 번째 인수로 전달하면, Credential Provider의 authorize 함수에 credentials 객체가 전달된다. (물론 credentials 설정에 token을 미리 지정해줘야 한다.)

signIn('telephone' , { token })

그 후 authorize 함수에서 token 값을 담은 User 객체를 반환시키도록 했다. 그러면 signIn 함수에 user 라는 이름의 객체가 인수로 전달된다. signIn 함수 안에서 토큰 값을 전역 변수(privateToken)에 업데이트하고 true를 반환하면, session 콜백이 실행되면서 SNS 로그인과 같이 accessToken 프로퍼티를 추가할 수 있다.

💡 signIn 함수를 호출하는 과정에서 해킹당할 수 있지 않을까? 일단 공식 문서에 따르면 next-auth에서 sign In이나 sign out 같은 POST 라우트 요청(/api/auth/signin/:provider 등)의 경우 CSRF 토큰을 사용하여, CSRF 토큰이 없는 POST 요청은 금지된다. (참고)

미들웨어 - Page Protection

Next.js 에서는 미들웨어를 통해 요청과 응답을 수정할 수 있다. 이를 활용해 로그인 유무에 따라 특정 페이지의 접근을 막고 리다이렉트 시킬 수 있는 기능(Page Protection)을 구현하였다.

next-auth에서도 자체적으로 middleware를 이용한 Protection 기능을 제공하고 있다. 또 withAuth 라는 함수를 제공하여 특정 조건인 경우 로그인 되도록 설정할 수 있다. (자세한 사항은 다음 참고)

import { withAuth } from "next-auth/middleware"

export default withAuth({
  callbacks: {
		// jwt 콜백 함수로부터 반환 받은 token 객체의 userRole이 "admin" 인 경우, 접근 허용
    authorized: ({ token }) => token?.userRole === "admin",
  },
})

하지만 위의 기능은 로그인이 안 되었을 때 접근을 막을 수 있지만, 로그인이 되었을 때 접근은 막지 못한다. 예를 들어 로그인한 다음 로그인 페이지로 접근을 막아야하는 경우 하나의 조건으로만 protection 하는 건 한계가 있다. 따라서 자체적으로 미들웨어를 구현하였다.

미들웨어를 사용하기 위해서는 pages 폴더와, 같은 레벨 위치에 middleware.ts(js) 파일을 만들어야 한다. 구현한 미들웨어에서는 2가지 경우만 체크하는데, 로그인이 되어야만 접근 가능한 경우(withAuth)와 로그인이 되면 접근 불가능한 경우(withOutAuth)이다.

  • withAuth - e.g 마이페이지 (로그인 해야만 접근 가능함)
  • withOutAuth - e.g 로그인 페이지 (로그인 한 다음에 접근이 불가능해야 함)
// src/middleware.ts

export async function middleware(req: NextRequest) {
  // 서버사이드에서 로그인 유무를 판단할 수 있는 next-auth 제공 함수 
  // 토큰 값이 falsy 하지 않으면 로그인 o
  const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET });

	// 사용자가 요청하는 페이지 pathname
  const { pathname } = req.nextUrl;
	// 해당 pathname이 미리 정의해둔 withAuth, withOutAuth 배열 중 어디에 속하는지 확인 
  const isWithAuth = withAuthList.includes(pathname);
  const isWithOutAuth = withOutAuthList.includes(pathname);

  if (isWithAuth) return withAuth(req, !!token); // 로그인 여부에 따라 redirect 하는 함수 
  else if (isWithOutAuth) return withOutAuth(req, !!token); // 로그인 여부에 따라 redirect 하는 함수 
}

// 미들웨어가 실행될 특정 pathname을 지정하면, 해당 pathname에서만 실행 가능 
export const config = {
	mathcher : [...withAuthList, ...withOutAuthList]
}

Axios Default Header 세팅

로그인이 되면 로그인 여부를 전역 상태로 알리고 Axios 요청 시 필요한 Authroization Header를 세팅해줘야 한다. 이를 위해 앱 상단에 Wrapper 컴포넌트를 만들어 _app.tsx 에 선언하였다. useSession 훅을 사용 하므로 <SessionProvider> 하위에 위치해야 한다. 페이지 컴포넌트가 렌더링 되기 전에, 로그인 전역 상태와 Header를 세팅하였다.

function SessionLoader({ children }: PropsWithChildren<{}>) {
  const { status, data: session } = useSession();
  const setLoginState = useSetRecoilState(loginStateSelector);

  const isLogin = !!session && status === 'authenticated';
  const token = isLogin ? session.accessToken : '';

  useEffect(() => {
    setToken(token);
    setLoginState(isLogin);
  }, [isLogin]);

  return <>{children}</>;
}

export default SessionLoader;
// pages/_app.jsx
export default function App({
  Component,
  pageProps: { session, ...pageProps },
}) {
  return (
    <SessionProvider session={session}>
	  <SessionLoader>
	    <Component {...pageProps} />
	  </SessionLoader>
    </SessionProvider>
  )
}

Hook이 아닌 Wrapper 컴포넌트를 사용한 이유는 다음과 같은 장점이 있다고 생각했기 때문이다.

  • 관심사가 분리될 수 있다. 만약 훅으로 선언하게 된다면 _app.tsx 컴포넌트 내부에서 다른 코드와 함께 섞여있게 된다. 물론 훅으로 분리하였다고 해도, 코드가 길어지거나 할 경우 한 눈에 어디에 해당 기능이 있는지 찾기 불편하다. 반면 Wrapper 컴포넌트는 그 위치가 고정되어 있어, 상대적으로 가독성에 좋다고 생각했다.

2️⃣ Trouble Shooting

💬 토큰 유효성 검사

만약 사용자가 로그인 한 후, 다른 기기에서 다시 로그인 할 경우 기존에 발급한 토큰은 만료된 토큰이 된다. 즉, 브라우저 쿠키에 저장된 토큰 정보를 더 이상 사용할 수 없기 때문에 토큰 유무 만으로 로그인 상태를 체크 해서는 안 된다. 이를 위해 페이지 진입 전, 유효성 검사가 필요했다.

현재 서버에서 로그인 시 refresh 토큰을 함께 보내주지 않기 때문에, 토큰이 유효하지 않을 경우 무조건 로그아웃 후에 로그인 페이지로 이동시켜야 했다.

클라이언트에서 유효성 검사 기능을 추가할 경우, 서버 사이드에 비해 단계가 추가되는 단점이 있다. 유효성 검사 API를 호출한 후 유효하지 않으면, 다시 signOut 함수를 호출하여 signIn 페이지로 이동하게 된다. 한 번 페이지가 마운트 된 다음 이동하는 것이기 때문에 사용자에게 혼란을 줄 수 있다.

반면 Next.js 의 미들웨어는 request가 완료 되기 전에 검증과 토큰 삭제가 가능해, signOut 함수를 다시 호출할 필요가 없다. 또 이미 Page Protection 이 구현된 미들웨어에 토큰 유효성 검증 로직을 응집시키는게 관리적으로 용이하였다.

로그아웃을 구현하는 게 조금 어려웠는데, 일단 next-auth 에서 제공하는 signOut 함수는 클라이언트 사이드에서만 사용할 수 있었다. 미들웨어에서 POST 요청으로 동적 API route(/api/auth/signout)를 요청해 보았지만 기대한 것처럼 로그아웃 된 상태로 페이지 이동이 되지 않았다.

그러다 어차피 토큰의 유효성은 서버 사이드에서 검증하기 때문에 클라이언트 사이드는 토큰의 유무만으로 로그인 상태를 판단해도 되겠다는 생각이 들어, 단순히 쿠키에 담겨 있는 토큰값을 삭제한 후 response를 반환해주는 방법으로 로그아웃을 구현했다.

// middleware.ts
const redirectRes = NextResponse.redirect(url);
const { data: isValid } = await getTokenValid(accessToken);

if (!isValid) {
  redirectRes.cookies.delete('next-auth.session-token');
}

return redirectRes;

💬 Page Protection Callback 처리 & CallbackURL 보안 처리

Callback 처리

Page Protection 은 로그인 했을 때 진입 가능한 경우(withAuth)와 진입 불가능한 경우(withOutAuth)에 따라 리다이렉트 설정을 한다. 보다 낳은 사용자 경험을 위해서, 만약 비로그인 사용자가 로그인 페이지로 리다이렉트 된 경우, 로그인 후에 원래 진입하고자 했던 페이지로 이동시키도록 callback 처리를 하였다.

먼저 비로그인 사용자가 withAuth 페이지에 진입하면, 미들웨어에서는 request 객체 중 nextUrl 안에 담긴 pathname 을 queryString으로 붙여 로그인 페이지로 리다이렉트 시킨다.

const withAuth = (req : NextRequest, accessToken: string) => {
	const url = req.nextUrl.clone(); 
	const { pathname } = req.nextUrl;
	
  ...
	if(!isLogin) {
		url.pathname = '/signin';
		url.search = `callbackUrl=${pathname}`;

		return NextResponse.redirect(url);
	}
	...
}

const withOutAuth = (req : NextRequest, accessToken: string, to:string | null) => {
	const url = req.nextUrl.clone(); 
	const { pathname } = req.nextUrl;
	
  ...
	if(isLogin) {
		url.pathname = to ?? FALLBACK_URL;
		url.search = '';

		return NextResponse.redirect(url);
	}
	...
}

export async function middleware(req: NextRequest) {	
	... 
	const { searchParams } = req.nextUrl;
  const callbackUrl = searchParams.get('callbackUrl');

	if (isWithAuth) return withAuth(req, !!token);
  else if (isWithOutAuth) return withOutAuth(req, !!token, callbackUrl);
}

next-auth 에서 signin 함수를 호출할 때 별도의 redirect 옵션을 전달하지 않으면 현재 페이지로 다시 이동한다. 마찬가지로 /signin 페이지(로그인 페이지)에서 로그인 하면 다시 /signin 로 이동하는데, 로그인 페이지는 로그인 후에 진입 불가하다. 따라서 미들웨어의 withOutAuth 핸들러를 통해 callbackUrl로 리다이렉트 시켜주었다.

Callback 보안 처리

queryString으로 콜백 URL을 전달하는 경우, 만약 해커의 의해서 악의적인 URL이 심겨질 수 있기 때문에 콜백 URL 이 유효한지 검사가 필요하다. 미들웨어에서는 콜백 URL로 pathname만 전달하기 때문에, callbackURL에 도메인 주소가 포함되면 해당 callback URL은 무시되도록 구현하였다.

3️⃣ 회고

💬 More is Better + Less is Better

로그인은 특히나 보안과 관련되어 있어, 부담이 되었다. 로그인 기능은 처음 해보고, 시니어가 없는 팀에 있다보니 어려움을 만났을 때 방향성에 대해 의견을 구할 사람도 없어, 난감하고 막막한 순간이 많았다. 그 때문에 초반에 에러와 장애를 만나면 겪는 스트레스 때문에 많이 힘들었다.

흔히들 무언가를 성취하기 위해서, 몰아붙여서 focus 하는 게 성취를 이루는 가장 빠른 길이라고 생각하고, 나도 그랬다. 하지만 이번 기회를 통해 때로는 잠깐 멈추고 생각할 수 있는 여유가, 잠시 쉬더라도 놓을 수 있는 용기가 필요하다는 걸 알게 되었다.

무언가 문제가 생기면, 그 문제에 집중하여 다른 길을 생각할 수 없게 되는데 잠시 놓고 다시 문제를 고민하면 또 다른 방법이 떠오를 수 있다는 걸 알게 되었다. 무엇보다 스트레스를 적절히 다스리지 못할 경우 그게 나의 태도로 연결될 위험이 있어, 이번 기회를 통해 개발 과정에서 만난 스트레스를 잘 관리하는 게 무엇보다 중요하다는 생각을 하였다. 즉, 몰입과 쉼의 적절한 조화(harmony)가 필요하다는 걸 알게 되었다.

이번 개발을 통해 나를 좀 더 이해할 수 있게 되었고, 어려움을 만났을 때 잠시 놓아주는 것도 하나의 방법이라는 걸 알게 되어 의미있는 과정이었다고 생각한다.

profile
배운 것을 기록하는 FrontEnd Junior 입니다

2개의 댓글

comment-user-thumbnail
2023년 8월 2일

좋은글 감사합니다. !!!

1개의 답글