API接口安全设计

2021年某社交平台的API被曝存在严重漏洞:黑客利用未授权访问的接口,爬取了超过1亿条用户数据,包括手机号、邮箱和社交关系。

技术团队排查后发现:这个接口原本是给内部数据统计用的,挂在内部网段,后来因为一次架构调整迁移到了公网,但没有任何权限校验和访问控制。

更可怕的是:这个接口返回的数据格式和正常API完全一致,黑客绕过了所有WAF规则。

这次数据泄露导致平台被罚款约2000万元,CEO引咎辞职。

【面试官手记】

API安全是生产环境最容易被忽视的问题。我面试过的候选人里,能说清楚"接口鉴权"的超过60%,能说出"签名验证"的不到30%,能说出"完整安全防护体系"的不到10%。API安全的关键词是纵深防御

一、API安全的四大威胁 🔴

1.1 四大威胁

API安全四大威胁:

1. 未授权访问
   - 接口没有权限校验
   - 越权访问他人数据
   - 典型场景:内部接口暴露公网

2. 参数篡改
   - 请求参数被恶意修改
   - 典型场景:修改订单金额、修改用户ID

3. 重放攻击
   - 请求被恶意重复发送
   - 典型场景:短信验证码重复使用、支付请求重放

4. 恶意爬虫
   - 大量请求爬取数据
   - 典型场景:爬取用户信息、商品信息

1.2 威胁场景详解

威胁1:未授权访问
请求:GET /api/user/123
问题:任何人都能访问,返回用户123的完整信息
危害:用户隐私数据泄露

威胁2:参数篡改
请求:POST /api/order/create
      {"productId": 1, "price": 100}
问题:price参数可以被客户端修改为1
危害:商品价格被篡改,财务损失

威胁3:重放攻击
请求:POST /api/payment
      {"orderId": "xxx", "amount": 100}
问题:请求被截获后重复发送
危害:重复扣款、重复发货

威胁4:恶意爬虫
请求:GET /api/user/list?page=1
问题:循环请求所有页面爬取数据
危害:数据被批量爬取

二、签名验证方案 🔴

2.1 签名算法

// 签名验证
public class SignUtil {

    private static final String APP_SECRET = "your_app_secret_key";

    /**
     * 生成签名
     * 签名规则:把所有参数按字典序排列,拼接后做MD5
     * 示例:GET /api/user?id=123&name=zhang
     *       sign = MD5("id=123&name=zhang&secret=xxx")
     */
    public static String generateSign(String method, String uri,
                                       Map<String, String> params) {
        // 1. 按字典序排序参数
        TreeMap<String, String> sorted = new TreeMap<>(params);

        // 2. 拼接参数
        StringBuilder sb = new StringBuilder();
        sb.append(method.toUpperCase()).append("\n");
        sb.append(uri).append("\n");
        for (Map.Entry<String, String> entry : sorted.entrySet()) {
            sb.append(entry.getKey()).append("=").append(entry.getValue()).append("&");
        }
        sb.append("secret=").append(APP_SECRET);

        // 3. MD5签名
        return DigestUtils.md5Hex(sb.toString());
    }

    /**
     * 验证签名
     */
    public static boolean verifySign(HttpServletRequest request) {
        String clientSign = request.getHeader("X-Sign");
        String timestamp = request.getHeader("X-Timestamp");

        // 时间戳校验:5分钟内有效
        long ts = Long.parseLong(timestamp);
        if (Math.abs(System.currentTimeMillis() - ts) > 5 * 60 * 1000) {
            return false;
        }

        // 获取所有参数
        Map<String, String> params = getAllParams(request);

        // 生成签名
        String serverSign = generateSign(
            request.getMethod(),
            request.getRequestURI(),
            params
        );

        return serverSign.equals(clientSign);
    }
}

2.2 请求拦截器

// 签名验证拦截器
@Component
public class SignInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response,
                             Object handler) {
        // 1. 校验时间戳
        String timestamp = request.getHeader("X-Timestamp");
        if (timestamp == null || isTimestampExpired(timestamp)) {
            response.setStatus(401);
            response.getWriter().write("{\"code\": 401, \"msg\": \"请求已过期\"}");
            return false;
        }

        // 2. 校验签名
        if (!SignUtil.verifySign(request)) {
            response.setStatus(401);
            response.getWriter().write("{\"code\": 401, \"msg\": \"签名验证失败\"}");
            return false;
        }

        return true;
    }

    private boolean isTimestampExpired(String timestamp) {
        try {
            long ts = Long.parseLong(timestamp);
            return Math.abs(System.currentTimeMillis() - ts) > 5 * 60 * 1000;
        } catch (Exception e) {
            return true;
        }
    }
}

2.3 防重放攻击

// 防重放:Nonce方案
@Component
public class NonceInterceptor implements HandlerInterceptor {

