Role Based 로그인 (feat. NextAuth.js)

terry yoon·2024년 6월 26일
0
post-thumbnail
post-custom-banner

간혹 서비스를 이용하면, 악성 게시글을 올리는 등 서비스 정책을 위반하는 사용자를 볼 수 있습니다. 이런 사용자의 경우 다른 사용자에게 피해를 줄 뿐만 아니라 장기적으로 서비스 환경에 부정적인 영향을 주기 때문에 적절한 규제가 필요합니다. 현재 재직 중인 회사에서도 이런 사용자를 지속적으로 모니터링하여, 관리자에 의해 해당 유저의 서비스 이용을 일부 제한하기도 합니다.

이런 사용자를 통칭하여 '블랙(Black) 유저' 라고 하는데, 이번 글에서는 기존 웹에서 이런 블랙 유저의 상태(Role)에 따라 로그인 및 서비스 이용을 어떻게 처리하였는지 소개합니다.

package version

  • Next.js : 13.1.2
  • NextAuth.js : 4.23.1

NextAuth Role

저희 서비스 Next.js 로 구현되어 있어, 로그인을 위해 NextAuth.js 를 사용하고 있습니다. (NextAuth.js 도입기 참고)
NextAuth 는 로그인 구현 시, 유저의 상태에 따라 role 을 부여하고 이에 따라 로그인 처리가 가능한 role based Access Control 을 제공하고 있습니다. (role based 로그인 참고)

JWT 방식의 로그인 기준으로 설명합니다.

import NextAuth from "next-auth"
import Google from "next-auth/providers/google"
 
export const { handlers, auth } = NextAuth({
  providers: [
    Google({
      profile(profile) {
        return { role: profile.role ?? "user", ... }
      },
    })
  ],
  callbacks: {
    jwt({ token, user }) {
      if(user) token.role = user.role
      return token
    },
    session({ session, token }) {
      session.user.role = token.role
      return session
    }
  }
})

해당 예시는 Google Oauth 로그인 결과에 다라 사용자의 role 을 부여하고 있습니다. 다만 저희는 Oauth 로그인 결과가 아닌, 내부 API 콜을 통해 반환 받은 사용자 code 값에 따라 블랙 유저 role 을 부여해야 했기 때문에 추가적인 작업을 진행했습니다.

Private Store

우선 사용자가 Oauth 로그인을 성공적으로 마친 경우, signIn 콜백이 실행됩니다. 이 콜백 안에서 다시 한 번 저희 로그인 API 를 호출하며 이 결과를 통해 로그인한 사용자가 블랙 유저인지 확인 할 수 있습니다.

다만 사용자에게 role 을 부여하기 위해서 signIn 콜백에서 내부 API 응답 결과를 jwt 콜백에서 참조할 수 있어야 하는데, signIn 콜백은 boolean | string 타입의 결과만 반환할 수 있습니다.

이를 우회하여 API Route 핸들러 내에 privateStore 라는 객체를 선언하여 로그인 로직 내부에서 데이터를 업데이트 하고 참조할 수 있도록 했습니다.

파일 전역에서 해당 객체를 참조할 수 있는만큼, 의도치 않는 객체 값의 변경과 참조를 피하기 위해 객체 내 value 를 freeze 설정하였고, setter/getter 메소드를 정의하였습니다.

privateStore 가 선언된 위치는 auth 함수 안이므로, api 요청 마다 초기화 되기 때문에 로그인 이후 이전에 값을 가지고 있지 않습니다.

// [...nextauth].ts
import NextAuth from "next-auth"
import Google from "next-auth/providers/google"

