SpringCloud 微服务,如何保证接口安全???

大家好,我是飘渺。如果你的微服务需要向第三方开放接口,如何确保你提供的接口是安全的呢?

1. 什么是安全接口

通常来说,要将暴露在外网的 API 接口视为安全接口,需要实现防篡改防重放的功能。

1.1 什么是篡改问题?

由于 HTTP 是一种无状态协议,服务端无法确定客户端发送的请求是否合法,也不了解请求中的参数是否正确。以一个充值接口为例:

ruby 复制代码
http://localhost/api/user/recharge?user_id=1001&amount=10

如果非法用户通过抓包获取接口参数并修改 user_id 或 amount 的值,就能为任意账户添加余额。

1.1.1 如何解决篡改问题?

虽然使用 HTTPS 协议能对传输的明文进行加密,但黑客仍可截获数据包进行重放攻击。两种通用解决方案是:

  1. 使用 HTTPS 加密接口数据传输,即使被黑客破解,也需要耗费大量时间和精力。
  2. 在接口后台对请求参数进行签名验证,以防止黑客篡改。

签名的实现过程如下图所示:

  • 步骤1:客户端使用约定好的规则对传输的参数进行加密,得到签名值sign1,并且将签名值也放入请求的参数中,随请求发送至服务端。
  • 步骤2:服务端接收到请求后,使用约定好的规则对请求的参数再次进行签名,得到签名值 sign2。
  • 步骤3:服务端比对 sign1 和 sign2 的值,若不一致,则认定为被篡改,判定为非法请求。

1.2. 什么是重放问题?

防重放也叫防复用。简单来说就是我获取到这个请求的信息之后什么也不改,,直接拿着接口的参数去 重复请求这个充值的接口。此时我的请求是合法的, 因为所有参数都是跟合法请求一模一样的。重放攻击会造成两种后果:

  1. 针对插入数据库接口:重放攻击,会出现大量重复数据,甚至垃圾数据会把数据库撑爆。
  2. 针对查询的接口:黑客一般是重点攻击慢查询接口,例如一个慢查询接口1s,只要黑客发起重放攻击,就必然造成系统被拖垮,数据库查询被阻塞死。

1.2.1 如何解决重放问题?

防重放,业界通常基于 nonce + timestamp 方案实现。每次请求接口时生成 timestamp 和 nonce 两个额外参数,其中 timestamp 代表当前请求时间,nonce 代表仅一次有效的随机字符串。生成这两个字段后,与其他参数一起进行签名,并发送至服务端。服务端接收请求后,先比较 timestamp 是否超过规定时间(如60秒),再查看 Redis 中是否存在 nonce,最后校验签名是否一致,是否有篡改。

如果看过我DDD&微服务系列中幂等方案的文章,对于nonce方案肯定比较熟悉,这就是幂等方案中的token机制,只不过此时幂等key是由客户端生成的。

2. 身份认证方案

我们已经了解了如何解决对外接口可能遇到的篡改和重放问题,但还遗漏了最关键的身份认证环节。一般而言,对互联网开放的接口不是任何人都能调用的,只有经过认证的用户或机构才有权限访问。解决身份认证问题通常通过 AppId 和 AppSecret 实现。

2.1 AppId + AppSecret

AppId作为一种全局唯一的标识符,主要用于用户身份识别。为防止其他用户恶意使用别人的 AppId 发起请求,通常采用配对 AppSecret 的方式,类似一种密码。在请求方发起请求时,需将 AppIDAppSecret 搭配上前文提到的安全方案,一并签名提交给提供方验证。

现在,让我们再来梳理一下完整的签名方案。

1、服务方提供一组 AppId 和 AppSecret,并由客户端保存。

2、将timestamp、nonce、AppId 与请求参数一起并按照字典排序,使用URL键值对(key1=value1&key2=value2...)的格式拼接形成字符串StringA。

3、在StringA的最后拼接上AppSecret,得到字符串StringB。

4、使用摘要算法对 StringB 进行加密,并将得到的字符串转为大写,得到签名值 sign,将其与参数一起发送给服务端。

5、服务端接收请求后,对接口进行校验(时间、随机字符串、身份验证、签名)。

在这个流程中,AppID 参与本地加密和网络传输,而 AppSecret 仅作本地加密使用,不参与网络传输。服务端拿到 AppID 后,从存储介质中获取对应的 AppSecret,然后采用与客户端相同的签名规则生成服务端签名,最后比较客户端签名和服务端签名是否一致。

3. 代码实现

