feat: production hardening - CORS whitelist, strong password, tokenVersion revoke, VIP card hashing, admin secret
This commit is contained in:
24
src/index.ts
24
src/index.ts
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 过期)
|
||||
// ============================================
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface JwtPayload {
|
||||
email: string;
|
||||
isVip: boolean;
|
||||
vipLevel: number;
|
||||
tokenVersion: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user