项目2:API Hunter 细节回顾 -1

一. 接口调用

对于开发者来说,接口的调用应当是方便快捷的,而且出于安全考虑,通常会选择在后端调用第三方 API,避免在前端暴露诸如密码的敏感信息。

若采用 HTTP 调用方式:

  1. HttpClient
  2. RestTemplate
  3. 第三方库(Hutool等)

项目中使用了 Hutool 工具库中的 Http 客户端工具类,快速调用其它的 http 请求。

Hutool 工具库官方依赖:

<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.8.16</version>
</dependency>

三个接口:

/**
 *
 * 查询用户名称的 API
 *
 */
@RestController
@RequestMapping("name")
public class NameController {
   @GetMapping("/") 
   public String getNameByGet(String name) {

       return "GET 你的名字是" + name;

   }

   @PostMapping("/") 
   public String getNameByPost(@RequestParam String name) {

       return "POST 你的名字是" + name;

   }

   @PostMapping("/user") 
   public String getUserNameByPost(@RequestParam User user) {

       return "POST 用户名字是" + user.getUsername();

   }
}

调用第三方接口,直接使用Http 客户端工具类官方代码,略微修改即可: 

/**
 *
 *调用第三方接口的客户端
 *
 */
public class kApiClient {

   // 使用 GET 方法从服务器获取名称信息
   public String getNameByGet(String name){
      // 可以单独传入 http 参数,这样参数会自动做 URL 编码,拼接在 URL 中。
      hashMap<String, Object> paramMap = new HashMap<>();
      // 将 "name" 参数添加到映射中
      paramMap.put("name", name);
      // 使用 HttpUtil 工具发起 GET 请求,并获取服务器返回结果
      String result = HttpUtil.get("http://localhost:8123/api/name",paramMap);
      // 打印服务器返回结果
      System.out.println(result);
      // 返回服务器返回结果
      return result;
   }

   // 使用 POST 方法从服务器获取名称信息
   public String getNameByPost(@RequestParam String name) {
      // 可以单独传入 http 参数,这样参数会自动做 URL 编码,拼接在 URL 中。
      hashMap<String, Object> paramMap = new HashMap<>();
      // 将 "name" 参数添加到映射中
      paramMap.put("name", name);
      // 使用 HttpUtil 工具发起 GET 请求,并获取服务器返回结果
      String result = HttpUtil.post("http://localhost:8123/api/name",paramMap);
      // 打印服务器返回结果
      System.out.println(result);
      // 返回服务器返回结果
      return result;  
   }

   // 使用 POST 方法向服务器发送 User 对象,并获取服务器返回结果
   public String getUserNameByPost(@RequestParam User user) { 
      // 将 User 对象转换为 JSON 字符串(轻量,方便在客户端服务端之间传输数据)
      String json = JSONUtil.toJsonStr(user);
      // 使用 HttpRequest 工具发起 POST 请求,并获取服务器的响应
      HttpResponse httpResponse = HttpRequest.post("http://localhost:8123/api/name")
             .body(json) // 将 JSON 字符串设置为请求体
             .execute(); // 执行请求 
      // 打印服务器返回的状态码
      System.out.println(httpResponse.getStatus());
      // 打印服务器返回结果
      System.out.println(result);
      // 返回结果
      return result;
   }
}

二. API 签名认证

这部分在 API Hunter — 客制化API开放平台 一文中进行过阐述,此处重新整理并再次回顾。

1. 为什么需要认证

思考一个问题:如果我们为外界提供了一些可用接口,却对请求调用者一无所知。

这可能会让我们面临严重的安全问题。假设服务器最多只允许100人同时调用,有攻击者疯狂地请求接口,刷量刷请求,会严重消耗服务器性能,影响正常用户的使用,并且也会使损害系统安全。

因此,我们需要为接口设置保护措施,例如限流,限制每个用户每秒只能调用接口十次等。同时我们需要知道调用者信息,即谁在请求调用接口。类似于管理系统中的权限检查,执行删除操作时,后端会先去检查用户是否具有管理员权限等。

但用户调用接口时,可能是从前端直接发起请求,用户没有登录操作,也就不涉及用户名和密码,所以后端无法从 session 中获取用户信息,因为根本就没有。因此在这种情况下,就采用 API 签名认证机制

2. 什么是 API 签名认证

通俗地讲,就是基于授权(许可证)的身份校验

