背景:某管理系统后端(Spring Boot + 自研 JWT 认证 + MyBatis + PostgreSQL,多服务部署)。本文来自一次完整的代码安全审查复盘:第一节是完整检查清单(通过的、没通过的都列出),后面把每个缺陷的原理、危害、日常容易忽略的要素、预防手段和修复方案逐一展开,并附上代码实例。既是复盘,也是一份可复用的审查清单。 文中代码均已做过抽象处理,不含任何真实系统、组织与人名信息。
一、检查清单总览
审查覆盖以下 16 项。打 ✅ 的是检查后确认没问题的,打 ❌ 的是发现了问题的(编号对应后文详解)。通过项同样值得记录——它们是这次防线里没塌的部分,也是下次回归时必须复测的用例来源。
| # | 检查项 | 检查手段 | 结论 |
|---|---|---|---|
| 1 | JWT 签发/校验、密钥管理 | 读签发与校验工具类、密钥配置 | ✅ 签名校验完整,密钥长度有下限校验,密钥未硬编码 |
| 2 | 令牌撤销(登出/强制下线立即生效) | 读过滤器 + 会话表 + 各服务实现类 | ❌ 问题①:撤销校验只在一个服务生效 |
| 3 | 认证过滤器白名单匹配逻辑 | 逐行读匹配函数 + 用真实路径模拟 | ❌ 问题②:/actuator 精确匹配挡死探活 |
| 4 | SQL 注入 | grep 全部 mapper XML 中的 ${} |
✅ 全部 #{} 预编译,无拼接 |
| 5 | RBAC 权限是否有服务端强制 | 全库 grep 权限注解 / AOP / 权限判断 | ❌ 问题③:零命中,权限纯前端展示 |
| 6 | 越权访问(IDOR)逐接口核对 | 对每个 {id} 接口追数据范围校验 |
❌ 问题④⑤⑥:详情 / 写接口 / 日志查询三处漏校验 |
| 7 | 敏感数据存储与展示 | 读加密工具类 + 详情字段装配 | ✅ AES-GCM 加密 + 掩码返回(密钥熵问题见问题⑮) |
| 8 | 公开接口滥用面 | 读认证白名单 + 公开写接口实现 | ❌ 问题⑦:公开提交无频控且自动建账号 |
| 9 | 登录防爆破 | 读登录实现 + 失败处理逻辑 | ❌ 问题⑧:无失败锁定/限流/验证码 |
| 10 | 统一错误信息(防账号枚举) | 对比"账号不存在"与"密码错误"的响应 | ✅ 两种失败统一提示,无枚举口子 |
| 11 | 事务边界 | 读 @Transactional / 显式事务用法 |
❌ 问题⑨:事务方法内做 HTTP 远程调用 |
| 12 | 并发竞态与状态机 | 找所有状态字段的 UPDATE,查前置条件 | ❌ 问题⑩:发布类接口存在 check-then-act 竞态 |
| 13 | 环境配置安全 | 逐个核对 ${XXX:默认值} 的默认值 |
❌ 问题⑪:profile 默认 dev,生产漏配即激活 dev 逻辑 |
| 14 | 架构与文档一致性 | 拿架构约束文档逐条对照实现 | ❌ 问题⑫:服务共享数据库,文档未同步登记 |
| 15 | 死代码与构建产物 | 对照父 pom 的 modules 与磁盘目录 | ❌ 问题⑬:废弃模块目录残留(含旧 jar) |
| 16 | 运行期资源管理 | grep @Scheduled / 清理任务 / 索引 |
❌ 问题⑭:会话表无清理、每请求点查一次 |
一张表看下来有个规律:✅ 的 4 项全部是"点"上的问题(单个函数、单条 SQL 写对了就行),❌ 的 12 项几乎全部是"线"和"面"上的问题——跨服务的机制一致性、跨接口的校验完整性、代码与文档的同步。安全缺陷很少坏在一个函数里,多坏在两个函数之间。
二、认证与会话类
问题① 令牌撤销只在一个服务生效
现象:系统用 JWT 做认证,同时配了会话表实现「登出 / 被顶下线 / 管理员强制下线后令牌立即失效」。机制是:过滤器解析完 token 后,通过 TokenSessionChecker 接口查一次会话表,确认这个 jti 还活着。但撤销校验的实现类只存在于其中一个服务,其他服务悄悄退化成了「纯无状态 JWT」。
原理:JWT 是无状态的——签名正确且未过期就有效,服务端无法主动让某个 token 失效。所以要加一层「有状态的撤销裁决」:签发时把 jti 写进会话表,每个请求再查一次表。问题出在 checker 是可选依赖:
// 反例:可选依赖静默降级——拿不到实现就跳过校验,没有任何日志
private boolean sessionAlive(String jti) {
TokenSessionChecker checker = checkerProvider.getIfAvailable();
if (checker == null) {
return true; // 撤销校验悄悄失效
}
return checker.isActive(jti);
}
ObjectProvider.getIfAvailable() 这种写法代码读起来完全正常,测试里配了 mock 也完全正常——只有部署形态变了(多服务、少了一个实现)才会暴露。
危害:用户登出、被顶下线、被管理员强制下线后,旧 token 在自然过期前(比如 2 小时)依然可以访问所有没有 checker 的服务。对安全审计来说,"强制下线"这个动作等于没做。
日常容易忽略的要素:
ObjectProvider/@Autowired(required=false)/getIfAvailable()这类"可选依赖"模式,静默降级时无感知;- 安全机制在多服务部署时,默认要问一句:**这条机制在每个服务上都生效吗?**尤其是"跨切面"的机制(认证、审计、限流),最容易被做成"只在第一个实现它的服务里存在";
- 测试通常只测单服务,跨服务的撤销一致性没有用例覆盖。
怎么避免:降级必须有痕、有界:
// 改进一:降级必须打日志(启动时探测一次即可,不必每请求都打)
@Component
public class SessionCheckerProbe implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) {
if (checkerProvider.getIfAvailable() == null) {
log.warn("未配置 TokenSessionChecker,令牌撤销校验不生效,请确认部署形态");
}
}
}
// 改进二:更严格的场景直接 fail-close
private boolean sessionAlive(String jti) {
TokenSessionChecker checker = checkerProvider.getIfAvailable();
if (checker == null) {
log.warn("TokenSessionChecker 缺失,按校验失败处理");
return false; // 宁可拒绝服务,不可放行已撤销的令牌
}
return checker.isActive(jti);
}
再把"撤销是否全局生效"写进测试红线:登出一个 token,逐一请求所有服务的受保护接口,都应 401。
出了以后怎么解决:给每个服务补齐 checker 实现;会话表放共享库或共享缓存(Redis)里,天然一致。如果会话校验走独立服务,要考虑它自己挂了怎么办(fail-close 还是 fail-open,提前写进设计文档);用缓存加速时要意识到缓存 TTL 就是撤销延迟的上限。
问题② /actuator 白名单精确匹配挡死健康检查
现象:白名单配置写的是 - /actuator,而过滤器对非通配项做精确匹配。健康检查的实际请求路径是 /actuator/health,永远匹配不上。
原理:白名单匹配通常要支持两种语义——精确匹配(/login)和前缀通配(/static/**)。实现时很容易只写两条分支,忘了 "/actuator" 这种"想要前缀但忘了写通配符"的中间态:
// 反例:只有"以 /** 结尾走前缀,其余走 equals"两条分支
private boolean isWhitelisted(String path) {
for (String entry : whitelist) {
if (entry.endsWith("/**")) {
if (path.startsWith(entry.substring(0, entry.length() - 3))) {
return true;
}
} else if (path.equals(entry)) { // "/actuator" ≠ "/actuator/health"
return true;
}
}
return false;
}
# 修复:写白名单的人以为覆盖了,实际一个子路径都没命中
whitelist:
- /actuator/** # 而不是 /actuator
- /api/v1/auth/login
危害:负载均衡 / 容器编排的探活请求全部 401,服务被判定不健康,重启循环或摘流。更隐蔽的变体:运维为了绕开它把 /actuator/** 全开——会把 env、beans 等敏感端点也暴露出去,等于用一个配置错误换另一个更大的。
日常容易忽略的要素:
- "看起来配了" ≠ "生效了":白名单属于必须运行时验证的配置,code review 很难看出
/actuator和/actuator/**的差别; - 探活请求不带业务 token,它的失败只出现在基础设施层(探针日志),应用日志里往往只有一条 401,容易淹没;
- 类似的高频踩坑路径还有:
/error(Spring Boot 错误页)、静态资源、文件下载链接、回调地址(支付回调、Webhook)。
怎么避免:
- 白名单启动时自检:对每个配置项断言它要么精确对应一个 Controller 映射,要么以
/**结尾;把"需要前缀语义的路径必须显式写通配符"定为规约; - 上线 checklist 固定一条:不带 token curl 一次
/actuator/health,期望 200; - 同时收紧端点暴露,两头都堵:
management:
endpoints:
web:
exposure:
include: health # 只暴露探活需要的端点
endpoint:
health:
show-details: never
出了以后怎么解决:白名单项加通配符(一行改动)+ 端点最小暴露;回归时把所有白名单路径逐个 curl 一遍(带 401 预期的、带 200 预期的分开列)。
问题⑧ 登录无防爆破
现象:登录接口正确地统一了"账号不存在"和"密码错误"的提示(防账号枚举,这个细节是对的),但对同一个账号(或同一个 IP)连续失败没有任何锁定、限流、验证码机制。
原理:BCrypt 这类慢哈希让单次猜测成本很高(几十毫秒),但接口级爆破的速度瓶颈不在哈希而在网络——不加限制时每秒几百次请求轻而易举。防爆破的本质是把攻击者的尝试速率压到"算完哈希也试不了几次"的水平。还有个反直觉的组合:统一提示防枚举 + 无锁定,攻击者虽然不知道账号是否存在,但可以对一批从泄露库里拿到的真实账号慢慢试密码——防枚举挡住了侦察,挡不住爆破本身。
危害:弱密码管理账号被撞库后直接接管系统;登录接口本身的请求量也是攻击面。
日常容易忽略的要素:
- 只做"账号级锁定"会被攻击者换一批账号绕开(分布式 spray:每个账号只试 1-2 次),IP 维度限流要同时存在;
- 锁定策略太狠(失败 3 次锁 1 天)会被反向利用成拒绝服务——别人可以故意锁死你的管理员账号,需要"锁定 + 解锁通道"配套;
- 前端限制(按钮置灰、倒计时)只防正常用户,防不住脚本。
怎么避免:分层防护。Nginx 层(改动最小、见效最快):
limit_req_zone $binary_remote_addr zone=login_zone:10m rate=10r/m;
location /api/v1/auth/login {
limit_req zone=login_zone burst=5 nodelay;
proxy_pass http://backend;
}
应用层(账号维度失败计数,放 Redis 或库表,注意计数器本身要有过期策略):
String key = "login:fail:" + account;
long fails = redis.increment(key);
redis.expireIfNew(key, Duration.ofMinutes(15));
if (fails >= 5) {
// 触发图形验证码或短暂延迟;不要直接抛"已锁定"——那会变成账号 DoS 的确认器
captchaRequired = true;
}
管理后台可以再加 IP 白名单或 VPN 前置。上线 checklist 加一条:用错误密码连打 20 次,观察第几次开始被拦。
出了以后怎么解决:先上 Nginx 限流止血,再补应用内失败计数;已有泄露迹象时强制全量改密 + 按账号批量撤销全部会话(这就是问题①那套撤销机制的用武之地)。
问题⑪ Spring profile 默认值是 dev
现象:application.yml 里 spring.profiles.active: ${SPRING_PROFILES_ACTIVE:dev}——环境变量没设时默认走 dev。而 dev profile 下有:联调用的快捷登录接口(@Profile("dev") 才注册)、dev 专用的默认 JWT 密钥。
原理:默认值是兜底行为,定义了"配置缺失时系统变成什么"。dev 作为默认值意味着"忘记配置"这种最常见的故障模式被定向到最不安全的状态。更微妙的是 profile 同时控制行为(多出的接口)和密钥(默认值),双重退化叠加。
危害:生产部署漏设一个环境变量 → dev 专用接口上线 → 全系统使用代码仓库里可查的 dev 密钥签发 JWT——攻击者拿到仓库或反编译 jar 就能离线伪造任意管理员的 token。这是从"配置失误"到"完全接管"的直通车。
日常容易忽略的要素:
- 部署清单核对时,人们盯的是"配了哪些变量",漏的是"没配的那个变量默认是什么";
@Profile("dev")的接口在生产 404 这个设计是对的,但前提是生产真的不是 dev profile——所有"仅 dev 生效"的安全设计都悬在同一个默认值上;- dev 默认密钥写进代码仓库(哪怕注释里写了"仅本地"),扩散面就是"所有能看到仓库的人 + 所有拿到 jar 的人"。
怎么避免:
# 反例
spring:
profiles:
active: ${SPRING_PROFILES_ACTIVE:dev}
# 改进:不设默认,缺失即启动失败(Spring 对 ${} 占位符缺失默认抛异常)
spring:
profiles:
active: ${SPRING_PROFILES_ACTIVE}
再加一道启动守卫,把"dev 只许本地起"变成机器检查而不是人的记忆:
@Component
public class ProfileGuard implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) {
boolean dev = Arrays.asList(env.getActiveProfiles()).contains("dev");
boolean explicitAllowed = "true".equals(System.getenv("ALLOW_DEV_PROFILE"));
if (dev && !explicitAllowed) {
throw new IllegalStateException("禁止以 dev profile 启动,如为本地调试请设置 ALLOW_DEV_PROFILE=true");
}
}
}
密钥类配置永不给默认值;启动日志输出密钥指纹(前 8 位哈希),运维看启动日志即可确认不是 dev 密钥。
出了以后怎么解决:改掉默认值 + 部署脚本补环境变量;如果 dev 密钥曾被生产使用过,按密钥泄露处理——轮换 JWT secret(注意:轮换会让所有在线用户掉线,选低峰执行;有会话表校验机制时轮换可即时生效)。
三、授权与越权类
问题③ RBAC 权限完全没有服务端强制
现象:登录响应里已经把账号的权限码列表下发给了前端(用于控制菜单和按钮显隐),但全代码库检索 @RequirePermission、AOP 权限切面、任何形式的 permissions.contains(...) 判断——零命中。角色管理(建角色、删角色、配权限)、审核、发布、删除……任何持有有效 token 的账号都能直接调用。
原理:前端权限控制(隐藏菜单、禁用按钮)是展示层的礼仪,不是安全边界。攻击者根本不需要你的前端——一个 curl 加上自己的 token,就能直接打后端接口。权限模型必须满足"服务端在处理每个请求时,独立校验调用者是否被授权",前端控制只是体验优化。
危害:
- 任意登录账号可以给自己配管理员权限(改角色-权限关系),完全接管系统;
- 普通账号可以执行所有业务写操作,且这些操作都会以自己的身份留下审计记录——破坏是"合规"地发生的;
- 对有等保、个人信息保护合规要求的系统,这属于整改项。
日常容易忽略的要素:
- 权限码"已经下发到前端了"会造成"权限已经做了"的错觉——下发和校验是两件完全不同的事;
- 迭代节奏里"权限点口径还没定"很容易变成"先不做校验",然后一直没做;默认放行的权限体系,欠的账只会越滚越大,因为每加一个接口都在往"无校验"的默认上堆;
- "都是自己人"的内部系统心态——账号被盗、离职员工、第三方集成全都打破这个假设。
怎么避免:自研方案下用注解 + AOP,大约一百行代码就能把红线立起来:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequirePermission {
String value();
}
@Aspect
@Component
public class PermissionAspect {
private final PermissionService permissionService;
@Before("@annotation(requirePermission)")
public void check(JoinPoint jp, RequirePermission requirePermission) {
String userId = UserContext.requireUserId(); // 未认证直接抛 401
Set<String> perms = permissionService.loadPermissions(userId); // 可加 30~60s 本地缓存
if (!perms.contains(requirePermission.value())) {
log.warn("权限拒绝: user={} required={} uri={}",
userId, requirePermission.value(), request.getRequestURI());
throw new BizException(ErrorCode.AUTH_NO_PERMISSION);
}
}
}
// 使用:没标注解的接口默认拒绝或至少默认告警(白名单思想)
@RequirePermission("role:manage")
@DeleteMapping("/roles/{id}")
public Result<Void> deleteRole(@PathVariable Long id) { ... }
配套两条:新接口合入前必须有权限注解,否则评审不过;用最低权限账号遍历所有写接口,断言全部被拒(测试红线)。
出了以后怎么解决:注解切面是增量改动,风险可控;关键是回填——把现存接口按权限码分类(读/写/管理),管理类优先补注解;补齐前可临时在网关层按路径前缀做粗粒度拦截止血;同时审计日志回查"是否已有越权操作发生"。
问题④ 详情接口 IDOR:列表有数据范围过滤,详情没有
现象:某业务对象的列表、统计、导出三个接口都正确地按"当前账号可见组织"过滤(visibleOrgIds),唯独 GET /{id} 详情不接收 userId、不做任何范围校验,直接按 ID 查询返回。
原理:这叫 IDOR(Insecure Direct Object Reference),属于水平越权——组织 A 的管理员访问组织 B 的数据。列表过滤 + 详情不过滤的组合极其常见:列表是"顺手"做了过滤(查询参数里自然有 scope),详情是按主键查,"顺手"不到,于是漏了。防御必须基于"拒绝"而非"隐藏"——前端不显示入口 ≠ 接口不存在。
// 反例:按主键直查,无数据范围
public DetailVO detail(Long id) {
BizEntity e = mapper.selectById(id);
if (e == null) throw new BizException(ErrorCode.NOT_FOUND);
return assemble(e);
}
// 改进:统一入口约定——按 ID 取数的方法,第一个参数固定是 userId
public DetailVO detail(Long userId, Long id) {
BizEntity e = mapper.selectById(id);
if (e == null || !visibleOrgIds(userId).contains(e.getOrgId())) {
// 故意与"不存在"同响应:返回 403 等于泄露资源存在性
throw new BizException(ErrorCode.NOT_FOUND);
}
return assemble(e);
}
SQL 层也可以把范围做成硬条件:
<select id="selectVisibleById" resultType="...">
SELECT * FROM biz_entity
WHERE id = #{id}
AND org_id IN
<foreach collection="visibleOrgIds" item="oid" open="(" separator="," close=")">
#{oid}
</foreach>
</select>
危害:低权限账号可遍历自增 ID 拖库——详情里往往是完整档案信息(住址、单位、联系方式等),是核心数据资产的直接泄露路径。
日常容易忽略的要素:
- 同一个资源的不同接口,数据范围校验要各自实现——不存在"列表做了详情就安全"的继承关系;
- 自增主键让遍历成本为零(
/1到/10000),UUID 只能提高遍历成本,不能替代鉴权; - 测试通常用管理员账号测详情接口,一切正常——必须用受限账号测才能暴露;
- 回归时新增接口往往复制现有代码,一个"忘记带 scope"的模板一旦产生就会被复制 N 次。
怎么避免:把数据范围校验做成模板方法或统一入口(如上面的 requireVisible 约定),这个模式在 code review 里一眼可见、缺了就问;MockMvc 层建一个"越权矩阵"用例集(账号 × 接口 × 资源归属),新接口必须进矩阵。
出了以后怎么解决:给 detail 补上与列表相同的范围校验,范围外按"不存在"返回;顺手排查同服务所有 {id} 接口,用同一把尺子;已发生的访问可以从网关日志 + 审计表回查异常 ID 遍历模式。
问题⑤ 写操作接口缺失鉴权上下文
现象:某个状态流转后的落档写接口(POST /xxx/provision/{applyId})的 Controller 和 Service 全链路没有任何 userId 参与——没有范围校验,也没有权限校验。
原理:写接口的危险度高于读接口,却更容易漏校验——因为写接口常被设计成"服务间内部调用",设计者默认"只有内部会调",但 URL 是平的,认证过滤器之后人人平等。接口不会因为你打算内部用就自动变内部。
// 反例:写接口全链路没有用户上下文,与前端接口同路径空间
@PostMapping("/resources/provision/{applyId}")
public Result<Void> provision(@PathVariable Long applyId) {
service.provision(applyId); // 谁调的?有权调吗?——答案都是"不知道"
return Result.ok();
}
// 改进:userId 进签名 + 数据范围校验,review 时缺了直接打回
@PostMapping("/resources/provision/{applyId}")
public Result<Void> provision(@RequestAttribute Long userId,
@PathVariable Long applyId) {
service.requireVisible(userId, applyId);
service.provision(userId, applyId);
return Result.ok();
}
真正的内部接口要单独的机制保护,而不是单独的意图:
# /internal/** 独立白名单 + 网络层限制(仅内网可达,不进对外网关路由)
whitelist:
- /internal/** # 且网关层不转发 /internal/**,双保险
危害:任意账号可对任意申请触发落档,绕过审核流程直接制造业务数据;与问题③叠加时,等于状态机可以被任意跳过。
日常容易忽略的要素:
- "这个接口是给其他服务调的"是一种意图,不是机制——除非有独立内网、内部认证(服务间 mTLS / 内网白名单 / 独立 token),否则它就是公网可达的;
- 服务间调用和前端调用的鉴权强度必须显式区分,混在同一路径空间里就会漏。
怎么避免:约定"写接口一律要求 userId 进方法签名(哪怕暂时不用)";内部接口单独路径前缀 + 独立认证;状态迁移类写操作尽量收敛到业务动作接口上(如 /{id}/review 内部完成落档),不单独暴露"步骤接口"。
出了以后怎么解决:先补数据范围校验,再评估是否把该接口收编进业务动作接口内部调用、对外下线。
问题⑥ 变更日志查询无数据范围
现象:业务对象的变更日志列表/详情接口按关键词全库搜索,没有按账号可见组织过滤。
原理:同问题④,但有个特别容易忽略的点——日志/审计类数据的归属感更弱。开发者会认真给"业务主数据"做范围过滤,因为主数据"明显属于某个组织";而日志按操作流水建模,脑子里是"全局流水",忘了每条日志背后都关联着具体组织的具体对象。审计数据往往还包含变更前后值(敏感字段的旧值、新值),泄露面比业务列表更大。
<!-- 反例:关键词全局搜索,无 scope 条件 -->
<select id="searchLogs" resultType="...">
SELECT * FROM biz_change_log
WHERE content ILIKE '%' || #{keyword} || '%'
</select>
<!-- 改进:强制带上数据范围,让范围过滤有落点 -->
<select id="searchLogs" resultType="...">
SELECT * FROM biz_change_log
WHERE org_id IN
<foreach collection="visibleOrgIds" item="oid" open="(" separator="," close=")">
#{oid}
</foreach>
AND content ILIKE '%' || #{keyword} || '%'
</select>
危害:受限账号可搜索全系统所有组织的变更历史,包括其他组织的人事变动轨迹、被驳回的修改内容等。
日常容易忽略的要素:
- 每新增一类"派生数据"(日志、快照、统计、导出),都要重新回答一次"它的数据范围是什么"——派生数据的 scope 不会自动从主数据继承;
- 关键词搜索接口(
?keyword=)是越权排查的重灾区,因为它天然绕过"按组织浏览"的路径,直接命中任意数据。
怎么避免:审查清单里固定一条:每遇到一个 keyword / 全局搜索接口,检查它的 WHERE 里有没有 scope 条件;日志表设计时带上 org_id 冗余列(或强制 join 主表过滤)。
出了以后怎么解决:列表 SQL 加范围过滤、详情校验归属后按不存在返回;已发生的访问按日志回查。
四、公开接口与滥用类
问题⑦ 公开提交接口无频控、无防重、自动建账号
现象:面向小程序/外部渠道的提交接口在认证白名单里公开(外部端无登录令牌,公开本身合理),接口对手机号、姓名做了格式校验,敏感字段加密落库——但没有任何速率限制、验证码、同号码重复提交限制,而且对新号码自动创建业务账号。
原理:公开写接口 = 互联网上任何脚本都可以无限调用。没有频控意味着资源(数据库行、序列号、存储)可以被无成本消耗;自动建账号则把"刷单"升级成"刷账号库"——攻击者用一万个号码提交,就有一万个账号落库,污染主数据且为后续攻击(撞库、冒名)准备了原料。
// 应用内频控示例(Bucket4j;更简单可用 Guava RateLimiter / Redis 计数)
Bucket bucket = Bucket.builder()
.addLimit(Bandwidth.classic(5, Refill.greedy(5, Duration.ofMinutes(10))))
.build();
public Result<Void> submit(SubmitReq req) {
// 1. 频控:按 IP + 按手机号各一道
if (!bucketFor(clientIp).tryConsume(1) || !bucketFor(req.getPhone()).tryConsume(1)) {
throw new BizException(ErrorCode.TOO_MANY_REQUESTS);
}
// 2. 幂等防重:同号码存在进行中的申请则拒绝
boolean exists = applyMapper.existsByPhoneAndStatus(
req.getPhone(), List.of("PENDING", "REVIEWING"));
if (exists) {
throw new BizException(ErrorCode.DUPLICATE_APPLY);
}
// 3. 账号创建动作后置:先落申请单(打"预创建"标记),审核通过再建账号
applyMapper.insert(toApply(req));
return Result.ok();
}
# 网关层兜底
limit_req_zone $binary_remote_addr zone=public_submit:10m rate=5r/m;
location /api/v1/public/submit {
limit_req zone=public_submit burst=3 nodelay;
proxy_pass http://backend;
}
危害:
- 资源型:申请单、账号、扩展表被灌满垃圾数据,真实数据被淹没,审核工作量被拖垮;
- 数据型:主数据污染后,清洗成本远高于拦截成本;
- 合规型:若被用于冒名提交他人信息,涉及个人信息处理的合规问题。
日常容易忽略的要素:
- "外部端有签名 / 有 Referer"不构成防刷——抓包重放是入门级操作;
- 格式校验(11 位手机号)只挡住格式错误,挡不住一万个格式正确的假号码;
- 过渡方案(如"验证码通道没接通前先上线")很容易长期裸奔在"先上线再补"里——过渡方案要有到期时间。
怎么避免:公开写接口的最小防护三件套——频控(按 IP + 按号码)、人机验证(短信/图形验证码)、幂等防重(同号码 + 进行中状态则拒绝);自动建账号的动作尽量后置到审核通过,减小被刷的不可逆影响面;给公开接口设置提交量突增告警。
出了以后怎么解决:按三件套补齐;已灌入的垃圾数据按提交特征(IP、时间窗口、号码段)识别后软删;账号库做一次标记清洗;发生冒名提交时按合规流程评估告知义务。
五、事务与并发类
问题⑨ 事务方法里做 HTTP 远程调用
现象:某发布流程的实现是 @Transactional 方法:先落库主表,再 HTTP 调用账号服务解析关联人列表,再批量写关联表,最后更新状态。
原理:@Transactional 从方法入口开启事务、拿到数据库连接,直到方法返回才提交。中间插入的任何耗时操作——HTTP 调用、发短信、写文件、MQ 同步等待——都被裹在事务里,连接被白白占住。连接池(Hikari 默认 10 个)是全服务共享的,几个慢请求就能把池子占满,后续所有请求排队等连接后超时报错,表现为"服务整体假死"。
// 反例:整个方法在事务里,HTTP 调用也裹进去了
@Transactional
public void publish(Long id) {
Message msg = messageMapper.selectById(id);
messageMapper.insert(msg); // 已拿到连接
List<Long> recipients = authClient.resolveRecipients(msg); // HTTP:连接被占住
recipientMapper.batchInsert(recipients);
messageMapper.updateStatus(id, "PUBLISHED");
}
// 改进:先解析(无事务),后落库(显式事务精确圈定必须原子的几步)
public void publish(Long id) {
Message msg = messageMapper.selectById(id);
List<Long> recipients = authClient.resolveRecipients(msg); // 事务外
transactionTemplate.executeWithoutResult(tx -> {
messageMapper.updateStatus(id, "PUBLISHED");
recipientMapper.batchInsert(recipients);
});
}
危害:
- 连接池耗尽 → 全服务不可用,而且故障面远超出这一个接口;
- 远程调用失败导致整个事务回滚,把"已经成功的本地写"也吞掉,语义上未必是想要的;
- 事务持有时间长还会拉高死锁、锁等待的概率(行锁在事务期间一直持有)。
日常容易忽略的要素:
- 事务注解的作用范围是整个方法,不是"包住数据库操作的几行"——写代码时注意力在业务步骤上,意识不到远程调用也被划进去了;
- 正常联调时远程服务都是毫秒级响应,问题只在下游抖动时爆发;压测若不模拟下游慢响应(延迟注入)测不出来;
- 嵌套调用更隐蔽:A 服务的
@Transactional方法里调 B 的接口,B 又回调 A——review 单个方法看不出来。
怎么避免:定一条硬规约——事务方法里只允许数据库操作,远程调用/IO 一律移到事务边界之外;确实无法拆开的长流程,考虑本地消息表 + 异步补偿,而不是把同步链路塞进事务。
出了以后怎么解决:重构为"先解析、后落库"两段式;同时给 Hikari 加 leak-detection-threshold(如 30 秒)、事务加 @Transactional(timeout=...) 兜底,让同类问题在爆发前以 WARN 日志暴露。
问题⑩ check-then-act 并发竞态:一次点击,两批数据
现象:发布流程是:查出单据 → 判断状态是不是 DRAFT → 解析关联人 → UPDATE 状态为 PUBLISHED → 批量插入。两个请求并发通过"是不是 DRAFT"的检查后,都会执行 UPDATE 和插入——数据写两遍。
原理:教科书级的 TOCTOU(Time-of-check to time-of-use):检查和动作之间没有原子性。两个事务都能读到 status='DRAFT'(READ COMMITTED 下互不阻塞),然后先后提交。触发条件极其日常:双击按钮、前端超时重试、开两个标签页。
// 反例:查出来在内存里判断,再无条件 UPDATE——两个并发请求都能通过检查
public void publish(Long id) {
Message m = messageMapper.selectById(id);
if (!"DRAFT".equals(m.getStatus())) {
throw new BizException(ErrorCode.WRONG_STATE);
}
// ... 此处与另一个并发请求互不感知
messageMapper.updateStatus(id, "PUBLISHED");
recipientMapper.batchInsert(resolve(m)); // 可能执行两次
}
// 改进:条件 UPDATE 做守卫,检查影响行数——成本最低、最可靠的方案
public void publish(Long id) {
int updated = messageMapper.casPublish(id, "DRAFT", "PUBLISHED");
if (updated == 0) {
log.info("状态已被并发请求变更,按幂等成功返回, id={}", id);
return;
}
recipientMapper.batchInsert(resolve(id)); // 只有抢到状态变更的请求会走到这
}
<update id="casPublish">
UPDATE biz_message
SET status = #{to}, published_at = now()
WHERE id = #{id} AND status = #{from}
</update>
危害:轻则重复数据(关联记录重复、已读状态错乱),重则状态机被打穿(一笔申请被两个审核人各审一次、余额被扣两次)。而且它只在并发下出现,测试环境几乎无法复现,往往是上线后才零星出现"莫名其妙的重复数据"。
日常容易忽略的要素:
- Service 层的"先查再改"在单线程语义下完全正确,review 时天然通过;
- 前端按钮防重(disabled)只防得住手速,防不住重试逻辑和网络层重发;
- 人们习惯性认为"加了 @Transactional 就没问题"——事务保证原子性和部分隔离性,不消除两个并发事务都通过同一检查。
怎么避免:状态流转一律用条件 UPDATE 守卫;或者对状态机迁移加排他锁(SELECT ... FOR UPDATE,但要意识到它拉长锁持有时间);写操作接口在架构上默认"可重入、可并发",把幂等当设计前提而不是补丁。
出了以后怎么解决:把所有裸 UPDATE 状态流转改成条件 UPDATE 并检查影响行数;排查一遍所有状态机字段(申请状态、审核状态、订单状态)逐一补守卫;已被打脏的数据按 created_at + 操作日志回溯清理。
六、工程与架构类
问题⑫ 服务共享数据库:架构与文档的双向漂移
现象:系统从多服务演进为两个服务,但两个服务连的是同一个数据库,其中一个也不再是"无库纯转发"的 BFF,而是内置了账号域。而架构文档依然写着"服务间通过 REST API 调用,不共享数据库"。
原理:共享数据库的微服务本质上是分布式单体(distributed monolith):部署上是两个进程,数据上是一个库。它有真实的好处(会话撤销校验就是靠共享会话表实现的,省掉了服务间回环调用),也有真实的代价:事务边界跨进程不可见(两个服务各开事务写同一批表,没有统一的补偿机制)、表结构变更影响面跨服务、服务无法独立选型和扩容。
危害不在运行时,而在漂移上——架构决策和文档各说各话之后:新成员按文档理解系统会做出错误设计;评审失去基准("文档说不共享库""实际早共享了,那文档还有什么用");下次演进时没人说清现状的全貌。
日常容易忽略的要素:
- 架构演进常常是"逐步发生、某天回头看已经变了",缺少一个"架构决策记录(ADR)"的轻量机制来登记每次转向;
- "文档过期"和"代码偏离文档"在体感上一样,但治理方式完全不同——先判断哪边是对的,再决定改哪边;
- 共享库不等于不能用,但它要求配套纪律(表按域分 schema / 明确表所有权 / 禁止跨域 join),这些纪律没有自动检查手段时很快失守。
怎么避免:轻量 ADR 一页纸(背景/决策/后果)放仓库,决策变更时强制回写文档;数据库层明确表所有权:
CREATE SCHEMA account; -- 门户/账号服务所有
CREATE SCHEMA biz; -- 业务服务所有
-- code review 检查各服务 mapper 的表归属:门户只许读写 account.*,业务只许读写 biz.*
把"会话校验依赖共享库"这类跨服务的数据依赖显式写进架构文档。
出了以后怎么解决:多数情况下正确解法不是拆库(收益配不上成本),而是改文档承认现状:"两服务同库部署,按域划分表所有权;未来拆库的触发条件是 XXX"。若未来真要拆,先从"读路径去交叉依赖"开始(把直读对方表的地方改成 REST 调用),写路径最后拆。
问题⑬ 废弃模块目录残留
现象:仓库里若干废弃模块目录还在磁盘上,已不在父 pom 的 <modules> 里,但各自的旧配置、旧源码、甚至 target/ 里构建好的旧 jar 都在。
原理:这是架构合并的"考古层"。风险有三:IDE 里它们仍被索引,全文搜索/重构可能误伤旧代码或制造"改了两份"的错觉;target/ 里的旧 jar 若被部署脚本按通配符捞走,废弃版本会被部署上线;旧配置里的连接串、密钥注释会误导排障。
# 反例:按通配符捞 jar——哪个目录多出一个 jar 都会被部署
cp */target/*.jar /data/app/
# 改进:显式指定产物路径,多一个字节都不上
cp app-service/target/app-service-1.4.2.jar /data/app/app-service.jar
危害:主要是误导性——误构建、误部署、误排障,每次花费不大但频繁;偶发的是部署事故(旧 jar 上线)。
日常容易忽略的要素:删除模块时人们习惯删 pom 引用就完事,磁盘目录"先放着吧";.gitignore 通常忽略 target,导致git 里看不到但磁盘上一直有的构建产物。
怎么避免:架构合并/删除模块的 checklist 加最后一步"确认目录物理删除或移入 archive/ 并在 README 说明";CI/部署脚本禁止通配符捞产物。
出了以后怎么解决:直接删除(git 历史里都有,无需手软);旧产物下线时同步清理对应的 systemd unit / 容器编排条目。
问题⑭ 会话表无清理、每请求一次点查
现象:每个认证请求都要查一次会话表(token_id 点查),而这张表没有任何清理机制——过期会话、被顶下线的会话永久累积。
原理:这是问题①方案的伴生成本。撤销校验把无状态 JWT 变成了有状态,代价是每个请求多一次数据库往返,且表只增不减。点查本身有索引时很快,但表无限增长后:索引变深、缓冲池命中率下降、统计信息失真,最终拖慢所有请求;同时它是全系统请求路径上的单点——会话库一抖,所有服务所有接口一起抖。
危害:慢性性能退化 + 可用性单点。日常没有明显症状,数据量上去后逐渐显形(假设日活一千、每人每天登录三次,两年就是两百万行)。
日常容易忽略的要素:
- "为安全加的表"往往没有从第一天起规划生命周期——业务表有需求驱动清理,安全设施表没人认领;
- 每请求查库的方案在 review 时看起来干净正确,成本藏在运行时;
- 顺带要确认
token_id上有唯一索引——撤销方案依赖它做点查,没有索引就是全表扫描。
怎么避免:建表时同步设计索引与清理策略:
-- 撤销点查依赖的唯一索引(没有它就是全表扫描)
CREATE UNIQUE INDEX uk_login_session_token ON tb_login_session (token_id);
CREATE INDEX idx_login_session_expires ON tb_login_session (expires_at);
@Scheduled(cron = "0 30 3 * * ?")
public void purgeExpiredSessions() {
// 保留 30 天供审计,过期即清理
int deleted = sessionMapper.deleteExpired(
Instant.now().minus(30, ChronoUnit.DAYS));
log.info("过期会话清理完成, deleted={}", deleted);
}
中期优化——会话有效性加短 TTL 本地缓存,把撤销延迟控制在可接受范围的同时削掉绝大部分点查:
LoadingCache<String, Boolean> sessionCache = Caffeine.newBuilder()
.expireAfterWrite(Duration.ofSeconds(30))
.maximumSize(100_000)
.build(jti -> sessionMapper.existsActive(jti));
撤销的及时性要求本来就是秒级而非毫秒级,30 秒缓存延迟在绝大多数业务里可接受。监控加两条:会话表行数、点查 P99 耗时。
出了以后怎么解决:先补索引与清理任务(立刻见效),再评估上缓存;如果点查已成瓶颈而业务不需要秒级撤销,可以把撤销粒度从"每请求校验"降为"每分钟批量失效列表同步"。
问题⑮ 附带:密钥熵与文档口径
加密密钥熵:某加密工具要求密钥为 16 字节字符串。16 字节本身够 AES-128 用,但"16 个字符的字符串"如果是人拍脑袋定的(如 myapp2026secret),实际熵远低于 128 位。避免:
openssl rand -hex 16 # 生成 32 个十六进制字符,恰好 16 字节高熵密钥
// 启动日志输出密钥指纹(不是密钥本身),运维核对即可
log.info("敏感字段密钥指纹: {}****",
DigestUtils.md5DigestAsHex(key.getBytes()).substring(0, 8));
出了问题(密钥疑似泄露)按轮换流程处理——注意字段加密密钥轮换比 JWT 轮换复杂:存量密文要用旧密钥解、新密钥重加密,需要写迁移任务并保留多版本前缀的解密能力(如 v1:/v2: 前缀,好的密文格式设计会提前留这个口子):
int sep = ciphertext.indexOf(':');
String version = ciphertext.substring(0, sep);
byte[] plain = switch (version) {
case "v1" -> cipherV1.decrypt(ciphertext.substring(sep + 1));
case "v2" -> cipherV2.decrypt(ciphertext.substring(sep + 1));
default -> throw new IllegalStateException("未知密文版本: " + version);
};
文档口径漂移:架构文档说"接口始终 HTTP 200 + 业务码",认证过滤器却按设计返回 401——代码是对的,文档没衔接。这类"两条都对但互相矛盾"的条款最迷惑新人。避免:文档变更时全文检索相关条款,口径不一致时当场补一句例外说明;拖久了文档公信力归零。
七、方法论:这种审查是怎么做的
把过程抽象出来,供下次复用:
- 先架构后细节:读父 pom(真实模块清单)→ 部署形态(几个服务、是否共享库)→ 认证链路(过滤器 → 会话 → 用户上下文)。安全问题的 80% 藏在"跨服务/跨模块的边界"上,单文件 review 看不见。
- 认证链路逐跳跟踪:从
Authorization头进来到UserContext落地,每一跳问三个问题:谁校验的?校验失败会怎样?这个机制在每个服务上都生效吗? - 接口清单 × 三个矩阵:把所有接口列出来,逐一核对——
- 鉴权矩阵:谁校验 token?(白名单放行的理由是否成立)
- 权限矩阵:写接口谁校验权限码?(grep 权限注解,零命中即红线)
- 范围矩阵:按 ID / 按关键词的接口,数据范围在哪一层过滤?(列表有 ≠ 详情有)
- 注入类靠 grep 兜底:mapper XML 搜
${}、Statement、Runtime.exec、ObjectInputStream;这类问题机器能查,不占人工时间。 - 状态机专项:找所有 status 字段的 UPDATE,检查 WHERE 里有没有前置状态条件(竞态防线)。
- 事务边界专项:grep
@Transactional逐个看方法体里有没有 HTTP / IO;写操作检查幂等性。 - 配置安全专项:所有
${XXX:默认值}的默认值逐个过——密钥类不允许有默认值;profile 默认值指向生产或缺失即失败。 - 文档对照:拿架构约束文档逐条核对实现,差异双向登记(代码错的改代码,文档过期的改文档)。
- 输出分级:P0(可被外部利用/上线红线)→ P1(上线前必办)→ P2(工程健康度),每条给修复方案和影响文件清单,先确认再动手。
另外记一条清单使用纪律:✅ 项和 ❌ 项同等重要。✅ 项是回归用例的来源(下次审查必须复测),❌ 项是整改跟踪的对象(修完必须回填结论),一份只记问题的清单会让你重复踩已经绕过的坑。
八、修复优先级建议
| 优先级 | 项 | 理由 |
|---|---|---|
| P0 | 问题③ RBAC 服务端强制 | 越权面最大,且放大所有其他问题 |
| P0 | 问题④ 详情 IDOR | 个人信息/核心数据直接泄露路径 |
| P1 | 问题⑤ 写接口无上下文、⑥ 日志无范围 | 同类越权,改动小 |
| P1 | 问题⑦ 公开接口防刷、⑧ 登录防爆破 | 上线前必办 |
| P1 | 问题⑪ profile 默认值 | 一行改动,消除生产误配置的直通车 |
| P2 | 问题⑫ 文档同步、⑬ 死代码清理、⑭ 会话表治理、⑮ 密钥约定 | 工程健康度,按节奏 |
评论 (0)
还没有评论,来抢沙发。