Skip to content
Java
认证与授权机制
认证与授权机制 的概念、用法、示例和注意点
2024/06/196 分钟Java
认证与授权机制
概述
认证回答“当前用户是谁”,授权回答“这个用户有没有权限做某个操作”。在基于 Express 的应用中,认证通常借助 Passport.js 这样的中间件完成,它将不同的登录方式抽象为可替换的“策略”;授权则通过额外的路由守卫或服务层判定来实现。两者解决的问题域不同,但在一个完整的安全体系里通常会组合出现。
基本概念
理解认证与授权的实现,需要先明确几个关键对象以及它们在请求周期中的角色。
请求中的用户状态
Passport 与express-session协作,在认证成功后通过req.logIn()将用户对象与当前会话绑定。之后每个请求到达时,deserializeUser会根据会话中保存的标识(通常是一个userId)从数据库或缓存中重建完整的用户对象,并将其挂载到req.user。req.isAuthenticated()返回这个重建是否已完成。策略(Strategy)
Passport 把用户名密码登录、OAuth、JWT 等认证方式统一抽象成策略。策略负责拿到请求中的凭证,执行验证逻辑,然后通过done回调通知 Passport 认证结果。例如passport-local策略会从请求体中取出username和password,交由开发者编写的验证函数处理。密码编码器(bcrypt)
密码在存储时永远不应保存明文。bcrypt是目前比较通用的选择,它每次对相同明文生成的哈希值都不一样,因为内部包含随机盐值。登录时只能用bcrypt.compare(plain, hash)比对,不能直接比较哈希字符串。序列化与反序列化
serializeUser决定把用户对象的哪一部分存入会话(通常只存user.id);deserializeUser则在后续请求中根据这个标识重新取出完整的用户对象。序列化时放的东西越少,会话体积越小,反序列化时查询成本也越可控。权限信息
角色(ADMIN、OPERATOR)和细粒度权限(order:read、order:write)可以直接作为数组字段挂在用户对象上(例如req.user.roles、req.user.permissions)。后续的授权中间件会读取这些字段做判断。用户数据模型可以这样表示(以 Mongoose 为例):
javascript
const userSchema = new Schema({
username: String,
passwordHash: String,
roles: [{ type: String }],
permissions: [{ type: String }]
});运行时 req.user 便携带这两个数组。授权逻辑可以同时判断 roles 和 permissions,也可以按需要在路由层只用角色、在服务层再校验权限。
工作原理
以“用户名 + 密码”登录为例,一次完整的请求链路大致如下:
- 客户端向
POST /login发送 JSON 或表单格式的username与password。 - 路由挂载
passport.authenticate('local', { ... })中间件,该中间件会触发LocalStrategy的验证函数。 - 验证函数接收
username、password和done。开发者在函数内部查询用户记录,使用bcrypt.compare比对密码。 - 如果比对成功,调用
done(null, user);如果失败,调用done(null, false, { message: '...' });发生意外错误时调用done(err)。 - Passport 根据
done的调用方式决定后续动作:成功则调用req.logIn(user),失败则根据配置重定向到登录页或返回 401。 logIn内部会执行serializeUser,将用户标识写入会话存储。之后同一个浏览器会话中的任何请求,Passport 都会通过deserializeUser恢复req.user。
对于使用 JWT 的无状态认证,上述会话环节被去除,由策略直接解析令牌并填充 req.user,但验证回调与 done 的调用模式类似。
基本用法
一个最小的本地认证配置包含策略定义、会话中间件和密码编码器。
javascript
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
const bcrypt = require('bcrypt');
const User = require('./models/User');
passport.use(
new LocalStrategy(async (username, password, done) => {
try {
const user = await User.findOne({ username });
if (!user) {
return done(null, false, { message: '用户名不存在' });
}
const isMatch = await bcrypt.compare(password, user.passwordHash);
if (!isMatch) {
return done(null, false, { message: '密码错误' });
}
return done(null, user);
} catch (err) {
return done(err);
}
})
);
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);
}
});Express 应用需要挂载 express-session、以及 passport.initialize() 和 passport.session()。
javascript
const express = require('express');
const session = require('express-session');
const app = express();
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false
}));
app.use(passport.initialize());
app.use(passport.session());登录路由直接使用 passport.authenticate 作为中间件。failureRedirect 控制认证失败后的行为,session 默认开启,登录成功后会建立会话。
javascript
app.post('/login',
passport.authenticate('local', {
failureRedirect: '/login',
failureMessage: true
}),
(req, res) => {
res.json({ username: req.user.username });
}
);在用户注册时需要对密码做单向编码。通常取 12 轮盐值:
javascript
const saltRounds = 12;
async function register(username, plainPassword) {
const passwordHash = await bcrypt.hash(plainPassword, saltRounds);
await User.create({ username, passwordHash });
}保护需要已认证才能访问的路由,可以抽离一个中间件:
javascript
function ensureAuthenticated(req, res, next) {
if (req.isAuthenticated()) {
return next();
}
res.status(401).json({ error: '未登录' });
}
app.get('/api/profile', ensureAuthenticated, (req, res) => {
res.json(req.user);
});自定义认证接口
在 REST API 场景下,经常需要自己控制认证失败时的响应格式,而不是依赖 Passport 内置的重定向机制。这时可以使用 passport.authenticate 的回调模式,在单个路由处理器里手动调用 req.logIn。
javascript
app.post('/auth/login', (req, res, next) => {
passport.authenticate('local', (err, user, info) => {
if (err) {
return next(err);
}
if (!user) {
return res.status(401).json({ message: info?.message || '认证失败' });
}
req.logIn(user, (loginErr) => {
if (loginErr) {
return next(loginErr);
}
return res.json({ username: user.username });
});
})(req, res, next);
});如果传入的 username / password 无效,authenticate 会调用 done(null, false),进而进入 !user 分支,返回 401 响应。凭证有效时,req.logIn 建立会话,随后控制器返回用户信息。
这种模式下同样需要确保 /auth/login 路径不被全局认证中间件拦截,否则请求到达控制器之前就会被挡住。此外,所有请求在进入该路由之前必须已经挂载了 passport.initialize() 和 passport.session()。
基于 URL 的授权
认证之后还需要按路径施加不同的权限。授权通常以中间件的形式挂在路由前:
javascript
function hasRole(...roles) {
return (req, res, next) => {
if (!req.isAuthenticated()) {
return res.sendStatus(401);
}
const userRoles = req.user.roles || [];
if (roles.some(role => userRoles.includes(role))) {
return next();
}
res.sendStatus(403);
};
}
app.get('/api/admin/users', hasRole('ADMIN'), (req, res) => { /* ... */ });
app.get('/api/orders', hasRole('OPERATOR', 'ADMIN'), (req, res) => { /* ... */ });hasRole('ADMIN') 检查 req.user.roles 中是否包含 'ADMIN',角色名称直接由业务规则决定,不自动加前缀。如果需要支持细粒度权限字符串(例如 order:read),可以按同样的方式编写 hasPermission 中间件:
javascript
function hasPermission(...permissions) {
return (req, res, next) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const userPerms = req.user.permissions || [];
if (permissions.some(p => userPerms.includes(p))) return next();
res.sendStatus(403);
};
}两者的区别仅在于检查的数组不同,实践中可以根据项目偏好选择“仅角色”“角色 + 权限”或完全只用权限字符串。
方法级安全
Spring Security 的 @PreAuthorize 可以直接挂在服务层方法上,由 AOP 代理自动拦截。这种模式在 Node.js 中没有语言级支持,但可以用高阶函数或装饰器模拟。目前 JavaScript 装饰器仍处于提案阶段,即便启用了 experimentalDecorators,也无法拦截同一个类内部的方法调用。
比较直接的替代是在服务方法内部做显式的权限校验:
javascript
class OrderService {
async deleteOrder(user, orderId) {
if (!user.roles.includes('ADMIN')) {
const err = new Error('无权限执行此操作');
err.status = 403;
throw err;
}
// 执行删除
}
}如果项目中广泛使用 TypeScript 且启用了装饰器,也可以用 reflect-metadata 与自定义装饰器对方法进行包装,但类内自调用不触发的限制依然存在,效果与上面手动检查没有本质差别。
注意点
会话存储
express-session默认使用内存存储,进程重启后所有会话丢失,且无法用于多进程部署。需要换成connect-redis或数据库存储,并注意secret配置与管理。密码哈希与性能
bcrypt的saltRounds越高加密越慢,同时也会增加登录时的比对耗时。12 轮在安全与响应时间之间是比较常见的折中。每个请求认证时都会执行一次bcrypt.compare,大量并发登录会让 CPU 负载明显上升。序列化中的数据量
serializeUser应只保存不可变的唯一标识,比如user.id。如果存了整个user对象,会话数据会在每次请求时占用更多内存且在反序列化时可能拿到过时信息;此外,修改用户角色后会话中保存的旧副本不会主动更新。中间件顺序
passport.initialize()和passport.session()必须在应用路由定义之前挂载,否则后续中间件中req.user和req.isAuthenticated()都不会正常工作。异步策略中的错误处理
LocalStrategy的验证函数内部如果使用async/await,必须用try/catch包裹,所有异常都应通过done(err)向外传递;否则 Promise 拒绝不会被 Passport 捕获,请求会一直挂起直到超时。异步任务中访问用户上下文
Node 中没有线程本地存储的默认传递机制。如果在某个请求中启动异步任务(setTimeout、queue.add等),任务执行时无法直接拿到req.user。需要显式将所需信息(如userId)传入任务函数或使用AsyncLocalStorage创建与请求绑定的上下文。方法级安全的代理限制
无论是装饰器还是高阶函数,只要在同一个类内通过this.method()调用,代理或包装都不会触发。必须通过外部获取代理实例来调用,或者在方法内部写显式检查。异常响应格式
认证失败和授权失败应返回不同的 HTTP 状态码——401 表示未认证,403 表示无权限。自定义ensureAuthenticated、hasRole这类中间件需要保持一致,这样前端的错误处理逻辑才能统一。
参考链接
相关推荐
2024/06/25 · 6 分钟Session、JWT、OAuth2
Session、JWT、OAuth2 的概念、用法、示例和注意点
2024/06/23 · 5 分钟Spring Security 6 总览Spring Security 6 总览 的概念、用法、示例和注意点
2024/06/17 · 5 分钟Spring Security 源码级原理解析Spring Security 源码级原理解析 的概念、用法、示例和注意点
2024/06/15 · 3 分钟SecurityFilterChain 配置方式SecurityFilterChain 配置方式 的概念、用法、示例和注意点
