人人都会AI编程

身份认证与权限校验

更新时间:2026-07-10

任何面向用户的 Web 系统都需要处理两个核心安全问题:身份认证(Authentication) 确认“你是谁”,权限校验(Authorization) 决定“你能做什么”。传统后端中这两部分代码往往和业务逻辑交织在一起,而 Node.js 生态提供了一套分层清晰、开箱即用的工具链。本节从密码加密入手,逐步覆盖 Session、JWT 两大认证方案,最终落地到 Passport 框架和 RBAC 权限模型,完整呈现一个可生产使用的认证鉴权体系。

14.1.1 密码加密:为什么一定要用 bcrypt

用户密码绝对不能以明文或简单 MD5/SHA 散列值存储。因为一旦数据库泄露,攻击者可以通过彩虹表或暴力碰撞瞬间还原大批弱密码。bcrypt 是目前社区公认的密码哈希标准,它有三个关键设计:

  • 自动加盐:每次加密都会生成一个随机盐值,不同用户即使密码相同,生成的密文也完全不同。
  • 成本因子:通过 saltRounds 参数控制计算强度(通常在 10~12 之间),每增加一级,破解时间翻倍,可以有效抵御算力增长。
  • 单向不可逆:只能从密码生成哈希,无法从哈希反推密码,验证时也需要重新计算。

在 Node.js 中使用 bcrypt 非常直观:

const bcrypt = require('bcrypt');
const saltRounds = 10;

// 注册时加密密码
async function hashPassword(plainPassword) {
  return bcrypt.hash(plainPassword, saltRounds);
}

// 登录时验证密码
async function comparePassword(plainPassword, hash) {
  return bcrypt.compare(plainPassword, hash);
}

注册接口拿到用户输入的密码后,调用 hashPassword 将生成的哈希存入数据库,原始密码随即丢弃。登录时使用 comparePassword 比对即可,整个过程绝不在应用内存中长期保留明文密码。

14.1.2 Session-Cookie 认证机制

基于 Session 的认证是最传统的 Web 登录方案,流程清晰:

  1. 用户提交账号密码,服务端验证成功后,在后端内存或 Redis 中创建一个 Session 对象,记录用户 ID 等信息。
  2. 服务端通过 Set-Cookie 头返回一个 Session ID,浏览器将其保存在 Cookie 中。
  3. 后续请求浏览器自动携带该 Cookie,服务端根据 Session ID 找到对应 Session,从而判定用户身份。
  4. 退出时销毁 Session,或设置 Cookie 过期。

在 Express 中,通常由 express-session 中间件负责 Session 的创建和持久化,搭配 Redis 存储可以实现跨进程共享和数据持久。

const session = require('express-session');
const RedisStore = require('connect-redis')(session);

app.use(session({
  store: new RedisStore({ client: redisClient }), // 生产环境使用 Redis
  secret: process.env.SESSION_SECRET,             // 用于签名 Cookie
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,   // 防止 XSS 读取
    secure: true,     // HTTPS 下发送
    maxAge: 1000 * 60 * 60 * 24 // 有效期
  }
}));

// 登录接口
app.post('/login', async (req, res) => {
  const user = await User.findOne({ username: req.body.username });
  if (user && await bcrypt.compare(req.body.password, user.passwordHash)) {
    req.session.userId = user.id;  // Session 中只存最少必要信息
    res.json({ success: true });
  } else {
    res.status(401).json({ error: 'Invalid credentials' });
  }
});

// 认证中间件
function requireAuth(req, res, next) {
  if (!req.session.userId) {
    return res.status(401).json({ error: 'Unauthorized' });
  }
  next();
}

适用场景:传统的服务端渲染网站、内部管理系统等,需要服务端主动销毁会话的场景。

限制:依赖 Cookie,不适合移动 App 或跨域 API;水平扩展时必须有共享 Session 存储(如 Redis);服务端状态化,不利于无状态微服务。

14.1.3 JWT 令牌认证

