httpOnly Cookie, JWT Nextjs+Apollo에서 SPA 로그인 로그아웃 구현하기

HyosikPark·2020년 12월 19일
0

next.js+graphql+mongodb

목록 보기
2/3
post-custom-banner

nextjs에서 httponly cookie로 로그인 로그아웃을 구현하기위해 getInitialProps, resolver context에서 쿠키 얻어 활용하기 등 이틀동안 고생을 해가며 spa로 인증을 활용하는 방법을 찾으려 애를 썼다.

쿠키를 set하는 것은 그냥 resolver에서 로그인 시에 저장해주면 되지만 httpOnly cookie를 nextjs에서 get하여 사용하는 것은 SPA로 활용할 때 보안과 성능을 고려하면 많이 까다로웠다.

결론적으로 Cookie를 두개 생성하여 활용하는 것이 가장 적합한 해결책이었다. 하나는 spa를 위한 일반쿠키, 하나는 유저인증을 위한 http-only cookie이다.

token 생성, cookie 저장

먼저 apolloServer의 context에 쿠키를 얻어 토큰을 verify하는 로직을 작성한다. verify가 성공하면 payload를 반환, 실패시 null을 반환하는데 context는 모든 resolver context인자에 전달되므로 유저 인증에 활용할 수 있다. 쿠키에는 생성한 JWT토큰을 저장할 것이며 JWT.sign의 payload에는 로그인한 유저의 아이디,닉네임,이메일, 생성일자 등의 정보를 기입한다. 후에 쿠키를 얻은 뒤 token을 verify하여 payload의 유저정보를 client에서 활용하기 위함이다.

쿠키 접근을 위해 서버에서는 Cookies, client에서는 cookie-cutter lib을 설치하여 사용하는 것이 편리하다.


npm i Cookies cookie-cutter

// api/graphql
export const verifyToken = (token) => {
  if (!token) return null;
  try {
    return jwt.verify(token, process.env.SECRET_KEY);
  } catch {
    return null;
  }
};

const apolloServer = new ApolloServer({
  schema,
  context: async ({ req, res }) => {
    	
    const cookies = new Cookies(req, res);
    const token = cookies.get('auth-token');
    // 토큰 검증 성공시 user에는 payload, 실패시 null이 담긴다.
    // 실제 인증이 필요한 경우 resolver에서 context.user 값 존재 여부로 판단이 가능하다.
    const user = verifyToken(token);
		
    	// mongoDB 연결로직..

    return { db, cookies, user };
  },
});

// resolver

export const generateToken = (user) => {
  return jwt.sign(
    {
      id: user._id,
      nickname: user.nickname,
      email: user.email,
      createdAt: user.createdAt,
    },
    process.env.SECRET_KEY,
    { expiresIn: '24h' }
  );
};

async login(_, { loginInput: { email, password } }, context) {
  
 			// 로그인 인증 로직...

  			// 로그인 완료시 토큰 두개 생성
      const token1 = generateToken(findUser);
      const token2 = generateToken(findUser);
			// 하나는 httponly
      context.cookies.set('auth-token', token1, {
        httpOnly: true,
        secure: process.env.NODE_ENV !== 'development',
      });
   			// 하나는 일반 쿠키
      context.cookies.set('spa', token2, {
        httpOnly: false,
        secure: process.env.NODE_ENV !== 'development',
      });
      return {
        ...findUser,
        id: findUser._id,
      };
    },

로그인 구현

처음엔 http only쿠키 하나만 사용하여 구현하려 했으나 문제점이 많았다.

getInitialProps로 활용할 시 문제점
client에서 쿠키를 얻을 수 없기 때문에 ssr을 해야만 쿠키를 얻을 수 있었다. SPA로 활용하기 부적절.

useQuery로 활용할 시 문제점
쿠키가 인증되었을 때 payload를 반환해주는 로직을 짜서 활용해봤는데 client에서만 Query 요청하여 사용할 수 있기 때문에(useQuery) 페이지가 렌더링되면서 Query요청하는 시간이 생각보다 길어서 UI변화가 깔끔하지 못하다.

쿠키 2개로 해결방법.

// 로그인 컴포넌트.

const verifyToken = (token) => {
  if (!token) return null;
  try {
    return jwt.verify(token, process.env.SECRET_KEY);
  } catch {
    return null;
  }
};

function Login() {
 
  const [user, setUser] = useState<UserState | null>(null);

		// 3. SPA용 토큰을 verify하여 상태에 저장.
  const getUserInfo = useCallback(() => {
    const token = cookieCutter.get('spa');
    const userInfo = verifyToken(token);
    setUser(userInfo);
  }, []);
 	
  // 1. mutation으로 로그인 검증
  const [login] = useMutation(LOGIN, {
    variables: value,
    onError(err) {
      alert(`${err.graphQLErrors[0].message}`);
    },
    onCompleted() {
      // 2. 로그인 성공시 getUserInfo함수 실행.
      getUserInfo();
    },
  });

  const logout = useCallback(() => {
    // 로그아웃 시에 쿠키를 SPA용 쿠키를 없애고 상태를 null로 변경.
    cookieCutter.set('spa', '', { expires: new Date(0) });
    setUser(null);
  }, []);

  useLayoutEffect(() => {
    // 5. 컴포넌트를 리렌더링 했을 때의 로직.
    if (cookieCutter.get('spa')) {
      getUserInfo();
    }
  }, []);
	// 4. 로그인 시 표시할 UI
  if (user) return <LoginUser user={user} logout={logout} />;
  
  // 0. 로그인 전 표시할 UI
  return (...)
}

// LoginUser 컴포넌트

function LoginUser({ logout, user }: LoginUserprops) {
  return (
    <div className='login_user'>
      <div className='info_logout'>
        <div className='user_info'>
          <h4>{user.nickname}</h4>
          <div className='user_post_info'>
            <p>게시글 0</p>
            <p>댓글 0</p>
          </div>
        </div>
        <button onClick={logout}>로그아웃</button>
      </div>
    </div>
  );
}

우선 nextjs 특성 상 client에서 useEffect없이 쿠키를 얻으려고하면 오류가 발생한다. pre-rendering시에 browser에서 쿠키를 얻을 수 없기 때문.

useEffect가 아닌 useLayoutEffect를 사용하였는데 useEffect사용 시 상태변화가 적용되기까지 시간이 걸려 UI변화가 매끄럽지 않기 때문이다.

결론

SPA로만 활용할 일반쿠키로는 실질적 인증이 필요한 수단에는 사용하면 안된다.
매끄러운 UI변화 정도에만 활용하고 만약 실제 유저임을 인증해야 서비스를 이용할 수 있는 부분에는 api를 통해 서버에서 httpcookie에 저장된 token을 verify하여 사용해야한다.

post-custom-banner

0개의 댓글