feat: production hardening - CORS whitelist, strong password, tokenVersion revoke, VIP card hashing, admin secret

This commit is contained in:
2026-05-10 22:53:32 +08:00
parent 5b23c88df9
commit 21709e5d97
9 changed files with 299 additions and 69 deletions

View File

@@ -27,9 +27,21 @@ app.use(helmet({
crossOriginEmbedderPolicy: false,
}));
// CORS
// CORS — 仅允许白名单来源
const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS?.split(',') || [
'http://localhost:5173',
'http://localhost:3000',
'app://.',
'file://',
];
app.use(cors({
origin: true, // 允许所有来源(开发模式)
origin: (origin, callback) => {
if (!origin || ALLOWED_ORIGINS.some(o => origin.startsWith(o))) {
callback(null, true);
} else {
callback(new Error('CORS blocked'));
}
},
credentials: true,
}));
@@ -221,11 +233,11 @@ releaseDate: '2024-01-01T00:00:00.000Z'
});
// ============================================
// 开发工具端点 (仅 NODE_ENV=development)
// ============================================
// 开发工具端点 (需 ADMIN_SECRET)
app.post('/api/admin/set-vip', async (req, res) => {
if (process.env.NODE_ENV !== 'development') {
return res.status(403).json({ success: false, message: '仅开发环境可用' });
const secret = process.env.ADMIN_SECRET;
if (!secret || req.headers['x-admin-secret'] !== secret) {
return res.status(403).json({ success: false, message: '禁止访问' });
}
try {
const { username, isVip } = req.body;

View File

@@ -44,12 +44,12 @@ export async function authMiddleware(req: Request, res: Response, next: NextFunc
});
}
// 检查用户是否存在且未被删除
// 检查用户是否存在且未被删除,同时验证 tokenVersion 一致
const user = await prisma.user.findUnique({
where: { id: payload.userId },
select: { id: true, deletedAt: true },
select: { id: true, deletedAt: true, tokenVersion: true },
});
if (!user || user.deletedAt) {
return res.status(401).json({
success: false,
@@ -57,6 +57,15 @@ export async function authMiddleware(req: Request, res: Response, next: NextFunc
code: 'USER_NOT_FOUND',
});
}
// token 已被吊销 (登出或密码修改后旧 token 失效)
if (user.tokenVersion !== payload.tokenVersion) {
return res.status(401).json({
success: false,
message: '访问令牌已失效,请重新登录',
code: 'TOKEN_EXPIRED',
});
}
// 将用户信息附加到请求对象
req.user = payload;

View File

@@ -13,7 +13,10 @@ const prisma = new PrismaClient();
// ============================================
const registerSchema = z.object({
username: z.string().min(3).max(50),
password: z.string().min(6).max(100),
password: z.string().min(8, '密码至少8位').max(100)
.regex(/[A-Z]/, '密码需包含大写字母')
.regex(/[a-z]/, '密码需包含小写字母')
.regex(/[0-9]/, '密码需包含数字'),
email: z.string().email(),
installId: z.string().optional(),
deviceHash: z.string().optional(),
@@ -22,34 +25,34 @@ const registerSchema = z.object({
router.post('/register', async (req: Request, res: Response) => {
try {
const body = registerSchema.parse(req.body);
// 检查用户名是否已存在
const existingUsername = await prisma.user.findUnique({
where: { username: body.username },
});
if (existingUsername) {
return res.status(400).json({
success: false,
message: '用户名已被使用',
});
}
// 检查邮箱是否已存在
const existingEmail = await prisma.user.findUnique({
where: { email: body.email },
});
if (existingEmail) {
return res.status(400).json({
success: false,
message: '邮箱已被注册',
});
}
// 密码哈希
const passwordHash = await bcrypt.hash(body.password, 10);
// 创建用户
const user = await prisma.user.create({
data: {
@@ -60,7 +63,7 @@ router.post('/register', async (req: Request, res: Response) => {
deviceHash: body.deviceHash,
},
});
// 生成 Token
const token = generateToken({
userId: user.id,
@@ -68,8 +71,9 @@ router.post('/register', async (req: Request, res: Response) => {
email: user.email,
isVip: user.isVip,
vipLevel: user.vipLevel,
tokenVersion: user.tokenVersion,
});
// 记录日志
await prisma.userLog.create({
data: {
@@ -80,7 +84,7 @@ router.post('/register', async (req: Request, res: Response) => {
ipAddress: req.ip,
},
});
res.json({
success: true,
token,
@@ -102,7 +106,7 @@ router.post('/register', async (req: Request, res: Response) => {
errors: error.errors,
});
}
console.error('Register error:', error);
res.status(500).json({ success: false, message: '注册失败' });
}
@@ -170,8 +174,9 @@ router.post('/login', async (req: Request, res: Response) => {
email: user.email,
isVip: user.isVip,
vipLevel: user.vipLevel,
tokenVersion: user.tokenVersion,
});
// 记录日志
await prisma.userLog.create({
data: {
@@ -182,7 +187,7 @@ router.post('/login', async (req: Request, res: Response) => {
ipAddress: req.ip,
},
});
res.json({
success: true,
token,
@@ -204,12 +209,28 @@ router.post('/login', async (req: Request, res: Response) => {
errors: error.errors,
});
}
console.error('Login error:', error);
res.status(500).json({ success: false, message: '登录失败' });
}
});
// ============================================
// 登出 (使当前 token 失效)
// ============================================
router.post('/logout', authMiddleware, async (req: Request, res: Response) => {
try {
await prisma.user.update({
where: { id: req.user!.userId },
data: { tokenVersion: { increment: 1 } },
});
res.json({ success: true, message: '已登出' });
} catch (error) {
console.error('Logout error:', error);
res.status(500).json({ success: false, message: '登出失败' });
}
});
// ============================================
// 会话状态 (从数据库查询最新状态,避免 token 缓存导致 isVip 过期)
// ============================================

View File

@@ -1,5 +1,6 @@
import { Router, Request, Response } from 'express';
import { PrismaClient } from '@prisma/client';
import crypto from 'crypto';
import { z } from 'zod';
import { authMiddleware } from '../middleware/auth';
import { generateToken } from '../utils/jwt';
@@ -7,6 +8,10 @@ import { generateToken } from '../utils/jwt';
const router = Router();
const prisma = new PrismaClient();
function hashCardKey(raw: string): string {
return crypto.createHash('sha256').update(raw).digest('hex');
}
// ============================================
// VIP 卡密激活
// ============================================
@@ -18,9 +23,10 @@ router.post('/activate-vip', authMiddleware, async (req: Request, res: Response)
try {
const body = activateSchema.parse(req.body);
// 查找卡密
// 查找卡密 (比对哈希)
const hashedKey = hashCardKey(body.cardKey);
const card = await prisma.vipCard.findUnique({
where: { cardKey: body.cardKey },
where: { cardKey: hashedKey },
});
if (!card) {
@@ -97,6 +103,7 @@ router.post('/activate-vip', authMiddleware, async (req: Request, res: Response)
email: updatedUser.email,
isVip: true,
vipLevel: updatedUser.vipLevel,
tokenVersion: updatedUser.tokenVersion,
});
res.json({

View File

@@ -12,6 +12,7 @@ export interface JwtPayload {
email: string;
isVip: boolean;
vipLevel: number;
tokenVersion: number;
}
/**