From 009c428d3701c846d3652875f246cee6b920c4aa Mon Sep 17 00:00:00 2001 From: caadiq Date: Thu, 1 Jan 2026 18:01:42 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 관리자 로그인 시스템 (JWT, 30일 만료) - admin_users 테이블 및 bcrypt 암호화 - 로그인 페이지 (/admin) - 대시보드 (/admin/dashboard) - 메뉴: 멤버, 앨범, 일정 관리 --- .env | 1 + backend/package-lock.json | 154 ++++++++++++++++ backend/package.json | 4 +- backend/routes/admin.js | 123 +++++++++++++ backend/server.js | 2 + frontend/src/App.jsx | 33 ++-- .../src/pages/pc/admin/AdminDashboard.jsx | 164 ++++++++++++++++++ frontend/src/pages/pc/admin/AdminLogin.jsx | 148 ++++++++++++++++ 8 files changed, 618 insertions(+), 11 deletions(-) create mode 100644 backend/routes/admin.js create mode 100644 frontend/src/pages/pc/admin/AdminDashboard.jsx create mode 100644 frontend/src/pages/pc/admin/AdminLogin.jsx diff --git a/.env b/.env index 614494b..7b0917d 100644 --- a/.env +++ b/.env @@ -8,3 +8,4 @@ DB_NAME=fromis9 # Server PORT=80 NODE_ENV=production +JWT_SECRET=fromis9-admin-jwt-secret-2026-xK9mP2vL7nQ4w diff --git a/backend/package-lock.json b/backend/package-lock.json index 7b636e6..47baa0d 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -8,7 +8,9 @@ "name": "fromis9-backend", "version": "1.0.0", "dependencies": { + "bcrypt": "^6.0.0", "express": "^4.18.2", + "jsonwebtoken": "^9.0.3", "mysql2": "^3.11.0" } }, @@ -40,6 +42,20 @@ "node": ">= 6.0.0" } }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/body-parser": { "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", @@ -64,6 +80,12 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -189,6 +211,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -475,6 +506,97 @@ "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", "license": "MIT" }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -628,6 +750,26 @@ "node": ">= 0.6" } }, + "node_modules/node-addon-api": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", + "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -745,6 +887,18 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/send": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", diff --git a/backend/package.json b/backend/package.json index 6f49576..7207a3a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,7 +7,9 @@ "start": "node server.js" }, "dependencies": { + "bcrypt": "^6.0.0", "express": "^4.18.2", + "jsonwebtoken": "^9.0.3", "mysql2": "^3.11.0" } -} \ No newline at end of file +} diff --git a/backend/routes/admin.js b/backend/routes/admin.js new file mode 100644 index 0000000..dcfa1da --- /dev/null +++ b/backend/routes/admin.js @@ -0,0 +1,123 @@ +import express from "express"; +import bcrypt from "bcrypt"; +import jwt from "jsonwebtoken"; +import pool from "../lib/db.js"; + +const router = express.Router(); + +// JWT 비밀키 (실제 운영에서는 환경변수로 관리) +const JWT_SECRET = process.env.JWT_SECRET || "fromis9-admin-secret-key-2026"; +const JWT_EXPIRES_IN = "30d"; // 30일 + +// 관리자 로그인 +router.post("/login", async (req, res) => { + try { + const { username, password } = req.body; + + if (!username || !password) { + return res + .status(400) + .json({ error: "아이디와 비밀번호를 입력해주세요." }); + } + + // 사용자 조회 + const [users] = await pool.query( + "SELECT * FROM admin_users WHERE username = ?", + [username] + ); + + if (users.length === 0) { + return res + .status(401) + .json({ error: "아이디 또는 비밀번호가 올바르지 않습니다." }); + } + + const user = users[0]; + + // 비밀번호 검증 + const isValidPassword = await bcrypt.compare(password, user.password_hash); + + if (!isValidPassword) { + return res + .status(401) + .json({ error: "아이디 또는 비밀번호가 올바르지 않습니다." }); + } + + // JWT 토큰 발급 + const token = jwt.sign( + { id: user.id, username: user.username }, + JWT_SECRET, + { expiresIn: JWT_EXPIRES_IN } + ); + + res.json({ + message: "로그인 성공", + token, + user: { + id: user.id, + username: user.username, + }, + }); + } catch (error) { + console.error("로그인 오류:", error); + res.status(500).json({ error: "로그인 처리 중 오류가 발생했습니다." }); + } +}); + +// 토큰 검증 미들웨어 +export const authenticateToken = (req, res, next) => { + const authHeader = req.headers["authorization"]; + const token = authHeader && authHeader.split(" ")[1]; // Bearer TOKEN + + if (!token) { + return res.status(401).json({ error: "인증이 필요합니다." }); + } + + jwt.verify(token, JWT_SECRET, (err, user) => { + if (err) { + return res.status(403).json({ error: "유효하지 않은 토큰입니다." }); + } + req.user = user; + next(); + }); +}; + +// 토큰 검증 엔드포인트 +router.get("/verify", authenticateToken, (req, res) => { + res.json({ + valid: true, + user: req.user, + }); +}); + +// 초기 관리자 계정 생성 (한 번만 실행) +router.post("/init", async (req, res) => { + try { + // 이미 계정이 있는지 확인 + const [existing] = await pool.query( + "SELECT COUNT(*) as count FROM admin_users" + ); + + if (existing[0].count > 0) { + return res.status(400).json({ error: "이미 관리자 계정이 존재합니다." }); + } + + // 비밀번호 해시 생성 + const password = "auddnek0403!"; + const saltRounds = 10; + const passwordHash = await bcrypt.hash(password, saltRounds); + + // 계정 생성 + await pool.query( + "INSERT INTO admin_users (username, password_hash) VALUES (?, ?)", + ["admin", passwordHash] + ); + + res.json({ message: "관리자 계정이 생성되었습니다." }); + } catch (error) { + console.error("계정 생성 오류:", error); + res.status(500).json({ error: "계정 생성 중 오류가 발생했습니다." }); + } +}); + +export default router; diff --git a/backend/server.js b/backend/server.js index b2490a9..e22faa7 100644 --- a/backend/server.js +++ b/backend/server.js @@ -4,6 +4,7 @@ import { fileURLToPath } from "url"; import membersRouter from "./routes/members.js"; import albumsRouter from "./routes/albums.js"; import statsRouter from "./routes/stats.js"; +import adminRouter from "./routes/admin.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -25,6 +26,7 @@ app.get("/api/health", (req, res) => { app.use("/api/members", membersRouter); app.use("/api/albums", albumsRouter); app.use("/api/stats", statsRouter); +app.use("/api/admin", adminRouter); // SPA 폴백 - 모든 요청을 index.html로 app.get("*", (req, res) => { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 5c6368b..0c98ee2 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -9,6 +9,10 @@ import PCAlbumDetail from './pages/pc/AlbumDetail'; import PCAlbumGallery from './pages/pc/AlbumGallery'; import PCSchedule from './pages/pc/Schedule'; +// 관리자 페이지 +import AdminLogin from './pages/pc/admin/AdminLogin'; +import AdminDashboard from './pages/pc/admin/AdminDashboard'; + // PC 레이아웃 import PCLayout from './components/pc/Layout'; @@ -16,16 +20,25 @@ function App() { return ( - - - } /> - } /> - } /> - } /> - } /> - } /> - - + + {/* 관리자 페이지 (레이아웃 없음) */} + } /> + } /> + + {/* 일반 페이지 (레이아웃 포함) */} + + + } /> + } /> + } /> + } /> + } /> + } /> + + + } /> + {/* 모바일 버전은 추후 구현 */} diff --git a/frontend/src/pages/pc/admin/AdminDashboard.jsx b/frontend/src/pages/pc/admin/AdminDashboard.jsx new file mode 100644 index 0000000..60552c6 --- /dev/null +++ b/frontend/src/pages/pc/admin/AdminDashboard.jsx @@ -0,0 +1,164 @@ +import { useState, useEffect } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; +import { motion } from 'framer-motion'; +import { + Disc3, Calendar, Users, LogOut, + Home, ChevronRight +} from 'lucide-react'; + +function AdminDashboard() { + const navigate = useNavigate(); + const [user, setUser] = useState(null); + + useEffect(() => { + // 로그인 상태 확인 + const token = localStorage.getItem('adminToken'); + const userData = localStorage.getItem('adminUser'); + + if (!token || !userData) { + navigate('/admin'); + return; + } + + setUser(JSON.parse(userData)); + + // 토큰 유효성 검증 + fetch('/api/admin/verify', { + headers: { Authorization: `Bearer ${token}` } + }) + .then(res => { + if (!res.ok) throw new Error('Invalid token'); + return res.json(); + }) + .catch(() => { + localStorage.removeItem('adminToken'); + localStorage.removeItem('adminUser'); + navigate('/admin'); + }); + }, [navigate]); + + const handleLogout = () => { + localStorage.removeItem('adminToken'); + localStorage.removeItem('adminUser'); + navigate('/admin'); + }; + + // 메뉴 아이템 + const menuItems = [ + { + icon: Users, + label: '멤버 관리', + description: '멤버 정보 및 프로필 관리', + path: '/admin/members', + color: 'bg-primary' + }, + { + icon: Disc3, + label: '앨범 관리', + description: '앨범, 트랙, 사진 업로드 및 관리', + path: '/admin/albums', + color: 'bg-purple-500' + }, + { + icon: Calendar, + label: '일정 관리', + description: '스케줄 추가 및 관리', + path: '/admin/schedule', + color: 'bg-blue-500' + }, + ]; + + return ( +
+ {/* 헤더 */} +
+
+
+ + fromis_9 + + + Admin + +
+
+ + 안녕하세요, {user?.username}님 + + +
+
+
+ + {/* 메인 콘텐츠 */} +
+ {/* 브레드크럼 */} +
+ + + 관리자 대시보드 +
+ + {/* 타이틀 */} +
+

