登录功能经常被当成唯一的门槛,一旦认证通过,后续请求就被默认可信。这种做法在实际环境中非常危险,因为会话可能被劫持、权限可能被横向绕过,敏感数据可能通过接口直接暴露。构建安全的用户认证与受限内容访问系统,应当从凭证存储、会话管理、服务端鉴权和审计加固四个层面同时推进。认证系统的第一道关口是密码存储。

一、密码存储:哈希与加盐是底线
绝不能把用户明文密码写入数据库。即使使用 MD5、SHA-1 这类快速摘要算法,也容易被彩虹表或碰撞攻击破解。正确的做法是使用自适应哈希算法,例如 bcrypt、scrypt 或 Argon2。这些算法通过提高计算成本,让攻击者难以批量猜测密码。
加盐是另一个关键手段。盐是一个随机字符串,每个用户独立生成,并与密码混合后再哈希。这样即使两个用户使用了相同密码,数据库中的哈希值也不同,彩虹表攻击也会失效。bcrypt 内部已经包含了盐,开发者无需手动拼接。下面代码演示如何使用 bcrypt 对密码进行哈希和校验。
const bcrypt = require('bcrypt');
const saltRounds = 12;
async function hashPassword(plainPassword) {
const salt = await bcrypt.genSalt(saltRounds);
const hash = await bcrypt.hash(plainPassword, salt);
return hash;
}
async function verifyPassword(plainPassword, storedHash) {
const match = await bcrypt.compare(plainPassword, storedHash);
return match;
}saltRounds 代表哈希计算成本,数值越大越安全,但登录响应时间也会增加。生产环境中通常取 10 到 12 之间。对于高安全要求的系统,可以定期升级哈希成本,并在用户登录成功后重新迁移哈希。
二、会话管理:Session 与 Token 怎么选
HTTP 是无状态协议,服务器无法仅凭请求本身知道两次请求是否来自同一个用户。会话管理的作用是让已认证用户在后续请求中保持身份。主流方案分为两类:服务端 Session 和客户端 Token。
Session 方案下,用户登录后服务器生成唯一会话 ID,保存在内存、Redis 或数据库中,客户端仅持有这个 ID。它的优势是服务端可以主动注销会话,权限变更能立即生效。缺点是分布式部署时需要共享会话存储。Token 方案通常使用 JWT,服务器签发后不再保存状态,客户端在每次请求中携带。它扩展性好,但注销和强制下线相对困难,必须配合短有效期和黑名单机制。
无论选择哪种方案,Cookie 属性的正确设置都至关重要。HttpOnly 可以阻止 JavaScript 读取 Cookie,降低 XSS 攻击后的会话泄露风险。Secure 要求浏览器只在 HTTPS 连接中发送 Cookie。SameSite 设置为 Lax 或 Strict 能显著减少跨站请求伪造。以下是 Express 的 Session 配置示例。
const session = require('express-session');
const app = require('express')();
app.use(session({
secret: 'replace-with-a-long-random-string',
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 1000 * 60 * 30
}
}));secret 应当使用足够长的随机字符串,并妥善保管。maxAge 控制会话有效时间,避免长期不活动的会话一直存在。对于 JWT 方案,同样要在客户端存储和传输环节加强保护,不建议放入 localStorage,优先使用 HttpOnly Cookie 携带。
三、授权与访问控制:中间件统一拦截
认证解决用户是谁,授权决定用户能做什么。受限内容不能只靠前端路由或按钮隐藏来保护,因为攻击者可以直接调用后端接口。服务端必须对每个受限请求进行校验,最清晰的方法是使用中间件统一拦截。
下面示例定义了两个中间件。requireAuth 用于检查会话中是否存在 userId,未登录用户返回 401。requireRole 接收一个角色参数,返回中间件函数,用于限制只有特定角色才能访问。这种工厂函数写法可以灵活复用。
function requireAuth(req, res, next) {
if (req.session && req.session.userId) {
return next();
}
res.status(401).json({ error: 'Unauthorized' });
}
function requireRole(role) {
return function(req, res, next) {
const user = req.session.user;
if (user && user.role === role) {
return next();
}
res.status(403).json({ error: 'Forbidden' });
};
}
app.get('/admin', requireAuth, requireRole('admin'), adminHandler);
app.get('/profile', requireAuth, profileHandler);基于角色的访问控制适合权限边界清晰的系统。更复杂的场景可以扩展为基于资源的访问控制,例如判断用户是否拥有某个文档的读取权限。同时要注意水平越权问题:用户 A 不能通过修改订单 ID 访问用户 B 的订单。这需要在业务逻辑中做对象级权限校验,而不是只检查用户是否登录。
四、安全加固:限流、锁定与审计
认证接口是暴力破解和撞库攻击的主要目标。登录接口必须增加限流机制,单个 IP 在短时间内的失败次数超过阈值时,应暂时拒绝登录或要求输入验证码。对于多次失败的账户,可以启用账户锁定,防止攻击者批量尝试常见密码。
下面是一个简易的登录失败限流实现,使用内存 Map 记录每个 IP 的失败次数和时间窗口。生产环境建议使用 Redis 等共享存储,保证多实例部署时策略一致。
const failedAttempts = new Map();
function checkRateLimit(ip) {
const limit = 10;
const windowMs = 60 * 1000;
const now = Date.now();
const record = failedAttempts.get(ip);
if (!record) {
return true;
}
if (now - record.timestamp >= windowMs) {
return true;
}
return record.count < limit;
}还需要在传输层启用 HTTPS,避免用户密码和会话 ID 在网络上被窃听。登录成功、登录失败、权限变更和受限资源访问都应记录审计日志,日志中避免记录密码和完整 Token。对管理后台等高权限功能,可以引入多因素认证,进一步提高安全等级。
综合来看,安全的用户认证与受限内容访问系统不是单个组件的任务,而是多个层次协同的结果。只有把密码存储、会话管理、服务端授权和安全加固都落实到位,受限内容才能真正处于可控制的保护范围之内。