    @Autowired
    private RedisTemplate redisTemplate;

    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response,
                             Object handler) {
        String nonce = request.getHeader("X-Nonce");
        if (nonce == null) {
            response.setStatus(401);
            return false;
        }

        // 检验Nonce是否使用过
        String key = "nonce:" + nonce;
        Boolean exists = redisTemplate.hasKey(key);
        if (Boolean.TRUE.equals(exists)) {
            response.setStatus(401);
            return false;
        }

        // 记录Nonce,5分钟内有效
        redisTemplate.opsForValue().set(key, "1", 5, TimeUnit.MINUTES);
        return true;
    }
}

三、参数校验方案 🟡

3.1 参数校验注解

// 参数校验
public class CreateOrderRequest {

    @NotNull(message = "用户ID不能为空")
    private Long userId;

    @NotNull(message = "商品ID不能为空")
    private Long productId;

    @Min(value = 1, message = "数量最小为1")
    @Max(value = 99, message = "数量最大为99")
    private Integer quantity;

    @DecimalMin(value = "0.01", message = "金额最小为0.01")
    private BigDecimal price;

    @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
    private String phone;
}

// 全局异常处理
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Result<?> handleValidationException(MethodArgumentNotValidException e) {
        String message = e.getBindingResult().getFieldErrors().stream()
            .map(FieldError::getDefaultMessage)
            .collect(Collectors.joining(", "));
        return Result.fail(400, "参数校验失败: " + message);
    }
}

3.2 业务参数校验

// 业务参数校验
@Service
public class OrderService {

    @Transactional
    public Order createOrder(CreateOrderRequest request) {
        // 1. 幂等校验
        String idempotentKey = "order:idempotent:" + request.getUserId() + ":" +
            DigestUtils.md5Hex(JSON.toJSONString(request));
        if (redisTemplate.hasKey(idempotentKey)) {
            throw new BizException("重复请求");
        }

        // 2. 金额校验:服务端重新计算,不能信任客户端
        Product product = productService.getById(request.getProductId());
        BigDecimal realPrice = product.getPrice();
        if (request.getPrice().compareTo(realPrice) < 0) {
            throw new BizException("金额不合法");
        }

        // 3. 权限校验:只能给自己下单
        User currentUser = getCurrentUser();
        if (!currentUser.getId().equals(request.getUserId())) {
            throw new BizException("无权为他人下单");
        }

        // 4. 库存校验
        if (product.getStock() < request.getQuantity()) {
            throw new BizException("库存不足");
        }

        // 下单
        Order order = doCreateOrder(request);

        // 记录幂等Key
        redisTemplate.opsForValue().set(idempotentKey, order.getId(), 24, TimeUnit.HOURS);

        return order;
    }
}

四、接口限流方案 🟡

4.1 限流算法

// 限流注解
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RateLimit {
    int value() default 100;           // QPS阈值
    int windowSeconds() default 1;    // 时间窗口
    String key() default "";           // 限流Key
}

// 限流实现
@Component
public class RateLimitInterceptor implements HandlerInterceptor {

    @Autowired
    private RedisTemplate redisTemplate;

    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response,
                             Object handler) {
        Method method = ((HandlerMethod) handler).getMethod();
        RateLimit rateLimit = method.getAnnotation(RateLimit.class);
        if (rateLimit == null) {
            return true;
        }

        // 构建限流Key
        String key = buildKey(request, rateLimit);
        int limit = rateLimit.value();

        // 使用Redis滑动窗口限流
        String now = String.valueOf(System.currentTimeMillis());
        String windowKey = "ratelimit:" + key;

        Long count = redisTemplate.opsForList().size(windowKey);
        if (count != null && count >= limit) {
            response.setStatus(429);
            response.getWriter().write("{\"code\": 429, \"msg\": \"请求过于频繁\"}");
            return false;
        }

        // 记录请求
        redisTemplate.opsForList().leftPush(windowKey, now);
        redisTemplate.expire(windowKey, rateLimit.windowSeconds(), TimeUnit.SECONDS);

        return true;
    }

    private String buildKey(HttpServletRequest request, RateLimit rateLimit) {
        if (StringUtils.isNotBlank(rateLimit.key())) {
            return rateLimit.key();
        }
        // 默认按IP限流
        return request.getRemoteAddr();
    }
}

4.2 分层限流

分层限流策略:

1. 网关层限流
   - 按IP限流:单IP QPS < 100
   - 按用户限流:单用户 QPS < 50
   - 按接口限流:敏感接口 QPS < 10

2. 应用层限流
   - 按业务限流:下单接口 QPS < 1000
   - 按资源限流:数据库连接数 < 100
   - 按服务限流:单服务实例 QPS < 500

3. 熔断降级
   - 错误率超过50%:熔断5分钟
   - 超时率超过30%:熔断2分钟
   - 拒绝率超过10%:限流告警

五、认证授权方案 🟡

5.1 JWT认证

// JWT工具类
public class JwtUtil {

