feat: 添加管理员用户名密码登录功能

新增 /v1/admin/auth/login 接口,支持用户名密码登录获取 JWT Token。
- 添加 admin_users 表存储管理员账号和哈希密码
- 使用 bcryptjs 进行密码哈希(cost=10)
- JWT Token 认证优先,保留 ADMIN_TOKEN 作为向后兼容
- 记录登录审计日志到 admin_audit_log
- 种子数据创建默认管理员(username: admin, password: admin123)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Wang Zhuoxuan 2026-04-11 15:25:31 +08:00
parent 4c0419649b
commit 3991a02a8c
9 changed files with 336 additions and 16 deletions

View File

@ -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=="],

View File

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

View File

@ -33,7 +33,8 @@
|------|--------|----------|
| 无需认证 | - | `/v1/health`, `/v1/auth/*` |
| JWT | `Authorization: Bearer <jwt_token>` | 大多数客户端 API |
| Admin Token | `Authorization: Bearer <admin_token>` | `/v1/admin/*` |
| Admin JWT | `Authorization: Bearer <admin_jwt_token>` | `/v1/admin/*` (推荐) |
| Admin Token | `Authorization: Bearer <admin_token>` | `/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`
**认证**: 无

View File

@ -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",

View File

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

View File

@ -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<void> {
app.addHook('onRequest', async (request) => {
@ -9,8 +17,9 @@ async function adminAuthMiddleware(app: FastifyInstance): Promise<void> {
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<void> {
}
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<JwtPayload>(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');
});
}

View File

@ -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<void> {
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) {

View File

@ -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<AdminLoginResponse> {
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<void> {
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<void> {
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<string> {
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<void> {
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,
});
}

View File

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