认证与系统集成:登录保护、单点登录与跨公司对接
登录失败几次该锁定?锁多久?多个站点怎么共享登录状态?和外部公司交换数据用什么协议、怎么保证不被重放?这些问题的答案都不在框架文档里,而在业务的风险偏好里。
本文讨论三件事:登录的失败保护、跨站点单点登录的方案选择、与外部系统对接时的安全设计。
一、先说结论
- 登录失败保护要分层:按账号、按 IP、按设备分别计数,用滑动窗口而不是固定窗口,锁定时间递增。
- 锁定策略要防「拒绝服务」:攻击者用错误密码反复尝试就能锁死别人的账号。所以账号锁定要配合 IP 维度判断,或者改用验证码而非直接锁定。
- 单点登录的选择取决于域名关系:同主域用 Cookie 共享最简单;跨主域用标准协议(OIDC / CAS);App 与前后端分离场景用 Token。
- 不要自己实现 OAuth/OIDC 的服务端,用成熟实现(Keycloak、Spring Authorization Server 等),自研的最大风险是安全细节漏项。
- 跨公司对接的核心是三件事:身份认证(谁在调用)、防重放(同一请求不能生效两次)、防篡改(签名)。
- 对外接口一定要有限流和配额,并且能按调用方精确定位问题。
二、登录失败保护
2.1 计数窗口:为什么用滑动窗口
固定窗口(每分钟计数清零)有明显的边界问题:攻击者可以在窗口末尾和下一个窗口开头各试 5 次,实际一瞬间试了 10 次。滑动窗口统计「最近 N 分钟」,没有这个缺口。
用 Redis 实现的滑动窗口计数:
// key 为 login:fail:{account},member 为唯一标识,score 为时间戳
public long recordFailure(String account) {
String key = "login:fail:" + account;
long now = System.currentTimeMillis();
long windowStart = now - Duration.ofMinutes(15).toMillis();
redis.opsForZSet().add(key, UUID.randomUUID().toString(), now);
redis.opsForZSet().removeRangeByScore(key, 0, windowStart); // 清掉窗口外的记录
redis.expire(key, Duration.ofMinutes(30)); // 兜底过期,防止 key 泄漏
return redis.opsForZSet().zCard(key); // 窗口内的失败次数
}三个细节:removeRangeByScore 保证窗口滑动;expire 防止长期不登录的账号留下垃圾 key;成功登录后要删除这个 key。
2.2 锁定策略与它的副作用
常见的策略是「15 分钟内失败 5 次,锁定 30 分钟」。这里有一个必须正视的问题:攻击者只要知道账号,就能用错误密码把它锁死——锁定机制本身成了拒绝服务的手段。
更稳妥的组合:
| 触发条件 | 动作 | 理由 |
|---|---|---|
| 同一账号连续失败 3 次 | 要求验证码 | 拦住脚本,不影响真实用户 |
| 同一账号连续失败 10 次 | 锁定,但允许通过邮箱或短信解锁 | 给真实用户自助恢复的通道 |
| 同一 IP 短时间内失败多个账号 | 按 IP 限流或封禁 | 这才是典型的撞库特征 |
| 异地、异常设备登录 | 二次验证 | 密码泄漏的主要防线 |
锁定时长递增(1 分钟、5 分钟、30 分钟)比固定时长更合理:对误操作的真实用户友好,对持续攻击的成本递增。
2.3 其他要点
- 失败提示不要区分「用户不存在」和「密码错误」,否则等于提供了账号枚举接口。
- 密码存储用 bcrypt、scrypt 或 Argon2,不要用 MD5、SHA 加盐——它们太快,适合被暴力破解。
- 登录日志要完整:时间、IP、设备、结果、失败原因,这是事后排查和风控建模的基础。
- 成功登录后重置计数,并考虑让旧会话失效(可配置)。
三、跨站点单点登录
3.1 先看域名关系
| 场景 | 方案 | 说明 |
|---|---|---|
同一主域的多个子域(a.corp.com、b.corp.com) | Cookie 设置 Domain=.corp.com | 最简单,会话集中存储在 Redis |
不同主域(shop.com、pay.com) | 标准协议:OIDC / OAuth 2.0 / CAS | 通过认证中心跳转换取令牌 |
| App、小程序、前后端分离 | Token(JWT 或不透明令牌) | 客户端存储,随请求携带 |
3.2 跨主域的流程
以 OIDC 为例(省略细节):
- 用户访问
shop.com,未登录,被重定向到认证中心sso.corp.com; - 认证中心检查自己的会话 Cookie:已登录则直接签发授权码,未登录则展示登录页;
- 带授权码回跳到
shop.com的回调地址; shop.com的后端用授权码换取 ID Token 和 Access Token(这一步是服务端之间的调用);- 校验 ID Token 的签名、
iss、aud、exp、nonce,建立本地会话。
这一套流程里最容易出错的是第 5 步的校验。不要跳过任何一项,尤其是签名和 aud——不校验 aud 意味着别的应用的令牌也能登录你的系统。
3.3 令牌怎么选
| JWT(自包含) | 不透明令牌(随机串) | |
|---|---|---|
| 校验方式 | 本地验签,无需查询 | 每次调用认证中心校验(或查 Redis) |
| 撤销 | 困难,需要黑名单 | 直接删除即可 |
| 体积 | 较大,每次请求都带 | 小 |
| 适合 | 内部服务间、短有效期 | 面向用户的会话 |
实践中常见的组合是:用户会话用不透明令牌(便于随时踢下线),服务间调用用短有效期的 JWT。如果一定要用 JWT 做用户会话,就把有效期设短(几分钟到几十分钟),配合刷新令牌,并准备好黑名单机制应对紧急撤销。
3.4 退出登录
单点登录的反面是单点退出,它比登录更难:
- 认证中心的会话要销毁;
- 各个已登录的应用要收到通知并销毁本地会话(OIDC 的后端通道退出规范);
- 已签发的 JWT 在过期前仍然有效,需要黑名单;
- 移动端可能离线,要在下次请求时失效。
设计时先问清楚:业务要求的是「彻底退出」还是「当前设备退出」,两者的复杂度差很多。
四、跨公司数据对接
和外部公司交换数据时,双方的信任边界很清楚:任何请求都可能是伪造的、重放的、被篡改的。
4.1 三件必须做的事
1. 身份认证。 给每个合作方分配独立的 appId 和密钥,不要共用。密钥要能单独轮换和吊销。
2. 防篡改:请求签名。
签名串 = appId + timestamp + nonce + body 的哈希
signature = HMAC-SHA256(签名串, appSecret)请求头携带 appId、timestamp、nonce、signature。服务端用同样的方式计算并比对。注意:签名要覆盖 body,否则内容可以被改;比较签名要用常量时间比较,避免时序侧信道。
3. 防重放。
timestamp与服务器时间相差超过 5 分钟的请求直接拒绝;nonce存入 Redis(TTL 略大于时间窗口),重复出现就拒绝。
两者必须同时使用:只有时间戳,攻击者可以在 5 分钟内重放;只有 nonce,服务端要永久保存所有 nonce。
4.2 接口设计
- 必须 HTTPS,敏感字段(身份证号、银行卡号)在传输之外再单独加密。
- 幂等:对方会重试,同一个业务请求 ID 只能生效一次,做法见 订单、库存与数据一致性。
- 限流与配额:按
appId限流,超出返回明确的错误码,不要让一个合作方影响其他人。 - 版本化:接口路径或请求头带版本号,避免升级时互相绑架。
- 错误码稳定:错误码是对外契约,不能随意改动含义。
4.3 数据推送与拉取
| 模式 | 适合 | 注意 |
|---|---|---|
| 对方主动拉取 | 数据量大、实时性要求不高 | 分页、增量标记(时间戳或游标)、限流 |
| 我方推送回调 | 状态变更通知 | 要重试、要幂等、要能查询补偿 |
| 文件交换 | 日终对账、批量数据 | 校验和、文件命名规范、重复投递处理 |
推送类接口必须配一个查询接口:对方漏收或处理失败时可以主动查询补偿,这比无限重试更可靠。
五、常见误区
- 「登录失败就锁账号最安全」:这给了攻击者锁死任意账号的能力,要结合 IP 维度和验证码。
- 「提示『用户不存在』更友好」:等于开放账号枚举。
- 「JWT 是无状态的,所以更好」:无状态意味着难以撤销,用户会话场景要谨慎。
- 「自己实现一套 SSO 更可控」:安全细节(PKCE、state、nonce、aud 校验)极易遗漏。
- 「内网调用不需要签名」:内网也会被横向渗透,关键接口同样要认证。
小结
这三个话题的共同点是:默认不信任。登录保护假设有人在撞库,单点登录假设令牌会泄漏,跨公司对接假设请求会被伪造和重放。对应的手段也很固定——分层计数与递增惩罚、标准协议与完整校验、签名加时间戳加 nonce。剩下的工作是把它们做全,而不是发明新方案。
参考资料