    private static final String SECRET = "your_jwt_secret_key_at_least_256_bits";
    private static final long EXPIRE_TIME = 7 * 24 * 60 * 60 * 1000; // 7天

    public static String generateToken(Long userId, String role) {
        Date now = new Date();
        Date expireDate = new Date(now.getTime() + EXPIRE_TIME);

        return Jwts.builder()
            .setSubject(String.valueOf(userId))
            .claim("role", role)
            .setIssuedAt(now)
            .setExpiration(expireDate)
            .signWith(SignatureAlgorithm.HS256, SECRET)
            .compact();
    }

    public static Claims parseToken(String token) {
        return Jwts.parser()
            .setSigningKey(SECRET)
            .parseClaimsJws(token)
            .getBody();
    }
}

// JWT认证拦截器
@Component
public class JwtInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response,
                             Object handler) {
        String token = request.getHeader("Authorization");
        if (token == null || !token.startsWith("Bearer ")) {
            response.setStatus(401);
            return false;
        }

        try {
            token = token.substring(7);
            Claims claims = JwtUtil.parseToken(token);
            request.setAttribute("userId", Long.parseLong(claims.getSubject()));
            request.setAttribute("role", claims.get("role"));
            return true;
        } catch (Exception e) {
            response.setStatus(401);
            return false;
        }
    }
}

5.2 权限校验

// 权限注解
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RequirePermission {
    String value();  // 如: "order:create", "user:admin"
}

// 权限校验实现
@Component
public class PermissionInterceptor implements HandlerInterceptor {

    @Autowired
    private PermissionService permissionService;

    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response,
                             Object handler) {
        HandlerMethod handlerMethod = (HandlerMethod) handler;
        RequirePermission annotation = handlerMethod.getMethodAnnotation(RequirePermission.class);

        if (annotation == null) {
            return true;
        }

        Long userId = (Long) request.getAttribute("userId");
        String requiredPermission = annotation.value();

        // 查询用户权限
        Set<String> userPermissions = permissionService.getUserPermissions(userId);
        if (!userPermissions.contains(requiredPermission)) {
            response.setStatus(403);
            return false;
        }

        return true;
    }
}

六、生产避坑 🟡

6.1 API安全的五大坑

坑1:内部接口暴露公网

问题:内部接口没有鉴权,直接暴露在公网
场景:数据统计接口迁移到公网
解决方案:
- 所有接口必须鉴权
- 内部接口使用VPN或内网隔离
- 定期安全扫描

坑2:参数不校验

问题:客户端提交的参数没有校验
场景:直接用客户端传来的金额下单
解决方案:
- 所有参数必须校验
- 金额等关键参数服务端重新计算
- 使用Bean Validation注解

坑3:签名算法泄露

问题:签名算法被逆向
场景:APP被反编译,签名算法泄露
解决方案:
- 签名密钥服务端存储
- 定期更换密钥
- 使用HTTPS防止抓包

坑4:没有限流

问题:接口没有限流,被恶意爬取
场景:循环请求用户列表页
解决方案:
- 网关层限流
- 应用层限流
- 爬虫识别和封禁

坑5:敏感数据明文传输

问题:敏感数据没有加密
场景:用户密码明文传输
解决方案:
- 使用HTTPS
- 敏感数据加密传输
- 日志中脱敏

6.2 安全检查清单

开发规范:
- [ ] 所有接口必须鉴权
- [ ] 关键参数必须校验
- [ ] 使用签名防篡改
- [ ] 使用Nonce防重放
- [ ] 敏感数据加密

定期检查:
- [ ] 安全扫描
- [ ] 渗透测试
- [ ] 日志审计
- [ ] 密钥轮换

七、真实面试回放 🟡

面试官:接口怎么防止参数篡改?

候选人(小张):两个方案:

一是签名验证。把参数按规则拼接后做MD5,服务器用同样的规则验签。

二是参数校验。金额等关键参数服务端重新计算,不能信任客户端。

面试官:怎么防止重放攻击?

小张:两个方案:

一是时间戳校验。请求超过5分钟视为无效。

二是Nonce方案。每个请求生成唯一Nonce,Redis记录已使用的Nonce。

面试官:JWT怎么实现?

小张:服务端生成Token,包含用户ID和过期时间,用密钥签名。

客户端每次请求带Token,服务端验证签名和过期时间。

【面试官手记】

小张这场面试的亮点:

  1. 知道签名验证的原理

  2. 知道重放攻击的防护方案

  3. 知道JWT的基本原理

API安全是P6工程师必备技能,能完整回答的候选人,说明有安全意识。

API安全的核心理念是纵深防御。记住三个要点:

  1. 签名验证:参数防篡改,时间戳+Nonce防重放
  2. 参数校验:服务端重新计算关键参数,不能信任客户端
  3. 认证授权:JWT认证,权限注解校验

API安全无小事,接口上线前必须过安全检查。