问题引入

我们为开发者提供了接口,却对调用者一无所知

假设我们的服务器只能允许 100 个人同时调用接口。如果有攻击者疯狂地请求这个接口,那是很危险的。一方面这可能会损害安全性,另一方面耗尽服务器性能,影响正常用户的使用。

因此我们必须为接口设置保护措施,例如限制每个用户每秒只能调用十次接口,即实施请求频次的限额控制。所以我们必须知道谁在调用接口,并且不能让无权限的人随意调用。

在我们之前开发后端时,我们会进行一些权限检查。例如,当管理员执行删除操作时,后端需要检查这个用户是否为管理员,直接从后端的 session 中获取的。但问题来了,比如我是前端直接发起请求,没有登录操作,没有输入用户名和密码,我怎么去调用呢?

API 签名认证

API 签名认证过程

签发签名  -> 使用签名或校验签名

为什么需要API签名认证

为了保证安全性,不能让任何人都能调用接口。

适用于无需保存登录态的场景。只认签名,不关注用户登录态(为了更通用)。

如何在后端实现签名认证?

需要两个东西,即 accessKeysecretKey,来标识用户

和用户名和密码类似,不过每次调用接口都需要带上,实现无状态的请求。

“无状态”指的是每个请求都是独立的,服务器不会保存客户端的任何状态信息。每次请求都包含所有必要的信息来完成该请求。这种设计使得系统更易于扩展和管理。

签发 accessKey 和 secretKey

一般来说,accessKey 和 secretKey 需要尽可能复杂无规律,防止黑客尝试破解,特别是密码。

签名认证实现

通过 http request header 头传递参数。

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

加密方式:

对称加密、非对称加密、md5 签名(不可解密)

用户参数 + 密钥 => 签名生成算法(MD5、HMac、Sha1) => 不可解密的值

怎么知道这个签名对不对?

服务端用一模一样的参数和算法去生成签名,只要和用户传的的一致,就表示一致。

怎么防重放?

  • 参数 5:加 nonce 随机数,只能用一次,(存在问题:服务端要保存使用过的随机数)

所以配合

  • 参数 6:加 timestamp 时间戳,校验时间戳是否过期。

API 签名认证是一个很灵活的设计,具体要有哪些参数、参数名如何一定要根据场景来。

为什么需要两个 key?

如果仅凭一个 key 就可以调用接口,那么任何拿到这个 key 的人都可以无限制地调用这个接口。这就好比,为什么你在登录网站时需要输入密码,而不是只输入用户名就可以了?其实这两者的原理是一样的。如果像 token 一样,一个 key 不行吗?token 本质上也是不安全的,有可能会通过重放等等方式来攻破的。

TODO:关于 accessKey、secretKey 的生成方法,自行编写代码实现

实践:

在客户端 拿到 accessKey、secretKey

需要获取用户传递的 accessKey 和 secretKey。

对于这种数据,建议不要直接在 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 不等于 "sujie" 或者 secretKey 不等于 "abcdefgh"
    if (!accessKey.equals("sujie") || !secretKey.equals("abcdefgh")){
        // 抛出一个运行时异常,表示权限不足
        throw new RuntimeException("无权限");
    }
    // 如果权限校验通过,返回 "POST 用户名字是" + 用户名
    return "POST 用户名字是" + user.getUsername();
}

改造一下 SuApiClient.java,发请求可以带上 header,用这个就可以去添加很多的请求头

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

    // 创建一个私有方法,用于构造请求头
    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;
    }

安全传递

存在的问题

我们的请求有可能被人拦截,我们将密码放在请求头中,如果有中间人拦截到了你的请求,他们就可以直接从请求头中获取你的密码,然后使用你的密码发送请求。

密码绝对不能传递。也就是说,在向对方发送请求时,密码绝对不能以明文的方式传递,必须通过特殊的方式进行传递。

我们需要对该密码进行加密,这里通常称之为签名。 

可以将用户传递的参数与该密钥拼接在一起,然后使用单向签名算法进行加密。

如何防止重放请求有两种方式可以考虑:

第一种方式是通过加入一个随机数实现标准的签名认证。每次请求时,发送一个随机数给后端。后端只接受并认可该随机数一次,一旦随机数被使用过,后端将不再接受相同的随机数。这种方式解决了请求重放的问题,因为即使对方使用之前的时间和随机数进行请求,后端会认识到该请求已经被处理过,不会再次处理。然而,这种方法需要后端额外开发来保存已使用的随机数。并且,如果接口的并发量很大,每次请求都需要一个随机数,那么可能会面临处理百万、千万甚至亿级别请求的情况。因此,除了使用随机数之外,我们还需要其他机制来定期清理已使用的随机数。

