본 글에서는 최근 진행한 이력서 포트폴리오 프로젝트의 Admin 페이지 구축 경험을 공유하고자 합니다. 특히 상태 관리와 보안 강화에 초점을 맞추어, Zustand를 이용한 상태 관리, Flask 미들웨어를 통한 토큰 검증 과정, 그리고 Refresh Token 구현에 대해 상세히 설명하겠습니다.

1. 상태 관리 라이브러리 선택: Zustand
프로젝트에서 상태 관리 라이브러리로 Zustand를 선택한 이유는 다음과 같습니다:
- 간결성과 사용 편의성:
- Zustand는 API가 간단하고 직관적이어서 학습 곡선이 낮습니다.
- 보일러플레이트 코드가 적어 빠르게 구현할 수 있습니다.
- 성능:
- 작은 번들 크기로 앱의 성능에 미치는 영향이 적습니다.
- 불필요한 리렌더링을 최소화하여 효율적입니다.
- Redux 대비 장점:
- Redux에 비해 설정이 간단하고 코드량이 적습니다.
- 복잡한 개념(예: 리듀서, 액션)을 배우지 않아도 됩니다.
2. Zustand를 이용한 상태 관리 구현
Zustand를 사용하여 인증 상태를 관리하는 스토어를 다음과 같이 구현하였습니다:
// authStore.js
import create from 'zustand';
import cookies from 'js-cookie';
const useAuthStore = create((set) => ({
token: null,
authorized: false,
setToken: (newToken) => {
set({ token: newToken, authorized: !!newToken });
if (!newToken) {
cookies.remove('refresh_token');
}
},
clearToken: () => {
set({ token: null, authorized: false });
cookies.remove('refresh_token');
},
setAuthorized: (isAuthorized) => set({ authorized: isAuthorized }),
}));
export default useAuthStore;
이 스토어는 토큰 관리와 인증 상태를 효과적으로 관리합니다. setToken
함수는 새 토큰을 설정하고 인증 상태를 업데이트하며, clearToken
함수는 토큰과 인증 상태를 초기화합니다.
3. Flask 미들웨어를 통한 토큰 검증
백엔드에서는 Flask 미들웨어를 사용하여 토큰을 검증하는 로직을 구현하였습니다. 이를 통해 인증이 필요한 엔드포인트에 대한 접근을 제어하도록 구현하였습니다. (인증이 필요한 엔드포인트 접근 시 자동으로 토큰을 검증할 수 있도록 구현 => 토큰을 발급 및 제거하는 엔드인트인 /login, /refresh, /logout 엔드포인트는 해당 토큰 검증 코드가 수행되지 않도록 구현)
@admin_auth_bp.before_request
def check_jwt():
if request.method == 'OPTIONS':
return # Skip JWT validation for preflight requests
exempt_routes = ['admin_auth_bp.login', 'admin_auth_bp.refresh', 'admin_auth_bp.logout']
if request.endpoint in exempt_routes:
return # Skip JWT validation for specific endpoints
token = None
auth_header = request.headers.get('Authorization')
if auth_header and auth_header.startswith('Bearer '):
token = auth_header.split(' ')[1]
else:
token = request.cookies.get('token')
if not token:
return jsonify({"status": "실패", "message": "토큰이 없습니다"}), 401
decoded = verify_token(token, JWT_SECRET_KEY)
if not decoded:
return jsonify({"status": "실패", "message": "유효하지 않거나 만료된 토큰입니다"}), 401
request.user = decoded['username']
이 미들웨어는 모든 요청에 대해 JWT 토큰을 검증합니다. 특정 라우트(로그인, 토큰 갱신, 로그아웃)에 대해서는 검증을 건너뛰며, 유효한 토큰이 없는 경우 401 Unauthorized 응답을 반환합니다.
4. 클라이언트 측 인증 구현
admin.js
클라이언트 측에서는 Zustand 스토어와 함께 다음과 같은 로그인 로직을 구현하였습니다:
import React, { useState } from 'react';
import { useRouter } from 'next/router';
import styles from '../styles/Admin.module.css';
import axios from 'axios';
import useAuthStore from '../stores/authStore';
function Admin() {
const [id, setId] = useState('');
const [password, setPassword] = useState('');
const setToken = useAuthStore((state) => state.setToken);
const router = useRouter();
const handleSubmit = async (e) => {
e.preventDefault();
const requestData = {
id: id,
pw: password,
};
try {
const response = await axios.post('https://api.jongwook.xyz/auth/login', requestData, { withCredentials: true });
if (response.data.status === "성공") {
setToken(response.data.access_token); // Store JWT token in Zustand
router.push('/management');
} else {
alert("Login failed!");
}
} catch (error) {
console.error('Error during login:', error);
alert("Login failed!");
}
};
return (
<div className={styles.container}>
<div className={styles.loginBox}>
<h2 className={styles.title}>Admin</h2>
<form className={styles.form} onSubmit={handleSubmit}>
<div className={styles.inputGroup}>
<label htmlFor="id" className={styles.label}>
ID
</label>
<input
type="text"
id="id"
value={id}
onChange={(e) => setId(e.target.value)}
required
className={styles.input}
/>
</div>
<div className={styles.inputGroup}>
<label htmlFor="password" className={styles.label}>
Password
</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className={styles.input}
/>
</div>
<button type="submit" className={styles.button}>
Login
</button>
</form>
</div>
</div>
);
}
export default Admin;
management.js
관리 페이지에서는 다음과 같은 인증 및 토큰 갱신 로직을 구현하였습니다:
import React, { useEffect, useState } from 'react';
import { useRouter } from 'next/router';
import axios from 'axios';
import useAuthStore from '../stores/authStore';
function Management() {
const router = useRouter();
const [loading, setLoading] = useState(true);
const { setAuthorized, clearToken, token, setToken } = useAuthStore((state) => ({
setAuthorized: state.setAuthorized,
clearToken: state.clearToken,
token: state.token,
setToken: state.setToken
}));
useEffect(() => {
const initializeAuth = async () => {
if (!token) {
await refreshAccessToken();
} else {
await checkAuthorization();
}
setLoading(false);
};
const checkAuthorization = async () => {
try {
const response = await axios.get('https://api.jongwook.xyz/auth/authenticate', {
headers: {
Authorization: `Bearer ${token}`,
},
withCredentials: true,
});
if (response.data.status === "성공") {
setAuthorized(true);
} else {
await refreshAccessToken();
}
} catch (error) {
console.error('Error checking authorization:', error);
await refreshAccessToken();
}
};
const refreshAccessToken = async () => {
try {
const response = await axios.post('https://api.jongwook.xyz/auth/refresh', {}, { withCredentials: true });
if (response.data.status === "성공") {
useAuthStore.getState().setToken(response.data.access_token);
setAuthorized(true);
} else {
clearToken();
router.push('/admin');
}
} catch (error) {
console.error('Error refreshing token:', error);
clearToken();
router.push('/admin');
}
};
initializeAuth();
}, []);
const handleLogout = async () => {
try{
await axios.post('https://api.jongwook.xyz/auth/logout', {}, { withCredentials: true });
clearToken();
router.push('/admin');
}
catch (error) {
console.error('Error logging out:', error);
}
};
if (loading) {
return <div>Loading...</div>;
}
if (!useAuthStore.getState().authorized) {
return <div>Unauthorized access. Redirecting...</div>;
}
return (
<div>
<h1>Management Page</h1>
<p>This page is only accessible to authenticated users.</p>
<button onClick={handleLogout}>Logout</button>
</div>
);
}
export default Management;
5. Refresh Token 구현
서버 측에서는 Refresh Token을 이용한 인증 시스템을 구현하였습니다. 이를 통해 사용자 경험을 개선하고 보안을 강화하였습니다.
로그인 및 Refresh Token 발급
def generate_refresh_token(username):
header = {"alg": "HS256"}
payload = {
"username": username,
"exp": datetime.now(timezone.utc) + timedelta(days=1) # Refresh token expiration set to 1 day
}
token = jwt.encode(header, payload, REFRESH_SECRET_KEY)
return token
@admin_auth_bp.route('/login', methods=['POST'])
def login():
db = connect_mongo()
id = request.json.get('id')
pw = request.json.get('pw')
user = mongo_find_user(db, id, pw)
if user:
access_token = generate_access_token(id)
refresh_token = generate_refresh_token(id)
response = make_response(jsonify({"status": "성공", "access_token": access_token.decode('utf-8')}))
# Set refresh token cookie with appropriate attributes
response.set_cookie(
'refresh_token',
refresh_token.decode('utf-8'),
httponly=True,
secure=True,
samesite='None',
path='/' # Ensure the cookie is sent for all paths in the domain
)
response.headers.add('Access-Control-Allow-Origin', 'https://resume.jongwook.xyz')
response.headers.add('Access-Control-Allow-Credentials', 'true')
return response
else:
return jsonify({"status": "실패"}), 401
Refresh Token을 이용한 Access Token 갱신
@admin_auth_bp.route('/refresh', methods=['POST', 'OPTIONS'])
def refresh():
if request.method == 'OPTIONS':
response = make_response()
response.headers.add('Access-Control-Allow-Origin', 'https://resume.jongwook.xyz')
response.headers.add('Access-Control-Allow-Methods', 'POST, OPTIONS')
response.headers.add('Access-Control-Allow-Headers', 'Content-Type')
response.headers.add('Access-Control-Allow-Credentials', 'true')
return response, 200
refresh_token = request.cookies.get('refresh_token')
if not refresh_token:
return jsonify({"status": "실패", "message": "Refresh token이 없습니다"}), 401
decoded = verify_token(refresh_token, REFRESH_SECRET_KEY)
if not decoded:
return jsonify({"status": "실패", "message": "유효하지 않거나 만료된 refresh token입니다"}), 401
username = decoded['username']
new_access_token = generate_access_token(username)
return jsonify({"status": "성공", "access_token": new_access_token.decode('utf-8')})
이 구현을 통해 Access Token이 만료되었을 때 Refresh Token을 이용하여 새로운 Access Token을 발급받을 수 있습니다. 이는 사용자가 자주 로그인할 필요 없이 장기간 인증 상태를 유지할 수 있게 해줍니다.
6. 트러블슈팅: CORS 이슈 해결
프로젝트 진행 중 CORS(Cross-Origin Resource Sharing) 관련 문제가 발생했습니다. 특히 admin 페이지에서 로그인 시 지속적으로 CORS 에러가 발생하는 것이 확인되었습니다.

