기존에는 JWT를 이용해 Access Token을 생성한 후 쿠키에 저장하여 인증하는 방식으로 로그인 및 Todo 관련 API를 Protected Route 로 처리하였습니다.
하지만 정보를 찾아본 결과 쿠키 기반 인증 방식에는 보안 취약점이 있기 때문에 이를 개선하고자 토큰 기반 인증 방식으로 변경하였습니다.
쿠키 기반 인증의 보안 문제
- 쿠키는 오랜 시간 유지될 수 있음 → 토큰이 탈취되면 장기간 악용될 가능성이 있음
- 자바스크립트로 쿠키 접근 가능 → XSS(Cross-Site Scripting) 공격에 취약함
- CSRF(Cross-Site Request Forgery) 공격 가능 → 악성 사이트에서 사용자의 인증된 요청을 위장할 수 있음
이러한 이유로 보안상 쿠키 기반 인증은 많이 사용되지 않는 방식이라고 합니다.
토큰 기반 인증 방식으로 변경
토큰 기반 인증으로 변경하기 위해 여러 가지 방식을 조사한 결과, Access Token을 브라우저의 Local Storage나 Session Storage에 저장하는 것도 보안에 취약하다는 점을 알게 되었습니다.
1. Access Token을 Local Storage / Session Storage에 저장하면?
- XSS(Cross-Site Scripting) 공격에 취약함
→ 악성 스크립트가 브라우저에서 실행되면 저장된 토큰을 탈취할 수 있습니다. - Local Storage는 브라우저를 껐다 켜도 유지됨
→ 해커가 한 번 탈취하면 장기간 사용할 수 있습니다.
2. 가장 안전한 방식: Access Token을 메모리(Private 변수)에 저장
이러한 보안 문제를 방지하기 위해 Access Token은 클라이언트의 메모리(자바스크립트의 Private 변수)에 저장하는 것이 가장 안전하다고 판단하였습니다.
메모리(Private 변수) 저장의 장점
- XSS 공격으로 토큰을 탈취할 수 없습니다.
- CSRF 공격 위험이 없습니다.
하지만 메모리에 저장된 토큰은 새로고침 시 사라지는 단점이 있습니다.
이를 해결하기 위해 Refresh Token을 이용해 Access Token을 갱신하는 API를 추가하였습니다.
3. Refresh Token을 활용한 로그인 유지
- Access Token은 클라이언트 메모리에 저장함
- Refresh Token은 HTTPOnly 쿠키에 저장함 (클라이언트에서 접근 불가)
- Access Token이 만료되거나 새로고침하면, Refresh Token을 이용해 새로운 Access Token을 발급함
우선, 서버와 클라이언트 측 모두에서 해당 방식으로 구현해 본 적이 없었기 때문에 전체적인 흐름을 먼저 파악하였습니다.
- 로그인 시
- Access Token(짧은 유효기간)과 Refresh Token(긴 유효기간)을 함께 발급
- Access Token은 클라이언트 측에서 zustand를 이용해 메모리(Private 변수)에 저장
- Refresh Token은 서버 측에서 HTTPOnly 쿠키에 저장
- API 요청 시
- 요청할 때마다 zustand에 저장된 Access Token을 Authorization 헤더에 포함하여 전송
- Access Token 만료 시
- Access Token이 없거나 만료되면, Refresh Token을 이용해 새로운 Access Token을 발급
- 새로운 Access Token을 받아 zustand 상태를 갱신하여 인증 유지
- 새로고침 시
- zustand에 저장된 Access Token은 사라짐
- Refresh Token을 이용해 자동으로 새로운 Access Token을 발급 및 저장하여 로그인 상태 유지
서버 측 변경 및 추가점
1. 로그인 API 수정
기존 로그인 API에서는 JWT를 생성한 후 쿠키에 저장하는 방식이었지만, 토큰 기반 인증 방식으로 변경하기 위해 Access Token과 Refresh Token을 함께 발급하는 방식으로 수정하였습니다.
변경된 로그인 API 동작 방식
- 로그인 요청이 들어오면, 이메일과 비밀번호를 확인합니다.
- 유효한 사용자인 경우 Access Token과 Refresh Token을 생성합니다.
- Refresh Token은 HTTPOnly 쿠키에 저장하여 보안성을 높입니다.
- Access Token은 클라이언트에서 활용할 수 있도록 JSON 형태로 응답합니다.
export async function login(req, res, next) {
try {
const { email, password } = req.body;
if (!email) return next(createError(400, '이메일을 입력해주세요.'));
if (!password) return next(createError(400, '비밀번호를 입력해주세요.'));
await connectToDB();
const user = await User.findOne({ email });
if (!user) return next(createError(400, '이메일 또는 비밀번호가 올바르지 않습니다.'));
const isPasswordCorrect = await bcrypt.compare(password, user.password);
if (!isPasswordCorrect) return next(createError(400, '이메일 또는 비밀번호가 올바르지 않습니다.'));
const accessToken = generateAccessToken(user._id);
const refreshToken = generateRefreshToken(user._id);
res.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'None',
maxAge: 24 * 60 * 60 * 1000,
});
res.json({
_id: user.id,
email: user.email,
access_token: accessToken,
});
} catch (error) {
next(error);
}
}
const generateAccessToken = (id) => {
return jwt.sign({ id }, process.env.ACCESS_TOKEN_SECRET, {
expiresIn: '15m',
});
};
const generateRefreshToken = (id) => {
return jwt.sign({ id }, process.env.REFRESH_TOKEN_SECRET, {
expiresIn: '1d',
});
};
2. 로그아웃 API 수정
로그아웃 API는 기존에 access_token을 clearCookie 하던 방식에서, refresh_token을 clearCookie 하도록 변경되었습니다.
export async function logout(req, res, next) {
res.clearCookie('refresh_token', {
httpOnly: true,
secure: true,
sameSite: 'None',
})
.status(200)
.json({ message: '로그아웃 완료' });
}
3. Access Token 재발급 기능 추가
기존에는 Access Token이 만료되면 별도의 처리 없이 로그아웃되었지만, 이제 Refresh Token을 이용해 새로운 Access Token을 발급할 수 있도록 API를 추가하였습니다.
HTTPOnly 쿠키에 저장된 refresh_token을 이용하여 새로운 Access Token을 재발급할 수 있습니다.
export async function refreshAccessToken(req, res, next) {
try {
const refreshToken = req.cookies.refresh_token;
if (!refreshToken) {
return next(createError(401, 'Refresh Token이 없습니다. 다시 로그인해주세요.'));
}
jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET, async (err, decoded) => {
if (err) {
console.error("Refresh Token 검증 실패:", err.message);
return next(createError(403, '유효하지 않은 Refresh Token입니다. 다시 로그인해주세요.'));
}
const user = await User.findById(decoded.id);
if (!user) {
console.error("사용자 찾기 실패");
return next(createError(404, '해당 사용자를 찾을 수 없습니다. 다시 로그인해주세요.'));
}
const newAccessToken = generateAccessToken(user.id);
console.log("새 Access Token 발급:", newAccessToken);
res.json({ access_token: newAccessToken });
});
} catch (error) {
console.error("refreshAccessToken 처리 중 오류 발생:", error);
next(error);
}
}
4. Auth 라우트 수정
기존 auth 라우트에 refreshAccessToken 엔드포인트를 추가하였습니다.
이제 클라이언트는 이 엔드포인트를 통해 새로운 Access Token을 발급받을 수 있습니다.
import express from 'express';
import { register, login, logout, refreshAccessToken } from '../controllers/auth.js';
const router = express.Router();
router.post('/login', login);
router.post('/register', register);
router.post('/logout', logout);
router.get('/refresh-token', refreshAccessToken); // 추가
export default router;
5. Protected Route 인증 방식 변경
기존에는 req.cookies.access_token을 확인했지만,
이제 요청 헤더의 Authorization에 Bearer {access_token}을 포함하여 인증하는 방식으로 변경하였습니다.
import jwt from 'jsonwebtoken';
import { createError } from './error.js';
import User from '../models/userModel.js';
export const verifyToken = async (req, res, next) => {
let token;
if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) {
try {
token = req.headers.authorization.split(' ')[1];
const decoded = jwt.verify(token, process.env.ACCESS_TOKEN_SECRET);
req.user = await User.findById(decoded.id).select('-password');
next();
} catch (error) {
return next(createError(401, '인증되지 않은 사용자입니다.'));
}
} else {
return next(createError(401, '유효하지 않은 토큰입니다.'));
}
};
- Bearer 접두어를 확인하여 올바른 형식의 토큰인지 검사.
- jwt.verify()를 사용하여 토큰을 해독하고, 인증된 사용자의 정보를 req.user에 저장.
이제 Protected Route에 대한 API 요청 시 Authorization 헤더에 Bearer {access_token}을 포함해야 합니다.
// 예제
fetch('http://localhost:3000/api/todos', {
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
credentials: 'include',
});
클라이언트 측 변경 및 추가점
토큰 기반 인증 방식으로 변경하면서 클라이언트 측에서도 Access Token 및 Refresh Token을 효율적으로 관리하고 인증을 유지하기 위해 여러 가지 기능을 추가 및 수정하였습니다.
1. Zustand를 활용한 Access Token 상태 관리
Access Token을 메모리에 저장하여 보안성을 높이고, 전역 상태에서 쉽게 관리할 수 있도록 Zustand를 활용하였습니다.
import { create } from 'zustand';
import { refreshAccessToken } from '@/utils/authService';
type AuthState = {
accessToken: string | null;
setAccessToken: (token: string | null) => void;
logout: () => void;
refreshToken: () => Promise<void>;
};
const useAuthStore = create<AuthState>((set) => ({
accessToken: null,
setAccessToken: (token) => set({ accessToken: token }),
logout: () => {
set({ accessToken: null });
},
refreshToken: async () => {
const newToken = await refreshAccessToken();
if (newToken) {
set({ accessToken: newToken });
} else {
set({ accessToken: null });
}
},
}));
export default useAuthStore;
- Access Token을 전역 상태로 관리
- 로그아웃 시 상태 초기화
- Refresh Token을 이용해 Access Token을 자동으로 갱신
2. Refresh Token을 이용한 자동 Access Token 재발급
Access Token은 메모리에 저장되기 때문에 새로고침하면 사라지는 문제가 발생합니다.
이를 해결하기 위해, 클라이언트에서 Refresh Token을 이용해 새로운 Access Token을 자동으로 재발급하는 기능을 추가하였습니다.
import useAuthStore from '../store/authStore';
export async function refreshAccessToken() {
try {
const res = await fetch('http://localhost:3000/api/user/refresh-token', {
method: 'GET',
credentials: 'include',
});
if (!res.ok) {
console.log("Refresh Token 만료됨, 다시 로그인 필요");
useAuthStore.getState().setAccessToken(null);
return null;
}
const data = await res.json();
console.log(data.access_token);
useAuthStore.getState().setAccessToken(data.access_token);
return data.access_token;
} catch (error) {
console.error("토큰 갱신 중 오류 발생:", error);
return null;
}
}
- 새로운 Access Token을 발급받고 Zustand 상태에 저장
- Refresh Token이 만료되면 로그아웃 처리
3. 로그인 시 Access Token 저장
로그인 시 서버에서 받은 Access Token을 Zustand 상태에 저장하도록 코드 한 줄 추가하였습니다.
export async function login(
previousState: RegisterState,
formData: FormData
): Promise<RegisterState> {
try {
const email = formData.get('email');
const password = formData.get('password');
console.log({ email, password });
const res = await fetch('http://localhost:3000/api/user/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (data?.error) {
return { ...previousState, error: data.error };
}
// 추가 (Access Token을 Zustand 상태에 저장)
if (data.access_token) {
useAuthStore.getState().setAccessToken(data.access_token);
}
return { error: null, success: data };
} catch (error) {
return { ...previousState, error: 'Something is wrong' };
}
}
4. 새로고침 시 로그인 유지 (AuthInitializer 적용)
Access Token은 메모리에 저장되므로 새로고침하면 사라지는 문제가 있습니다.
이를 해결하기 위해 새로고침 시 자동으로 Refresh Token을 이용하여 Access Token을 갱신하는 로직을 추가하였습니다.
Access Token이 Todo 페이지에서만 필요하기 때문에 Auth 페이지에서 불필요한 API 호출하는 것을 방지하기 위해서 별도의 컴포넌트로 분리하여 Todo 페이지에서만 적용되도록 구성했습니다.
import { useEffect } from 'react';
import useAuthStore from '@/store/authStore';
export default function AuthInitializer() {
const refreshToken = useAuthStore((state) => state.refreshToken);
useEffect(() => {
console.log('Refresh Token 실행');
refreshToken();
}, []);
return null;
}
5. API 요청 시 자동으로 Access Token 포함 (fetchWithAuth 적용)
매번 API 요청 시 Authorization 헤더에 Access Token을 추가해야 하는 불편함을 해결하기 위해, 자동으로 토큰을 포함하는 유틸 함수(fetchWithAuth) 를 생성하였습니다.
Access Token이 만료될 경우 Refresh Token을 사용하여 새로운 Access Token을 발급받도록 구현하였습니다.
import useAuthStore from '../store/authStore';
import { refreshAccessToken } from './authService';
export async function fetchWithAuth(url: string, options: RequestInit = {}) {
let token = useAuthStore.getState().accessToken;
const res = await fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${token}`,
},
});
if (res.status === 401) {
console.log('Access Token 만료됨, 새로 발급 요청...');
token = await refreshAccessToken();
if (!token) return null;
return fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${token}`,
},
});
}
return res.json();
}
6. Protected Route 적용
Access Token이 만료된 경우 자동으로 Refresh Token을 이용하여 새로운 Access Token을 재발급하도록 처리하였습니다.
만약 Refresh Token도 만료되었거나 유효하지 않다면, 로그인 페이지로 리디렉션합니다.
또한, replace 옵션을 사용하여 뒤로 가기를 통해 보호된 페이지로 다시 돌아갈 수 없도록 처리하였습니다.
import useAuthStore from '@/store/authStore';
import { refreshAccessToken } from '@/utils/authService';
import { useEffect, useState } from 'react';
import toast from 'react-hot-toast';
import { Navigate, Outlet } from 'react-router-dom';
export default function ProtectedRoute() {
const accessToken = useAuthStore((state) => state.accessToken);
const setAccessToken = useAuthStore((state) => state.setAccessToken);
const [loading, setLoading] = useState(true);
useEffect(() => {
const checkAuth = async () => {
if (!accessToken) {
const newToken = await refreshAccessToken();
if (newToken) {
setAccessToken(newToken);
} else {
toast.error('로그인이 필요합니다.');
}
}
setLoading(false);
};
checkAuth();
}, [accessToken]);
if (loading) return;
if (!accessToken) {
return <Navigate to='/' replace />;
}
return <Outlet />;
}
유사하게 ProtectedAuthPage도 만들어서 만약 Access Token이 있거나 Refresh Token이 있다면 회원가입/로그인 페이지의 접근을 막고 자동을 /todo 페이지로 리다이렉트되게 하였습니다.
import useAuthStore from '@/store/authStore';
import { refreshAccessToken } from '@/utils/authService';
import { useEffect, useState } from 'react';
import { Navigate, Outlet } from 'react-router-dom';
export default function ProtectedAuthPage() {
const accessToken = useAuthStore((state) => state.accessToken);
const setAccessToken = useAuthStore((state) => state.setAccessToken);
const [loading, setLoading] = useState(true);
useEffect(() => {
const checkAuth = async () => {
if (!accessToken) {
const newToken = await refreshAccessToken();
if (newToken) {
setAccessToken(newToken);
}
}
setLoading(false);
};
checkAuth();
}, [accessToken]);
if (loading) return;
if (accessToken) {
return <Navigate to='/todo' replace />;
}
return <Outlet />;
}
7. 최종적으로 적용된 라우터 구조
Todo 페이지는 로그인한 사용자만 접근할 수 있도록 ProtectedRoute를 적용하였습니다.
로그인/회원가입 페이지는 로그인한 사용자가 접근하지 못하게 적용하였습니다.
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import Todos from './pages/Todos.tsx';
import AuthPage from './pages/AuthPage.tsx';
import { Toaster } from 'react-hot-toast';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import ProtectedRoute from './components/routes/ProtectedRoute.tsx';
import ProtectedAuthPage from './components/routes/ProtectedAuthPage.tsx';
const router = createBrowserRouter([
{
path: '/',
element: <ProtectedAuthPage />,
children: [{ path: '/', element: <AuthPage /> }],
},
{
path: '/todo',
element: <ProtectedRoute />,
children: [{ path: '/todo', element: <Todos /> }],
},
]);
createRoot(document.getElementById('root')!).render(
<StrictMode>
<Toaster position='top-center' />
<RouterProvider router={router} />
</StrictMode>
);
8. Todo 페이지에서 적용
기존 fetch 대신, 자동으로 Access Token을 포함하는 fetchWithAuth를 사용하도록 수정하였습니다.
ProtectedRoute로 이미 보호된 페이지지만, 혹시 몰라 Access Token이 없으면 API 요청을 보내지 않도록 null 처리하였습니다.
새로고침 시 UI를 최신 상태로 유지하기 위해 useEffect를 사용하여 Access Token이 변경될 때 mutate()를 호출하도록 설정하였습니다.
const fetcher = (url:string, options: RequestInit = {}) => {
return fetchWithAuth(url, {
method: options.method || 'GET',
headers: { 'Content-Type': 'application/json' },
body: options.body,
});
};
export default function Todos() {
const { accessToken } = useAuthStore();
const { data, error, mutate, isLoading } = useSWR<Todo[]>(
accessToken ? 'http://localhost:3000/api/todos' : null,
fetcher
);
useEffect(() => {
if (accessToken && mutate) {
mutate();
}
}, [accessToken]);
mutate()는 SWR에서 캐시된 데이터를 갱신하는 함수이며, 이 기능에 대한 설명은 추후 별도의 포스팅에서 다룰 예정입니다.
'Node.js > Todo' 카테고리의 다른 글
모바일 환경에서 Refresh Token 저장 방식 변경 (Third-Party Cookie 이슈) (0) | 2025.03.03 |
---|---|
Google 로그인 구현 (OAuth 2.0 + JWT + Zustand) (0) | 2025.02.27 |
회원가입 및 로그인 기능 구현 (0) | 2025.02.20 |
Todo CRUD API 구현하기 (0) | 2025.02.19 |
쿠키 기반 JWT 인증 및 로그인/로그아웃 기능 (0) | 2025.02.18 |