export default async function auth(
  request: NextApiRequest,
  response: NextApiResponse,
) {
  // store 내 
  type StorValue = {
    ..., 
    role : 'USER' | 'BLACK'
  }
  type PrivateStore = {
    _value : StoreValue;
    getter<T extends keyof StoreValue>(property : T) : StoreValue[T]; 
    setter<T extends keyof StoreValue>(property: T, value: StoreValue[T]): void;
  }
  
  const privateStore = {
    ...,
    _value : Object.freeze({
      ...,
      role : 'USER'
    })
    
    gettter (property) {
      return this.value[property]
    }, 
    settter (property, value) {
      const prevValue = this._value; 
      this._value = Object.freeze({ ...prevValue, [property] : value });
  }
  
  return NextAuth(request, response, {
    providers: [
      NaverProvider({
        id: 'naver',
        clientId: `${process.env.NAVER_CLIENT_ID}`,
        clientSecret: `${process.env.NAVER_CLIENT_SECRET}`,
      }),
      KakaoProvider({
        id: 'kakao',
        clientId: `${process.env.KAKAO_CLIENT_ID}`,
        clientSecret: `${process.env.KAKAO_CLIENT_SECRET}`,
      }),
    ],
    callbacks: {
      signIn() {
        try {
          ...
          const result = await fetchSnsLogin(...); 
          const isBlackUser = result.code === process.env.BLACK_CODE                                   
                                             
          if(isBlackUser) {
            // 내부 로그인 API 결과에 따라 privateStore 를 업데이트 합니다. 
            privateStore.setter('role', 'BLACK')
          }
          
          return true;
        } catch (error) {
          ...
          // 로그인 API 호출 과정에서 에러가 발생할 경우, redirect 할 경로를 명시할 수 있습니다. 
          return `/signin?error=${error.code}`
        }
      },
      jwt({ token }) {
        ...
        token.role = privateStore.getter('role') 
        return token;
      },
      session({ session, token }) {
        session.role = token.role
        return session;
      },
    },
  });
}

✅ 타입스크립트 확장

타입스크립트를 사용한다면 token 과 session 에 값을 할당하기 위해 각각의 타입을 확장시켜줘야 합니다.

declare module 'next-auth {
  interface Session extends DefaultSession {
    ...
    role : 'USER' | 'BLACK'; 
    ... 
   }
}

declare module 'next-auth/jwt' {
  interface JWT extends DefaultJWT {
    ...
    role : 'USER' | 'BLACK'
    ...
  }
}

이렇게 하면, 기존에 useSession 에서 반환받은 session 객체 내부에서 role 정보를 가져올 수 있습니다.

const { session } = useSession(); 

console.log(session.role); 

Role based Protection

블랙 유저는 서비스에 로그인 되지만 모든 기능을 이용할 수 있는 것은 아닙니다. 고객센터를 이용하는 등의 몇 가지 기능을 제외하면, 특정 페이지의 접근이나 동작을 방어해야 합니다.

물론 백엔드에서 블랙 유저 토큰으로 요청할 경우 에러를 반환해주시지만, 사용자가 에러를 만났을 때 당황해서 계속 반복된 API 요청을 할 수 있으므로 사전에 이를 방지하는 것이 필요하다고 판단했습니다.

반복적인 로직을 응집시키자

기존에 로그인을 구현하면서, 특정 액션(채팅하기 등)을 하기 전 로그인 여부를 미리 체크할 수 있는 HOC 함수를 반환하도록 훅을 만들었습니다.

type Callback<T> = (props : ExtractProps<T>) => unknown; 
type Options<T> = {
  fallback?: Callback<T>;
}

const useAuthWrapper = () => {
  const { push } = useRouter();
  const { status } = useSession(); 
  const isLogin = status === 'authenticated'; 
  
  return <T extends Callback<T>>(
    callback : T, 
    options ?: Options<T>
  ) => {
    return (props : ExtractProps<T>): ReturnType<T> => {
      if(!isLogin){
         if(options && options.fallback) {
           options.fallback(props)
           return;
         }
        
         push('/signin')
      }
      
      return callback(props)
    };
  };
};


function Page () {  
  const withAuth = useAuthWrapper(); 
  const handleClick = withAuth<MouseEventHandler>((e) => { e.preventDefault() ... }); 
                             
  return <button onClick={handleClick}>다음</button>
}

해당 훅을 이용해, 로그인 여부만 아니라 블랙 유저인지 확인하는 조건을 추가하여 특정 핸들러의 실행을 방어하도록 설정하고자 했습니다.

const useAuthWrapper = () => {
  const { session } = useSession(); 
  const isBlackUser = session && session.role === 'BLACK'; 
  ...
    return (props : ExtractProps<T>): ReturnType<T> => {
      if(!isLogin && !isBlack){
      	...
      }
      ...
    };
  };
};
 