관리자 대시보드

+

fromis_9 팬사이트를 관리하세요

+
+ + {/* 메뉴 그리드 */} +
+ {menuItems.map((item, index) => ( + + +
+ +
+

{item.label}

+

{item.description}

+ +
+ ))} +
+ + {/* 빠른 통계 */} +
+

빠른 통계

+
+
+

-

+

총 앨범

+
+
+

-

+

총 사진

+
+
+

-

+

총 일정

+
+
+

5

+

멤버

+
+
+
+
+
+ ); +} + +export default AdminDashboard; diff --git a/frontend/src/pages/pc/admin/AdminLogin.jsx b/frontend/src/pages/pc/admin/AdminLogin.jsx new file mode 100644 index 0000000..e77f185 --- /dev/null +++ b/frontend/src/pages/pc/admin/AdminLogin.jsx @@ -0,0 +1,148 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { motion } from 'framer-motion'; +import { Lock, User, AlertCircle, Eye, EyeOff } from 'lucide-react'; + +function AdminLogin() { + const navigate = useNavigate(); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const [showPassword, setShowPassword] = useState(false); + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + const response = await fetch('/api/admin/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || '로그인에 실패했습니다.'); + } + + // JWT 토큰 저장 + localStorage.setItem('adminToken', data.token); + localStorage.setItem('adminUser', JSON.stringify(data.user)); + + // 관리자 메인 페이지로 이동 + navigate('/admin/dashboard'); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + return ( +
+ + {/* 로고 */} +
+

fromis_9

+

관리자 페이지

+
+ + {/* 로그인 카드 */} +
+

로그인

+ + {/* 에러 메시지 */} + {error && ( + + + {error} + + )} + +
+ {/* 아이디 입력 */} +
+ +
+ + setUsername(e.target.value)} + className="w-full bg-gray-50 border border-gray-200 rounded-lg pl-10 pr-4 py-3 text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all" + placeholder="아이디를 입력하세요" + required + /> +
+
+ + {/* 비밀번호 입력 */} +
+ +
+ + setPassword(e.target.value)} + className="w-full bg-gray-50 border border-gray-200 rounded-lg pl-10 pr-12 py-3 text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all" + placeholder="비밀번호를 입력하세요" + required + /> + +
+
+ + {/* 로그인 버튼 */} + +
+
+ + {/* 하단 링크 */} + +
+
+ ); +} + +export default AdminLogin;