"Talk is cheap. Show me the code." 说了这么久,现在让我们从代码的角度来看看如何在 DailyMart 中将上面的理论知识串联起来,安全地对外提供接口。

本文涉及到的所有代码都已上传至github,如果需要请参考文末方式进行获取。

3.1 AppId 和 AppSecret的生成

在生成 AppId 和 AppSecret 时,只需确保 AppId 的全局唯一性,然后将生成的 AppId 和 AppSecret 进行绑定。在 DailyMart 中,我们使用短链的生成算法来生成 AppId,再对 AppId 进行 SHA 加密后得到对应的 AppSecret。

java 复制代码
 private static String getAppKey() {
	long num = IdUtils.nextId();
	StringBuilder sb = new StringBuilder();
	do {
		int remainder = (int) (num % 62);
		sb.insert(0, BASE62_CHARACTERS.charAt(remainder));
		num /= 62;
	} while (num != 0);
	return sb.toString();
}

通过这个算法生成的 AppId 和 AppSecret 形如:

java 复制代码
appKey=6iYWoL2hBk9, appSecret=5de8bc4d8278ed4f14a3490c0bdd5cbe369e8ec9

3.2 API校验器

在一个系统中可能存在多种认证逻辑,比如既要支持今天所讲的开放接口校验逻辑,还需要支持内部服务的 JWT 认证逻辑。为了方便处理,我们抽象一个 API 认证接口,各种认证逻辑独立到自己的实现中,对于今天所讲的开放接口认证,主要关注 ProtectedApiAuthenticator

java 复制代码
//认证接口
public interface ApiAuthenticator {
  AuthenticatorResult auth(ServerWebExchange request); 
}

//具体实现
@Slf4j
public class ProtectedApiAuthenticator implements ApiAuthenticator {
  ...
}

3.2 网关过滤器

接口的安全校验很适合放在网关层实现,因此我们需要在网关服务中创建一个过滤器 ApiAuthenticatorFilter

java 复制代码
@Component
@Slf4j
public class ApiAuthenticatorFilter implements GlobalFilter, Ordered {
    ...
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
       
        // 获取认证逻辑
        ApiAuthenticator apiAuthenticator = getApiAuthenticator(rawPath);
        AuthenticatorResult authenticatorResult = apiAuthenticator.auth(exchange);
      
        if (!authenticatorResult.isResult()) {
            return Mono.error(new HttpServerErrorException(
                    HttpStatus.METHOD_NOT_ALLOWED, authenticatorResult.getMessage()));
        }
        
        return chain.filter(exchange);
        
    }
    
   
 		/**
     * 确定认证策略
     * @param rawPath 请求路径
     */
    private ApiAuthenticator getApiAuthenticator(String rawPath) {
        String[] parts = rawPath.split("/");
        if (parts.length >= 4) {
            String parameter = parts[3];
              return switch (parameter) {
                case PROTECT_PATH ->   new ProtectedApiAuthenticator();
                case PRIVATE_PATH ->   new PrivateApiAuthenticator();
                case PUBLIC_PATH ->    new PublicApiAuthenticator();
                case DEFAULT_PATH ->   new DefaultApiAuthenticator();
                default -> throw new IllegalStateException("Unexpected value: " + parameter);
              };
        }
        return new DefaultApiAuthenticator();
    }
    
}

上面提到过,不同类型的服务其接口认证不一样,为了便于区分,可以规定对于外部请求都增加一个特定的请求前缀 /pt/,如 apigw.xxx.com/order-service/api/pt/creadeOrder。这样在过滤器内部就需要通过 getApiAuthenticator() 方法确定认证逻辑。

3.3 接口安全认证

正如上文所说,服务端获取到请求参数以后需要检查请求时间是否过期,nonce是否已经被使用,签名是否正确。

按照这个逻辑我们很容易在ProtectedApiAuthenticator认证器中写出这样的代码。

java 复制代码
@Slf4j
public class ProtectedApiAuthenticator implements ApiAuthenticator {

