008、API安全加固:OAuth2.0、JWT与防注入实战


一、从一次深夜告警说起

上周三凌晨两点,手机突然狂震——监控显示某个内部管理接口的调用量在十分钟内暴涨了500倍。登录服务器一看,日志里全是同一个token在重复调用用户列表接口,请求频率高得离谱。显然,这不是正常业务行为。

紧急排查发现:这个接口虽然用了OAuth2.0做认证,却漏了权限细粒度控制;token是长期有效的,且客户端把token硬编码在了前端代码里。攻击者通过浏览器调试工具轻松拿到token后,直接写脚本疯狂爬取数据。

这件事让我再次意识到:用了安全框架不等于安全。今天我们就聊聊那些在真实企业场景里,让API真正“硬”起来的实战技巧。


二、OAuth2.0:别只停留在“能用”层面

很多团队实现OAuth2.0时,只关心“能不能跑通授权流程”,却忽略了企业场景下的特殊需求。看看这段典型的配置代码:

// 常见的、但问题多多的配置
@Bean
public AuthorizationServerConfigurerAdapter authConfig() {
    return new AuthorizationServerConfigurerAdapter() {
        @Override
        public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
            clients.inMemory()
                .withClient("web_app")
                .secret("{noop}client_secret_123") // 问题1:弱密码还明文存
                .authorizedGrantTypes("password", "refresh_token") // 问题2:password模式直接暴露
                .accessTokenValiditySeconds(30 * 24 * 3600); // 问题3:token一个月不过期
        }
    };
}

这段代码至少有三个坑:

第一坑:password模式滥用
在企业内部系统,除非绝对必要,否则别用password模式。它要求客户端收集用户密码,增加了密码泄露风险。建议用authorization_code模式,或者针对内部服务间调用用client_credentials模式。

第二坑:token生命周期过长
access_token给30天,refresh_token给90天——这是给自己埋雷。我们现在的策略是:access_token最长4小时,refresh_token最多用一次就失效(必须配合refresh_token轮换机制)。

第三坑:客户端凭证管理随意
用inMemory存client_secret、用弱密码、不加密存储……这些低级错误在真实项目里真不少见。至少得这样:

// 改进后的配置片段
.withClient("internal_service")
.secret(passwordEncoder.encode(strongRandomSecret)) // BCrypt加密
.autoApprove(false) // 必须显式授权
.redirectUris("https://internal.company.com/callback") // 严格限制回调地址
.scopes("read_profile", "write_log") // 按需最小化scope
.accessTokenValiditySeconds(2 * 3600); // 2小时过期

三、JWT:别把token当数据库用

JWT最大的优点是“自包含”,但这也是最容易被滥用的地方。见过有人在JWT的payload里塞几十个字段,包括用户权限列表、部门结构、甚至个人偏好设置——token长度直接突破2KB。

// 反面教材:JWT payload过大
{
  "sub": "user123",
  "name": "张三",
  "department": "技术部-后端组-微服务团队",
  "roles": ["admin", "editor", "viewer", "auditor", "approver"],
  "permissions": ["user:read", "user:write", "log:read", "report:generate", ...],
  "preferences": {"theme": "dark", "language": "zh-CN"},
  // ... 还有十几个字段
}

问题在哪?
每次请求都携带这么大的token,浪费带宽不说,更关键的是权限信息更新延迟。用户权限变了,得等token过期才能生效,除非你实现一套复杂的token强制失效机制。

我们的经验是:JWT只放身份标识和必要元数据。权限信息走单独的API实时查询,或者用短缓存的权限码。比如:

// 精简后的payload设计
public class JwtPayload {
    private String sub;      // 用户ID
    private String jti;      // token唯一标识(用于强制失效)
    private Long iat;        // 签发时间
    private Long exp;        // 过期时间
    private String aud;      // 受众(哪个服务)
    private String scope;    // 核心权限范围,如"api:read"
    // 不!要!塞!权!限!列!表!
}

签名算法选择
别再用HS256(对称加密)了,一旦secret泄露,攻击者可以伪造任意token。用RS256或ES256(非对称加密),私钥签名、公钥验证,私钥严格保存在服务端安全存储里。


四、防注入:参数校验别只靠框架

Spring Validation用起来很顺手,但容易让人产生虚假安全感。看这个例子:

@PostMapping("/users")
public User createUser(@Valid @RequestBody UserCreateRequest request) {
    // 看起来安全,其实还有漏洞
    return userService.create(request);
}

