#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,服务端验证签名和过期时间。
【面试官手记】
小张这场面试的亮点:
知道签名验证的原理
知道重放攻击的防护方案
知道JWT的基本原理
API安全是P6工程师必备技能,能完整回答的候选人,说明有安全意识。
API安全的核心理念是纵深防御。记住三个要点:
- 签名验证:参数防篡改,时间戳+Nonce防重放
- 参数校验:服务端重新计算关键参数,不能信任客户端
- 认证授权:JWT认证,权限注解校验
API安全无小事,接口上线前必须过安全检查。