举例:客人想要参加我的宴会,需要有我事前签发的请帖,作为授权或许可证。当客人赴约时,需要带上请帖,只要有请帖,就能参加。

API 签名认证的过程:签发签名 -> 校验签名。

API 签名认证不仅保证了安全性,不让随便一个人调用接口,而且实现了用户的无状态请求,即只认签名,不关注用户登录态。

3. 涉及的参数和组件

通过 http request header 头传递参数。

  • 参数1:accessKey(aK),调用的标识 userA/userB … (复杂、无序、无规律)
  • 参数2:secretKey(sK),密钥 (复杂、无序、无规律),该参数不能放到请求头中

认证过程中主要依靠签发 aK 和 sK 来完成身份的校验。类似于用户名和密码,只是是无状态的。

  • 参数3:用户请求参数
  • 参数4:sign

加密组件:

利用用户参数和密钥,然后通过签名生成算法(MD5、SHA256等)加密,变成不可解密的值,防止泄露和被破译。

用户参数 + 密钥 \overset{MD5}{\rightarrow} 不可解密的值

防重放:

  • 参数5:nonce 随机数,只能用一次。服务端保存用过的随机数。
  • 参数6:timestapm 时间戳,校验时间戳是否过期。

API 签名认证过程相对灵活,具体的参数应当根据实际业务场景合理选择。

4. 基本流程实现

首先给数据库中的用户表增加两个字段 accessKey 和 secretKey。

aK 和 sK 的生成通常要求无规律且复杂,为了模拟效果,此处先自行设置。

aK:khr123   sK:1q2w3e4r

Tips:之所以需要两个 key,还是为了保证安全性。就像在登陆网站时不仅需要用户名还需要密码。如果只有一个,那么任何一个拿到这个 key 的人都可以调用接口,不安全。

然后在调用接口客户端中增加这两个字段及其构造方法:

/**
 *
 *调用第三方接口的客户端
 *
 */
public class kApiClient {
    
  private String accessKey;
 
  private String secretKey;

  public KApiClient(String accessKey, String secretKey) {
      this.accessKey = accessKey;
      this.secretKey = secretKey;
  }

  ……

}

之后在调用的 KApiClient 的地方,将 aK、sK 拿到即可,客户端改造完成:

public class Main {
   public static void main(String[] args){ 
       String accessKey ="khr123";
       String secretKey ="1q2w3e4r";
       KApiClient kApiClient = new KApiClient(accessKey, secretKey);
       ……
   }
}

接下来服务端要校验 aK、sK,以 getUserNameByPost 接口为例说明:

首先要获取到用户传递的 aK、sK。这种数据建议不要直接在 URL 中传递,而是在请求头中传递更为妥当。因为 GET 请求的 URL 存在最大长度限制,如果传递的其它参数过多,会导致关键数据被挤出,所以建议从请求头中获取这些数据。

@PostMapping("/user")
public String getUserNameByPost(@RequestBody User user, HttpServletRequest request) {
    // 从请求头中获取名为 "accessKey" 的值
    String accessKey = request.getHeader("accessKey");
    // 从请求头中获取名为 "secretKey" 的值
    String secretKey = request.getHeader("secretKey");
    // 如果 accessKey 不等于 "khr123" 或者 secretKey 不等于 "1q2w3e4r"
    if(!accessKey.equals("khr123")||!secretKey.equals("1q2w3e4r")){
        // 抛出运行异常,提示权限不足
        throw new RuntimeException("无权限");
    }
    // 如果权限校验通过,返回"POST 用户名是" + 用户名
    return "POST 用户名是" + user.getUsername();
} 

其实在实际应用中,后端应该根据提供的 key 去数据库中查询,检查对应的用户是否合法或者该 key 是否被分配过等,此处仅作模拟。

/**
 *
 *调用第三方接口的客户端
 *
 */
public class kApiClient {

  private String accessKey;
 
  private String secretKey;

  public KApiClient(String accessKey, String secretKey) {
      this.accessKey = accessKey;
      this.secretKey = secretKey;
  }

   ……
    
   // 创建一个私有方法,用于构造请求头
   private Map<String, String> getHeaderMap() {
       // 创建一个新的 HashMap 对象
       Map<String, String> hashMap = new HashMap<>();
       // 将 "accessKey" 和其对应的值放入 map 中
        hashMap.put("accessKey",accessKey);
       // 将 "secretKey" 和其对应的值放入 map 中
        hashMap.put("secretKey",secretKey);
       // 返回构造的请求头 map
       return hashMap;
   }

