任何面向用户的 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 登录方案,流程清晰:
- 用户提交账号密码,服务端验证成功后,在后端内存或 Redis 中创建一个 Session 对象,记录用户 ID 等信息。
- 服务端通过 Set-Cookie 头返回一个 Session ID,浏览器将其保存在 Cookie 中。
- 后续请求浏览器自动携带该 Cookie,服务端根据 Session ID 找到对应 Session,从而判定用户身份。
- 退出时销毁 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 对象,可以在令牌内携带用户信息和过期时间。流程如下:
- 用户登录成功后,服务端生成一个 JWT 返回给客户端。
- 客户端将 JWT 存储起来(localStorage 或 Cookie),后续请求在 Authorization 头中附带
Bearer <token>。 - 服务端收到请求后验证签名和解码载荷,即可获得用户身份,无需查询 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-oauth20、passport-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):如
admin、editor、viewer。 - 权限(Permission):具体操作,如
create:article、delete: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 实战整合与安全清单
一个完整的认证鉴权体系至少应做到以下几点:
- 密码永远加密存储:使用 bcrypt,成本因子至少 10。
- 传输安全:生产环境全程 HTTPS,Cookie 设置
Secure; HttpOnly; SameSite=Strict。 - 令牌安全:JWT 的密钥足够复杂并定期轮换;敏感操作(修改密码、支付)建议加二次验证。
- 防暴力破解:登录接口加上限流(如
express-rate-limit),连续失败后增加验证码或临时锁定。 - 最小权限原则:默认角色只授予必需权限,管理员权限慎重分配。
- 统一错误处理:401 为未认证,403 为无权限,避免给攻击者泄露过多信息。
- CSRF 防护:若使用 Cookie 传递认证凭据,需引入 CSRF token 或启用
SameSite属性,纯 JWT 放在 Authorization 头中则天然抵御 CSRF。
在后续章节中,我们还会在“接口安全”(21.2 节)和“常见 Web 攻击与防御”(21.1 节)中深入讨论安全细节。通过本节的这些基础工具和模型,你已经能够在 Node.js 项目中搭建起一个健壮、可扩展的身份认证与权限校验体系了。