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:
parent
4c0419649b
commit
3991a02a8c
6
bun.lock
6
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=="],
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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`)。
|
||||
|
||||
**认证**: 无
|
||||
|
||||
|
||||
14
package.json
14
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",
|
||||
|
||||
@ -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),
|
||||
]);
|
||||
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
153
src/services/admin/admin-auth.ts
Normal file
153
src/services/admin/admin-auth.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user