人人都会AI编程

26.1 用户权限系统:注册、登录、鉴权、权限管理

更新时间:2026-07-11

用户权限系统是绝大多数 Web 应用的基础骨架,由于它直接关系到数据安全与用户体验,设计上稍有不慎就会留下难以修复的漏洞。用 Node.js 实现一套完整的注册登录加权限体系,并不需要从零造轮,借助成熟的库和清晰的架构分层就能快速落地。

下面我们按照“注册 → 登录 → 鉴权 → 权限控制”的流程,一步步搭出可跑在生产环境的方案。示例以 Express + TypeScript 为主,思路可以轻松迁移到 Koa、NestJS 等框架。

26.1.1 注册:安全地保存用户凭证

注册流程的核心是密码的加密存储和对输入的基本校验。永远不要明文存储密码,也永远不要相信客户端已经做了足够的校验。

1. 数据库设计

最少需要一张用户表(users)和一张角色表(roles),如果权限简单,可以直接在用户表中用 role 字段区分角色(如 admineditorviewer)。建议使用独立的角色表和用户-角色关联表,为未来扩展留空间。

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. 实操提醒

  • 防止重复注册:usernameemail 加唯一索引,插入前先查询或直接捕获数据库唯一键冲突错误。
  • 邮箱验证(可选):发送激活链接,避免恶意注册。激活 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:readtask: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 检查如 jsonwebtokenbcrypt 等库的漏洞,及时升级。

小结

用户权限系统虽然功能“老套”,但每一个环节都隐藏着安全陷阱。借助 Node.js 生态中成熟的库,我们可以快速搭建出可靠的注册、登录、JWT 鉴权和 RBAC 权限控制。关键在于:

  • 密码用 bcrypt 加盐哈希,绝不平存。
  • 令牌体系要区分 Access / Refresh 并做好轮换。
  • 鉴权中间件要性能与安全兼顾,角色或权限码放入 JWT 时权衡即时性。
  • 权限控制从简单的角色判断开始,按需演进为权限码或数据级过滤。

完成这层基础设施后,业务扩展与迭代会更加踏实。下一节我们将把注意力转向同样常见的后台管理系统 API 设计,看如何用 Node.js 高效地构建 CRUD、分页、筛选等核心能力。