fix: 보안 강화, 리프레시 토큰 도입, 연계 오류 수정
- api/client: 리프레시 토큰 자동 갱신 (401 시 재시도, 동시 요청 dedup) - api/client: 204 No Content 처리 추가 (res.json() 크래시 방지) - api/client: 서버 에러 메시지 body에서 파싱하여 전달 - api/auth: logout 함수 추가 (서버 세션 삭제), 미사용 refreshToken 함수 제거 - AuthContext: 로그인 시 refreshToken 저장, 로그아웃 시 서버 호출 분리 - AuthContext: 401 이벤트는 로컬 세션만 정리 (clearSession 분리) - DownloadSection: 게임 시작 토큰을 localStorage에서 직접 읽기 (스테일 방지) - DownloadAdmin: XHR 401 처리, Content-Type 헤더 추가 - AnnouncementAdmin: 등록/수정/삭제 에러 상태 표시 추가 - AnnouncementBoard: API 실패 시 에러 메시지 표시 - UserAdmin: 권한 변경/삭제 에러 처리 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -13,3 +13,8 @@ export async function register(username, password) {
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
return apiFetch('/api/auth/logout', { method: 'POST' });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,84 @@
|
||||
const BASE = import.meta.env.VITE_API_BASE_URL || '';
|
||||
|
||||
// 동시 401 발생 시 refresh를 한 번만 실행하기 위한 Promise 공유
|
||||
let refreshingPromise = null;
|
||||
|
||||
async function tryRefresh() {
|
||||
if (refreshingPromise) return refreshingPromise;
|
||||
|
||||
refreshingPromise = (async () => {
|
||||
const rt = localStorage.getItem('refreshToken');
|
||||
if (!rt) throw new Error('no_refresh_token');
|
||||
|
||||
const res = await fetch(BASE + '/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refreshToken: rt }),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('refresh_failed');
|
||||
|
||||
const data = await res.json();
|
||||
localStorage.setItem('token', data.token);
|
||||
localStorage.setItem('refreshToken', data.refreshToken);
|
||||
return data.token;
|
||||
})().finally(() => {
|
||||
refreshingPromise = null;
|
||||
});
|
||||
|
||||
return refreshingPromise;
|
||||
}
|
||||
|
||||
async function doFetch(path, options, token) {
|
||||
const headers = { 'Content-Type': 'application/json', ...options.headers };
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
return fetch(BASE + path, { ...options, headers });
|
||||
}
|
||||
|
||||
async function parseError(res) {
|
||||
let message = res.statusText;
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (body.error) message = body.error;
|
||||
} catch {}
|
||||
const err = new Error(message);
|
||||
err.status = res.status;
|
||||
return err;
|
||||
}
|
||||
|
||||
// 204 No Content는 null 반환, 나머지는 JSON 파싱
|
||||
async function parseResponse(res) {
|
||||
if (res.status === 204) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function apiFetch(path, options = {}) {
|
||||
const token = localStorage.getItem('token');
|
||||
const headers = { 'Content-Type': 'application/json', ...options.headers };
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const res = await doFetch(path, options, token);
|
||||
|
||||
const res = await fetch(BASE + path, { ...options, headers });
|
||||
if (res.status === 401) {
|
||||
window.dispatchEvent(new Event('auth:unauthorized'));
|
||||
try {
|
||||
const newToken = await tryRefresh();
|
||||
// 새 토큰으로 원래 요청 재시도
|
||||
const retryRes = await doFetch(path, options, newToken);
|
||||
if (retryRes.status === 401) {
|
||||
window.dispatchEvent(new Event('auth:unauthorized'));
|
||||
throw await parseError(retryRes);
|
||||
}
|
||||
if (!retryRes.ok) throw await parseError(retryRes);
|
||||
return parseResponse(retryRes);
|
||||
} catch (e) {
|
||||
// refresh 자체 실패 → 로그아웃
|
||||
if (e.message === 'no_refresh_token' || e.message === 'refresh_failed') {
|
||||
window.dispatchEvent(new Event('auth:unauthorized'));
|
||||
const err = new Error('인증이 필요합니다');
|
||||
err.status = 401;
|
||||
throw err;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = new Error(res.statusText);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
return res.json();
|
||||
|
||||
if (!res.ok) throw await parseError(res);
|
||||
return parseResponse(res);
|
||||
}
|
||||
|
||||
@@ -5,14 +5,18 @@ import './AnnouncementBoard.css';
|
||||
export default function AnnouncementBoard() {
|
||||
const [list, setList] = useState([]);
|
||||
const [expanded, setExpanded] = useState(null);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getAnnouncements().then(setList);
|
||||
getAnnouncements()
|
||||
.then(setList)
|
||||
.catch(() => setError(true));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="announcement-board">
|
||||
<h2 className="announcement-heading">공지사항</h2>
|
||||
{error && <p className="announcement-error">공지사항을 불러오지 못했습니다.</p>}
|
||||
<ul className="announcement-list">
|
||||
{list.map((item) => (
|
||||
<li key={item.id} className="announcement-item">
|
||||
|
||||
@@ -22,7 +22,10 @@ export default function DownloadSection() {
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = 'a301://launch?token=' + user.token;
|
||||
// user.token은 로그인 시점 값으로 만료됐을 수 있으므로
|
||||
// tryRefresh가 갱신한 최신 토큰을 localStorage에서 직접 읽음
|
||||
const token = localStorage.getItem('token');
|
||||
window.location.href = 'a301://launch?token=' + token;
|
||||
};
|
||||
|
||||
const handleDownloadLauncher = () => {
|
||||
|
||||
@@ -8,14 +8,16 @@ export default function AnnouncementAdmin() {
|
||||
const [form, setForm] = useState({ title: '', content: '' });
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const load = () => getAnnouncements().then(setList);
|
||||
const load = () => getAnnouncements().then(setList).catch(() => {});
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!form.title || !form.content) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
if (editingId) {
|
||||
await apiFetch(`/api/announcements/${editingId}`, {
|
||||
@@ -31,6 +33,8 @@ export default function AnnouncementAdmin() {
|
||||
setForm({ title: '', content: '' });
|
||||
setEditingId(null);
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err.message || '처리에 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -39,17 +43,23 @@ export default function AnnouncementAdmin() {
|
||||
const handleEdit = (item) => {
|
||||
setEditingId(item.id);
|
||||
setForm({ title: item.title, content: item.content });
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('삭제하시겠습니까?')) return;
|
||||
await apiFetch(`/api/announcements/${id}`, { method: 'DELETE' });
|
||||
load();
|
||||
try {
|
||||
await apiFetch(`/api/announcements/${id}`, { method: 'DELETE' });
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err.message || '삭제에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditingId(null);
|
||||
setForm({ title: '', content: '' });
|
||||
setError('');
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -70,6 +80,7 @@ export default function AnnouncementAdmin() {
|
||||
value={form.content}
|
||||
onChange={(e) => setForm({ ...form, content: e.target.value })}
|
||||
/>
|
||||
{error && <p className="admin-error">{error}</p>}
|
||||
<div className="admin-form-actions">
|
||||
<button className="btn-admin-primary" type="submit" disabled={loading}>
|
||||
{editingId ? '수정 완료' : '공지 등록'}
|
||||
|
||||
@@ -31,6 +31,10 @@ function UploadForm({ title, hint, accept, endpoint, onSuccess }) {
|
||||
|
||||
xhr.onload = () => {
|
||||
setUploading(false);
|
||||
if (xhr.status === 401) {
|
||||
window.dispatchEvent(new Event('auth:unauthorized'));
|
||||
return;
|
||||
}
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
onSuccess(JSON.parse(xhr.responseText));
|
||||
setFile(null);
|
||||
@@ -50,6 +54,7 @@ function UploadForm({ title, hint, accept, endpoint, onSuccess }) {
|
||||
|
||||
xhr.open('POST', `${BASE}${endpoint}?filename=${encodeURIComponent(file.name)}`);
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
|
||||
setUploading(true);
|
||||
setError('');
|
||||
xhr.send(file);
|
||||
|
||||
@@ -5,27 +5,39 @@ import './AdminCommon.css';
|
||||
|
||||
export default function UserAdmin() {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [error, setError] = useState('');
|
||||
const { user: me } = useAuth();
|
||||
|
||||
const load = () => getUsers().then(setUsers);
|
||||
const load = () => getUsers().then(setUsers).catch(() => {});
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const handleRoleToggle = async (u) => {
|
||||
const newRole = u.role === 'admin' ? 'user' : 'admin';
|
||||
if (!confirm(`${u.username}의 권한을 ${newRole}로 변경하시겠습니까?`)) return;
|
||||
await updateUserRole(u.id, newRole);
|
||||
load();
|
||||
setError('');
|
||||
try {
|
||||
await updateUserRole(u.id, newRole);
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err.message || '권한 변경에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (u) => {
|
||||
if (!confirm(`${u.username} 계정을 삭제하시겠습니까?`)) return;
|
||||
await deleteUser(u.id);
|
||||
load();
|
||||
setError('');
|
||||
try {
|
||||
await deleteUser(u.id);
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err.message || '삭제에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-section">
|
||||
<h2 className="admin-section-title">유저 관리</h2>
|
||||
{error && <p className="admin-error">{error}</p>}
|
||||
<ul className="admin-list">
|
||||
{users.map((u) => (
|
||||
<li key={u.id} className="admin-list-item">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { login as apiLogin } from '../api/auth';
|
||||
import { login as apiLogin, logout as apiLogout } from '../api/auth';
|
||||
import { AuthContext } from './authContextValue';
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
@@ -13,22 +13,34 @@ export function AuthProvider({ children }) {
|
||||
const login = useCallback(async (username, password) => {
|
||||
const data = await apiLogin(username, password);
|
||||
localStorage.setItem('token', data.token);
|
||||
localStorage.setItem('refreshToken', data.refreshToken);
|
||||
localStorage.setItem('username', data.username);
|
||||
localStorage.setItem('role', data.role);
|
||||
setUser({ token: data.token, username: data.username, role: data.role });
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
// 로컬 세션만 정리 (토큰 만료·강제 로그아웃 시)
|
||||
const clearSession = useCallback(() => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('refreshToken');
|
||||
localStorage.removeItem('username');
|
||||
localStorage.removeItem('role');
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
// 정상 로그아웃: 서버 세션(액세스 + 리프레시)도 삭제
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await apiLogout();
|
||||
} catch {}
|
||||
clearSession();
|
||||
}, [clearSession]);
|
||||
|
||||
// 401 응답 시 서버 호출 없이 로컬 세션만 정리
|
||||
useEffect(() => {
|
||||
window.addEventListener('auth:unauthorized', logout);
|
||||
return () => window.removeEventListener('auth:unauthorized', logout);
|
||||
}, [logout]);
|
||||
window.addEventListener('auth:unauthorized', clearSession);
|
||||
return () => window.removeEventListener('auth:unauthorized', clearSession);
|
||||
}, [clearSession]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, login, logout }}>
|
||||
@@ -36,4 +48,3 @@ export function AuthProvider({ children }) {
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user