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/helmet": "^13.0.0",
|
||||||
"@fastify/jwt": "^9.0.0",
|
"@fastify/jwt": "^9.0.0",
|
||||||
"@fastify/rate-limit": "^10.2.0",
|
"@fastify/rate-limit": "^10.2.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"dotenv": "^16.5.0",
|
"dotenv": "^16.5.0",
|
||||||
"drizzle-orm": "^0.44.0",
|
"drizzle-orm": "^0.44.0",
|
||||||
"fastify": "^5.3.0",
|
"fastify": "^5.3.0",
|
||||||
@ -19,6 +20,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.25.0",
|
"@eslint/js": "^9.25.0",
|
||||||
|
"@types/bcryptjs": "^3.0.0",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"drizzle-kit": "^0.31.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=="],
|
"@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/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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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 { v4 as uuid } from 'uuid';
|
||||||
import { db } from '../../src/db/client.js';
|
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 { eq } from 'drizzle-orm';
|
||||||
|
import * as adminAuthService from '../../src/services/admin/admin-auth.js';
|
||||||
|
|
||||||
import categoriesData from '../../content/categories.json' with { type: 'json' };
|
import categoriesData from '../../content/categories.json' with { type: 'json' };
|
||||||
import historyData from '../../content/history.json' with { type: 'json' };
|
import historyData from '../../content/history.json' with { type: 'json' };
|
||||||
@ -191,6 +192,9 @@ async function seedAchievements() {
|
|||||||
async function main() {
|
async function main() {
|
||||||
console.log('Starting seed data import...\n');
|
console.log('Starting seed data import...\n');
|
||||||
|
|
||||||
|
// Step 0: Admin users (no dependencies)
|
||||||
|
await seedAdminUsers();
|
||||||
|
|
||||||
// Step 1: Categories (no dependencies)
|
// Step 1: Categories (no dependencies)
|
||||||
await seedCategories();
|
await seedCategories();
|
||||||
|
|
||||||
@ -212,6 +216,13 @@ async function main() {
|
|||||||
process.exit(0);
|
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) => {
|
main().catch((err) => {
|
||||||
console.error('Seed failed:', err);
|
console.error('Seed failed:', err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@ -33,7 +33,8 @@
|
|||||||
|------|--------|----------|
|
|------|--------|----------|
|
||||||
| 无需认证 | - | `/v1/health`, `/v1/auth/*` |
|
| 无需认证 | - | `/v1/health`, `/v1/auth/*` |
|
||||||
| JWT | `Authorization: Bearer <jwt_token>` | 大多数客户端 API |
|
| 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
|
#### POST /admin/auth
|
||||||
|
|
||||||
管理端认证。
|
管理端 Token 认证(向后兼容,推荐使用 `/admin/auth/login`)。
|
||||||
|
|
||||||
**认证**: 无
|
**认证**: 无
|
||||||
|
|
||||||
|
|||||||
14
package.json
14
package.json
@ -19,20 +19,22 @@
|
|||||||
"test:coverage": "vitest run --coverage"
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"drizzle-orm": "^0.44.0",
|
|
||||||
"fastify": "^5.3.0",
|
|
||||||
"@fastify/cors": "^11.0.0",
|
"@fastify/cors": "^11.0.0",
|
||||||
"@fastify/helmet": "^13.0.0",
|
"@fastify/helmet": "^13.0.0",
|
||||||
"@fastify/rate-limit": "^10.2.0",
|
|
||||||
"@fastify/jwt": "^9.0.0",
|
"@fastify/jwt": "^9.0.0",
|
||||||
"mysql2": "^3.12.0",
|
"@fastify/rate-limit": "^10.2.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"dotenv": "^16.5.0",
|
"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",
|
"pino": "^9.6.0",
|
||||||
"uuid": "^11.1.0"
|
"uuid": "^11.1.0",
|
||||||
|
"zod": "^3.24.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.25.0",
|
"@eslint/js": "^9.25.0",
|
||||||
|
"@types/bcryptjs": "^3.0.0",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"drizzle-kit": "^0.31.0",
|
"drizzle-kit": "^0.31.0",
|
||||||
|
|||||||
@ -228,3 +228,18 @@ export const adminAuditLog = mysqlTable('admin_audit_log', {
|
|||||||
ipAddress: varchar('ip_address', { length: 45 }),
|
ipAddress: varchar('ip_address', { length: 45 }),
|
||||||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`),
|
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 fp from 'fastify-plugin';
|
||||||
import { UnauthorizedError, ForbiddenError } from '../utils/errors.js';
|
import { UnauthorizedError, ForbiddenError } from '../utils/errors.js';
|
||||||
import { config } from '../utils/config.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> {
|
async function adminAuthMiddleware(app: FastifyInstance): Promise<void> {
|
||||||
app.addHook('onRequest', async (request) => {
|
app.addHook('onRequest', async (request) => {
|
||||||
@ -9,8 +17,9 @@ async function adminAuthMiddleware(app: FastifyInstance): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip admin login endpoint
|
// Skip public admin endpoints
|
||||||
if (request.url === '/v1/admin/auth') {
|
const publicPaths = ['/v1/admin/auth', '/v1/admin/auth/login'];
|
||||||
|
if (publicPaths.some((p) => request.url === p)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -20,9 +29,30 @@ async function adminAuthMiddleware(app: FastifyInstance): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const token = authHeader.slice(7);
|
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 { FastifyInstance } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import * as adminAuthService from '../../services/admin/admin-auth.js';
|
||||||
import { config } from '../../utils/config.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> {
|
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 };
|
const { token } = request.body as { token: string };
|
||||||
|
|
||||||
if (token !== config.ADMIN_TOKEN) {
|
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 {
|
export interface JwtPayload {
|
||||||
userId: string;
|
userId: string;
|
||||||
authType: AuthType;
|
authType: AuthType;
|
||||||
tier: string;
|
tier?: string;
|
||||||
|
role?: 'admin' | 'super_admin';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
@ -18,3 +19,14 @@ export interface LoginResponse {
|
|||||||
streakDays: number;
|
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