nextjs에서 httponly cookie로 로그인 로그아웃을 구현하기위해 getInitialProps, resolver context에서 쿠키 얻어 활용하기 등 이틀동안 고생을 해가며 spa로 인증을 활용하는 방법을 찾으려 애를 썼다.
쿠키를 set하는 것은 그냥 resolver에서 로그인 시에 저장해주면 되지만 httpOnly cookie를 nextjs에서 get하여 사용하는 것은 SPA로 활용할 때 보안과 성능을 고려하면 많이 까다로웠다.
결론적으로 Cookie를 두개 생성하여 활용하는 것이 가장 적합한 해결책이었다. 하나는 spa를 위한 일반쿠키, 하나는 유저인증을 위한 http-only 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하여 사용해야한다.