@Data
public class UserCreateRequest {
    @NotBlank
    @Size(min=2, max=20)
    private String username;  // 好像校验了长度
    
    @Email
    private String email;     // 格式校验有了
}

漏了什么?

  1. 没过滤特殊字符(比如username里带HTML标签)
  2. 没做业务逻辑校验(比如username是否已被占用)
  3. 没防批量注册(单个IP短时间内创建大量用户)

我们现在的做法是三层校验链

// 第一层:基础格式校验(用注解)
// 第二层:业务规则校验(在Service里)
public User createUser(UserCreateRequest request) {
    // 防脚本注入
    if (containsScriptTags(request.getUsername())) {
        throw new IllegalArgument("用户名包含非法字符");
    }
    
    // 防业务滥用
    rateLimiter.check("user_create", currentIp, 10, 3600); // 1小时最多10次
    
    // 防逻辑漏洞
    if (userRepo.existsByUsername(request.getUsername())) {
        throw new ConflictException("用户名已存在"); // 别返回"用户已存在",避免信息泄露
    }
    
    // 第三层:数据库层面约束(唯一索引、外键等)
    return userRepo.save(request.toEntity());
}

特别注意:错误信息别太详细
返回“用户名或密码错误”而不是“密码错误”,返回“请求参数无效”而不是“邮箱格式错误”。避免给攻击者提供信息便利。


五、企业场景的特殊加固技巧

1. 内部API也要认证
别以为内网就安全。我们要求所有内部服务间调用必须带service-account的JWT token,哪怕它们在同一台物理机上。零信任网络,从内部开始。

2. 动态scope机制
标准OAuth2.0的scope是静态配置的,但我们扩展了一套动态scope:根据用户风险等级、登录设备、访问时间等因素,动态调整token的权限范围。比如下班时间访问敏感接口,scope自动缩减。

3. Token绑定策略
重要操作(如转账、删除)的token必须绑定设备指纹或IP段。我们实现了一个简单的token绑定中间件:

@Component
public class TokenBindingFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                   HttpServletResponse response, 
                                   FilterChain chain) {
        String token = extractToken(request);
        String deviceFingerprint = request.getHeader("X-Device-Fingerprint");
        
        // 从Redis查这个token应该绑定的设备指纹
        String expectedFingerprint = redis.get("token_bind:" + token);
        
        if (expectedFingerprint != null && 
            !expectedFingerprint.equals(deviceFingerprint)) {
            // 设备不匹配,强制token失效
            redis.delete("token:" + token); // 加入黑名单
            throw new UnauthorizedException("检测到异常设备");
        }
        
        chain.doFilter(request, response);
    }
}

4. 审计日志必须独立通道
别把操作日志和业务日志混在一起。我们专门有个带独立认证的审计服务,所有敏感操作(登录、删数据、改权限)通过同步调用记录,确保日志不被篡改。


六、个人经验与建议

API安全不是一次性项目,而是持续对抗的过程。几个血泪教训:

关于token存储:前端别存localStorage,用httpOnly的cookie,至少加个secure和sameSite属性。见过太多XSS直接偷localStorage里的token。

关于密钥管理:别把任何密钥、密码硬编码在代码里。用KMS或至少是环境变量。我们吃过亏——某同事把测试环境数据库密码提交到了GitHub公开仓库,虽然半小时内发现并删除,但已经被爬虫抓走了。

关于依赖更新:定期更新安全相关的依赖库。去年那个Spring Security的绕过漏洞(CVE-2022-22978),我们因为及时升级到补丁版本,躲过一劫。隔壁团队晚了三天升级,被扫出漏洞扣了安全分。

关于压力测试:安全测试别只做功能验证。上压力工具模拟高并发场景,有时候认证系统在正常流量下没问题,一到高压就出现token重复使用、缓存击穿导致权限校验绕过。

最后说个心态问题:别追求“绝对安全”,那不存在。我们的目标是让攻击成本高于攻击收益。就像你家防盗门——防不了专业大盗,但能让小偷觉得撬你家不如撬隔壁的。

每次设计API安全方案时,问自己三个问题:

  1. 这个方案被攻破后,影响范围有多大?(最小化爆炸半径)
  2. 攻击者需要付出多少成本?(提高攻击门槛)
  3. 我能否在1小时内发现异常?(加强监控能力)

安全是道高一尺魔高一丈的持久战,保持警惕,持续迭代。共勉。


下一篇预告:009、高并发下的API性能优化:从缓存策略到异步化改造

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