feat: 어드민 로그인 API 구현

- @fastify/jwt 플러그인 기반 인증 시스템
- POST /api/admin/login: 로그인 (JWT 토큰 발급)
- GET /api/admin/verify: 토큰 검증
- bcrypt 비밀번호 해싱 검증
- JWT 설정 config 분리

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-16 21:38:54 +09:00
parent 19ba8bcddf
commit 613c4c69a5
7 changed files with 324 additions and 3 deletions

View file

@ -8,6 +8,8 @@
"name": "fromis9-backend", "name": "fromis9-backend",
"version": "2.0.0", "version": "2.0.0",
"dependencies": { "dependencies": {
"@fastify/jwt": "^10.0.0",
"bcrypt": "^6.0.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"fastify": "^5.2.1", "fastify": "^5.2.1",
"fastify-plugin": "^5.0.1", "fastify-plugin": "^5.0.1",
@ -88,6 +90,29 @@
], ],
"license": "MIT" "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": { "node_modules/@fastify/merge-json-schemas": {
"version": "0.2.1", "version": "0.2.1",
"resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz",
@ -133,6 +158,15 @@
"integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==", "integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==",
"license": "MIT" "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": { "node_modules/@pinojs/redact": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", "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": { "node_modules/atomic-sleep": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
@ -206,6 +252,26 @@
"node": ">= 6.0.0" "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": { "node_modules/cluster-key-slot": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
@ -269,6 +335,15 @@
"node": ">=6" "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": { "node_modules/fast-decode-uri-component": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", "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" "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": { "node_modules/fast-querystring": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz",
@ -330,6 +420,18 @@
], ],
"license": "BSD-3-Clause" "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": { "node_modules/fastify": {
"version": "5.7.1", "version": "5.7.1",
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.7.1.tgz", "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.7.1.tgz",
@ -379,6 +481,16 @@
], ],
"license": "MIT" "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": { "node_modules/fastq": {
"version": "1.20.1", "version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
@ -388,6 +500,16 @@
"reusify": "^1.0.4" "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": { "node_modules/find-my-way": {
"version": "9.4.0", "version": "9.4.0",
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.4.0.tgz", "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.4.0.tgz",
@ -427,6 +549,12 @@
"url": "https://opencollective.com/express" "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": { "node_modules/ioredis": {
"version": "5.9.2", "version": "5.9.2",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.2.tgz", "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.2.tgz",
@ -561,6 +689,21 @@
"url": "https://github.com/sponsors/wellwelwel" "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": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -599,6 +742,15 @@
"node": ">=8.0.0" "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": { "node_modules/node-cron": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz",
@ -611,6 +763,23 @@
"node": ">=6.0.0" "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": { "node_modules/on-exit-leak-free": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
@ -743,6 +912,26 @@
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT" "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": { "node_modules/safe-regex2": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz", "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz",
@ -849,6 +1038,19 @@
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
"license": "MIT" "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": { "node_modules/thread-stream": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
@ -878,6 +1080,15 @@
"bin": { "bin": {
"uuid": "dist/bin/uuid" "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"
}
} }
} }
} }

View file

@ -7,11 +7,13 @@
"dev": "node --watch src/server.js" "dev": "node --watch src/server.js"
}, },
"dependencies": { "dependencies": {
"@fastify/jwt": "^10.0.0",
"bcrypt": "^6.0.0",
"dayjs": "^1.11.13",
"fastify": "^5.2.1", "fastify": "^5.2.1",
"fastify-plugin": "^5.0.1", "fastify-plugin": "^5.0.1",
"mysql2": "^3.12.0",
"ioredis": "^5.4.2", "ioredis": "^5.4.2",
"node-cron": "^3.0.3", "mysql2": "^3.12.0",
"dayjs": "^1.11.13" "node-cron": "^3.0.3"
} }
} }

View file

@ -4,10 +4,14 @@ import config from './config/index.js';
// 플러그인 // 플러그인
import dbPlugin from './plugins/db.js'; import dbPlugin from './plugins/db.js';
import redisPlugin from './plugins/redis.js'; import redisPlugin from './plugins/redis.js';
import authPlugin from './plugins/auth.js';
import youtubeBotPlugin from './services/youtube/index.js'; import youtubeBotPlugin from './services/youtube/index.js';
import xBotPlugin from './services/x/index.js'; import xBotPlugin from './services/x/index.js';
import schedulerPlugin from './plugins/scheduler.js'; import schedulerPlugin from './plugins/scheduler.js';
// 라우트
import adminRoutes from './routes/admin/index.js';
export async function buildApp(opts = {}) { export async function buildApp(opts = {}) {
const fastify = Fastify({ const fastify = Fastify({
logger: { logger: {
@ -22,10 +26,14 @@ export async function buildApp(opts = {}) {
// 플러그인 등록 (순서 중요) // 플러그인 등록 (순서 중요)
await fastify.register(dbPlugin); await fastify.register(dbPlugin);
await fastify.register(redisPlugin); await fastify.register(redisPlugin);
await fastify.register(authPlugin);
await fastify.register(youtubeBotPlugin); await fastify.register(youtubeBotPlugin);
await fastify.register(xBotPlugin); await fastify.register(xBotPlugin);
await fastify.register(schedulerPlugin); await fastify.register(schedulerPlugin);
// 라우트 등록
await fastify.register(adminRoutes, { prefix: '/api/admin' });
// 헬스 체크 엔드포인트 // 헬스 체크 엔드포인트
fastify.get('/api/health', async () => { fastify.get('/api/health', async () => {
return { status: 'ok', timestamp: new Date().toISOString() }; return { status: 'ok', timestamp: new Date().toISOString() };

View file

@ -19,4 +19,8 @@ export default {
youtube: { youtube: {
apiKey: process.env.YOUTUBE_API_KEY, apiKey: process.env.YOUTUBE_API_KEY,
}, },
jwt: {
secret: process.env.JWT_SECRET || 'fromis9-admin-secret-key-2026',
expiresIn: '30d',
},
}; };

View file

@ -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',
});

View file

@ -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 };
});
}

View file

@ -0,0 +1,9 @@
import authRoutes from './auth.js';
/**
* 어드민 라우트 통합
*/
export default async function adminRoutes(fastify, opts) {
// 인증 라우트 (prefix 없음)
fastify.register(authRoutes);
}