当系统接口最为开放接口提供给第三方调用时,接口的安全问题必须考虑,请求身份是否合法?请求是否被篡改?请求是否唯一?
请求身份
为开发者分配AccessKey(开发者标识,确保唯一)和SecretKey(秘钥,用于接口加密),在项目中AccessKey和SecretKey可以做类似用户管理的完备管理机制,成为系统功能的一部分。
防止篡改
为防止请求在发送过程中可能会被拦截,修改参数之后再将请求发往服务器,或者在请求发送的过程中参数出现缺失,可以对参数进行签名,生成Sign(Sign生成的方式有多重多样,根据自身系统的需求选择不同的方式,但客户端与服务端必须采用相同的方式生成Sign),请求携带参数AccessKey和Sign,只有拥有合法的身份AccessKey和正确的签名Sign才能放行。
重放攻击
重放攻击即用同一个请求重复向服务器发送请求,很容易对服务的数据或性能造成较大的影响,解决重放攻击的一种方案是时间戳+随机数。 nonce指唯一的随机字符串,用来标识每个被签名的请求。通过为每个请求提供一个唯一的标识符,服务器能够防止请求被多次使用(记录所有用过的nonce以阻止它们被二次使用)。然而,对服务器来说永久存储所有接收到的nonce的代价是非常大的。可以使用timestamp来优化nonce的存储。假设允许客户端和服务端最多能存在5分钟的时间差,同时追踪记录在服务端的nonce集合。当有新的请求进入时,首先检查携带的timestamp是否在5分钟内,如超出时间范围,则拒绝,然后查询携带的nonce,如存在已有集合,则拒绝。否则,记录该nonce,并删除集合内时间戳大于5分钟的nonce(可以使用redis的expire,新增nonce的同时设置它的超时失效时间为5分钟)。
代码实现
由于我的demo使用的是Gateway网关,并使用Spring Security做鉴权,所以实现的是ReactiveAuthorizationManager,只需将OpenAuthorizationManager配置到ServerHttpSecurity中即可。
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.binary.Hex;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.ReactiveAuthorizationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.server.authorization.AuthorizationContext;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;
import reactor.core.publisher.Mono;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
/**
* 开放平台接口API鉴权管理器
* Created by yangxiangjun on 2020/12/11.
*/
@Slf4j
public class OpenAuthorizationManager implements ReactiveAuthorizationManager<AuthorizationContext> {
private static final AntPathMatcher pathMatch = new AntPathMatcher();
private static final long EXPIRE_TIME = 5 * 60;
private static final String NONCE_KEY = "hutan:";
private static final char SEPARATOR = '&';
private static final char CONNECTOR = '=';
private static Map<String, String> ACCESSKEY = new HashMap<String, String>();
@Autowired
RedisTemplate<String, Object> redisTemplate;
@SneakyThrows
@Override
public Mono<AuthorizationDecision> check(Mono<Authentication> mono, AuthorizationContext authorizationContext) {
ServerHttpRequest request = authorizationContext.getExchange().getRequest();
MultiValueMap<String, String> queryParams = request.getQueryParams();
String sign = queryParams.getFirst("Sign");//签名
String accessKey = queryParams.getFirst("AccessKey");//开发者标识
String timestamp = queryParams.getFirst("Timestamp");//时间戳
String nonce = queryParams.getFirst("Nonce");//随机数
if (StringUtils.isEmpty(sign)) {
log.info("未鉴权,拒绝请求 path:{}",request.getPath());
return mono.just(new AuthorizationDecision(false));
}
//判断时间是否超过5分钟,如果超过,拒绝请求
long timeDifference = StringUtils.isEmpty(timestamp) ? EXPIRE_TIME : Duration.between(LocalDateTime.parse(timestamp, DateTimeFormatter.ISO_DATE_TIME), LocalDateTime.now()).getSeconds();
if (timeDifference > EXPIRE_TIME) {
log.info("请求携带的时间戳超过15分钟,拒绝请求");
return mono.just(new AuthorizationDecision(false));
}
//判断nonce随机数是否已在系统中存在,若已存在,代表重复请求
if (redisTemplate.hasKey(NONCE_KEY + nonce)) {
log.info("重复请求,拒绝请求 path:{}");
return mono.just(new AuthorizationDecision(false));
}
HashMap params = new HashMap();
//判断参数传递的签名与计算的签名是否一致
params.put("AccessKey", accessKey);
params.put("Timestamp", timestamp);
params.put("Nonce", nonce);
params.put("SecretKey", ACCESSKEY.get(accessKey));
params.put("Method", request.getMethod());
boolean compareSign = compareSign(params, sign);
if (!compareSign) {
log.info("签名不一致,拒绝请求 path:{}");
return mono.just(new AuthorizationDecision(false));
}
//验证通过,记录nonce
redisTemplate.opsForValue().set(NONCE_KEY + nonce, "");
redisTemplate.expire(NONCE_KEY + nonce, EXPIRE_TIME, TimeUnit.SECONDS);
return mono.just(new AuthorizationDecision(true));
}
/**
* 重新进行MD5计算,并将计算结果与签名进行比对,若结果一致,代表校验通过,否则鉴权不通过
* @param params
* @param sign
* @return
* @throws NoSuchAlgorithmException
*/
private boolean compareSign(Map<String, String> params, String sign) throws NoSuchAlgorithmException {
Objects.requireNonNull(sign);
String[] sortedKeys = params.keySet().toArray(new String[]{});
Arrays.sort(sortedKeys);
StringBuilder canonicalizedQueryString = new StringBuilder();
for (String key : sortedKeys) {
// 这里注意对key和value进行编码
canonicalizedQueryString.append(SEPARATOR)
.append(key).append(CONNECTOR)
.append(params.get(key));
}
canonicalizedQueryString = canonicalizedQueryString.deleteCharAt(0);
// log.info("canonicalizedQueryString:{}",canonicalizedQueryString);
MessageDigest md5 = MessageDigest.getInstance("MD5");
md5.update(canonicalizedQueryString.toString().getBytes());
String relSign = new String(Hex.encodeHex(md5.digest()));
log.info(relSign);
return sign.equals(relSign);
}
}
接口鉴权在项目中往往比较复杂,以上只是最简单的实践。