   // 使用 POST 方法向服务器发送 User 对象,并获取服务器返回结果
   public String getUserNameByPost(@RequestParam User user) { 
      // 将 User 对象转换为 JSON 字符串(轻量,方便在客户端服务端之间传输数据)
      String json = JSONUtil.toJsonStr(user);
      // 使用 HttpRequest 工具发起 POST 请求,并获取服务器的响应
      HttpResponse httpResponse = HttpRequest.post("http://localhost:8123/api/name")
             // 添加构造的请求头
             .addHeaders(getHeaderMap())
             .body(json) // 将 JSON 字符串设置为请求体
             .execute(); // 执行请求 
      // 打印服务器返回的状态码
      System.out.println(httpResponse.getStatus());
      // 打印服务器返回结果
      System.out.println(result);
      // 返回结果
      return result;
   }
}

测试后发现能够获取到 aK、sK,并且通过了验证。如果将 secretKey 随意修改为其它值,则会提示无权限:

hashMap.put("secretKey", "qweasdax");

5. 安全传递

虽然目前实现了通过签发 aK、sK 进行身份校验,但依然存在安全隐患。因为发送的请求可能被拦截,而我们传递的参数信息均放在请求头中,所以如果请求被拦截,那么攻击者可以直接从请求头中获取到密钥,然后使用密钥发送请求。

此外,secretKey 绝对不能传递,或者说不能以明文的形式直接传递,需要经过加密处理。在标准的 API 签名认证中,通常需要传递一个签名。签名是由用户传递的参数和 secretKey 拼接,并经过签名算法加密生成的。

加密算法通常又对称加密、非对称加密、单向加密等,详细内容可参考:一文读懂密码学

在项目中使用了 MD5 签名算法,即单向加密。这种加密方式不可逆,无法解密,通常用来生成签名。将生成的签名发送给服务器,服务器只需验证签名是否正确即可,这样根本不会暴露密码。

对于服务器而言,它会使用相同的参数与加密算法再次生成签名,以检验签名是否正确。

但这样做可能仍然存在被重放攻击的风险,所谓重放攻击,就是攻击者复制并重复之前发布的请求。也就是说即使攻击者不知道签名内容,将其拦截后,再次以请求者的身份发送给后端,依然能够完成调用。所以,为了避免重放攻击,再增加两个参数,分别是 nonce 随机数和 timestamp 时间戳

nonce 随机数:每次请求时,发送一个随机数给后端。后端只接受并认可该随机数一次,如果请求中带有重复的随机数,则不会处理请求。但这样会带来额外的存储开销,因为后端需要记录所有随机数。

timestamp 时间戳:每个请求在发送时携带一个时间戳,并且后端会验证该时间戳是否在指定的时间范围内,例如不超过 10 分钟,这样就可以防止攻击者使用先前的请求进行重放。可以和随机数配合使用,能够在一定程度上控制随机数的过期时间,后端也就不需要保存所有的随机数,减轻了存储开销。例如,只需要保存 10 分钟以内的随机数,因为后端需要校验随机数和时间戳两个参数,只要其中一个不符合要求,直接拒绝请求。

因此,在项目的签名认证算法中,至少需要添加五个参数:accessKey、secretKey、sign、nonce、timestamp。其它参数,比如接口的 name 参数等也可以添加到签名中,根据具体业务情况选择,以增加安全性。

无论如何,要确保密码绝不能在服务器直接传输,任何在服务器之间传输的内容都有可能被拦截。

6. 安全传递实现

首先在客户端中增加新的参数:

private Map<String, String> getHeaderMap() {
    Map<String, String> hashMap = new HashMap<>();

    hashMap.put("accessKey", accessKey);

    // 记住!不能直接发送密码
    // hashMap.put("secretKey", secretKey);

    // nonce 随机数
    hashMap.put("nonce", RandomUtil.randomNumber(4));

    // 请求体内容
    hashMap.put("body", body);

    // timestamp 当前时间戳
    hashMap.put("timestamp", String.valueof(System.currentTimeMillis() / 1000));

    // 生成签名
    hashMap.put("sign", genSign(body, secretKey));

    return hashMap;
}

