diff --git a/bun.lock b/bun.lock index 8270cc5..6e2cda5 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@fastify/helmet": "^13.0.0", "@fastify/jwt": "^9.0.0", "@fastify/rate-limit": "^10.2.0", + "bcryptjs": "^3.0.3", "dotenv": "^16.5.0", "drizzle-orm": "^0.44.0", "fastify": "^5.3.0", @@ -19,6 +20,7 @@ }, "devDependencies": { "@eslint/js": "^9.25.0", + "@types/bcryptjs": "^3.0.0", "@types/node": "^24.0.0", "@types/uuid": "^10.0.0", "drizzle-kit": "^0.31.0", @@ -188,6 +190,8 @@ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + "@types/bcryptjs": ["@types/bcryptjs@3.0.0", "https://registry.npmmirror.com/@types/bcryptjs/-/bcryptjs-3.0.0.tgz", { "dependencies": { "bcryptjs": "*" } }, "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg=="], + "@types/chai": ["@types/chai@5.2.3", "https://registry.npmmirror.com/@types/chai/-/chai-5.2.3.tgz", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], "@types/deep-eql": ["@types/deep-eql@4.0.2", "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], @@ -260,6 +264,8 @@ "balanced-match": ["balanced-match@1.0.2", "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "bcryptjs": ["bcryptjs@3.0.3", "https://registry.npmmirror.com/bcryptjs/-/bcryptjs-3.0.3.tgz", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="], + "bn.js": ["bn.js@4.12.3", "https://registry.npmmirror.com/bn.js/-/bn.js-4.12.3.tgz", {}, "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g=="], "brace-expansion": ["brace-expansion@1.1.13", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.13.tgz", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w=="], diff --git a/db/seeds/index.ts b/db/seeds/index.ts index a1180bb..d2e7d05 100644 --- a/db/seeds/index.ts +++ b/db/seeds/index.ts @@ -1,7 +1,8 @@ import { v4 as uuid } from 'uuid'; import { db } from '../../src/db/client.js'; -import { categories, questions, knowledgeCards, skillTree, achievements } from '../../src/db/schema.js'; +import { categories, questions, knowledgeCards, skillTree, achievements, adminUsers } from '../../src/db/schema.js'; import { eq } from 'drizzle-orm'; +import * as adminAuthService from '../../src/services/admin/admin-auth.js'; import categoriesData from '../../content/categories.json' with { type: 'json' }; import historyData from '../../content/history.json' with { type: 'json' }; @@ -191,6 +192,9 @@ async function seedAchievements() { async function main() { console.log('Starting seed data import...\n'); + // Step 0: Admin users (no dependencies) + await seedAdminUsers(); + // Step 1: Categories (no dependencies) await seedCategories(); @@ -212,6 +216,13 @@ async function main() { process.exit(0); } +async function seedAdminUsers() { + // Create default admin user (username: admin, password: admin123) + // Note: In production, change the password immediately! + await adminAuthService.createAdminUser('admin', 'admin123', 'super_admin'); + console.log('Admin user seeded: username=admin, password=admin123 (CHANGE IN PRODUCTION!)'); +} + main().catch((err) => { console.error('Seed failed:', err); process.exit(1); diff --git a/docs/api-reference.md b/docs/api-reference.md index b3b069e..7b84381 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -33,7 +33,8 @@ |------|--------|----------| | 无需认证 | - | `/v1/health`, `/v1/auth/*` | | JWT | `Authorization: Bearer ` | 大多数客户端 API | -| Admin Token | `Authorization: Bearer ` | `/v1/admin/*` | +| Admin JWT | `Authorization: Bearer ` | `/v1/admin/*` (推荐) | +| Admin Token | `Authorization: Bearer ` | `/v1/admin/*` (向后兼容) | ### 统一响应格式 @@ -708,9 +709,54 @@ ### 管理端认证 +#### POST /admin/auth/login + +管理员用户名密码登录。 + +**认证**: 无 + +**请求体**: +```json +{ + "username": "string (必填)", + "password": "string (必填, 最少8字符)" +} +``` + +**响应**: +```json +{ + "success": true, + "data": { + "accessToken": "jwt_token (1h有效)", + "refreshToken": "jwt_token (30d有效)", + "admin": { + "id": "uuid", + "username": "admin", + "role": "super_admin" + } + }, + "error": null +} +``` + +**错误 (401)**: +```json +{ + "success": false, + "data": null, + "error": { + "code": "UNAUTHORIZED", + "message": "Invalid username or password" + } +} +``` + +--- + #### POST /admin/auth -管理端认证。 +管理端 Token 认证(向后兼容,推荐使用 `/admin/auth/login`)。 **认证**: 无 diff --git a/package.json b/package.json index 4db682a..7c01f31 100644 --- a/package.json +++ b/package.json @@ -19,20 +19,22 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { - "drizzle-orm": "^0.44.0", - "fastify": "^5.3.0", "@fastify/cors": "^11.0.0", "@fastify/helmet": "^13.0.0", - "@fastify/rate-limit": "^10.2.0", "@fastify/jwt": "^9.0.0", - "mysql2": "^3.12.0", + "@fastify/rate-limit": "^10.2.0", + "bcryptjs": "^3.0.3", "dotenv": "^16.5.0", - "zod": "^3.24.0", + "drizzle-orm": "^0.44.0", + "fastify": "^5.3.0", + "mysql2": "^3.12.0", "pino": "^9.6.0", - "uuid": "^11.1.0" + "uuid": "^11.1.0", + "zod": "^3.24.0" }, "devDependencies": { "@eslint/js": "^9.25.0", + "@types/bcryptjs": "^3.0.0", "@types/node": "^24.0.0", "@types/uuid": "^10.0.0", "drizzle-kit": "^0.31.0", diff --git a/src/db/schema.ts b/src/db/schema.ts index 7c5f118..b62bffb 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -228,3 +228,18 @@ export const adminAuditLog = mysqlTable('admin_audit_log', { ipAddress: varchar('ip_address', { length: 45 }), createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), }); + +// ── Admin Users ─────────────────────────────────────────────────────── + +export const adminUsers = mysqlTable('admin_users', { + id: char('id', { length: 36 }).primaryKey(), + username: varchar('username', { length: 50 }).notNull(), + passwordHash: varchar('password_hash', { length: 255 }).notNull(), + role: mysqlEnum('role', ['admin', 'super_admin']).default('admin'), + isActive: tinyint('is_active').default(1), + lastLoginAt: datetime('last_login_at'), + createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), + updatedAt: datetime('updated_at').default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), +}, (table) => [ + uniqueIndex('uk_admin_username').on(table.username), +]); diff --git a/src/middleware/admin-auth.ts b/src/middleware/admin-auth.ts index 5d69b01..0e39e32 100644 --- a/src/middleware/admin-auth.ts +++ b/src/middleware/admin-auth.ts @@ -2,6 +2,14 @@ import { FastifyInstance } from 'fastify'; import fp from 'fastify-plugin'; import { UnauthorizedError, ForbiddenError } from '../utils/errors.js'; import { config } from '../utils/config.js'; +import type { JwtPayload } from '../types/auth.js'; + +// Extend @fastify/jwt's type system for admin JWT +declare module '@fastify/jwt' { + interface FastifyJWT { + payload: JwtPayload; + } +} async function adminAuthMiddleware(app: FastifyInstance): Promise { app.addHook('onRequest', async (request) => { @@ -9,8 +17,9 @@ async function adminAuthMiddleware(app: FastifyInstance): Promise { return; } - // Skip admin login endpoint - if (request.url === '/v1/admin/auth') { + // Skip public admin endpoints + const publicPaths = ['/v1/admin/auth', '/v1/admin/auth/login']; + if (publicPaths.some((p) => request.url === p)) { return; } @@ -20,9 +29,30 @@ async function adminAuthMiddleware(app: FastifyInstance): Promise { } const token = authHeader.slice(7); - if (token !== config.ADMIN_TOKEN) { - throw new ForbiddenError('Invalid admin token'); + + // Try JWT verification first (new way) + try { + const decoded = app.jwt.verify(token); + if (decoded.authType === 'admin') { + // Successfully verified admin JWT - request.jwtVerify() will attach the decoded payload + return; + } + } catch { + // JWT verification failed, try fallback (backward compatibility) } + + // Fallback: ADMIN_TOKEN (legacy way) + if (token === config.ADMIN_TOKEN) { + // Manually attach a fake decoded payload for legacy token + request.jwtVerify = async () => ({ + userId: 'legacy-admin', + authType: 'admin', + role: 'super_admin', + }); + return; + } + + throw new ForbiddenError('Invalid admin token'); }); } diff --git a/src/routes/admin/auth.ts b/src/routes/admin/auth.ts index 970ea58..71f4082 100644 --- a/src/routes/admin/auth.ts +++ b/src/routes/admin/auth.ts @@ -1,8 +1,53 @@ import { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import * as adminAuthService from '../../services/admin/admin-auth.js'; import { config } from '../../utils/config.js'; +// Zod schema for login request +const loginSchema = z.object({ + username: z.string().min(1, 'Username is required'), + password: z.string().min(8, 'Password must be at least 8 characters'), +}); + export async function adminAuthRoutes(app: FastifyInstance): Promise { - app.post('/admin/auth', async (request, reply) => { + // New: Username/password login + app.post('/login', async (request, reply) => { + const parsed = loginSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ + success: false, + data: null, + error: { + code: 'VALIDATION_ERROR', + message: parsed.error.issues[0]?.message ?? 'Invalid request body', + }, + }); + } + + const { username, password } = parsed.data; + + // Verify credentials + const adminUser = await adminAuthService.verifyAdminPassword(username, password); + if (!adminUser) { + await adminAuthService.logFailedLogin(username, request.ip); + return reply.status(401).send({ + success: false, + data: null, + error: { code: 'UNAUTHORIZED', message: 'Invalid username or password' }, + }); + } + + // Create session + const session = await adminAuthService.createAdminSession(adminUser, app); + + // Log successful login + await adminAuthService.logAdminLogin(adminUser.id, request.ip, true); + + return { success: true, data: session, error: null }; + }); + + // Legacy: ADMIN_TOKEN authentication (backward compatibility) + app.post('/auth', async (request, reply) => { const { token } = request.body as { token: string }; if (token !== config.ADMIN_TOKEN) { diff --git a/src/services/admin/admin-auth.ts b/src/services/admin/admin-auth.ts new file mode 100644 index 0000000..3002fcd --- /dev/null +++ b/src/services/admin/admin-auth.ts @@ -0,0 +1,153 @@ +import { db } from '../../db/client.js'; +import { adminUsers, adminAuditLog } from '../../db/schema.js'; +import { eq } from 'drizzle-orm'; +import * as bcrypt from 'bcryptjs'; +import { v4 as uuid } from 'uuid'; +import type { FastifyInstance } from 'fastify'; +import { ForbiddenError } from '../../utils/errors.js'; +import type { JwtPayload, AdminLoginResponse } from '../../types/auth.js'; + +const SALT_ROUNDS = 10; + +/** + * Verify admin username and password, return user if valid + */ +export async function verifyAdminPassword( + username: string, + password: string, +): Promise<{ id: string; username: string; role: string } | null> { + const [admin] = await db + .select() + .from(adminUsers) + .where(eq(adminUsers.username, username)) + .limit(1); + + if (!admin) { + return null; + } + + if (!admin.isActive) { + throw new ForbiddenError('Admin account is disabled'); + } + + const isValid = await bcrypt.compare(password, admin.passwordHash); + if (!isValid) { + return null; + } + + return { + id: admin.id, + username: admin.username, + role: admin.role ?? 'admin', + }; +} + +/** + * Create admin JWT session (access + refresh tokens) + */ +export async function createAdminSession( + adminUser: { id: string; username: string; role: string }, + app: FastifyInstance, +): Promise { + const payload: JwtPayload = { + userId: adminUser.id, + authType: 'admin', + role: adminUser.role as 'admin' | 'super_admin', + }; + + const accessToken = app.jwt.sign(payload, { expiresIn: '1h' }); + const refreshToken = app.jwt.sign(payload, { expiresIn: '30d' }); + + return { + accessToken, + refreshToken, + admin: { + id: adminUser.id, + username: adminUser.username, + role: adminUser.role, + }, + }; +} + +/** + * Log admin login to audit log + */ +export async function logAdminLogin( + adminId: string, + ipAddress: string | undefined, + success: boolean, +): Promise { + try { + await db.insert(adminAuditLog).values({ + adminId, + action: 'LOGIN', + resource: '/v1/admin/auth/login', + details: { success, timestamp: new Date().toISOString() }, + ipAddress, + }); + } catch { + // Silently fail - audit log should not block login + } +} + +/** + * Log failed admin login attempt + */ +export async function logFailedLogin( + username: string, + ipAddress: string | undefined, +): Promise { + try { + await db.insert(adminAuditLog).values({ + adminId: `failed:${username}`, + action: 'LOGIN', + resource: '/v1/admin/auth/login', + details: { success: false, username, timestamp: new Date().toISOString() }, + ipAddress, + }); + } catch { + // Silently fail + } +} + +/** + * Hash password for storage + */ +export async function hashPassword(password: string): Promise { + return bcrypt.hash(password, SALT_ROUNDS); +} + +/** + * Create admin user (for seeding) + */ +export async function createAdminUser( + username: string, + password: string, + role: 'admin' | 'super_admin' = 'admin', +): Promise { + const existing = await db + .select() + .from(adminUsers) + .where(eq(adminUsers.username, username)) + .limit(1); + + if (existing.length > 0) { + // Update password if user exists + const passwordHash = await hashPassword(password); + await db + .update(adminUsers) + .set({ passwordHash, updatedAt: new Date() }) + .where(eq(adminUsers.username, username)); + return; + } + + const passwordHash = await hashPassword(password); + const id = uuid(); + + await db.insert(adminUsers).values({ + id, + username, + passwordHash, + role, + }); +} diff --git a/src/types/auth.ts b/src/types/auth.ts index 0c43bef..3b2ff3b 100644 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -1,9 +1,10 @@ -export type AuthType = 'huawei' | 'guest' | 'phone' | 'apple' | 'google'; +export type AuthType = 'huawei' | 'guest' | 'phone' | 'apple' | 'google' | 'admin'; export interface JwtPayload { userId: string; authType: AuthType; - tier: string; + tier?: string; + role?: 'admin' | 'super_admin'; } export interface LoginResponse { @@ -18,3 +19,14 @@ export interface LoginResponse { streakDays: number; }; } + +// Admin Login Response +export interface AdminLoginResponse { + accessToken: string; + refreshToken: string; + admin: { + id: string; + username: string; + role: string; + }; +}