JSON Web Token (JWT) 是另一种主流方案,它是一个自包含的、经过签名的 JSON 对象,可以在令牌内携带用户信息和过期时间。流程如下:

  1. 用户登录成功后,服务端生成一个 JWT 返回给客户端。
  2. 客户端将 JWT 存储起来(localStorage 或 Cookie),后续请求在 Authorization 头中附带 Bearer <token>
  3. 服务端收到请求后验证签名和解码载荷,即可获得用户身份,无需查询 Session 存储。

使用 jsonwebtoken 库实现:

const jwt = require('jsonwebtoken');
const JWT_SECRET = process.env.JWT_SECRET;

// 生成 token(登录成功后)
function generateToken(user) {
  return jwt.sign(
    { userId: user.id, role: user.role },  // 载荷中仅放不敏感信息
    JWT_SECRET,
    { expiresIn: '2h' }                   // 过期时间
  );
}

// 验证中间件
function authenticateJWT(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader) return res.sendStatus(401);

  const token = authHeader.split(' ')[1];
  jwt.verify(token, JWT_SECRET, (err, decoded) => {
    if (err) return res.sendStatus(403); // 令牌无效或过期
    req.user = decoded;  // 将解析后的用户信息挂载到请求对象
    next();
  });
}

JWT 的优势

  • 无状态:服务端不需要存储任何会话数据,容易水平扩展,天然适合微服务和 RESTful API。
  • 跨域友好:移动端、前后端分离架构均可直接使用,不依赖 Cookie。
  • 自包含:可在令牌内嵌入用户角色、权限等基础信息,减少数据库查询。

使用中的真实考量

  • 无法主动失效:JWT 签发后,在过期时间前服务端无法令其失效。常见的弥补方案是维护一个 Token 黑名单(如 Redis 存储已登出用户的 jti),或采用较短的过期时间配合 Refresh Token 机制。
  • 载荷不宜过大:每次请求都会携带 JWT,因此只应放置必要的用户标识,避免影响网络传输性能。
  • 安全存储:客户端不能将 JWT 存放在容易被 XSS 攻击读取的地方(如 localStorage),在 Web 应用中推荐使用 httpOnly 的 Cookie 配合 CSRF 防护,而不是将令牌裸放在客户端 JavaScript 可访问的位置。

Session 与 JWT 的选择:没有绝对的优劣,取决于架构。需要强制会话管理等场景(如银行系统)偏向 Session;追求无状态和跨平台 RESTful API 则常用 JWT。

14.1.4 Passport 统一认证框架

无论是 Session 还是 JWT,自己手写完整逻辑仍会涉及很多重复代码。Passport 是 Node.js 生态中最成熟的认证框架,它将认证过程抽象为“策略(Strategy)”,你只需选择对应的策略并提供验证逻辑,Passport 会自动处理 cookie、session、token 等细节。

常用的策略包括:

  • passport-local:基于用户名密码的表单登录。
  • passport-jwt:从 Authorization 头或 Cookie 中提取并验证 JWT。
  • passport-google-oauth20passport-github 等:OAuth 第三方登录。

集成 Passport 的步骤:

const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;

// 1. 定义本地策略
passport.use(new LocalStrategy(
  async (username, password, done) => {
    try {
      const user = await User.findOne({ username });
      if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
        return done(null, false, { message: 'Invalid credentials' });
      }
      return done(null, user);
    } catch (err) {
      return done(err);
    }
  }
));

// 2. 序列化用户到 session(仅当使用 session 时需要)
passport.serializeUser((user, done) => done(null, user.id));
passport.deserializeUser(async (id, done) => {
  try {
    const user = await User.findById(id);
    done(null, user);
  } catch (err) {
    done(err);
  }
});

// 3. 在登录路由中使用
app.post('/login', passport.authenticate('local', {
  successRedirect: '/dashboard',
  failureRedirect: '/login',
  failureFlash: true
}));

对于 API 服务,使用 passport-jwt 同样简单:

const JwtStrategy = require('passport-jwt').Strategy;
const ExtractJwt = require('passport-jwt').ExtractJwt;

