先说结论:管理后台是多租户 SaaS 里价值密度最高的攻击面——商户数据、订单、退款、会员手机号全在这一侧。我们最早用一把静态 X-Management-Key 保护全部管理接口,上线前安全自查时推翻了这个自己做的设计:静态密钥的原罪不是"不够长",而是没有身份、没有失效、没有审计。本文记录从静态密钥到 Spring Security 会话 + MFA + 权限码 + CSRF 的完整加固过程,配置均为脱敏示意。
一、起点与教训:一把钥匙走天下
项目最初只有三个人碰管理端,接口保护方案简单到一句话:所有 /manage/** 请求带一个静态请求头 X-Management-Key,校验通过即放行。当时觉得够用——钥匙只在几个人手里。
自查那天 grep 了一遍仓库,这把钥匙硬编码在三处:配置文件、部署脚本、一个测试小工具。再往前翻,半年前排查线上问题时,它还被整段贴进过工作群。复盘会上同事一句话点破了要害:"这把钥匙在群里躺了半年,我们连它有没有被人捡走都回答不了。"——知晓范围早已不可枚举,而它一旦泄露就是全线失守:不能按人吊销,不能按接口收缩,换钥匙要三处代码联动发版。
更致命的是三个"没有":
- 没有身份。 日志里所有操作都长一个样:
management。出了问题,无法回答"谁干的"。 - 没有失效。 钥匙不会过期,泄露了你也不知道——攻击者用它拉数据,和正常巡检在日志里毫无区别。
- 没有粒度。 客服只需要看订单,拿到的却是和开发一样的全量权限,包括退款和会员手机号导出。
那天我们承认:这不是把钥匙换长一点能修的问题,是身份模型缺席。重构方案定了四层。
二、目标设计:分层身份,各挡各的攻击
| 层 | 手段 | 防什么 |
|---|---|---|
| 认证层 | Spring Security 会话(HttpOnly Cookie) | 防密钥泄露即永久失守:凭证可失效、可强制下线 |
| 因素层 | TOTP 双因素(MFA) | 防弱口令、撞库、钓鱼:口令被盗还有第二道锁 |
| 授权层 | 权限码 + 方法级注解 | 防横向扩张:进来了也只能动被授权的那几个接口 |
| 请求层 | CSRF Token | 防跨站伪造:恶意页面借浏览器自动带 Cookie 冒充管理员 |
四层不是冗余,是分工。会话解决"凭证可吊销";MFA 解决"口令不代表你本人";权限码解决"账号被盗后损失有上限";CSRF 解决"浏览器会自动携带 Cookie"这个天然缺陷——管理员在登录状态下点开一个恶意页面,页面里一个隐藏表单就能以他的身份发起退款请求,Cookie 是浏览器自动带的,用户全程无感。任何一层缺席,其余三层都要替它兜底,兜不住就是事故。
三、实施要点
1. 登录的加固
三件事。失败锁定:同一账号连续 5 次失败锁定 15 分钟;同一 IP 对不同账号的失败单独计数——前者防针对单人的爆破,后者防撞库扫号。会话超时:空闲 30 分钟、绝对生命周期 12 小时,超时即服务端销毁;同一账号最多一个活跃会话,后登录踢前登录。MFA 绑定与恢复码:首次登录进入"半登录态",只能访问绑定页;扫码绑定 TOTP、验证一次通过后才发正式会话。绑定的同时生成 10 张一次性恢复码,仅在页面明文展示一次,库里只存哈希。绑定页本身也限速,TOTP 验证失败同样计入失败锁定——防止有人对着绑定接口批量撞验证码。
恢复码的保管是被严重低估的设计问题。我们的规矩:恢复码不进共享文档、不进聊天记录,管理员纸质密封保管;补发恢复码或重置他人 MFA,必须另一名管理员审批——否则"重置 MFA"本身就成了整个体系的后门。
2. 接口的收敛
权限收敛靠注解统一。所有管理端接口必须挂 @PreAuthorize,权限码按 域:资源:动作 命名,例如退款是 merchant:order:refund。CI 里加了一步静态扫描:发现没挂注解的管理端接口直接红——37 个管理接口、27 个业务域,靠 review 保证"一个不漏"是不现实的。
敏感操作做服务端二次确认:重置他人 MFA、导出会员手机号、批量退款,要求重新验证口令或 TOTP。确认必须由服务端校验,只在弹窗里问一句"确定吗"属于表演级安全。
3. 审计日志:出事后的黑匣子
审计日志回答三个问题:谁、什么时候、动了什么。记录账号、时间、接口、权限码、来源 IP 与 UA,敏感字段记改前/改后值,表只增不改——"只增不改"不是口头约定:日志表不授予 UPDATE 和 DELETE 权限,归档清理走独立角色单独审批并留痕。它不防攻击,但决定了出事之后 48 小时内能不能给商户一个交代——对 SaaS 来说,这决定了这家商户还留不留。
四、脱敏示意:过滤链怎么配
// 脱敏示意,非真实配置
http.securityMatcher("/manage/**")
.authorizeHttpRequests(auth -> auth
.requestMatchers("/manage/login", "/manage/mfa/bind").permitAll()
.anyRequest().hasRole("ADMIN")) // 半登录态另用临时角色圈在绑定页
.sessionManagement(s -> s
.invalidSessionUrl("/manage/login")
.maximumSessions(1) // 同账号单活跃会话,后来者顶掉先来者
.sessionFixation().newSession()) // 登录成功必须换新会话 ID
.csrf(c -> c.csrfTokenRepository(
CookieCsrfTokenRepository.withHttpOnlyFalse()))
.logout(l -> l.invalidateHttpSession(true)
.deleteCookies("JSESSIONID")); // 登出要服务端销毁,不能只清前端状态
登录失败锁定用认证失败事件监听器累加计数实现,键分"账号"与"IP+账号"两套;恢复码与 MFA 绑定关系各一张独立表,全部走 Flyway 迁移管理(85 个迁移,至 V113),随 1241 个测试方法回归。
五、踩坑清单
- MFA 的可用性平衡。 第一版每次登录都要输 TOTP,运营同事一天登录四五次,第二周就有人把 6 位验证码抄在便签上贴到显示器边框。她的原话:"我不是不配合,是一天输八遍谁受得了。"安全设计把人逼向不安全的行为,等于白做。改成"可信设备 7 天免验":绑定设备发一个专用 Cookie,服务端记录设备指纹与到期时间,管理台可一键吊销全部可信设备。可用性与安全性的交点要主动设计,不能等用户替你设计。
- CSRF 与小程序端接口分治。 全局启用 CSRF 后,小程序端接口集体 403——小程序请求根本不回传 CSRF Token。根因是两类接口的信任模型不同:管理端 Web 用 Cookie 会话,浏览器会自动带 Cookie,所以要 CSRF Token;小程序端凭据放在请求头,第三方页面无法让浏览器自动携带,本来就不在 CSRF 威胁模型内。解法是
securityMatcher分治:/manage/走会话 + CSRF,/api/走无状态令牌认证,两套过滤链互不沾边。最初想用一条链加白名单省事,两周后承认省下的那点事不够填坑。
- 二次确认只做前端等于没做。 中期 review 抓到一个导出接口:前端弹窗确认,后端直接放行。绕过页面直接发请求,"二次确认"毫无作用。敏感操作的确认必须是服务端重新校验凭证(口令或 TOTP),前端弹窗只是体验层。
- 审计日志自己就是敏感数据。 第一版把改前/改后值原样入库,review 时发现手机号、收款账号的明文躺在日志表里——审计日志成了第二份泄密源。敏感字段入库前脱敏或加密,日志表的查询权限单独授予,不与应用运行角色混用。
三句话总结
- 静态密钥的原罪不是长度,而是无身份、无失效、无审计——泄露即失守,且你永远不知道它已经泄露。
- 分层身份是分工不是堆料:会话管失效、MFA 挡盗号、权限码限损失、CSRF 防冒用,砍掉任何一层,其余层都要被迫兜底。
- 加固的终点不是"进不来",而是"进来了干不了大事、干了也查得到"——最小权限加只增不改的审计日志,比任何单一机制都可靠。
运营主体:北京位元跃迁科技有限公司。本文同步发布于本号技术专栏,可搬运至 CSDN/掘金。