    @Override
    public AuthenticatorResult auth(ServerWebExchange exchange)  {
        
        // 1. 校验参数
        boolean checked = preAuthenticationCheck(requestHeader);
        if (!checked) {
            return new AuthenticatorResult(false, "请携带正确参数访问");
        }

        // 2 . 重放校验
        // 判断timestamp时间戳与当前时间是否操过60s(过期时间根据业务情况设置),如果超过了就提示签名过期。
        long now = System.currentTimeMillis() ;      
         if (now - Long.parseLong(requestHeader.getTimestamp()) > 60000) {
            return new AuthenticatorResult(false, "请求超时,请重新访问");
         }

        // 3. 判断nonce
        boolean nonceExists = distributedCache.hasKey(NONCE_KEY + requestHeader.getNonce());
        if (nonceExists) {
            return new AuthenticatorResult(false, "请勿重复提交请求");
        } else {
            distributedCache.put(NONCE_KEY + requestHeader.getNonce(), requestHeader.getNonce(), 60000);
        }
      
        // 4. 签名校验
       SortedMap<String, Object> requestBody = CachedRequestUtil.resolveFromBody(exchange);
       String sign = buildSign(requestHeader,requestBody);
      if(!sign.equals(requestHeader.getSign())){
        return new AuthenticatorResult(false, "签名错误");
      }
      
      return new AuthenticatorResult(true, "");
}

这样的写法虽然能够完成校验逻辑,但稍显不够优雅。在这种场景中,使用设计模式中的责任链模式是非常合适的选择。通过责任链模式,将校验逻辑分解为多个责任链节点,每个节点专注于一个方面的校验,使得代码更加清晰和易于维护。

责任链模式已经在我星球设计模式专栏中有详细介绍与说明,感兴趣的可以翻翻~

JAVA 复制代码
@Slf4j
public class ProtectedApiAuthenticator implements ApiAuthenticator {

    @Override
    public AuthenticatorResult auth(ServerWebExchange exchange)  {
        ...
        //构建校验对象
        ProtectedRequest protectedRequest = ProtectedRequest.builder()
                .requestHeader(requestHeader)
                .requestBody(requestBody)
                .build();

				//责任链上下文
        SecurityVerificationChain securityVerificationChain = SpringBeanUtils.getInstance().getBean(SecurityVerificationChain.class);

        return securityVerificationChain.handler(protectedRequest);

    }

}

3.4 基于责任链的认证实现

3.4.1 创建责任链的认证接口

JAVA 复制代码
public interface SecurityVerificationHandler extends Ordered {
    /**
     * 请求校验
     */
    AuthenticatorResult handler(ProtectedRequest protectedRequest);
}

3.4.2 实现参数校验逻辑

java 复制代码
@Component
public class RequestParamVerificationHandler implements SecurityVerificationHandler {

    @Override
    public AuthenticatorResult handler(ProtectedRequest protectedRequest) {

        boolean checked = checkedHeader(protectedRequest.getRequestHeader());

        if(!checked){
            return new AuthenticatorResult(false,"请携带正确的请求参数");
        }
        return new AuthenticatorResult(true,"");
    }

    private boolean checkedHeader(RequestHeader requestHeader) {
        return Objects.nonNull(requestHeader.getAppId()) &&
                Objects.nonNull(requestHeader.getSign()) &&
                Objects.nonNull(requestHeader.getNonce()) &&
                Objects.nonNull(requestHeader.getTimestamp());
    }

    @Override
    public int getOrder() {
        return 1;
    }
}

3.4.3 实现nonce的校验

JAVA 复制代码
@Component
public class NonceVerificationHandler implements SecurityVerificationHandler {
    private static final String NONCE_KEY = "x-nonce-";

    @Value("${dailymart.sign.timeout:60000}")
    private long expireTime ;
  
    @Resource
    private DistributedCache distributedCache;

    @Override
    public AuthenticatorResult handler(ProtectedRequest protectedRequest) {
        String nonce = protectedRequest.getRequestHeader().getNonce();
        boolean nonceExists = distributedCache.hasKey(NONCE_KEY + nonce);

        if (nonceExists) {
            return new AuthenticatorResult(false, "请勿重复提交请求");
        } else {
            distributedCache.put(NONCE_KEY + nonce, nonce, expireTime);
            return new AuthenticatorResult(true, "");
        }
    }

    @Override
    public int getOrder() {
        return 3;
    }
}

3.4.4 实现签名认证

JAVA 复制代码
@Component
@Slf4j
public class SignatureVerificationHandler implements SecurityVerificationHandler {
    @Override
    public AuthenticatorResult handler(ProtectedRequest protectedRequest) {

        //1. 服务端按照规则重新签名
        String serverSign = sign(protectedRequest);
        log.info("服务端签名结果: {}", serverSign);

        String clientSign = protectedRequest.getRequestHeader().getSign();
        // 2、获取客户端传递的签名
        log.info("客户端签名: {}", clientSign);

        if (!Objects.equals(serverSign,clientSign)) {
            return new AuthenticatorResult(false, "请求签名无效");
        }
        return new AuthenticatorResult(true, "");
    }