passport.use(new JwtStrategy({
  jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
  secretOrKey: process.env.JWT_SECRET
}, async (payload, done) => {
  try {
    const user = await User.findById(payload.userId);
    if (user) return done(null, user);
    return done(null, false);
  } catch (err) {
    return done(err);
  }
}));

// 保护路由
app.get('/api/protected', passport.authenticate('jwt', { session: false }), (req, res) => {
  res.json({ user: req.user });
});

Passport 的好处在于,认证逻辑和业务路由解耦,切换认证方式时只需更换策略,而不必修改业务层代码。对于有第三方登录需求的项目尤其高效。

14.1.5 RBAC 权限模型与实现

身份认证后,接下来是权限控制。最常用且易扩展的模型是 RBAC(Role-Based Access Control,基于角色的访问控制),核心思想是:用户 → 角色 → 权限

  • 用户(User):拥有一个或多个角色。
  • 角色(Role):如 admineditorviewer
  • 权限(Permission):具体操作,如 create:articledelete:user 等。

基本实现

在简单系统中,可以直接在用户数据中存储角色字段,路由使用自定义中间件校验:

// 用户模型(Mongoose 示例)
const userSchema = new mongoose.Schema({
  username: String,
  role: { type: String, enum: ['admin', 'editor', 'viewer'], default: 'viewer' }
});

// 权限中间件工厂
function requireRole(...roles) {
  return (req, res, next) => {
    if (!req.user || !roles.includes(req.user.role)) {
      return res.status(403).json({ error: 'Forbidden' });
    }
    next();
  };
}

// 使用
app.delete('/api/users/:id', authenticateJWT, requireRole('admin'), async (req, res) => {
  // 只有 admin 角色可以删除用户
});

这种硬编码方式适用于角色固定、权限边界清晰的小型项目。当权限关系变复杂(如资源级控制、不同部门的不同权限),更推荐将权限信息解耦到数据库或配置文件中。

动态权限表

建立三张表(或集合):用户表、角色表、权限表,以及关联表。用户可以拥有多个角色,角色可以绑定多项权限。然后通过中间件动态验证权限字符串:

// 验证权限的中间件
function requirePermission(permission) {
  return async (req, res, next) => {
    const user = await User.findById(req.user.id).populate({
      path: 'roles',
      populate: { path: 'permissions' }
    });
    const hasPermission = user.roles.some(role =>
      role.permissions.some(p => p.name === permission)
    );
    if (!hasPermission) {
      return res.status(403).json({ error: 'Forbidden' });
    }
    next();
  };
}

对于资源级权限(如用户只能编辑自己创建的文章),可以在业务层进一步判断 req.user.id === article.authorId,这是 ABAC 的雏形,但多数项目只需要 RBAC 加上少量自定义业务判断即可。

14.1.6 实战整合与安全清单

一个完整的认证鉴权体系至少应做到以下几点:

  1. 密码永远加密存储:使用 bcrypt,成本因子至少 10。
  2. 传输安全:生产环境全程 HTTPS,Cookie 设置 Secure; HttpOnly; SameSite=Strict
  3. 令牌安全:JWT 的密钥足够复杂并定期轮换;敏感操作(修改密码、支付)建议加二次验证。
  4. 防暴力破解:登录接口加上限流(如 express-rate-limit),连续失败后增加验证码或临时锁定。
  5. 最小权限原则:默认角色只授予必需权限,管理员权限慎重分配。
  6. 统一错误处理:401 为未认证,403 为无权限,避免给攻击者泄露过多信息。
  7. CSRF 防护:若使用 Cookie 传递认证凭据,需引入 CSRF token 或启用 SameSite 属性,纯 JWT 放在 Authorization 头中则天然抵御 CSRF。

在后续章节中,我们还会在“接口安全”(21.2 节)和“常见 Web 攻击与防御”(21.1 节)中深入讨论安全细节。通过本节的这些基础工具和模型,你已经能够在 Node.js 项目中搭建起一个健壮、可扩展的身份认证与权限校验体系了。