diff --git a/backend/package-lock.json b/backend/package-lock.json index 32f17f7..11a76b9 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -8,6 +8,8 @@ "name": "fromis9-backend", "version": "2.0.0", "dependencies": { + "@fastify/jwt": "^10.0.0", + "bcrypt": "^6.0.0", "dayjs": "^1.11.13", "fastify": "^5.2.1", "fastify-plugin": "^5.0.1", @@ -88,6 +90,29 @@ ], "license": "MIT" }, + "node_modules/@fastify/jwt": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@fastify/jwt/-/jwt-10.0.0.tgz", + "integrity": "sha512-2Qka3NiyNNcsfejMUvyzot1T4UYIzzcbkFGDdVyrl344fRZ/WkD6VFXOoXhxe2Pzf3LpJNkoSxUM4Ru4DVgkYA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.2.0", + "@lukeed/ms": "^2.0.2", + "fast-jwt": "^6.0.2", + "fastify-plugin": "^5.0.1", + "steed": "^1.1.3" + } + }, "node_modules/@fastify/merge-json-schemas": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", @@ -133,6 +158,15 @@ "integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==", "license": "MIT" }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -178,6 +212,18 @@ } } }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -206,6 +252,26 @@ "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/bn.js": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", + "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "license": "MIT" + }, "node_modules/cluster-key-slot": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", @@ -269,6 +335,15 @@ "node": ">=6" } }, + "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/fast-decode-uri-component": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", @@ -305,6 +380,21 @@ "rfdc": "^1.2.0" } }, + "node_modules/fast-jwt": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/fast-jwt/-/fast-jwt-6.1.0.tgz", + "integrity": "sha512-cGK/TXlud8INL49Iv7yRtZy0PHzNJId1shfqNCqdF0gOlWiy+1FPgjxX+ZHp/CYxFYDaoNnxeYEGzcXSkahUEQ==", + "license": "Apache-2.0", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "asn1.js": "^5.4.1", + "ecdsa-sig-formatter": "^1.0.11", + "mnemonist": "^0.40.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/fast-querystring": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", @@ -330,6 +420,18 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fastfall": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/fastfall/-/fastfall-1.5.1.tgz", + "integrity": "sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==", + "license": "MIT", + "dependencies": { + "reusify": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/fastify": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.7.1.tgz", @@ -379,6 +481,16 @@ ], "license": "MIT" }, + "node_modules/fastparallel": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/fastparallel/-/fastparallel-2.4.1.tgz", + "integrity": "sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4", + "xtend": "^4.0.2" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -388,6 +500,16 @@ "reusify": "^1.0.4" } }, + "node_modules/fastseries": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/fastseries/-/fastseries-1.7.2.tgz", + "integrity": "sha512-dTPFrPGS8SNSzAt7u/CbMKCJ3s01N04s4JFbORHcmyvVfVKmbhMD1VtRbh5enGHxkaQDqWyLefiKOGGmohGDDQ==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.0", + "xtend": "^4.0.0" + } + }, "node_modules/find-my-way": { "version": "9.4.0", "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.4.0.tgz", @@ -427,6 +549,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/ioredis": { "version": "5.9.2", "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.2.tgz", @@ -561,6 +689,21 @@ "url": "https://github.com/sponsors/wellwelwel" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/mnemonist": { + "version": "0.40.3", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz", + "integrity": "sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ==", + "license": "MIT", + "dependencies": { + "obliterator": "^2.0.4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -599,6 +742,15 @@ "node": ">=8.0.0" } }, + "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-cron": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", @@ -611,6 +763,23 @@ "node": ">=6.0.0" } }, + "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/obliterator": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", + "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==", + "license": "MIT" + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -743,6 +912,26 @@ "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "license": "MIT" }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-regex2": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz", @@ -849,6 +1038,19 @@ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", "license": "MIT" }, + "node_modules/steed": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/steed/-/steed-1.1.3.tgz", + "integrity": "sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA==", + "license": "MIT", + "dependencies": { + "fastfall": "^1.5.0", + "fastparallel": "^2.2.0", + "fastq": "^1.3.0", + "fastseries": "^1.7.0", + "reusify": "^1.0.0" + } + }, "node_modules/thread-stream": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", @@ -878,6 +1080,15 @@ "bin": { "uuid": "dist/bin/uuid" } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } } } } diff --git a/backend/package.json b/backend/package.json index fce3e25..b3d49c3 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,11 +7,13 @@ "dev": "node --watch src/server.js" }, "dependencies": { + "@fastify/jwt": "^10.0.0", + "bcrypt": "^6.0.0", + "dayjs": "^1.11.13", "fastify": "^5.2.1", "fastify-plugin": "^5.0.1", - "mysql2": "^3.12.0", "ioredis": "^5.4.2", - "node-cron": "^3.0.3", - "dayjs": "^1.11.13" + "mysql2": "^3.12.0", + "node-cron": "^3.0.3" } } diff --git a/backend/src/app.js b/backend/src/app.js index d3e773f..1b19310 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -4,10 +4,14 @@ import config from './config/index.js'; // 플러그인 import dbPlugin from './plugins/db.js'; import redisPlugin from './plugins/redis.js'; +import authPlugin from './plugins/auth.js'; import youtubeBotPlugin from './services/youtube/index.js'; import xBotPlugin from './services/x/index.js'; import schedulerPlugin from './plugins/scheduler.js'; +// 라우트 +import adminRoutes from './routes/admin/index.js'; + export async function buildApp(opts = {}) { const fastify = Fastify({ logger: { @@ -22,10 +26,14 @@ export async function buildApp(opts = {}) { // 플러그인 등록 (순서 중요) await fastify.register(dbPlugin); await fastify.register(redisPlugin); + await fastify.register(authPlugin); await fastify.register(youtubeBotPlugin); await fastify.register(xBotPlugin); await fastify.register(schedulerPlugin); + // 라우트 등록 + await fastify.register(adminRoutes, { prefix: '/api/admin' }); + // 헬스 체크 엔드포인트 fastify.get('/api/health', async () => { return { status: 'ok', timestamp: new Date().toISOString() }; diff --git a/backend/src/config/index.js b/backend/src/config/index.js index 729017f..ed94744 100644 --- a/backend/src/config/index.js +++ b/backend/src/config/index.js @@ -19,4 +19,8 @@ export default { youtube: { apiKey: process.env.YOUTUBE_API_KEY, }, + jwt: { + secret: process.env.JWT_SECRET || 'fromis9-admin-secret-key-2026', + expiresIn: '30d', + }, }; diff --git a/backend/src/plugins/auth.js b/backend/src/plugins/auth.js new file mode 100644 index 0000000..8908bc0 --- /dev/null +++ b/backend/src/plugins/auth.js @@ -0,0 +1,26 @@ +import fp from 'fastify-plugin'; +import fastifyJwt from '@fastify/jwt'; +import config from '../config/index.js'; + +async function authPlugin(fastify, opts) { + // JWT 플러그인 등록 + await fastify.register(fastifyJwt, { + secret: config.jwt.secret, + sign: { + expiresIn: config.jwt.expiresIn, + }, + }); + + // 인증 데코레이터 + fastify.decorate('authenticate', async function (request, reply) { + try { + await request.jwtVerify(); + } catch (err) { + reply.status(401).send({ error: '인증이 필요합니다.' }); + } + }); +} + +export default fp(authPlugin, { + name: 'auth', +}); diff --git a/backend/src/routes/admin/auth.js b/backend/src/routes/admin/auth.js new file mode 100644 index 0000000..47ce3d0 --- /dev/null +++ b/backend/src/routes/admin/auth.js @@ -0,0 +1,61 @@ +import bcrypt from 'bcrypt'; + +/** + * 어드민 인증 라우트 + */ +export default async function adminAuthRoutes(fastify, opts) { + /** + * POST /api/admin/login + * 관리자 로그인 + */ + fastify.post('/login', async (request, reply) => { + const { username, password } = request.body || {}; + + if (!username || !password) { + return reply.status(400).send({ error: '아이디와 비밀번호를 입력해주세요.' }); + } + + try { + const [users] = await fastify.db.query( + 'SELECT * FROM admin_users WHERE username = ?', + [username] + ); + + if (users.length === 0) { + return reply.status(401).send({ error: '아이디 또는 비밀번호가 올바르지 않습니다.' }); + } + + const user = users[0]; + const isValidPassword = await bcrypt.compare(password, user.password_hash); + + if (!isValidPassword) { + return reply.status(401).send({ error: '아이디 또는 비밀번호가 올바르지 않습니다.' }); + } + + // JWT 토큰 생성 + const token = fastify.jwt.sign({ + id: user.id, + username: user.username, + }); + + return { + message: '로그인 성공', + token, + user: { id: user.id, username: user.username }, + }; + } catch (err) { + fastify.log.error(err); + return reply.status(500).send({ error: '로그인 처리 중 오류가 발생했습니다.' }); + } + }); + + /** + * GET /api/admin/verify + * 토큰 검증 + */ + fastify.get('/verify', { + preHandler: [fastify.authenticate], + }, async (request, reply) => { + return { valid: true, user: request.user }; + }); +} diff --git a/backend/src/routes/admin/index.js b/backend/src/routes/admin/index.js new file mode 100644 index 0000000..3ecf453 --- /dev/null +++ b/backend/src/routes/admin/index.js @@ -0,0 +1,9 @@ +import authRoutes from './auth.js'; + +/** + * 어드민 라우트 통합 + */ +export default async function adminRoutes(fastify, opts) { + // 인증 라우트 (prefix 없음) + fastify.register(authRoutes); +}