为什么要使用api签名认证算法
接口请求的安全性问题:
- 请求是否合法
- 请求参数是否被篡改
- 防止重放攻击
为了保证接口安全, 防止以上问题的发生, 所以要使用api签名认证算法, 有签名的用户才能访问.
api签名认证算法
ak和sk
所谓ak就是accessKey(也有平台叫appKey或appID), ak用来唯一的标识用户, sk就是secretKey, 即该用户的秘钥.
ak和sk就像相当于用户名和密码. 可以校验用户的身份, 保证接口安全
但是与用户名密码不同的是, ak, sk是无状态的, 每次调用时, 不在乎你上次是否有ak sk, 只校验当前请求是否携带了正确的ak sk.
ak和sk可以在用户注册的时候生成, 保存到数据库中.
sign
不能直接将ak和sk放到请求头中进行传递, 如果请求被拦截, 将会直接暴露秘钥.
所以可以使用使用单向加密算法, 根据ak和sk生成sign签名, 将ak和sign签名放入请求头中传递.
服务器端从请求头中获取ak和sign, 然后根据ak查询sk, 根据同样的单向加密算法, 生成sign, 然后校验生成的sign和请求头中的sign是否一致即可. 这样就避免了sk在请求头中传递带来的风险.
请求参数篡改
以上操作可以验证请求是否合法, 但是无法知道请求参数是否被篡改. 如果有人了拦截了请求, 然后篡改请求参数怎么办?
所以在生成sign签名的时候, 需要将用户的请求参数和aksk放到一起进行加密, 生成sign签名.
sign = 单向加密算法(ak, sk, 用户参数)
重放攻击
虽然解决了请求参数被篡改的问题,但是还存在着重复使用请求参数伪造二次请求的隐患.
给每次请求的时候生成一个时间戳, 将时间戳也放入请求头. 服务端校验时间戳, 与当前时间相差1分钟就认为请求失败. 这个时间戳就确保了请求的时效性。
增加时间戳后, 还是有一分钟时间可以伪造请求来重放. 所以需要再生成一个随机数, 将随机数也放入请求头. 服务器端将收到的随机数保存到redis中, 每次请求校验随机数是否存在, 如果随机数已经存在, 说明请求被重放了. 但是保存大量的随机数会占用大量空间, 所以保存随机数时可以设置一个过期时间, 这样就能避免大量存储时间戳带来的空间占用问题.
一旦旧的随机数被淘汰,那么依旧可以进行重放攻击。因此,必须将随机数和时间戳结合起来,服务端首先验证随机数是否存在,再校验时间戳是否在规定的期限内。如果旧随机数被清理,也有时间戳进行把关,使得请求无法被重放。
然后生成sign签名的方式就变成了 sign = 单向加密算法(ak, sk, 用户参数, 随机数, 时间戳)
总结
用户注册时, 生成 ak, sk
客户端发送请求时
- 生成随机数
- 获取当前时间戳
- 使用单向加密算法, 根据ak、sk、时间戳、随机数、用户参数生成sign签名
- 将ak、时间戳、随机数、sign签名放入请求头(sk不能放在请求头中传递)
服务器端校验请求时
- 获取请求参数
- 从请求头中获取ak、时间戳、随机数、sign签名
- 校验ak是否存在
- 根据ak从数据库中获取sk
- 校验时间戳与当前时间的差是否在1分钟内
- 校验随机数是否在redis中存在,若存在则请求失败,不存在则将随机数放入redis中并设置过期时间
- 根据请求参数和从请求头中获取的ak、时间戳、随机数,以及从数据库中获取的sk生成sign签名
- 对比生成sign签名和从请求头中获取的签名是否一致
代码如下
class SignUtils {
/**
* 加密创建sign
* @param map 请求头参数(用户参数, ak, nonce随机值, 时间戳)
* @param secretKey sk
*/
public static String createSign(Map<String, String> map, String secretKey) {
String content = map.toString() + secretKey;
return DigestUtils.md5DigestAsHex(content.getBytes(StandardCharsets.UTF_8));
}
/**
* @param paramMap 用户的请求参数
* 发送请求前, 传入ak sk以及用户参数, 生成随机数和时间戳, 生成sign, 封装为map, 放入请求头中
* sk不能放入请求头中传递
*/
public static Map<String, String> getMap(
String accessKey, String secretKey, Map<String, ?> paramMap) {
Map<String, String> map = new HashMap<>();
map.put(SignConstant.ACCESS_KEY, accessKey);
map.put(SignConstant.NONCE, String.valueOf(new Random().nextInt(SignConstant.NONCE_MAX_VALUE)));
map.put(SignConstant.TIMESTAMP, String.valueOf(Instant.now().getEpochSecond()));
map.put(SignConstant.PARAMS, paramMap.toString());
map.put(SignConstant.SIGN, createSign(map, secretKey));
return map;
}
/**
* 校验sign
* @param signGot 请求头中获取的sign
* @param accessKey 请求头中获取的ak
* @param secretKey 根据ak从数据库查出的sk
* @param nonce 请求头中获取的nonce
* @param timestamp 请求头中获取的timestamp
* @param paramMap 用户的请求参数
*/
public static boolean checkSign(
String signGot,
String accessKey,
String secretKey,
String nonce,
String timestamp,
Map<String, ?> paramMap) {
if (StrUtil.hasBlank(signGot, accessKey, secretKey, nonce, timestamp)) {
return false;
}
boolean nonceValid = checkNonce(nonce);
boolean timestampValid = checkTimestamp(nonce);
if (!nonceValid || !timestampValid) {
return false;
}
Map<String, String> map = new HashMap<>();
map.put(SignConstant.ACCESS_KEY, accessKey);
map.put(SignConstant.NONCE, nonce);
map.put(SignConstant.TIMESTAMP, timestamp);
map.put(SignConstant.PARAMS, paramMap.toString());
String sign = createSign(map, secretKey);
return Objects.equals(sign, signGot);
}
private static boolean checkNonce(String nonce) {
int nonceInt = Integer.parseInt(nonce);
/*校验nonce长度*/
if (nonceInt < SignConstant.NONCE_MIN_VALUE
|| nonceInt > SignConstant.NONCE_MAX_VALUE) {
return false;
}
/*nonce是否在redis中存在, 存在则抛异常, 不存在则保存到set集合中, 并加上过期时间*/
StringRedisTemplate stringRedisTemplate = SpringContextUtils.getBean(StringRedisTemplate.class);
BoundValueOperations<String, String> valueOps = stringRedisTemplate.boundValueOps(RedisConstant.NONCE_KEY + ":" + nonce);
if (ObjectUtil.isNotNull(valueOps.get())) {
return false;
}
valueOps.set(nonce, SignConstant.NONCE_EXPIRE_MINUTES, TimeUnit.MINUTES);
return true;
}
private static boolean checkTimestamp(String timestampStr) {
long timestamp = Long.parseLong(timestampStr);
if (timestamp < 0) {
return false;
}
/*判断时间戳是否在当前时间的1分钟内, 如果不是则抛异常*/
LocalDateTime dateTimeGot = LocalDateTime.ofInstant(Instant.ofEpochSecond(timestamp), ZoneId.systemDefault());
LocalDateTime now = LocalDateTime.now();
Duration duration = Duration.between(dateTimeGot, now);
long minutes = duration.toMinutes();
return minutes <= SignConstant.TIMESTAMP_EXPIRE_MINUTES;
}
}
class SignConstant {
public static final String ACCESS_KEY = "accessKey";
public static final String SECRET_KEY = "secretKey";
public static final String NONCE = "nonce";
public static final String TIMESTAMP = "timestamp";
public static final String SIGN = "sign";
public static final int NONCE_MIN_VALUE = 0;
public static final int NONCE_MAX_VALUE = 100000;
public static final String PARAMS = "params";
public static final long NONCE_EXPIRE_MINUTES = 2L;
public static final long TIMESTAMP_EXPIRE_MINUTES = 1L;
}