第二种方式是加入一个时间戳(timestamp)。每个请求在发送时携带一个时间戳,并且后端会验证该时间戳是否在指定的时间范围内,例如不超过10分钟或5分钟。这可以防止对方使用昨天的请求在今天进行重放。通过这种方式,我们可以一定程度上控制随机数的过期时间。因为后端需要同时验证这两个参数,只要时间戳过期或随机数被使用过,后端会拒绝该请求。因此,时间戳可以在一定程度上减轻后端保存随机数的负担。通常情况下,这两种方法可以相互配合使用。

因此,在标准的签名认证算法中,建议至少添加以下五个参数:accessKey、secretKey、sign、nonce(随机数)、timestamp(时间戳)。此外,建议将用户请求的其他参数,例如接口中的 name 参数,也添加到签名中,以增加安全性。

安全传递实现

新建一个 utils 包,在 utils 包下新建 SignUtils.java(签名工具)

这个 hashmap 还需要进行拼接,我们传递的是用户的这些参数,但其实没有必要传递那么多参数,直接将 body 作为参数传递进来(在这里,我们也可以传递 hashmap,只要有一些共同的参数,能让客户端和服务端之间保持一致即可)。

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

刚刚客户端只有这两个参数 accessKey、secretKey

现在再加几个参数

/**
 * 获取请求头的哈希映射
 * @param body 请求体内容
 * @return 包含请求头参数的哈希映射
 */
private Map<String, String> getHeaderMap(String body) {
    Map<String, String> hashMap = new HashMap<>();
    hashMap.put("accessKey", accessKey);
    // 注意:不能直接发送密钥
    // hashMap.put("secretKey", secretKey);
	// 生成随机数(生成一个包含100个随机数字的字符串)
    hashMap.put("nonce", RandomUtil.randomNumbers(4));
	// 请求体内容
    hashMap.put("body", body);
	// 当前时间戳
	// System.currentTimeMillis()返回当前时间的毫秒数。通过除以1000,可以将毫秒数转换为秒数,以得到当前时间戳的秒级表示
	// String.valueOf()方法用于将数值转换为字符串。在这里,将计算得到的时间戳(以秒为单位)转换为字符串
    hashMap.put("timestamp", String.valueOf(System.currentTimeMillis() / 1000));
	// 生成签名
    hashMap.put("sign", genSign(body, secretKey));
    return hashMap;
}

/**
 * 通过POST请求获取用户名
 * @param user 用户对象
 * @return 从服务器获取的用户名
 */
public String getUserNameByPost(@RequestBody User user) {
    // 将用户对象转换为JSON字符串
    String json = JSONUtil.toJsonStr(user);
    HttpResponse httpResponse = HttpRequest.post("http://localhost:8123/api/name/user")
            // 添加请求头
            .addHeaders(getHeaderMap(json))
            // 设置请求体
            .body(json)
            // 发送POST请求
            .execute();
    // 打印响应状态码
    System.out.println(httpResponse.getStatus());
    // 打印响应体内容
    String result = httpResponse.body();
    System.out.println(result);
    return result;
}

接下来服务端

        @PostMapping("/user")
        public String getUserNameByPost(@RequestBody User user, HttpServletRequest request) {
        // 1.拿到这五个我们可以一步一步去做校验,比如 accessKey 我们先去数据库中查一下
        // 从请求头中获取参数
        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");
        // 不能直接获取秘钥
        // String secretKey = request.getHeader("secretKey");

        // TODO 2.校验权限,这里模拟一下,直接判断 accessKey 是否为"yupi",实际应该查询数据库验证权限
        if (!accessKey.equals("sujie")){
            throw new RuntimeException("无权限");
        }

        // TODO 3.校验一下随机数,因为时间有限,就不带大家再到后端去存储了,后端存储用hashmap或redis都可以
        // 校验随机数,模拟一下,直接判断nonce是否大于10000
        if (Long.parseLong(nonce) > 10000) {
            throw new RuntimeException("无权限");
        }

        // TODO 4.校验时间戳与当前时间的差距,交给大家自己实现
        //
        //   if (timestamp) {}

        // TODO 5. 从实际数据库中取得用户secretKey
        String serverSign = SignUtils.genSign(body, "abcdefgh");
        if (!serverSign.equals(sign)) {
            throw new RuntimeException("无权限");
        }

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

整个签名认证算法的流程就是这样

需要强调的是,API签名认证是一种非常灵活的设计,具体需要哪些参数以及参数名的选择都应根据具体场景来确定。尽量避免在前端进行签名认证,而是由服务端来处理 

例如,某些公司或项目的签名认证可能会包含 userId 字段以区分用户。还可能包含 appId 和 version 字段来表示应用程序的版本号。有时还会添加一些固定的盐值等等。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部