一 引言
在日常开发中我们会向第三提供api接口,所以必须暴露到外网,并提供了具体请求地址和请求参数,为了防止被第别有用心之人获取到真实请求参数后再次发起请求获取信息,需要采取很多安全机制;
安全策略
- 1.首先: 需要采用https方式对第三方提供接口,数据的加密传输会更安全,即便是被破解,也需要耗费更多时间
- 2.其次:需要有安全的后台验证机制【本文重点】,达到防参数篡改+防二次请求,防止重放攻击必须要保证请求仅一次有效;
防参数篡改
客户端使用约定好的秘钥对传输参数进行加密,得到签名值signature,并且将签名值也放入请求参数中,发送请求给服务端
服务端接收客户端的请求,然后使用约定好的秘钥对请求的参数(除了signature以外)再次进行签名,得到签名值autograph。
服务端对比signature和autograph的值,如果对比一致,认定为合法请求。如果对比不一致,说明参数被篡改,认定为非法请求
防二次请求
- 基于timestamp的方案
- 基于nonce的方案
- 基于timestamp和nonce的方案(推荐)
二 代码实现
定义校验注解
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface XlcpAntiReplay {
/**
* 是否检查签名
* @return
*/
boolean checkSignature() default true;
/**
* 反重放校验
* @return
*/
boolean antiReplay() default true;
}
定义参数构造器及相应的校验规则(构造者模式)
@Slf4j
public class XlcpSignatureBuilder {
/**
* 请求标识
*/
private String nonce;
/**
* 请求时间
*/
private Long timestamp;
/**
* 请求URL
*/
private String url;
/**
* 请求URL
*/
private String token;
/**
* 请求参数
*/
private Object[] arguments;
/**
* 请求参数
*/
private String params;
/**
* 请求URL
*/
private String signature;
/**
* 盐值
*/
private String salt;
private XlcpSignatureBuilder nonce(String nonce) {
Assert.notNull(nonce, "请求标识不能为空");
this.nonce = nonce;
return this;
}
private XlcpSignatureBuilder timestamp(Long timestamp) {
this.timestamp = timestamp;
return this;
}
private XlcpSignatureBuilder url(String url) {
Assert.notNull(url, "请求URL不能为空");
this.url = url;
return this;
}
private XlcpSignatureBuilder token(String token) {
this.token = token;
return this;
}
private XlcpSignatureBuilder arguments(Object[] arguments) {
this.arguments = arguments;
return this;
}
private XlcpSignatureBuilder params(Map<String, String[]> parameterMap) {
this.params = JSONUtil.toJsonStr(parameterMap);
return this;
}
private XlcpSignatureBuilder signature(String signature) {
Assert.notNull(signature, "签名摘要不能为空");
this.signature = signature;
return this;
}
private XlcpSignatureBuilder salt(String salt) {
Assert.notNull(salt, "盐值不能为空");
this.salt = salt;
return this;
}
public static XlcpSignatureBuilder build(HttpServletRequest request, XlcpReplayProperties properties){
XlcpReplayProperties.HeaderKey headerKey = properties.getHeaderKey();
XlcpReplayProperties.SignatureAlgorithm signatureAlgorithm = properties.getSignatureAlgorithm();
XlcpSignatureBuilder signatureBuilder = new XlcpSignatureBuilder();
signatureBuilder.nonce(request.getHeader(headerKey.getNonce()))
.timestamp(Convert.toLong(request.getHeader(headerKey.getTimestamp())))
.url(request.getHeader(headerKey.getUrl()))
.token(request.getHeader(headerKey.getToken()))
.params(request.getParameterMap())
.signature(request.getHeader(headerKey.getSignature()))
.salt(signatureAlgorithm.getSalt());
return signatureBuilder;
}
public void validate(){
String digest =
MdFiveUtils.digest(
this.salt,this.nonce, this.url, this.timestamp, this.token, this.params, this.arguments);
if (!StrUtil.equals(this.signature, digest)) {
if (log.isDebugEnabled()) {
log.debug("数据签名验证未通过, 传入签名:[ {} ], 生成签名:[ {} ]", signature, digest);
}
throw new DataSignatureException("data signature verification failed");
}
}
}
@Slf4j
public class XlcpAntiReplayBuilder implements AutoCloseable{
private static final Long VERSION_INCREMENT_STEP = 1L;
/**
* 请求方法名称
*/
private String methodName;
/**
* 请求路径 (请求头)
*/
private String url;
/**
* 目标url
*/
private String targetUrl;
/**
* 请求标识
*/
private String nonce;
/**
* 请求时间戳
*/
private Long timestamp;
/**
* redis版本
*/
private Long version;
private XlcpAntiReplayBuilder methodName(String methodName){
Assert.notNull(methodName,"请求方法不能为空!");
this.methodName=methodName;
return this;
}
private XlcpAntiReplayBuilder url(String url){
Assert.notNull(url,"URL不能为空");
this.url = startAt(url, "/");
return this;
}
private XlcpAntiReplayBuilder targetUrl(String targetUrl){
Assert.notNull(url,"targetUrl不能为空");
this.targetUrl = startAt(url, "/");
return this;
}
private XlcpAntiReplayBuilder nonce(String nonce){
Assert.notNull(nonce,"请求标识不能为空");
this.nonce=nonce;
return this;
}
private XlcpAntiReplayBuilder timestamp(Long timestamp){
Assert.notNull("请求时间戳不能为空");
this.timestamp=timestamp;
return this;
}
private String startAt(String str, CharSequence prefix){
return StrUtil.startWith(str,prefix)?str:prefix+str;
}
public static XlcpAntiReplayBuilder build(HttpServletRequest request, ProceedingJoinPoint point, String contextPath, XlcpReplayProperties props){
XlcpAntiReplayBuilder xlcpAntiReplayBuilder = new XlcpAntiReplayBuilder();
xlcpAntiReplayBuilder.methodName(SpringUtils.getMethodName(point))
.nonce(request.getHeader(props.getHeaderKey().getNonce()))
.url(request.getHeader(props.getHeaderKey().getUrl()))
.targetUrl(StrUtil.replace(request.getRequestURI(),contextPath,""))
.timestamp(Convert.toLong(request.getHeader(props.getHeaderKey().getTimestamp())));
return xlcpAntiReplayBuilder;
}
public void validate(){
if (!StrUtil.equals(this.targetUrl,this.url)){
throw new AntiReplayException("请求url与实际url不相符");
}
XlcpReplayProperties props = SpringContextHolder.getBean(XlcpReplayProperties.class);
if (DateTimeUtils.betweenNowSeconds(this.timestamp)>props.getRequest().getExpireTime()){
throw new AntiReplayException("请求已过期");
}
String key = genKey(props);
this.version = RedisUtil.increment(key);
RedisUtil.expire(key,props.getCache().getLockHoldTime(), TimeUnit.SECONDS);
if (version>VERSION_INCREMENT_STEP){
throw new AntiReplayException("当前请求正在处理中,请不要重复提交");
}
}
private String genKey(XlcpReplayProperties props){
return props.getCache().getCacheKeyPrefix()+this.methodName+ StrPool.COLON+this.nonce;
}
@Override
public void close() throws Exception {
log.debug("删除缓存");
String key = genKey(SpringContextHolder.getBean(XlcpReplayProperties.class));
if (RedisUtil.exists(key)){
RedisUtil.remove(key);
}
}
}
定义配置参数
@ConfigurationProperties(prefix = XlcpReplayProperties.PRE_FIX)
@Data
public class XlcpReplayProperties {
/**
* Request Header信息对象
*/
private HeaderKey headerKey = new HeaderKey();
/**
* 请求配置
*/
private Request request = new Request();
/**
* 缓存配置
*/
private Cache cache = new Cache();
private SignatureAlgorithm signatureAlgorithm = new SignatureAlgorithm();
/**
* 配置前缀
*/
public static final String PRE_FIX = "config.anti.replay";
@Data
public class HeaderKey{
/**
* 请求id防止重放
*/
private String nonce ="nonce";
/**
* 请求时间 避免缓存时间过后重放
*/
private String timestamp = "timestamp";
/**
* 请求url
*/
private String url = "url";
/**
* Token
*/
private String token = "token";
/**
* 签名
*/
private String signature = "signature";
}
@Data
public class Request{
/**
* 请求有效期
*/
private Long expireTime = 60L;
}
@Data
public class Cache {
/**
* 缓存Key前缀
*/
private String cacheKeyPrefix = "XLCP:SECURITY:ANTI-REPLAY:REQUEST_ID_";
/**
* 锁持续时间(避免异常造成锁不释放)
*/
private long lockHoldTime = 300L;
}
@Data
public class SignatureAlgorithm{
/**
* 加密盐值
*/
private String salt = "XLCP_ANTI_REPLAY";
}
}
定义AOP切面
@Aspect
@RequiredArgsConstructor
public class XlcpAntiReplayAspect {
private final XlcpReplayProperties properties;
private final Environment environment;
public static final String CONTEX_TPATH_PRE = "server.servlet.context-path";
@Around("@annotation(xlcpAntiReplay)")
@SneakyThrows
public Object around(ProceedingJoinPoint point, XlcpAntiReplay xlcpAntiReplay){
HttpServletRequest request = WebUtils.getRequest();
// 签名校验
if (xlcpAntiReplay.checkSignature()){
XlcpSignatureBuilder
.build(request,properties)
.validate();
}
// 反重放校验
if (xlcpAntiReplay.antiReplay()){
String servletContextPath = environment.getProperty(CONTEX_TPATH_PRE);
try(XlcpAntiReplayBuilder xlcpAntiReplayBuilder = XlcpAntiReplayBuilder
.build(request,point,servletContextPath,properties)) {
xlcpAntiReplayBuilder.validate();
}
return point.proceed();
}
return point.proceed();
}
}
使用到的工具类
public class DateTimeUtils {
public DateTimeUtils() {
}
public static long betweenNowSeconds(Long timestamp) {
return betweenNowSeconds(LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneId.systemDefault()));
}
public static long betweenNowSeconds(LocalDateTime time) {
return ChronoUnit.SECONDS.between(time, LocalDateTime.now());
}
}
public class MdFiveUtils {
private static final String EMPTY_JSON = "{}";
public static String digest(DigestWorker worker) {
return digest(null, worker.nonce, worker.url, worker.timestamp, worker.token, worker.params, worker.arguments);
}
public static String digest(
final String salt,
final String nonce,
final String url,
final Long timestamp,
final String token,
final Object... arguments) {
return digest(salt, nonce, url, timestamp, token, null, arguments);
}
public static String digest(
final String salt,
final String nonce,
final String url,
final Long timestamp,
final String token,
final String params,
final Object... arguments) {
Assert.notBlank(nonce, "nonce不能为空");
Assert.notBlank(url, "url不能为空");
StringBuilder sb = new StringBuilder(salt + nonce + url);
if (!Objects.isNull(timestamp)) {
sb.append(timestamp);
}
if (!Objects.isNull(token)) {
sb.append(token);
}
if (StrUtil.isNotBlank(params) && !StrUtil.equals(params, EMPTY_JSON)) {
sb.append(params);
}
sb.append(argumentsSort(arguments));
return DigestUtils.md5DigestAsHex(sb.toString().getBytes(StandardCharsets.UTF_8));
}
private static String argumentsSort(final Object... arguments) {
if (arguments != null && arguments.length > 0) {
List<Object> list =
Arrays.stream(arguments)
.sorted(Comparator.comparing(Object::hashCode))
.collect(Collectors.toList());
char[] chars = JSONUtil.toJsonStr(list).toCharArray();
Arrays.sort(chars);
return Arrays.toString(chars);
}
return "";
}
public static DigestWorker builder() {
return new DigestWorker();
}
@Data
public static class DigestWorker {
private String nonce;
private String url;
private Long timestamp;
private String token;
private String params;
private Object[] arguments;
public DigestWorker nonce(String nonce) {
this.nonce = nonce;
return this;
}
public DigestWorker url(String url) {
this.url = url;
return this;
}
public DigestWorker timestamp(Long timestamp) {
this.timestamp = timestamp;
return this;
}
public DigestWorker token(String token) {
this.token = token;
return this;
}
public DigestWorker params(String params) {
this.params = params;
return this;
}
public DigestWorker arguments(Object... arguments) {
this.arguments = arguments;
return this;
}
public String execute() {
return MdFiveUtils.digest(this);
}
}
}
public class SpringUtils {
@SneakyThrows
public static Method getMethod(ProceedingJoinPoint point) {
Signature signature = point.getSignature();
MethodSignature ms = (MethodSignature) signature;
return point.getTarget().getClass().getMethod(ms.getName(), ms.getParameterTypes());
}
public static String getMethodName(ProceedingJoinPoint point) {
return getClass(point).getName() + '.' + getMethod(point).getName();
}
public static Class<?> getClass(ProceedingJoinPoint point) {
return point.getTarget().getClass();
}
public static HttpServletRequest getHttpServletRequest() {
return getServletRequestAttributes().getRequest();
}
public static ServletRequestAttributes getServletRequestAttributes() {
return (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
}
}
添加springboot自动配置管理
@Configuration
@EnableConfigurationProperties(XlcpReplayProperties.class)
@RequiredArgsConstructor
public class XlcpAntiReplayAutoConfiguration {
private final Environment environment;
@Bean
public XlcpAntiReplayAspect xlcpAntiReplayAspect(XlcpReplayProperties xlcpReplayProperties){
return new XlcpAntiReplayAspect(xlcpReplayProperties,environment);
}
}
三 测试案列
注解使用
@RestController
@RequestMapping("/test")
public class TestController {
@GetMapping("/sendTest")
@Inner(value = false)
@XlcpAntiReplay
public R sendTest(String userName){
return R.ok(userName);
}
@PostMapping("/testPost")
@Inner(value = false)
@XlcpAntiReplay
public R testPost(@RequestBody SysUser sysUser){
return R.ok(sysUser);
}
}
第三方使用
@Slf4j
public class XlcpAntiReplayTest {
@Test
public void testAntiReplay(){
HttpRequest request = HttpUtil.createGet("localhost:4000/test/sendTest");
// 请求标识
String nonce = UUID.randomUUID().toString();
request.header("nonce", nonce);
long timestamp = System.currentTimeMillis();
request.header("timestamp", Convert.toStr(timestamp));
String url = request.getUrl();
request.header("url",url);
// 系统配置的盐
String salt = "XLCP_ANTI_REPLAY";
HashMap<String, Object> paramMap = new HashMap<>();
paramMap.put("userName","lktest");
request.form(paramMap);
String signature = MdFiveUtils.digest(salt, nonce, url, timestamp, null, null);
request.header("signature",signature);
HttpResponse response = request.execute();
log.info("响应结果:{}",response);
}
@Test
public void testPost(){
HttpRequest request = HttpUtil.createPost("localhost:4000/test/testPost");
// 请求标识
String nonce = UUID.randomUUID().toString();
request.header("nonce", nonce);
long timestamp = System.currentTimeMillis();
request.header("timestamp", Convert.toStr(timestamp));
String url = request.getUrl();
request.header("url",url);
// 系统配置的盐
String salt = "XLCP_ANTI_REPLAY";
String signature = MdFiveUtils.digest(salt, nonce, url, timestamp, null, null);
request.header("signature",signature);
HashMap<String, Object> map = new HashMap<>();
map.put("userName","lktest");
request.body(JSONUtil.toJsonStr(map));
HttpResponse response = request.execute();
log.info("响应结果:{}",response);
}
}