Introduction
개인 프로젝트로 X(구 트위터) 클론코딩을 진행 중 로그인을 구현하기 위해 찾아본 내용들을 정리해보고자 한다. 로그인을 구현하기 위해서는 쿠키, 세션, 헤더, JWT에 대한 선수 지식이 있으면 좋다.
일반적으로 로그인은 세션 혹은 JWT를 이용하여 구현하는데 나는 JWT와 aws lambda를 이용하여 자동 로그인을 구현하였다. 이 글에서 간략한 배경 지식과 JWT와 세션의 차이에 대해 설명해 보고자 한다.
Preliminaries
쿠키
쿠키는 사용자 정보를 저장하는 데 사용되는 작은 데이터 조각이다. 쿠키는 서버에서 브라우저에 정보를 저장하기 위해 쓴다. 쿠키는 토큰이나 세션과 대비되는 개념이 아니며 매번 반복되는 요청을 줄이기 위해 브라우저에 정보를 저장하여 서버에 통신할 때 쿠키를 함께 보낼 수 있다. http response의 header name에 set-cookie가 있는 것을 보면 이에 대해 더 확실하게 알 수 있다.
또한 쿠키는 각 사이트에 제한되어 사용된다. 예를 들어 크롬 개발자 도구에서 애플리케이션-쿠키로 들어가면 지금 열려있는 탭의 사이트에 해당되는 쿠키만 저장되어 있는 것을 알 수 있다. 쿠키는 인증뿐만 아니라 장바구니, 언어, 팝업 닫기 등 다양한 정보를 저장할 수 있기에 자동 로그인을 구현하기 위해서 필요하다. 따라서 쿠키를 이용해 기존의 stateless entity에서 stateful entity를 구현할 수 있다.
세션
세션은 서버에서 사용자의 정보를 저장할 때 쓴다. 예를 들어 사용자가 로그인을 요청하면 서버는 사용자의 정보를 확인한 후 맞다면 세션 db에 사용자 정보를 저장한다. 이 정보는 세션 id로 구분되고 서버는 클라이언트에 이 세션 id를 쿠키에 담아 보낸다. 이 후 사용자가 다시 요청 시 서버는 쿠키에 담겨 요청으로 들어온 세션 id를 세션 db에서 확인해 해당 사용자를 인증한다. 사용자의 정보는 서버에 저장되고 클라이언트에는 세션 id만 전송되기에 보안상 이점이 있다.
참고로 세션과 브라우저의 세션 저장소는 서로 다른 개념이다. 세션 저장소는 웹 스토리지의 한 종류로 데이터를 일시적으로 저장하고 클라이언트에서 접근 가능하기에 민감한 정보를 담아서는 안된다.
JWT
JWT는 Json web token의 약자로 json으로 정보를 전달하기 위해 사용하는 token의 일종이다. JWT는 header, payload 그리고 signature로 구성되며 header는 토큰의 유형, 여기서는 JWT, 그리고 JWT를 만드는데 사용한 알고리즘에 대한 정보를 담는다. JWT library들의 기본 알고리즘은 대부분 HS256이다 . payload에는 정보를 담을 수 있고 로그인을 위해 사용한다면 유저의 아이디 등을 담을 것이다. 마지막으로 signature는 토큰의 무결성을 위해 사용된다. JWT를 사용할 때 주의할 점은 JWT는 누구나 열어볼 수 있기에 민감한 정보(비밀번호 등)를 담아서는 안된다는 점이다. 서명을 위해 주로 사용되는 HS256 알고리즘은 sha2 알고리즘의 일종으로 sha2 알고리즘은 복호화가 불가능하지만 rainbow table을 이용해서 쉽게 평문을 찾아낼 수 있기 때문이다.
Results
JWT vs 세션
나는 세션 대신 JWT를 사용하여 로그인을 구현하였다. 세션 대신 JWT을 쓰는 이유는 JWT로 stateless한 로그인을 구현할 수 있기 때문이다. 세션은 앞서 설명한 것과 같이 매번 세션 db에서 사용자를 찾아내야 하기에 stateful하여 서버에 많은 부하를 줄 수 있다. 물론 JWT는 토큰 방식이고 stateless하기에 세션보다 보안상 취약하다는 단점이 있다. 왜냐하면 토큰이 탈취될 경우 토큰만을 이용해 로그인이 가능하기 때문이다. 카카오, 네이버, firebase도 accesstoken과 refreshtoken을 이용해서 자동 로그인을 구현한다는 걸 api를 통해 알 수 있었다.
Access token and Refresh token with JWT
JWT로 자동 로그인을 구현할 때 일반적으로 access token과 refresh token으로 구분하여 구현하는데 먼저 access token 은 처음 로그인을 할 때 사용한다. 사용자가 처음 로그인을 요청할 때 서버는 해당 정보가 유효한 지 db등에서 확인한다. 유효한 것이 확인되면 access token을 클라이언트에 쿠키로 전달한다. 이 후 사용자가 다시 로그인 하려할 때 사용자는 access token을 서버에 전달하고 서버는 이 토큰이 유효한지 검증한다. 따라서 stateless한 로그인을 구현할 수 있다. 이 과정을 flow diagram으로 나타내면 figure2와 같다.
나는 로그인을 구현할 때 패킷 스니핑 등 공격에 대해 보안강화를 위해 비밀번호를 client에서 server로 보낼 때 salt 알고리즘으로 hash후 전송하고 통신 프로토콜은 https를 사용하였다. JWT는 세션과 다르게 토큰만 있으면 지속적으로 로그인이 되기 때문에 보안상 약점이 있어 일반적으로 access token은 유효시간을 1분~5분정도로 짧게 설정한다. 그래도 보안에 좀 더 신경 쓰면 좋을 것 같아 복호화가 안되는 sha256알고리즘에 32비트 난수를 붙여 salt 알고리즘으로 hash하고 https통신을 사용하였다. 여담으로 salt 알고리즘을 나는 로그인할 때와 회원가입 시 db에 유저 비밀번호를 저장할 때 두번 사용하였는데 서로 다른 salt 알고리즘과 난수값을 사용함으로써 클라이언트에서와 서버에서의 salt 알고리즘에 차별을 뒀다.
말이 조금 샜는데 서버에서 사용자가 유효하면 access token과 refresh token id를 발급해주는 것을 볼 수 있다. refresh token은 access token의 유효기간이 짧은 것을 고려하여 유효기간이 더 긴 토큰을 발급하는 것이다. refresh token은 유효기간이 더 길기에 보안상 더 취약하다고 할 수 있다. 토큰이 탈취되면 더 긴 시간동안 로그인이 자유로워지기 때문이다. 이를 방지하기 위해 refresh token은 db에 저장하고 사용자에게는 refresh token의 id만을 전달한다. 자동 로그인 시 access token과 refresh token id를 둘 다 서버로 전달해 access token이 만료됐을 시 db에서 refresh token id에 해당하는 refresh token이 존재하는지 확인하고 존재하면 새로운 access token을 발급하는 방식으로 보안상 이점을 키우고자 했다. 이 과정을 나타낸 flow diagram은 figrue3와 같다.
Show me the code
import { useMutation } from 'react-query';
import axios from 'axios';
import { setExpiryCookie } from '../util/cookie.jsx';
import { useRecoilState } from 'recoil';
import { toastTextState, loginState, userObjState } from '../util/recoil.jsx';
import { useNavigate } from 'react-router-dom';
export const useLogin = () => {
// 전역 변수 recoil
const [toastText, setToastText] = useRecoilState(toastTextState);
const [isLoggedIn, setIsLoggedIn] = useRecoilState(loginState);
const [userObj, setUserObj] = useRecoilState(userObjState);
const navigate = useNavigate();
return useMutation(
async (queryParam) => {
const response = await axios.post(
`${import.meta.env.VITE_REACT_APP_BACKEND_URL}/login`,
queryParam,
);
if (response.status !== 200) {
throw new Error('서버 오류가 발생했습니다.');
}
return response.data;
},
{
onSuccess: (data) => {
setExpiryCookie('accessToken', data.accessToken, 5, 'minutes');
setExpiryCookie('refreshTokenId', data.refreshTokenId, 30, 'minutes');
setUserObj(data.userObj);
setIsLoggedIn({ login: true, social: false });
navigate('/');
},
onError: (error) => {
if (error.response?.status === 202) {
setToastText({
type: 'error',
text: '잘못된 비밀번호입니다.',
});
} else {
setToastText({
type: 'error',
text: '서버 오류가 발생했습니다.',
});
}
},
},
);
};
React를 이용해 구현한 프론트단 로그인 코드이다. 상태관리를 위해 react query와 recoil을 사용하고 있다. vite를 이용해서 프로젝트를 빌드하고 있기에 환경변수를 가져올 때 vite를 붙이는 것을 볼 수 있다.
import { initializeApp, getApps, cert } from 'firebase-admin/app';
import { getFirestore } from 'firebase-admin/firestore';
import jwt from 'jsonwebtoken';
import dotenv from 'dotenv';
import { createHash } from 'crypto';
dotenv.config();
const firebaseConfig = {
type: 'service_account',
project_id: process.env.VITE_REACT_APP_PROJECT_ID,
private_key_id: process.env.VITE_REACT_APP_PRIVATE_KEY_ID,
private_key: process.env.VITE_REACT_APP_PRIVATE_KEY.replace(/\\n/g, '\n'),
client_email: process.env.VITE_REACT_APP_CLIENT_EMAIL,
client_id: process.env.VITE_REACT_APP_CLIENT_ID,
auth_uri: process.env.VITE_REACT_APP_AUTH_URI,
token_uri: process.env.VITE_REACT_APP_TOKEN_URI,
auth_provider_x509_cert_url:
process.env.VITE_REACT_APP_AUTH_PROVIDER_X509_CERT_URL,
client_x509_cert_url: process.env.VITE_REACT_APP_CLIENT_X509_CERT_URL,
universe_domain: process.env.VITE_REACT_APP_UNIVERSE_DOMAIN,
};
// Firebase 앱 초기화
if (!getApps().length) {
initializeApp({
credential: cert(firebaseConfig),
});
}
const JWT_SECRET = process.env.VITE_REACT_APP_JWT_SECRET;
const REFRESH_SECRET = process.env.VITE_REACT_APP_REFRESH_SECRET;
// Netlify 함수 정의
export async function handler(event) {
// POST 요청이 아닐 경우 처리하지 않음
if (event.httpMethod !== 'POST') {
return { statusCode: 405, body: 'Method Not Allowed' };
}
try {
const db = getFirestore();
const body = JSON.parse(event.body);
const { email, id, password } = body;
let query;
let user;
if (email) {
query = db.collection('users').where('email', '==', email);
} else {
query = db.collection('users').where('id', '==', id);
}
const snapshot = await query.get();
const hashedPassword = createHash('sha256')
.update(password + process.env.VITE_REACT_APP_BACK_HASH)
.digest('hex');
if (!snapshot.empty) {
user = snapshot.docs[0].data();
}
if (!user || user.password !== hashedPassword) {
return { statusCode: 202, body: '잘못된 비밀번호입니다.' };
}
const accessToken = jwt.sign(
{ userId: user.id, userEmail: user.email },
JWT_SECRET,
{ expiresIn: '5m' },
);
const refreshToken = jwt.sign(
{ userId: user.id, userEmail: user.email },
REFRESH_SECRET,
{ expiresIn: '30m' },
);
const refreshTokenDoc = await db
.collection('refreshTokens')
.add({ token: refreshToken });
return {
statusCode: 200,
body: JSON.stringify({
accessToken,
refreshTokenId: refreshTokenDoc.id,
userEmail: user.email,
userObj: { ...user },
}),
headers: { 'Content-Type': 'application/json' },
};
} catch (error) {
console.error(error);
return { statusCode: 500, body: '서버 오류가 발생했습니다.' };
}
}
js를 이용해 구현한 서버단 로그인 코드이다. netlify function을 이용해 구현하였고 netlify function은 aws lambda를 이용한다. js를 이용해 구현하였기에 handler부분만 수정하면 express로 구현할 수도 있다.
개인 프로젝트이기에 서버를 두개를 띄우는 것은 부담스러워서 netlify function을 이용하였고 db는 무료이고 no sql이라 간편한 firebase를 사용하였다. 실제 프로젝트를 런칭한다면 유저 id와 비밀번호는 구조화 되어 있기에 sql database에 그리고 refresh token은 비구조화되어 있고 수평적 확장에 유연한 no sql database에 저장할 것 같지만 개인 프로젝트이기에 우선 no sql database에 모두 저장하였다.
위의 방법들로 만든 결과물이다. 로그인 후 새로고침하여도 로그인 상태가 유지되는 것을 볼 수 있다.
Conclusion
JWT를 이용해서 자동 로그인을 구현하는 방법에 대해 알아보았다. flow diagram부터 실제 코드까지 살펴봄으로써 원리와 근거 그리고 구현 방법에 대해 알아 보았다. 보안과 사용자 경험은 서로 trade off관계가 있기에 이 글을 읽는 사람들이 어떤 방법을 이용해 어떻게 구현할지에 대해 약간의 도움이라도 되었으면 한다.
'React' 카테고리의 다른 글
React lifecycle 이해하기 (0) | 2024.07.10 |
---|---|
SPA와 MPA의 차이, 작동 원리에 대해서 (feat CSR, SSR) (0) | 2024.07.04 |
동적 라우팅을 이용한 게시글 별로 달라지는 URL 구현 (0) | 2023.08.06 |
Useeffect hook의 리턴구문 실행 조건 (0) | 2023.08.06 |
React로 Modal 만들기(with React-router-dom v6) (1) | 2023.07.26 |