// 使用 POST 方法向服务器发送 User 对象,并获取服务器返回结果
public String getUserNameByPost(@RequestParam User user) { 
    // 将 User 对象转换为 JSON 字符串(轻量,方便在客户端服务端之间传输数据)
    String json = JSONUtil.toJsonStr(user);
    // 使用 HttpRequest 工具发起 POST 请求,并获取服务器的响应
    HttpResponse httpResponse = HttpRequest.post("http://localhost:8123/api/name")
           // 添加构造的请求头
           .addHeaders(getHeaderMap(json))
           .body(json) // 将 JSON 字符串设置为请求体
           .execute(); // 执行请求 
    // 打印服务器返回的状态码
    System.out.println(httpResponse.getStatus());
    // 打印服务器返回结果
    System.out.println(result);
    // 返回结果
    return result;
 }

然后把用户参数进行拼接,经过签名算法生成唯一的字符串:

使用了 Hutool 工具库中的加密算法工具类(摘要加密),直接将生成签名当作一个工具类使用,

/**
 * 签名工具
 */
public class SignUtils {
   /**
    * 生成签名
    * @param body 请求体内容(用户参数)
    * @param secretKey 密钥
    * @return 生成的签名字符串
    */
  public static String genSign(String body, String secretKey) {
     // 使用 MD5 算法的 Digester
     Digester md5 = new Digester(DigesterAlgorithm.MD5);
     // 构建签名内容,将哈希映射转换为字符串并拼接密钥
     String content = body + "." + secretKey;
     // 计算签名的摘要并返回摘要的十六进制表示形式
     return md5.digestHex(content);  
  }
}

服务端的校验:

@PostMapping("/user") 
public String getUserNameByPost(@RequestParam User user) {
    // 1.拿到五个参数后一步步校验
    // 从请求头中获取参数
    String accessKey = request.getHeader("accessKey");
    String nonce = request.getHeader("nonce");
    String timestamp = request.getHeader("timestamp");
    String sign = request.getHeader("sign");
    String body = request.getHeader("body");
    
    // 2.权限校验(实际应为到数据库中去查)
    if(!accessKey.equals("khr123")) {
       throw new RuntimeException("无权限"); 
    }

    // 3.校验随机数
    if(Long.parseLong(nonce) > 10000) {
       throw new RuntimeException("无权限");
    }
    
    // 拼接 sign,实际 secretKey 应该从数据库中去查
    String serverSign = SignUtils.genSign(body, "1q2w3e4r"); 

    // 如果生成的签名不一致,抛出异常,提示无权限
    if(!sign.equals(serverSign)) {
       throw new RuntimeException("无权限");
    } 

       return "POST 用户名字是" + user.getUsername();

   }

签名认证中具体的参数应当根据业务场景灵活选择,可能会包含 userId 字段用来区分用户,也可能会增加 version 字段来表示应用程序版本号等。

三. 开发 SDK

1. 为什么需要 SDK

思考一个问题,上述 API 签名验证过程的确能够保证安全性,但如果要求开发者每次调用都要自己写一套签名算法未免太过繁琐。理想的情况是,开发者只需要关注调用哪些接口、传递哪些参数即可,就跟调用自己写的代码一样简单。

因此,需要开发一个简单易用的 SDK,给调用接口的开发者提供一个 starter,引入后,直接在 application.yml 中编写配置,即可自动创建客户端,更加方便开发者使用。类似于 spring boot 中的各种 starter,如引入 mybatis、redis、swagger 接口文档等。

<denpendency>
   <groupId>com.baomidou</groupId>
   <artifactId>mybatis-plus-boot-starter</artifactId>
   <version>3.5.1</version>
</denpendency>

引入 starter 后,可以直接在 application.yml 配置文件中编写相关配置,如 redis。

# redis 配置
redis:
   port: 6379
   host: localhost
   database: 0

2. 开发流程

首先创建一个 spring boot 项目,并选择引入 Lombok、Spring Configuration Processor 依赖。Spring Configuration Processor 能够帮助开发者自动生成配置的代码提示。

然后进入 pom.xml 配置文件,删除一些不需要的依赖配置,比如测试类和 maven 构建项目的依赖(build 的内容)。

我们的目标是为用户生成一个可用的客户端对象,并且用户引入 starter 后能够直接使用,因此创建一个配置类 KApiClientConfig:

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;

