用户认证与安全
本文将介绍如何在 ZenWeb 项目中实现完整的用户认证体系,包括 JWT 令牌管理、认证中间件、Context 类型扩展、密码安全以及防暴力破解等安全实践。
安装依赖
npm install jsonwebtoken
npm install -D @types/jsonwebtoken
JWT 认证实现
JSON Web Token(JWT)是一种轻量级的认证方案,适用于前后端分离架构。我们将其封装为独立的工具模块。
创建 JWT 工具模块
src/lib/jwt.ts
import * as crypto from 'crypto';
import * as jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
const JWT_EXPIRES_IN = '7d';
/**
* JWT 载荷数据结构
*/
export interface JwtPayload {
userId: number;
}
/**
* 生成 JWT 令牌
*/
export function generateToken(payload: JwtPayload): string {
return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
}
/**
* 验证 JWT 令牌
* - 验证成功返回载荷数据
* - 验证失败返回 undefined
*/
export function verifyToken(token: string): JwtPayload | undefined {
try {
return jwt.verify(token, JWT_SECRET) as JwtPayload;
} catch {
return undefined;
}
}
/**
* 生成随机盐值
*/
export function generateSalt(): string {
return crypto.randomBytes(16).toString('hex');
}
/**
* 使用 HMAC-SHA256 对密码进行哈希
*/
export function hashPassword(password: string, salt: string): string {
return crypto.createHmac('sha256', salt).update(password).digest('hex');
}
安全建议
JWT_SECRET应通过环境变量注入,不要硬编码在源码中- 生产环境中应使用足够复杂的密钥(建议 32 字符以上)
JWT_EXPIRES_IN根据业务需求设置合理的过期时间
认证中间件
认证中间件负责从请求头中提取令牌、验证有效性、加载用户信息,并将用户对象挂载到上下文中。
src/middleware/auth.ts
import { Context } from 'zenweb';
import { verifyToken, JwtPayload } from '../lib/jwt';
import { User } from '../model';
/**
* 扩展 Context 类型,添加用户相关属性
*/
declare module 'zenweb' {
interface Context {
user?: User;
jwtPayload?: JwtPayload;
}
}
/**
* 认证中间件
* - 从 Authorization 请求头中提取 Bearer 令牌
* - 验证令牌有效性并加载用户信息
*/
export async function authMiddleware(ctx: Context, next: () => Promise<void>) {
const authHeader = ctx.get('Authorization');
if (!authHeader) {
ctx.throw(401, '未登录');
}
// 提取 Bearer 令牌
const token = authHeader.startsWith('Bearer ')
? authHeader.slice(7)
: authHeader;
// 验证令牌
const payload = verifyToken(token);
if (!payload) {
ctx.throw(401, '登录已过期');
}
// 加载用户信息
const user = await User.getByPk(payload.userId);
if (!user) {
ctx.throw(401, '用户不存在');
}
// 将用户信息挂载到上下文
ctx.jwtPayload = payload;
ctx.user = user;
await next();
}
Context 类型扩展
ZenWeb 基于 Koa 的 Context 对象,通过 TypeScript 的 declare module 机制实现类型扩展。这样在控制器或中间件中使用 ctx.user 时可以获得完整的类型提示。
扩展方式
// 在项目中的类型声明文件或中间件文件顶部添加
declare module 'zenweb' {
interface Context {
// 添加自定义属性
user?: User;
jwtPayload?: JwtPayload;
tenantId?: number;
}
}
在控制器中使用扩展属性
import { Context, Get } from 'zenweb';
export class ProfileController {
@Get()
async profile(ctx: Context) {
// TypeScript 能够识别 ctx.user 属性
return {
id: ctx.user!.id,
name: ctx.user!.name,
};
}
}
最佳实践
建议将 declare module 'zenweb' 类型扩展集中放置在一个类型声明文件中(如 src/types/zenweb.d.ts),方便统一管理。
在控制器中使用
以下示例展示了完整的注册、登录和获取当前用户信息的控制器实现。
用户服务层
src/service/auth.ts
import { Context, Inject } from 'zenweb';
import { User } from '../model';
import { generateToken, hashPassword, generateSalt } from '../lib/jwt';
export class AuthService {
@Inject ctx!: Context;
/**
* 用户注册
*/
async register(name: string, password: string) {
// 检查用户名是否已存在
const exists = await User.find({ name }).get();
if (exists) {
throw new Error('用户名已存在');
}
// 生成盐值并哈希密码
const salt = generateSalt();
const passwordHash = hashPassword(password, salt);
// 创建用户
const user = await User.createAndGet({
name,
password: passwordHash,
salt,
});
if (!user) {
throw new Error('创建用户失败');
}
// 生成令牌
const token = generateToken({ userId: user.id! });
return { user, token };
}
/**
* 用户登录
*/
async login(name: string, password: string) {
const user = await User.find({ name }).get();
if (!user) {
throw new Error('用户名或密码错误');
}
// 验证密码
const passwordHash = hashPassword(password, user.salt);
if (passwordHash !== user.password) {
throw new Error('用户名或密码错误');
}
// 生成令牌
const token = generateToken({ userId: user.id! });
return { user, token };
}
}
认证控制器
src/controller/auth.ts
import { Context, Post, Get, $query } from 'zenweb';
import { AuthService } from '../service/auth';
import { authMiddleware } from '../middleware/auth';
export class AuthController {
/**
* 用户注册
*/
@Post()
async register(ctx: Context, service: AuthService) {
const { name, password } = await $query.get({
name: '!trim1',
password: '!trim1',
});
try {
return await service.register(name, password);
} catch (e: any) {
ctx.throw(400, e.message);
}
}
/**
* 用户登录
*/
@Post()
async login(ctx: Context, service: AuthService) {
const { name, password } = await $query.get({
name: '!trim1',
password: '!trim1',
});
try {
return await service.login(name, password);
} catch (e: any) {
ctx.throw(400, e.message);
}
}
/**
* 获取当前用户信息(需要认证)
*/
@Get()
async me(ctx: Context) {
// 在需要认证的路由中调用认证中间件
await authMiddleware(ctx, async () => {});
return ctx.user;
}
}
在路由级别使用认证中间件
对于需要全局认证的路由,可以在路由配置中直接使用中间件:
src/controller/profile.ts
import { Context, Get, Put } from 'zenweb';
import { authMiddleware } from '../middleware/auth';
export class ProfileController {
/**
* 获取个人信息 - 需要认证
*/
@Get(authMiddleware)
async profile(ctx: Context) {
return ctx.user;
}
/**
* 修改个人信息 - 需要认证
*/
@Put(authMiddleware)
async updateProfile(ctx: Context) {
// ctx.user 由 authMiddleware 注入
const { name } = $query.get({ name: '!trim1' });
// ... 更新逻辑
return 'ok';
}
}
密码安全
Salt + HMAC 模式
ZenWeb 推荐使用 随机盐值 + HMAC 哈希 的密码安全方案,而不是简单的单向哈希:
import * as crypto from 'crypto';
// 为每个用户生成独立的随机盐值
export function generateSalt(): string {
return crypto.randomBytes(16).toString('hex');
}
// 使用 HMAC-SHA256 结合盐值对密码进行哈希
export function hashPassword(password: string, salt: string): string {
return crypto.createHmac('sha256', salt).update(password).digest('hex');
}
每个用户拥有独立的盐值,即使两个用户使用相同的密码,其哈希结果也完全不同。数据库中存储的结构如下:
user 表:
- id: 1
- name: "alice"
- password: "a3f7b2c9d1e8..." (HMAC 哈希值)
- salt: "4e5f6a7b8c9d..." (随机盐值)
安全要点
- 绝对不要 存储明文密码
- 不要 使用 MD5 或 SHA1 等弱哈希算法
- 每个用户使用独立的随机盐值
- 密码验证时使用恒定时间比较(
crypto.timingSafeEqual)可进一步防止时序攻击
与 ratelimit 配合防暴力破解
登录接口是暴力破解的常见目标。结合 @zenweb/ratelimit 模块可以对登录接口进行请求频率限制。
安装 ratelimit 模块
npm install @zenweb/ratelimit
配置 ratelimit
src/index.ts
import { create } from 'zenweb';
import modRatelimit from '@zenweb/ratelimit';
create()
.setup(modRatelimit({
redis: {
host: '127.0.0.1',
port: 6379,
},
}))
.start();
在登录接口应用限流
src/controller/auth.ts
import { Context, Post, QueryHelper } from 'zenweb';
import { rateLimit } from '@zenweb/ratelimit';
import { AuthService } from '../service/auth';
export class AuthController {
/**
* 登录接口 - 限制同一 IP 每分钟最多 5 次尝试
*/
@Post({
middleware: [
rateLimit({
key: (ctx) => `login:${ctx.ip}`,
limit: 5,
duration: 60,
denyMessage: '登录尝试过于频繁,请稍后再试',
}),
],
})
async login(ctx: Context, query: QueryHelper, service: AuthService) {
const { name, password } = query.get({
name: '!trim1',
password: '!trim1',
});
try {
return await service.login(name, password);
} catch (e: any) {
ctx.throw(400, e.message);
}
}
}
常用限流策略
| 场景 | key | limit | duration | 说明 |
|---|---|---|---|---|
| 登录防爆破 | login:${ctx.ip} | 5 | 60 | 同一 IP 每分钟 5 次 |
| 注册防刷 | register:${ctx.ip} | 3 | 3600 | 同一 IP 每小时 3 次 |
| 全局 API | api:${ctx.ip} | 100 | 60 | 同一 IP 每分钟 100 次 |
| 短信验证码 | sms:${phone} | 1 | 60 | 同一手机号每分钟 1 次 |
CORS 安全配置
前后端分离架构下,需要配置 CORS(跨源资源共享)以确保前端应用可以正常访问 API。
安装 CORS 模块
npm install @zenweb/cors
基础配置
src/index.ts
import { create } from 'zenweb';
import modCors from '@zenweb/cors';
create()
.setup(modCors({
origin: 'https://example.com', // 允许的源
allowMethods: ['GET', 'POST', 'PUT', 'DELETE'],
allowHeaders: ['Content-Type', 'Authorization'],
credentials: true, // 允许携带 Cookie
}))
.start();
动态 origin 配置
在多环境部署时,可以根据请求来源动态决定是否允许跨域:
src/index.ts
import { create } from 'zenweb';
import modCors from '@zenweb/cors';
const ALLOWED_ORIGINS = [
'https://example.com',
'https://admin.example.com',
];
create()
.setup(modCors({
origin: (ctx) => {
const reqOrigin = ctx.get('Origin');
if (ALLOWED_ORIGINS.includes(reqOrigin)) {
return reqOrigin;
}
return '';
},
allowMethods: ['GET', 'POST', 'PUT', 'DELETE'],
allowHeaders: ['Content-Type', 'Authorization'],
credentials: true,
maxAge: 86400, // 预检请求缓存 24 小时
}))
.start();
安全建议
- 生产环境不要使用
origin: '*',这会允许任何来源的跨域请求 - 开启
credentials: true时,origin不能设置为* - 只开放必要的 HTTP 方法和请求头
- 使用
maxAge缓存预检请求结果,减少 OPTIONS 请求次数
完整认证流程总结
1. 用户注册
POST /auth/register
→ 创建用户(随机盐值 + HMAC 哈希密码)
→ 返回 JWT 令牌
2. 用户登录
POST /auth/login
→ 验证用户名密码
→ 返回 JWT 令牌
3. 访问受保护资源
GET /profile (携带 Authorization: Bearer <token>)
→ authMiddleware 验证令牌
→ 加载用户信息到 ctx.user
→ 控制器通过 ctx.user 访问当前用户