用户权限系统是绝大多数 Web 应用的基础骨架,由于它直接关系到数据安全与用户体验,设计上稍有不慎就会留下难以修复的漏洞。用 Node.js 实现一套完整的注册登录加权限体系,并不需要从零造轮,借助成熟的库和清晰的架构分层就能快速落地。
下面我们按照“注册 → 登录 → 鉴权 → 权限控制”的流程,一步步搭出可跑在生产环境的方案。示例以 Express + TypeScript 为主,思路可以轻松迁移到 Koa、NestJS 等框架。
26.1.1 注册:安全地保存用户凭证
注册流程的核心是密码的加密存储和对输入的基本校验。永远不要明文存储密码,也永远不要相信客户端已经做了足够的校验。
1. 数据库设计
最少需要一张用户表(users)和一张角色表(roles),如果权限简单,可以直接在用户表中用 role 字段区分角色(如 admin、editor、viewer)。建议使用独立的角色表和用户-角色关联表,为未来扩展留空间。
users
id INT PRIMARY KEY AUTO_INCREMENT
username VARCHAR(50) UNIQUE NOT NULL
email VARCHAR(100) UNIQUE NOT NULL
password_hash VARCHAR(255) NOT NULL
role_id INT REFERENCES roles(id)
created_at TIMESTAMP
roles
id INT PRIMARY KEY
name VARCHAR(50) UNIQUE (如 admin, editor, viewer)
2. 密码加密
使用 bcrypt 对密码进行哈希处理,salt 轮数通常设为 10~12,兼顾安全与性能。
import bcrypt from 'bcrypt';
const SALT_ROUNDS = 12;
// 注册处理
async function register(username: string, email: string, plainPassword: string) {
// 1. 基础校验:用户名/邮箱格式、密码强度(至少8位含大小写字母和数字)
if (!isStrongPassword(plainPassword)) {
throw new Error('密码强度不足');
}
// 2. 加密
const passwordHash = await bcrypt.hash(plainPassword, SALT_ROUNDS);
// 3. 持久化
await db.query(
'INSERT INTO users (username, email, password_hash) VALUES (?, ?, ?)',
[username, email, passwordHash]
);
}
3. 实操提醒
- 防止重复注册:
username和email加唯一索引,插入前先查询或直接捕获数据库唯一键冲突错误。 - 邮箱验证(可选):发送激活链接,避免恶意注册。激活 token 可以用 JWT 或随机字符串,有效期 24 小时。
- 避免时序攻击:密码强度校验本身不依赖数据库,可以不暴露用户是否存在。登录时比对哈希即可。
26.1.2 登录:生成安全令牌
登录本质是比对密码,成功后向客户端颁发一个短期有效的访问令牌(Access Token)和一个长期有效的刷新令牌(Refresh Token),让后续请求可以在无状态的情况下携带身份信息。
1. 密码比对
使用 bcrypt.compare 是安全的,它会防时序攻击。
async function login(usernameOrEmail: string, plainPassword: string) {
const user = await db.query(
'SELECT id, username, password_hash, role_id FROM users WHERE username = ? OR email = ?',
[usernameOrEmail, usernameOrEmail]
);
if (user.length === 0) {
throw new Error('用户名或密码错误'); // 不透露具体原因
}
const match = await bcrypt.compare(plainPassword, user[0].password_hash);
if (!match) {
throw new Error('用户名或密码错误');
}
// 生成 token
const accessToken = generateAccessToken(user[0]);
const refreshToken = generateRefreshToken(user[0]);
return { accessToken, refreshToken };
}
2. JWT 的生成与配置
- Access Token 有效期建议 15 分钟~1 小时,避免太短导致频繁刷新,太长增加泄露风险。
- Refresh Token 有效期可以 7~30 天,存储在 Redis 等缓存中,并支持主动撤销(退出登录时删除)。
import jwt from 'jsonwebtoken';
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET!;
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET!;
const ACCESS_EXPIRES_IN = '15m';
const REFRESH_EXPIRES_IN = '7d';
function generateAccessToken(user: any) {
return jwt.sign(
{ userId: user.id, roleId: user.role_id },
ACCESS_SECRET,
{ expiresIn: ACCESS_EXPIRES_IN }
);
}
function generateRefreshToken(user: any) {
const token = jwt.sign(
{ userId: user.id },
REFRESH_SECRET,
{ expiresIn: REFRESH_EXPIRES_IN }
);
// 存入 Redis,key: `refresh:${userId}:${token}`,过期时间与 token 一致
redis.set(`refresh:${user.id}:${token}`, 'valid', 'EX', 60 * 60 * 24 * 7);
return token;
}
3. 刷新令牌(Refresh Token Rotation)
当 Access Token 过期时,客户端用 Refresh Token 换取新的 Access Token。为防止 Refresh Token 被泄露导致长期有效,每次刷新时都应轮换 Refresh Token(即 old token 失效,返回 new token)。同时发现一个已被使用过的 Refresh Token 再次出现,应立即撤销该用户所有 Refresh Token——这叫做 Refresh Token 重用检测。
async function refreshTokens(oldRefreshToken: string) {
let payload: any;
try {
payload = jwt.verify(oldRefreshToken, REFRESH_SECRET);
} catch (err) {
throw new Error('refresh token expired or invalid');
}
const key = `refresh:${payload.userId}:${oldRefreshToken}`;
const val = await redis.get(key);
if (!val) {
// token 不存在,可能是已使用或被撤销——撤销该用户所有 token
await revokeAllUserRefreshTokens(payload.userId);
throw new Error('refresh token reused, all tokens revoked');
}
// 删除旧 token
await redis.del(key);
// 生成新的一对
const user = await db.query('SELECT id, role_id FROM users WHERE id = ?', [payload.userId]);
const newAccessToken = generateAccessToken(user[0]);
const newRefreshToken = generateRefreshToken(user[0]);
return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}
4. 安全传输与存储
- 永远通过 HTTPS 传输 token。
- Access Token 推荐放在
Authorization: Bearer <token>头中,不存 localStorage,可用内存变量存储(SPA)或 HttpOnly Cookie(服务端渲染时可避免 XSS)。 - Refresh Token 应通过 HttpOnly、Secure、SameSite=Strict 的 Cookie 传递,或仅在需要时通过携带的请求体发送(配合代码校验
Origin/Referer)。防止 XSS 窃取。
落地提醒:如果使用 Cookie 携带 JWT,需要结合 CSRF 保护(如 SameSite 和 CSRF Token)。
26.1.3 鉴权:验证每个请求的身份
鉴权(Authentication)就是确认“你是谁”。通常实现为中间件,在每个需要登录的请求前拦截并验证 Access Token。
中间件实现
import { Request, Response, NextFunction } from 'express';
async function authenticate(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ message: '未提供令牌' });
}
const token = authHeader.split(' ')[1];
try {
const payload = jwt.verify(token, ACCESS_SECRET) as any;
// 可选:从数据库或缓存里补全用户信息(如角色列表),但会增加额外 I/O
req.user = { id: payload.userId, roleId: payload.roleId };
next();
} catch (err) {
if (err instanceof jwt.TokenExpiredError) {
return res.status(401).json({ message: '令牌已过期,请刷新' });
}
return res.status(401).json({ message: '令牌无效' });
}
}
注意点:
- 为了性能,可以在 JWT payload 里就包含必要的角色信息,避免每次查数据库。但一旦角色变更,旧的 JWT 仍然保持旧角色直到过期。这种方案适用于角色不经常变动且对即时性要求不高的场景。如需即刻生效,可在中间件里查询数据库实时角色。
- 推荐将
req.user类型通过 TypeScript 声明扩展,方便后续编写类型安全的代码。
26.1.4 权限管理:谁能做什么
权限管理(Authorization)回答“你能做什么”。最常见的模型是 RBAC(基于角色的访问控制),即给用户分配角色,给角色分配权限。小型项目可以直接用角色判断,中型以上使用“权限码”更灵活。
1. 简单角色中间件
function requireRole(...roles: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({ message: '未登录' });
}
// 假设角色通过 user.roleId 映射为角色名
const roleName = getRoleNameById(req.user.roleId);
if (!roles.includes(roleName)) {
return res.status(403).json({ message: '没有操作权限' });
}
next();
};
}
// 使用
app.delete('/users/:id', authenticate, requireRole('admin'), deleteUser);
2. 基于权限码的中间件(更灵活)
定义权限码如 user:read、task:write,角色拥有多个权限码。在用户登录或刷新时,将权限码列表放入 JWT payload 或缓存,中间件只需检查是否包含指定的权限码。
function requirePermission(...permissions: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
const userPermissions: string[] = req.user.permissions || [];
const hasPermission = permissions.every(p => userPermissions.includes(p));
if (!hasPermission) {
return res.status(403).json({ message: '权限不足' });
}
next();
};
}
app.delete('/users/:id', authenticate, requirePermission('user:delete'), deleteUser);
3. 动态权限与数据级权限
有时不仅需要判断用户能否执行某个操作,还需要限定可操作的数据范围(如只能操作自己所在部门的用户)。这需要更细粒度的过滤,可以在 Service 层或数据库查询级附加条件,例如:
async function getUsers(req: Request) {
if (req.user.role === 'admin') {
return await db.query('SELECT * FROM users');
} else {
// 只返回同部门的用户
return await db.query('SELECT * FROM users WHERE department_id = ?', [req.user.departmentId]);
}
}
这种“基于资源的权限”更适合在业务逻辑中控制,而非完全依赖单一中间件。
26.1.5 踩坑与生产级加固
下面是根据实际部署经验整理出的几个高频问题,帮助系统避开常见雷区。
1. 暴力破解与撞库
登录接口需要进行限流,可以根据用户名、IP 在时间窗口内计数。使用 express-rate-limit 配合 Redis 实现。多次失败后锁定账户一段时间或在登录增加验证码(仅当超过失败次数时)。
2. 忘记密码与重置流程
重置令牌也要有较短有效期(例如 15 分钟),一次性使用,存储在数据库而非 JWT(因为 JWT 没法强制失效)。发送重置链接时使用随机生成的 token 哈希存储。
3. 多设备登录与会话管理
如果需要限制同时在线设备数量,可以在 Redis 中为每个用户维护一组 Refresh Token,超过数量则删除最早的。
4. 密码强度策略
- 最小长度 8~12 位,包含大小写字母、数字;允许特殊字符。
- 在修改密码时要求输入旧密码。
5. 日志与监控
记录登录、权限拒绝等关键事件,可配合 winston 等日志库输出到文件或集中日志系统,便于排查安全事件。
6. 定期审计与依赖安全
使用 npm audit 检查如 jsonwebtoken、bcrypt 等库的漏洞,及时升级。
小结
用户权限系统虽然功能“老套”,但每一个环节都隐藏着安全陷阱。借助 Node.js 生态中成熟的库,我们可以快速搭建出可靠的注册、登录、JWT 鉴权和 RBAC 权限控制。关键在于:
- 密码用 bcrypt 加盐哈希,绝不平存。
- 令牌体系要区分 Access / Refresh 并做好轮换。
- 鉴权中间件要性能与安全兼顾,角色或权限码放入 JWT 时权衡即时性。
- 权限控制从简单的角色判断开始,按需演进为权限码或数据级过滤。
完成这层基础设施后,业务扩展与迭代会更加踏实。下一节我们将把注意力转向同样常见的后台管理系统 API 设计,看如何用 Node.js 高效地构建 CRUD、分页、筛选等核心能力。