// 通过 @Configuration 注解,将该类标记为一个配置类,告诉 Spring 这是一个用于配置的类
@Configuration
// 能够读取application.yml的配置,读取到配置之后,把这个读到的配置设置到我们这里的属性中,
// 这里给所有的配置加上前缀为"kapi.client"
@ConfigurationProperties("kapi.client")
// @Data 注解是一个 Lombok 注解,自动生成了类的getter、setter方法
@Data
// @ComponentScan 注解用于自动扫描组件,使得 Spring 能够自动注册相应的 Bean
@ComponentScan
public class KApiClientConfig {

    private String accessKey;

    private String secretKey;

    // 创建一个名为 kApiClient 的 Bean
    @Bean
    public KApiClient kApiClient() {
       // 使用 aK 和 sK 创建一个 KApiClient 实例
       return new KApiClient(accessKey, secretKey);
    }
}

将接口项目中的 client 包、model 包和 utils 包复制粘贴过来,注意添加 hutool 工具类的依赖,

给 KApiClient 类重新引入包,删除多余注解,完整代码如下:

import cn.hutool.core.util.RandomUtil;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.http.HttpUtil;
import cn.hutool.json.JSONUtil;
import com.khr.kapiclientsdk.model.User;

import java.util.HashMap;
import java.util.Map;

import static com.khr.kapiclientsdk.utils.SignUtils.genSign;


/**
 * 调用第三方接口的客户端
 *
 */
public class KApiClient {

    private String accessKey;

    private String secretKey;

    public KApiClient(String accessKey, String secretKey) {
        this.accessKey = accessKey;
        this.secretKey = secretKey;
    }

    public String getNameByGet(String name) {
        // 可以单独传入http参数,这样参数会自动做URL编码,拼接在URL中
        HashMap<String, Object> paramMap = new HashMap<>();
        paramMap.put("name", name);
        String result = HttpUtil.get("http://localhost:8123/api/name/", paramMap);
        System.out.println(result);
        return result;
    }

    public String getNameByPost(String name) {
        // 可以单独传入http参数,这样参数会自动做URL编码,拼接在URL中
        HashMap<String, Object> paramMap = new HashMap<>();
        paramMap.put("name", name);
        String result= HttpUtil.post("http://localhost:8123/api/name/", paramMap);
        System.out.println(result);
        return result;
    }

    private Map<String, String> getHeaderMap(String body) {
        Map<String, String> hashMap = new HashMap<>();
        hashMap.put("accessKey", accessKey);
        // 一定不能直接发送
        // hashMap.put("secretKey", secretKey);
        hashMap.put("nonce", RandomUtil.randomNumbers(4));
        hashMap.put("body", body);
        hashMap.put("timestamp", String.valueOf(System.currentTimeMillis() / 1000));
        hashMap.put("sign", genSign(body, secretKey));
        return hashMap;
    }

    public String getUserNameByPost(User user) {
        String json = JSONUtil.toJsonStr(user);
        HttpResponse httpResponse = HttpRequest.post("http://localhost:8123/api/name/user")
                .addHeaders(getHeaderMap(json))
                .body(json)
                .execute();
        System.out.println(httpResponse.getStatus());
        String result = httpResponse.body();
        System.out.println(result);
        return result;
    }
}

最后,还需要创建一个 properties 文件用于指定要自动配置的类。在 resources 目录下创建 META-INF 目录,并在 META-INF 下新建一个 spring.factories 文件。

该文件定义了 Spring Boot 的自动配置,文件中每一行都是一个配置项,包含两个部分,= 前面是配置项的全限定类名,后面是对应的自动配置类。Spring Boot 应用启动时,会加载这个文件,并根据其中的配置项自动进行相应配置。

# spring boot starter
org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.khr.kapiclientsdk.KApiClientConfig

上述配置指定的要自动配置的类就是刚才编写的配置类 KApiClientConfig,当 Spring Boot 启动时会自动加载并实例化 KApiClientConfig,将其应用在程序中。这样就可以自动生成一个客户端对象,而无需手动创建,大大简化了开发者的调用过程。

将该 SDK 项目打包后,会保存在 maven 仓库中。

这样回到之前的接口项目,删除 client、model、utils 包和测试类,然后引入刚才写好的 SDK 依赖,在 application.yml 配置文件中编写配置,指定自己的 aK、sK,即可创建客户端:

kapi:
  client:
     access-key:khr123
     secret-key:1q2w3e4r
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Phoenixxxxxxxxxxxxx

感谢支持!

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值