하지만 이렇게 서로 다른 조건이 하나로 뭉쳐질 경우, 문제가 발생했습니다. 특정 핸들러의 경우 로그인 상태만 체크해야 하고, 특정 핸들러는 블랙 유저 여부만 확인해야 하는 경우에 두 조건을 분리하는 게 어려워졌습니다. 지금은 2개의 조건이지만 앞으로 조건이 더 추가될 필요가 있을 경우, 확장하는데 한계가 있다고 판단했습니다.

useConditional 어뎁터

이를 위해 어댑터 훅을 정의하였습니다. 처리할 조건을 내부에 가지고 있는게 아니라, 외부에서 주입받도록 하어, 각 조건에 맞는 HOC 훅을 정의할 수 있도록 하였습니다. (HOC 훅이라고 말한 건, HOC 함수를 반환하는 훅을 지칭하기 위함입니다.)

기존 useAuthWrapper 함수에서 일부 코드를 수정하여 다음과 같이 useConditional 훅을 생성하였습니다.

const useCondition = (condition : boolean) => {
  return <T extends Callback<T>>(
    callback : T, 
    options : Options<T> = {
      fallback: () => { 
    	...
  	  }
  ) => {
    return (props : ExtractProps<T>): ReturnType<T> => {
      if(!condition){
         return options.fallback?.(props)
       }
       return callback(props);
    };
  };
};

해당 어댑터 훅을 이용해 원하는 조건에 따라 커스텀 훅을 다시 정의할 수 있습니다.

const useBlackUserWrapper = () => {
  const { session } = useSession();
  const role = session && session.role; 

  const withBlack = useConditional(role === 'BLACK');

  return <T extends Callback<T>>(callback: T, options?: Options<T>) => {
    const fallback = () => {
      ...
    };

    return withBlack(callback, { fallback: options?.fallback ?? fallback });
  };
};

이렇게 하나의 훅이 체크해야 하는 조건을 단일 조건으로 유지할 수 있습니다. 또 원하는 조건에 따라 커스텀 훅을 자유롭게 만들 수 있어 확장도 용이합니다. 따라서 조건이 추가 되더라도 새로운 훅을 정의하여 사용하면 되기 때문에 기존에 만들어 두었던 HOC 훅에는 영향을 주지 않습니다.

const withBlack = useBlackUserWrapper(); 

const handleClick = withBlack(() => {}); 

fallback 의 경우 실행되는 환경에 따라 변경될 수 있으므로 options 를 통해 override 할 수 있습니다. 또 만약 callback 함수에 전달되는 인수가 필요할 경우를 위해 fallback 에 역시 인수를 전달하도록 했습니다.

const handleClick = withBlack<MouseEventHandler>(callback, { fallback : (event) => {
    // 해당 fallback 은 기존 withBlack 의 기본 fallback 을 override 합니다. 
	event.preventDefault(); // 또 인수로 MouseEvent 가 전달됩니다. 
} }); 

HOC 함수이기 때문에, 고차함수에 다시 고차함수를 전달할 수 있어 여러 조건을 처리하는 것도 가능합니다 (조건 처리는 밖에서부터 안으로 실행됩니다)

const withAuth = useAuthWrapper(); 
const withBlack = useBlackUserWrapper(); 

const handleClick = withAuth(withBlack(() => {})); 

Page Protection

현재 middleware 에서 로그인이 필요한 페이지의 경우 사용자의 로그인 여부에 따라 페이지 방어 처리를 하고 있습니다. 마찬가지로 블랙 회원이 접근할 경우 에러가 발생하는 페이지의 경우 접근을 막기 위한 조건을 추가하였습니다.

하나의 미들웨어 안에 서로 다른 관심사

로그인 뿐만 아니라 블랙 회원인지 여부까지, 하나의 미들웨어에서 처리해야 하는 조건이 증가하여 코드의 가독성 및 복잡도가 증가하였습니다. 따라서 관심사에 따라 미들웨어를 분리하여 실행할 수 있도록 middleware chain 을 구현하였습니다.

관련 영상 참고

영상에서 소개된 chain 메소드는 배열로 middleware Factory 함수를 받고 이를 재귀적으로 호출하여 실행합니다.

type MiddleWareFactory = (middleware: NextMiddleware) => NextMiddleware;

const chain = (middlewares: MiddleWareFactory[], index = 0): NextMiddleware => {
  const current = middlewares[index];

  if (current) {
    const next = chain(middlewares, index + 1);
    return current(next);
  }

  return () => NextResponse.next();
};

export default chain;

주의할 점은 해당 chain 은 배열에 전달한 순서대로 실행되기 때문에, 미들웨어 순서가 중요하다면 이 부분을 염두해두어야 합니다.

const middlewares = [
  withBlackAuthMiddlware,
  withAuthMiddleware,
];

export default chain(middlewares);

미들웨어 early return 설정하기

관심사에 따라 미들웨어를 분리하였기 때문에 코드의 가독성이 올라가고, 변경 사항에 대한 사이드이펙트 범위가 한정되어 코드 변경에 대한 부담이 덜어졌습니다. 무엇보다 실행의 흐름을 한눈에 볼 수 있다는 점이 마음에 들었습니다.

반면 단점도 있습니다. 예를 들어 각 미들웨어에서 공통으로 처리하는 로직이나 API 호출이 있다면, 이전에는 하나의 미들웨어 상위에서 이를 처리하면 되기 때문에 한 번만 실행해도 되었습니다. 하지만 미들웨어 분리에 따라 이런 로직의 호출이 여러 번 실행될 수 있는 단점도 존재합니다.

이를 위해서 각 미들웨어가 실행되어야 하는 조건(저의 경우, request pathname)에 따라, 각 미들웨어를 실행할지를 판단해서 실행이 필요 없는 경우 다음 미들웨어를 실행하도록 early return 처리를 하였습니다. 이를 통해 실행될 필요가 없는 미들웨어를 건너뛸 수 있어, 페이지 응답 속도에 최소한으로 영향을 주도록 했습니다.

const withBlackAuthMiddleWare : MiddlewareFactory = (middleware) => {
  return (request, event) => {
    const { pathname } = request.nextUrl; 
	const isRouteMatch = blackAuthList.some((url) => routeMatch(url, pathname) // 조건을 확인한 후, 미들웨어 실행 여부를 결정합니다. 
                      
    if(!isRouteMatch){
      return middleware(request, event)
    }
    
    ...
  }
}

Trouble Shooting

사실 위처럼 미들웨어 chain 을 만든 이유는 에러를 만나면서 이를 해결하고 개선하는 과정에서 도입하게 되었습니다.

미들웨어를 특정 경로에서만 실행될 수 있도록 config.matcher 를 지정할 수 있습니다. 블랙 회원이 특정 경로로 진입하는 것을 막기 위해 matcher 조건을 추가했는데, 이후 갑자기 광고 이벤트 페이지에서 장애가 발생했습니다. (지금 생각해도 굉장히 당황스러운 순간이었습니다 😂)

우선 단편적인 원인은 url 에 있는 이벤트 seq 정보를 useRouter 훅에서 제대로 읽지 못해 API 에러가 발생하였습니다. 왜 제대로 파싱이 안되었는지 확인해봤더니, 기존에 저희가 설정한 next.config의 rewrite 경로와 middleware matcher 경로가 중복되는 경우 rewrite 이 제대로 작동되지 않는 이슈가 보고된 것이 있었습니다.

저희가 SEO 를 위해 기존 이벤트 페이지 route 경로를 수정하고 검색봇에 의해 색인된 구 버전의 경로로 접근하는 경우를 대비하여 rewrite 설정을 했는데, 해당 경로가 middleware 경로와 일치하면서 rewrite 되지 않고 구 버전 경로로 접근하게되어 seq 정보를 읽는데 실패한 것이었습니다. 때문에 미들웨어에서 해당 경로인 경우 명시적으로 NextResponse.rewrite 로 반환해주어 해결하였습니다.

다만 문제를 해결하는 과정에서 하나의 미들웨어 내의 조건이 얽혀 있어 실행의 흐름을 파악하는 것이 어렵기 때문에, 이를 쉽게 파악할 수 있는 구조를 찾아보고 체인을 도입하여 이를 개선하였습니다.

회고

개발을 하면서 완벽하고 멋있는 코드보다 가장 우선시 되어야 하는 건 "이상 없이 잘 동작하는 코드"라는 것을 느끼게 되었습니다. 왜냐하면 개발자는 제품을 만드는 사람이지 코드를 만드는 사람이 아니기 때문입니다. 이번 장애를 통해 다시 한 번 내가 작성한 코드에 대해 세심하게 파악하는 능력이 필요함을 느꼈습니다.

profile
배운 것을 기록하는 FrontEnd Junior 입니다
post-custom-banner

0개의 댓글