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

2024年 3月 6日 70.4k 0

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

1. 什么是安全接口

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

1.1 什么是篡改问题?

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

http://localhost/api/user/recharge?user_id=1001&amount=10

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

1.1.1 如何解决篡改问题?

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

  • 使用 HTTPS 加密接口数据传输,即使被黑客破解,也需要耗费大量时间和精力。
  • 在接口后台对请求参数进行签名验证,以防止黑客篡改。
  • 签名的实现过程如下图所示:

    图片图片

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

    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 的方式,类似一种密码。在请求方发起请求时,需将 AppID 和 AppSecret 搭配上前文提到的安全方案,一并签名提交给提供方验证。

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

    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。

    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 形如:

    appKey=6iYWoL2hBk9, appSecret=5de8bc4d8278ed4f14a3490c0bdd5cbe369e8ec9

    3.2 API校验器

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

    图片图片

    //认证接口
    public interface ApiAuthenticator {
      AuthenticatorResult auth(ServerWebExchange request); 
    }
    
    //具体实现
    @Slf4j
    public class ProtectedApiAuthenticator implements ApiAuthenticator {
      ...
    }

    3.2 网关过滤器

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

    @Component
    @Slf4j
    public class ApiAuthenticatorFilter implements GlobalFilter, Ordered {
        ...
        @Override
        public Mono 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认证器中写出这样的代码。

    @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 requestBody = CachedRequestUtil.resolveFromBody(exchange);
           String sign = buildSign(requestHeader,requestBody);
          if(!sign.equals(requestHeader.getSign())){
            return new AuthenticatorResult(false, "签名错误");
          }
          
          return new AuthenticatorResult(true, "");
    }

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

    责任链模式已经在我星球设计模式专栏中有详细介绍与说明,这里就不再赘述了~

    @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 创建责任链的认证接口

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

    3.4.2 实现参数校验逻辑

    @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的校验

    @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 实现签名认证

    @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 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 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 责任链上下文

    @Component
    @Slf4j
    public class SecurityVerificationChain {
        @Resource
        private List 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进行加密传输,并结合接口参数签名验证,以确保数据传输的完整性和安全性。对于重放问题,我们采纳了基于nonce和timestamp的方案,以保证请求的唯一性和有效性。

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

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

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论