登录与会话
把密码验证与后续会话分开设计,掌握慢哈希、随机令牌、服务器会话、Cookie 属性和退出失效。
建议先读:HTTP 接口与服务分层PostgreSQL 数据建模
本页内容
本章解决什么问题#
前端知道如何保存登录态和跳转登录页,但服务端必须回答更具体的问题:怎样保存密码,怎样证明请求属于某个人,令牌泄漏后能否失效,退出是否真的撤销访问能力。本章目标是实现可验证的密码哈希与会话生命周期核心,理解浏览器 Cookie 的传输边界。前置是 HTTP、错误合同和数据存储;本章不要求自己实现 OAuth 协议。
把“用户已登录”拆成两步。认证是在某个时刻验证凭据,成功后创建会话;后续请求携带一个随机会话标识,服务器检查它是否存在、过期或撤销。Vue store 中的 user 对象只是界面状态,不能当成服务器身份依据。浏览器修改 localStorage 中的 role,不应改变后台任何权限。
密码、令牌和 Cookie 各自是什么#
密码通常由人选择,熵低且可能重复;需要使用带随机盐、可调成本的密码哈希,增加离线猜测成本。普通 SHA-256 太快,不适合直接保存密码。本章使用 Node 自带的 scrypt,明确设置参数;实际产品也可选择维护良好的 Argon2id 实现,并跟随安全建议调整成本。OWASP 密码存储说明 给出了算法与参数建议。
会话令牌由密码学安全随机数生成,应该具有足够不可预测性。服务器可只保存令牌的 SHA-256 摘要:这里适合快哈希,因为令牌是高熵随机值,而不是人类密码。数据库泄漏时,原始令牌不应可以直接从会话表读出。密码哈希的盐并非秘密,参数与盐可以和哈希一起保存,便于未来迁移。
Cookie 是浏览器自动附带数据的机制,不是加密算法。HttpOnly 阻止网页 JavaScript 直接读取 Cookie;Secure 要求安全传输;SameSite 限制某些跨站请求中的附带行为;Path 约束发送路径。它们降低风险,但不能代替 HTTPS、XSS 防护、CSRF 验证和权限判断。__Host- 前缀要求 Secure、Path=/ 且不能设置 Domain,适合绑定单一主机的会话。
核心 API 合同#
randomBytes(size) 返回指定字节数的随机 Buffer;本例用三十二字节生成会话值并编码为 base64url。scrypt(password, salt, keylen, options, callback) 通过回调返回派生密钥,可用 promisify 转成 Promise。N、r、p 控制成本,maxmem 限制内存;参数不合规或内存限制过低会抛错或拒绝。示例采用 N=131072、r=8、p=1,并给出足够 maxmem,不能直接套默认内存上限。
timingSafeEqual(a, b) 比较等长字节数组,长度不同会报错;它只保护这一步比较,不自动让整个登录流程具有相同时间。未知账号也进行一次预设哈希验证,有助于避免非常明显的用户名枚举差异,但仍需要限速、统一错误和监控。Node Crypto 是参数与返回值的正式说明。
完整示例:认证核心与会话失效#
环境:Node 22.22 或 24,无依赖;由于 scrypt 参数包含内存成本,运行需留有足够内存。保存为 auth-session.mjs,执行 node auth-session.mjs。这是一份本地核心逻辑自检,不启动 HTTP 服务,不声称已经验证浏览器 Cookie 行为。示例密码只属于练习用户,不能作为部署默认账号。
// auth-session.mjs
import { randomBytes, scrypt, timingSafeEqual, createHash } from 'node:crypto';
import { promisify } from 'node:util';
import assert from 'node:assert/strict';
const derive = promisify(scrypt);
const policy = { N: 131072, r: 8, p: 1, maxmem: 256 * 1024 * 1024 };
const TTL_MS = 15 * 60 * 1000;
const sessions = new Map(); // 教学存储;生产需要共享且可清理的会话存储。
async function hashPassword(password) {
const salt = randomBytes(16);
const hash = await derive(password, salt, 64, policy);
return `scrypt$131072$8$1$${salt.toString('hex')}$${hash.toString('hex')}`;
}
async function verifyPassword(password, stored) {
if (typeof password !== 'string' || Buffer.byteLength(password) > 128) return false;
const [algorithm, n, r, p, saltHex, hashHex, extra] = stored.split('$');
// 只接受当前支持的参数,避免坏记录触发无限制资源消耗。
if (algorithm !== 'scrypt' || n !== '131072' || r !== '8' || p !== '1' || extra !== undefined
|| !/^[a-f0-9]{32}$/.test(saltHex ?? '')
|| !/^[a-f0-9]{128}$/.test(hashHex ?? '')) return false;
const actual = await derive(password, Buffer.from(saltHex, 'hex'), 64, policy);
return timingSafeEqual(actual, Buffer.from(hashHex, 'hex'));
}
function tokenDigest(token) {
return createHash('sha256').update(token).digest('hex');
}
function createSession(userId, now = Date.now()) {
const token = randomBytes(32).toString('base64url');
const csrfToken = randomBytes(32).toString('base64url');
sessions.set(tokenDigest(token), { userId, csrfToken, expiresAt: now + TTL_MS });
const cookie = `__Host-session=${token}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=900`;
return { token, csrfToken, cookie };
}
function readSession(token, now = Date.now()) {
if (typeof token !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(token)) return null;
const digest = tokenDigest(token);
const session = sessions.get(digest);
if (!session) return null;
if (session.expiresAt <= now) {
sessions.delete(digest);
return null;
}
return session;
}
function logout(token) {
if (typeof token === 'string') sessions.delete(tokenDigest(token));
return '__Host-session=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0';
}
const user = {
id: 'user-1', email: 'learner@example.test',
passwordHash: await hashPassword('Lesson-only-password-42!'),
};
const dummyHash = await hashPassword('Unknown-user-dummy-password');
async function login(email, password, previousToken) {
const found = email === user.email ? user : null;
const valid = await verifyPassword(password, found?.passwordHash ?? dummyHash);
if (!found || !valid) throw Object.assign(new Error('账号或密码错误'), { code: 'AUTH_FAILED' });
// 认证成功后创建全新会话,避免沿用登录前不可信的会话标识。
if (previousToken) logout(previousToken);
return createSession(found.id);
}
const issued = await login(user.email, 'Lesson-only-password-42!');
assert.equal(readSession(issued.token).userId, user.id);
assert.ok(issued.cookie.includes('HttpOnly; Secure; SameSite=Lax'));
assert.equal(readSession(issued.token, Date.now() + TTL_MS + 1), null);
const next = await login(user.email, 'Lesson-only-password-42!');
logout(next.token);
assert.equal(readSession(next.token), null);
await assert.rejects(() => login('unknown@example.test', 'wrong'), { code: 'AUTH_FAILED' });
console.log('密码验证、会话过期、退出失效与统一登录错误检查通过');
预期输出一条检查通过说明,运行时间取决于本机 scrypt 成本。不要把耗时较长立即当成 bug,密码哈希的计算成本正是设计的一部分;但需要用部署硬件测量并限制并发,避免登录洪峰耗尽线程池与内存。
关键代码逐段说明#
hashPassword 每次生成独立盐,因此同一密码两次注册也应得到不同存储字符串。verifyPassword 从存储记录读取算法标记与参数,限制为本实现支持的版本;未来升级参数时,可在成功验证旧记录后重新哈希,而不是要求所有用户同一天改密码。
readSession 验证令牌格式、查摘要、检查服务端过期时间。Cookie 的 Max-Age 只控制客户端保存行为,不能替代服务端过期判断;攻击者可以绕过浏览器直接发送旧 Cookie。logout 同时删除服务端会话并生成删除 Cookie 的响应值,只有删除浏览器值而保留服务端会话,会让已复制的令牌继续有效。
csrfToken 在例子中已生成,但没有假装完成请求层 CSRF 防护。真正接入 HTTP 时,写操作应检查可信 Origin,并按会话验证自定义 CSRF 头中的令牌,配合 SameSite;登录接口本身也应考虑登录 CSRF。跨站部署需要重新设计 Cookie、CORS 和凭据策略,不能随手改成 SameSite=None 后宣布完成安全配置。
把登录看成凭据交换,而不是保存一个 user 对象#
“团队文档与任务助手”登录成功时,服务器验证了一次密码,然后发出一份可撤销、可过期的会话凭据。浏览器之后不应在每个文档请求里再次发送密码,而是携带会话。服务端用会话恢复用户编号,再到成员关系中判断当前团队权限。这条链把密码验证、会话有效性和资源授权分开,每一层都有不同的失效条件。
Vue store 中保存的昵称、头像和角色用于展示,刷新后可以通过受保护的当前用户接口重新获取。不能因为 localStorage 里还存在一个 user 就跳过服务器认证,也不能因为前端路由判断为登录就让后端信任 body.userId。客户端状态是方便交互的缓存,服务器才负责确认凭据是否仍有效。
认证失败与服务暂时不可用也应区分。密码错误可以返回统一的凭据错误;数据库连接失败不应被翻译成“密码错误”,否则用户会不断修改正确密码,监控也看不见服务故障。公开错误可以保持简洁,内部日志仍应记录失败阶段和请求标识,同时排除明文密码与会话值。
密码哈希输入的细节会影响长期兼容#
密码是用户选择的秘密文本,不应像标题一样随手 trim。注册时去掉空格、登录时保留空格,或者某次升级改变 Unicode 归一化方式,都会让用户无法用原密码登录。产品可以明确输入长度和允许策略,但注册、修改和验证必须一致。长度限制应说明按字节还是字符计算,避免多字节密码在不同实现里被悄悄截断。
前端先对密码做普通哈希,并不能代替 HTTPS 与服务端慢哈希。如果服务器把这个前端哈希直接当凭据,窃取它的人仍可重放登录,它只是换了一种形状的密码。服务端保存的密码哈希应具有独立随机盐和可调成本;浏览器传输仍要通过安全连接,日志与错误上报也不能记录输入秘密。
scrypt 的 N、r、p 共同影响计算与内存成本,不能只看一个数字大不大。教材使用固定参数让读者能够理解与复现,不代表所有部署硬件都适合同样并发。应测量目标机器上一次验证的耗时与内存,再限制同时进行的密码验证。否则为了提高离线猜测成本而增加的资源消耗,也可能被大量登录请求利用。
密码哈希记录需要版本信息。未来调整成本或算法时,先按照记录中的旧规则验证,成功后再用新规则重新生成;不应把旧哈希当作明文重新哈希,否则会改变验证语义。对损坏或不支持的存储格式,应拒绝验证并记录内部异常,而不是把未知参数无上限交给密码函数执行。
API 细读:随机数、比较与会话存储#
randomBytes(size) 同步形式返回 Buffer,也有接受回调的异步形式;size 以字节计量,必须是有效范围内的整数。会话令牌选择三十二随机字节,再编码成 base64url,这个编码不是加密,只是让字节适合 Cookie 与 URL 安全字符集。不能用 Date.now、Math.random 或连续数据库编号生成登录令牌,因为可预测性会直接破坏身份边界。
createHash('sha256').update(token).digest('hex') 适合把高熵随机令牌转换成数据库查找键。摘要不可逆不意味着令牌可以很短,随机空间仍要足够大。数据库记录可保存 user_id、创建时间、过期时间、最近使用时间与撤销状态;原始令牌只在签发时交给客户端,不应再次从会话表直接查询出来。
timingSafeEqual(a,b) 要求长度一致;长度不同时应先返回失败,不能让异常变成服务错误。它仅比较字节,前面的账号查询、格式检查与慢哈希成本仍可能形成时间差异。未知用户使用固定虚拟哈希可以减少明显差异,但不是声称整个系统完全恒时。限速、统一公开消息和异常监控依然是必要部分。
会话读取函数的返回值应清楚区分有效身份和无会话,例如 Session 或 null;内部存储故障则应抛出可观察的服务错误,不能悄悄当成匿名。有效期没有“浏览器替我验证”的默认保证,每次服务端读取都要检查;客户端可以直接构造请求继续发送已经过期的 Cookie。
Cookie 的几个属性分别约束什么#
HttpOnly 限制网页脚本直接读取 Cookie,但浏览器仍会在符合条件的请求中自动附带它,所以不能阻止脚本借用当前会话发起操作。Secure 限制安全传输,生产应使用 HTTPS;本地 HTTP 的浏览器特殊处理不适合作为部署依据。Path 决定发送路径范围,通常不是隔离同源不同应用的强安全边界。
SameSite 的“站点”与浏览器同源策略中的“源”不是同一个概念。两个子域可能同站却不同源,CORS 与 SameSite 解决的限制也不同。Lax 可以减少部分跨站写操作携带会话,但不能替代完整 CSRF 设计;跨站嵌入场景若确实需要 None,还要满足 Secure 并重新验证请求来源与凭据策略。不要仅为了消除一次前端跨域报错就放宽所有来源。
__Host- 前缀适合主机限定会话,要求安全属性、根路径且不设置 Domain。清除 Cookie 时也要匹配名称和相关作用范围,否则浏览器可能保留原来的那一份。服务端撤销与浏览器清理应同时发生:只让浏览器忘记,不能阻止别人使用复制的令牌;只撤销不清理,则界面可能持续携带失效值。
最小实验:CSRF 令牌与 Origin 必须匹配会话#
保存为 csrf-contract.mjs,Node 22.22 或 24 执行 node csrf-contract.mjs,无依赖。它验证请求检查逻辑,不启动浏览器,也不声称字符串测试已经证明 Cookie 在跨站环境中的行为。真实服务器应从 Cookie 恢复会话后再调用同类检查。
// csrf-contract.mjs
import { randomBytes,timingSafeEqual } from 'node:crypto';
import assert from 'node:assert/strict';
const allowedOrigin='https://assistant.example.test';
function requireWrite(request,session){
if(!session)throw Object.assign(new Error('未登录'),{status:401});
if(!['POST','PUT','PATCH','DELETE'].includes(request.method))throw new Error('本函数仅检查写操作');
if(request.headers.get('origin')!==allowedOrigin)throw Object.assign(new Error('来源不允许'),{status:403});
const supplied=request.headers.get('x-csrf-token')??'';
if(!/^[A-Za-z0-9_-]{43}$/.test(supplied))throw Object.assign(new Error('CSRF 无效'),{status:403});
const expected=Buffer.from(session.csrfToken,'utf8');const actual=Buffer.from(supplied,'utf8');
if(expected.length!==actual.length||!timingSafeEqual(expected,actual))throw Object.assign(new Error('CSRF 无效'),{status:403});
return session.userId;
}
const session={userId:'user-1',csrfToken:randomBytes(32).toString('base64url')};
function request(origin,token){return new Request(`${allowedOrigin}/documents`,{
method:'POST',headers:{origin,'x-csrf-token':token},body:'{}',
});}
assert.equal(requireWrite(request(allowedOrigin,session.csrfToken),session),'user-1');
assert.throws(()=>requireWrite(request('https://other.example.test',session.csrfToken),session),{status:403});
assert.throws(()=>requireWrite(request(allowedOrigin,randomBytes(32).toString('base64url')),session),{status:403});
assert.throws(()=>requireWrite(request(allowedOrigin,session.csrfToken),null),{status:401});
console.log('合法会话与来源通过;错误来源、错误 CSRF 和未登录均拒绝');
预期输出一条说明。Origin 不是身份凭据,非浏览器客户端可以自行发送它;CSRF 防护的目的,是阻止另一网站借用浏览器自动附带的会话,不是替代密码或会话验证。若同源页面已经有 XSS,脚本可能读取 CSRF 值并执行操作,所以输出编码、内容安全和依赖安全仍然重要。
会话期限、续期与退出全部设备#
绝对过期表示会话从创建时起最多有效多久,空闲过期表示距离最近活动多久以后失效。只采用每次访问续期的空闲策略,持续活动的被盗令牌可能长期有效;可以同时设置绝对上限,并对敏感操作要求重新验证。过期时间应由服务端可信时钟判断,不能接受客户端说“我还没过期”。
单设备退出可以删除一条会话,全部设备退出则要撤销同用户所有会话。会话版本是一种实现:用户记录保存版本号,每个会话带签发版本,退出全部设备递增用户版本,后续读取比较是否一致。它简化撤销动作,但读取版本时若使用长缓存,就会引入撤销延迟,因此一致性合同仍需明确。
登录成功后应创建新会话标识,避免沿用登录前可被攻击者固定的会话。权限提升、密码修改或可疑活动之后,也可以按策略轮换或撤销。轮换不是简单把旧令牌永久保留为备用,否则实际攻击面会增长;旧值什么时候失效、并发请求如何处理,需要结合产品体验设计。
原练习的完整参考:撤销一个用户的全部会话#
保存为 session-revoke.mjs,无依赖,执行 node session-revoke.mjs。以下用内存模拟用户版本和会话表,完整验证两台同用户设备失效、另一用户保持有效。生产必须把版本与会话放入可共享存储,并验证多实例缓存策略。
完整代码已收录在本章末尾的练习参考答案中;可先阅读说明,再展开复制运行。
登录防护与可恢复的账号流程#
慢哈希应在合理的请求限速之后执行。可以同时限制来源和账号尝试,避免一个来源尝试大量账号,或多个来源集中攻击同一个账号;但策略不能轻易让攻击者通过反复失败把别人永久锁死。拒绝时提供清楚且不泄漏账号存在性的反馈,记录异常模式,必要时要求额外验证。
密码重置令牌与登录会话也应分开,具有独立目的、短有效期和一次性消费语义。重置成功是否撤销所有会话,应写进产品策略。不要把重置链接里的令牌记录在普通访问日志、分析平台或错误上报中;链接属于临时凭据,不是普通页面参数。
如果产品选择外部身份提供商,应使用维护中的 OIDC/OAuth 客户端并验证回调、状态和令牌用途,不能把“拿到一个 access token”直接等同于本地用户已经安全登录。外部身份与内部租户成员关系仍然不同,登录来源认证了某个人,不代表自动加入任何团队。
有状态会话与 JWT 的取舍#
不透明随机会话标识容易集中撤销和查看设备,代价是后续请求需要访问会话存储或受控缓存。签名令牌可以让某些服务本地验证身份声明,但退出、权限变化与签发密钥轮换会增加设计复杂度。令牌里携带的角色属于签发时的声明,不会随着数据库成员变化自动更新。
JWT 不是加密的同义词,普通签名载荷可以被读取,不应放秘密文档、密码或模型密钥。验证必须使用预期算法、签发者、受众和期限,并明确是否需要撤销机制。对于规模不大的团队文档助手,清楚的服务器会话往往更容易起步;跨服务场景需要什么能力,再有针对性选择令牌方案。
调试与真实浏览器验收#
浏览器中先看登录响应是否包含 Set-Cookie,再看后续请求是否实际携带 Cookie;如果没有,检查域名、协议、Path、SameSite 与跨域凭据配置。不要在控制台打印原始会话值来调试,可以观察是否存在、是否过期和服务器关联的匿名标识。Node fetch 不会自动变成完整浏览器 Cookie 管理器,本章本地字符串断言不能替代这一步。
验收应包含正确登录、错误密码、未知账号、会话过期、单设备退出、全部设备退出、密码修改后策略以及跨站写请求。还要测试存储不可用时的服务响应,确保它不会意外降级成绕过认证。只有登录成功这一条路径通过,距离可靠身份系统仍有很大差距。
敏感操作可以要求最近重新认证#
用户拥有一个仍有效的会话,不一定意味着适合立刻更改密码、导出全部私有文档或移交团队所有权。产品可以对这些高影响动作要求最近一次密码验证或额外因子,并在会话中记录服务器确认的认证时间。客户端传来的“刚验证过”标记不能作为依据,也不应仅因为页面显示过一次确认弹窗就认为已经重新认证。
账号被禁用、组织安全策略变化或发现令牌泄漏时,会话读取还需考虑账户当前状态。只有 expiresAt 的记录不能表达所有撤销原因。可以使用账户状态与会话版本配合,并保留不含秘密的审计事件,让用户知道哪些设备被退出、为什么需要重新登录。安全流程应让正常用户能够恢复工作,同时不给失效凭据留下隐蔽的继续访问路径。
边界、练习与参考解答#
本例 Map 在多实例之间不共享,未访问的过期记录也不会主动消失,必须替换为有过期清理和撤销能力的共享存储。生产登录还需要账号与来源限速、异常监控、密码重置、凭据泄漏响应和可能的多因素认证。密码、原始会话令牌、CSRF 值都不应出现在普通日志中。
练习:实现“退出全部设备”。要求同一用户其他会话立即失效,不影响别的用户。提示:可以删除该用户的所有会话,或为用户增加会话版本;说明哪种方案便于多实例一致性。
参考答案(含完整可运行实现)
小规模共享数据库可在事务中删除 user_id 对应全部 sessions。版本方案是在用户表维护 session_version,会话记录保存创建时版本,每次认证时比较;退出全部设备递增版本。但如果服务端把版本缓存很久,撤销就不会立即生效,必须明确缓存失效策略。测试创建两份同用户会话和一份另一用户会话,执行撤销后前两份均失败,后一份仍可使用。本例可先用 Map 遍历实现逻辑,再迁移到共享存储。
// session-revoke.mjs
import { randomBytes,createHash } from 'node:crypto';
import assert from 'node:assert/strict';
const users=new Map([['alice',{version:1}],['bob',{version:1}]]);
const sessions=new Map();
const digest=token=>createHash('sha256').update(token).digest('hex');
function issue(userId,now=Date.now()){
const user=users.get(userId);if(!user)throw new Error('用户不存在');
const token=randomBytes(32).toString('base64url');
sessions.set(digest(token),{userId,version:user.version,expiresAt:now+900000});
return token;
}
function resolve(token,now=Date.now()){
if(typeof token!=='string'||!/^[A-Za-z0-9_-]{43}$/.test(token))return null;
const key=digest(token);const row=sessions.get(key);
if(!row)return null;
const user=users.get(row.userId);
if(!user||row.expiresAt<=now||row.version!==user.version){sessions.delete(key);return null;}
return {userId:row.userId};
}
function revokeAll(userId){
const user=users.get(userId);if(!user)throw new Error('用户不存在');
user.version+=1;
}
const alicePhone=issue('alice');const aliceWeb=issue('alice');const bobWeb=issue('bob');
assert.equal(resolve(alicePhone).userId,'alice');
revokeAll('alice');
assert.equal(resolve(alicePhone),null);assert.equal(resolve(aliceWeb),null);
assert.equal(resolve(bobWeb).userId,'bob');
const renewed=issue('alice');assert.equal(resolve(renewed).userId,'alice');
assert.equal(resolve(renewed,Date.now()+900001),null);
console.log('Alice 两台设备被撤销,Bob 不受影响,新会话按新版本签发并能过期');
可验证的验收标准#
密码不以明文或快哈希保存;相同密码不同盐产生不同记录;错误账号与错误密码返回相同公开错误;过期令牌和退出令牌均不能恢复身份;Cookie 包含所需属性且没有 Domain。上线前还需真实 HTTPS 浏览器验证,不能用字符串断言代替端到端 Cookie 验收。
三个自测问题与答案#
- 为什么密码用 scrypt,随机会话令牌却可用 SHA-256 摘要存储?答案:人类密码可猜测,需要提高猜测成本;随机高熵令牌的威胁模型不同。
- HttpOnly 能完全阻止 XSS 带来的操作风险吗?答案:不能,脚本虽然读不到 Cookie,仍可能利用浏览器已有会话发请求。
- 只清空前端 store 是否完成退出?答案:没有,必须让服务端会话失效并清理客户端凭据。
本章验证记录#
编写时使用 Node 22.22.0 对本章全部 3 个 JavaScript 完整文件执行了语法检查。已在本机实际运行通过:auth-session.mjs、csrf-contract.mjs、session-revoke.mjs。本机脚本检查不代表已经完成真实 HTTPS 浏览器、Cookie 或跨域部署验收。
本章官方参考#
- Node Crypto:scrypt、随机数与定时安全比较。
- OWASP Password Storage:密码哈希与成本选择。
- OWASP Session Management:会话创建、Cookie 与撤销。
- OWASP CSRF Prevention:写操作的请求来源与令牌防护。