문제 상황
네트워크 통신 패킷 확인 결과, 인증 통신 진행 과정에서 OPTIONS 메소드가 설정된 패킷이 보내지고 401 응답값이 반환된 후, POST 요청을 통해 서버 측에 토큰을 요청하는 과정에서 Origin 헤더가 설정되지 않고 요청이 보내져 CORS 에러가 발생했습니다.
원인 분석
이 문제는 authorization 헤더를 사용하여 인증을 처리(커스텀 헤더)하려고 했기 때문에 발생했습니다. 브라우저는 이러한 요청을 "preflighted" 요청으로 간주하고, 실제 요청 전에 OPTIONS 메서드를 사용한 예비 요청을 보냅니다. 서버가 이 예비 요청에 적절히 응답하지 않으면 CORS 에러가 발생합니다.
해결 방법
1. Preflighted Request(OPTIONS) 처리: OPTIONS 메서드에 대한 응답을 명시적으로 처리하여 필요한 CORS 헤더를 포함시켰습니다.
@admin_auth_bp.route('/login', methods=['POST', 'OPTIONS'])
def login():
if request.method == 'OPTIONS':
response = make_response()
response.headers.add('Access-Control-Allow-Origin', 'https://resume.jongwook.xyz')
response.headers.add('Access-Control-Allow-Methods', 'POST, OPTIONS')
response.headers.add('Access-Control-Allow-Headers', 'Content-Type, Authorization')
response.headers.add('Access-Control-Allow-Credentials', 'true')
return response
# 기존의 로그인 로직...
결과
이러한 변경 사항을 적용한 후, CORS 관련 오류가 해결되었고 클라이언트와 서버 간의 안전한 통신이 가능해졌습니다. 특히 Refresh Token을 활용한 인증 시스템이 원활하게 작동하게 되었습니다.
⭐️ 조금 더 알아본 결과 현재 포스팅에서 진행한 내용은 Custom Header(Authorization)를 사용하여 Access Token이 Zustand Store 에 저장되어 인증이 이루어지는 형태로 되어 있는데 이는 결국 프론트측에 Token 을 저장하는 방식이기 때문에 보안상 취약할 수 있음 => 이와 같은 이유로 일반적으로 프론트엔드와 백엔드가 같은 Origin을 사용하는 경우 더 안전한 방식은 HTTP Only 옵션을 적용하여 Token을 쿠케로 반환하는 방식이고 해당 방식이 보편적으로 더 안전하게 사용되는 인증 방식임
=> 위와 같은 이유로 Access Token 도 Refresh Token 발급 방식과 동일하게 옵션을 적용하여 cookie 를 통해 발급하는 형식으로 수정 예정..
'Project > resume' 카테고리의 다른 글
Flask와 Next.js를 이용한 인증 시스템 구현 (JWT, Authlib) (2) | 2024.08.29 |
---|---|
AI Chatbot 응답 개선 : 프론트엔드 코드 최적화 (0) | 2024.08.24 |
MongoDB, Flask, Next.js 를 활용한 동적 블로그 컴포넌트 구현 (0) | 2024.08.17 |
AI Chatbot 추천 질문 시스템 구현 / OpenAI Assistant Intruction 을 활용한 AI 응답 지정 (0) | 2024.08.17 |
[Github Actions] CI/CD 파이프라인 구축 / Github Actions 사용법 / Snyk Application CI 파이프라인 통합 / EC2 배포 자동화 (0) | 2024.08.13 |