    /**
     * 服务端重建签名
     * @param protectedRequest 请求体
     * @return 签名结果
     */
    private String sign(ProtectedRequest protectedRequest) {
        RequestHeader requestHeader = protectedRequest.getRequestHeader();
        String appId = requestHeader.getAppId();

        String appSecret = getAppSecret(appId);
        // 1、 按照规则对数据进行签名
        SortedMap<String, Object> requestBody = protectedRequest.getRequestBody();
        requestBody.put("app_id",appId);
        requestBody.put("nonce_number",requestHeader.getNonce());
        requestBody.put("request_time",requestHeader.getTimestamp());

        StringBuilder signBuilder = new StringBuilder();
        for (Map.Entry<String, Object> entry : requestBody.entrySet()) {
            signBuilder.append(entry.getKey()).append("=").append(entry.getValue()).append("&");
        }
        signBuilder.append("appSecret=").append(appSecret);

        return DigestUtils.md5DigestAsHex(signBuilder.toString().getBytes()).toUpperCase();
    }


    @Override
    public int getOrder() {
        return 4;
    }

}

3.4.5 责任链上下文

java 复制代码
@Component
@Slf4j
public class SecurityVerificationChain {
    @Resource
    private List<SecurityVerificationHandler> securityVerificationHandlers;

    public AuthenticatorResult handler(ProtectedRequest protectedRequest){
        AuthenticatorResult authenticatorResult = new AuthenticatorResult(true,"");
        for (SecurityVerificationHandler securityVerificationHandler : securityVerificationHandlers) {
            AuthenticatorResult result = securityVerificationHandler.handler(protectedRequest);
            // 有一个校验不通过理解返回
            if(!result.isResult()){
                return result;
            }
        }
        return authenticatorResult;

    }

}

组合所有的校验逻辑,任意一个校验逻辑不通过则直接返回。

小结

在本文中,我们深入研究了微服务架构中对外开放接口的安全性保障机制。我们着重关注了那些暴露在外网的API接口面临的两个关键安全问题:篡改和重放。为了应对篡改问题,我们引入了双重手段:采用HTTPS进行加密传输,并结合接口参数签名验证,以确保数据传输的完整性和安全性。对于重放问题,我们采纳了基于noncetimestamp的方案,以保证请求的唯一性和有效性。

在具体的代码实现中,我们不仅考虑了文章中提到的安全认证逻辑,还充分考虑了其他可能的校验规则。为了更好地组织和管理这些校验规则,我们将它们拆分成独立的模块,根据请求路径动态选择相应的接口校验器。在第三方接口校验逻辑中,我们通过责任链的设计模式实现了具体的校验规则,使得代码逻辑更为模块化和可扩展。这样的结构不仅使得每个校验步骤聚焦于特定的安全性验证,而且提供了良好的可维护性和可扩展性。

最后给大家一个小建议:对外提供的接口协议尽量简单,不要使用Restful接口风格,全部使用post+json或post+form风格的接口协议即可,这样对客户端和服务端都方便。

DailyMart是一个基于领域驱动设计(DDD)和Spring Cloud Alibaba的微服务商城系统。我们将在该系统中整合博主其他专栏文章的核心内容。如果你对这两大技术栈感兴趣,可以在公众号 JAVA日知录 回复关键词 DDD 以获取相关源码。

相关推荐
追逐时光者5 小时前
推荐 12 款开源美观、简单易用的 WPF UI 控件库,让 WPF 应用界面焕然一新!
后端·.net
Jagger_5 小时前
敏捷开发流程-精简版
前端·后端
苏打水com6 小时前
数据库进阶实战:从性能优化到分布式架构的核心突破
数据库·后端
间彧7 小时前
Spring Cloud Gateway与Kong或Nginx等API网关相比有哪些优劣势?
后端
间彧7 小时前
如何基于Spring Cloud Gateway实现灰度发布的具体配置示例?
后端
间彧7 小时前
在实际项目中如何设计一个高可用的Spring Cloud Gateway集群?
后端
间彧7 小时前
如何为Spring Cloud Gateway配置具体的负载均衡策略?
后端
间彧7 小时前
Spring Cloud Gateway详解与应用实战
后端
EnCi Zheng9 小时前
SpringBoot 配置文件完全指南-从入门到精通
java·spring boot·后端
烙印6019 小时前
Spring容器的心脏:深度解析refresh()方法(上)
java·后端·spring