API 接口参数签名的几种方案

API 接口参数签名的几种方案

在涉及跨系统接口调用时,我们容易碰到以下安全问题:

  • 请求身份被伪造。
  • 请求参数被篡改。
  • 请求被抓包,然后重放攻击。

本篇将根据假设的需求场景,循序渐进讲明白跨系统接口调用时必做的几个步骤,以及为什么要有这些步骤的原因。

1、需求场景

假设我们有如下业务需求:

用户在 A 系统参与活动成功后,活动奖励以余额的形式下发到 B 系统。

2、初始方案:直接裸奔

在不考虑安全问题的情况下,我们很容易完成这个需求:

  • 1、在 B 系统开放一个接口。
/**
 * 为指定用户添加指定余额
 * 
 * @param userId 用户 id
 * @param money 要添加的余额,单位:分
 * @return / 
 */
@RequestMapping("addMoney")
public JsonResult addMoney(long userId, long money) {
    // 处理业务 
    // ...
    
    // 返回 
    return JsonResult.ok();
}
  • 2、在 A 系统使用 http 工具类调用这个接口。
long userId = 10001;
long money = 1000;
String res = HttpUtil.request("http://b.com/api/addMoney?userId=" + userId + "&money=" + money);

上述代码简单的完成了需求,但是很明显它有一个安全问题:

  • B 系统开放的接口不仅可以被 A 系统调用,还可以被其它任何人调用,甚至别人可以本地跑一个 for 循环调用这个接口,为自己无限充值金额。

3、方案升级:增加 secretKey 校验

为防止 B 系统开放的接口被陌生人任意调用,我们增加一个 secretKey 参数

// 为指定用户添加指定余额
@RequestMapping("addMoney")
public JsonResult addMoney(long userId, long money, String secretKey) {
    // 1、先校验 secretKey 参数是否正确,如果不正确直接拒绝响应请求
    if( ! check(secretKey) ) {
        return JsonResult.error("无效 secretKey,无法响应请求");
    }
    
    // 2、业务代码 
    // ...
    
    // 3、返回
    return JsonResult.ok();
}

由于 A 系统是我们 “自己人”,所以它可以拿着 secretKey 进行合法请求:

long userId = 10001;
long money = 1000;
String secretKey = "xxxxxxxxxxxxxxxxxxxx";
String res = HttpUtil.request("http://b.com/api/addMoney?userId=" + userId + "&money=" + money + "&secretKey=" + secretKey);

现在,即使 B 系统的接口被暴露了,也不会被陌生人任意调用了,安全性得到了一定的保证,但是仍然存在一些问题:

  • 如果请求被抓包,secretKey 就会泄露,因为每次请求都在 url 中明文传输了 secretKey 参数。
  • 如果请求被抓包,请求的其它参数就可以被任意修改,例如可以将 money 参数修改为 9999999,B系统无法确定参数是否被修改过。

4、方案再升级:使用摘要算法生成参数签名

首先,在 A 系统不要直接发起请求,而是先计算一个 sign 参数:

// 声明变量
long userId = 10001;
long money = 1000;
String secretKey = "xxxxxxxxxxxxxxxxxxxx";

// 计算 sign 参数
String sign = md5("money=" + money + "&userId=" + userId + "&key=" + secretKey);

// 将 sign 拼接在请求地址后面
String res = HttpUtil.request("http://b.com/api/addMoney?userId=" + userId + "&money=" + money + "&sign=" + sign);

注意此处计算签名时,需要将所有参数按照字典顺序依次排列(key除外,挂在最后面)。以下所有计算签名时同理,不再赘述。

然后在 B 系统接收请求时,使用同样的算法、同样的秘钥,生成 sign 字符串,与参数中 sign 值进行比较:

// 为指定用户添加指定余额
@RequestMapping("addMoney")
public JsonResult addMoney(long userId, long money, String sign) {

    // 在 B 系统,使用同样的算法、同样的密钥,计算出 sign2,与传入的 sign 进行比对
    String sign2 = md5("money=" + money + "&userId=" + userId + "&key=" + secretKey);
    if( ! sign2.equals(sign)) {
        return JsonResult.error("无效 sign,无法响应请求");
    }

    // 2、业务代码 
    // ...
    
    // 3、返回
    return JsonResult.ok();
}

因为 sign 的值是由 userId、money、secretKey 三个参数共同决定的,所以只要有一个参数不一致,就会造成最终生成 sign 也是不一致的,所以,根据比对结果:

如果 sign 一致,说明这是个合法请求。
如果 sign 不一致,说明发起请求的客户端秘钥不正确,或者请求参数被篡改过,是个不合法请求。
此方案优点:

不在 url 中直接传递 secretKey 参数了,避免了泄露风险。
由于 sign 参数的限制,请求中的参数也不可被篡改,B 系统可放心的使用这些参数。
此方案仍然存在以下缺陷:

  • 被抓包后,请求可以被无限重放,B 系统无法判断请求是真正来自于 A 系统发出的,还是被抓包后重放的。

5、方案再再升级:追加 nonce 随机字符串

首先,在 A 系统发起调用前,追加一个 nonce 参数,一起参与到签名中:

// 声明变量
long userId = 10001;
long money = 1000;
String nonce = RandomUtil.getRandomString(32); // 随机32位字符串
String secretKey = "xxxxxxxxxxxxxxxxxxxx";

// 计算 sign 参数
String sign = md5("money=" + money + "&nonce=" + nonce + "&userId=" + userId + "&key=" + secretKey);

// 将 sign 拼接在请求地址后面
String res = HttpUtil.request("http://b.com/api/addMoney?userId=" + userId + "&money=" + money + "nonce=" + nonce + "&sign=" + sign);

然后在 B 系统接收请求时,也把 nonce 参数加进去生成 sign 字符串,进行比较:

// 为指定用户添加指定余额
@RequestMapping("addMoney")
public JsonResult addMoney(long userId, long money, String nonce, String sign) {

    // 1、检查此 nonce 是否已被使用过了
    if(CacheUtil.get("nonce_" + nonce) != null) {
        return JsonResult.error("此 nonce 已被使用过了,请求无效");
    }

    // 2、验证签名
    String sign2 = md5("money=" + money + "&nonce=" + nonce + "&userId=" + userId + "&key=" + secretKey);
    if( ! sign2.equals(sign)) {
        return JsonResult.error("无效 sign,无法响应请求");
    }

    // 3、将 nonce 记入缓存,防止重复使用
    CacheUtil.set("nonce_" + nonce, "1");

    // 4、业务代码 
    // ...

    // 5、返回
    return JsonResult.ok();
}

代码分析:

为方便理解,我们先看第 3 步:此处在校验签名成功后,将 nonce 随机字符串记入缓存中。
再看第 1 步:每次请求进来,先查看一下缓存中是否已经记录了这个随机字符串,如果是,则立即返回:无效请求。
这两步的组合,保证了一个 nonce 随机字符串只能被使用一次,如果请求被抓包后重放,是无法通过 nonce 校验的。

至此,问题似乎已被解决了 …… 吗?

别急,我们还有一个问题没有考虑:这个 nonce 在字符串在缓存应该被保存多久呢?

  • 保存 15 分钟?那抓包的人只需要等待 15 分钟,你的 nonce 记录在缓存中消失,请求就可以被重放了。
  • 那保存 24 小时?保存一周?保存半个月?好像无论保存多久,都无法从根本上解决这个问题。
  • 你可能会想到,那我永久保存吧。这样确实能解决问题,但显然服务器承载不了这么做,即使再微小的数据量,在时间的累加下,也总一天会超出服务器能够承载的上限。

6、方案再再再升级:追加 timestamp 时间戳

我们可以再追加一个 timestamp 时间戳参数,将请求的有效性限定在一个有限时间范围内,例如 15分钟。

首先,在 A 系统追加 timestamp 参数:

// 声明变量
long userId = 10001;
long money = 1000;
String nonce = RandomUtil.getRandomString(32); // 随机32位字符串
long timestamp = System.currentTimeMillis(); // 随机32位字符串
String secretKey = "xxxxxxxxxxxxxxxxxxxx";

// 计算 sign 参数
String sign = md5("money=" + money + "&nonce=" + nonce + "&timestamp=" + timestamp + "&userId=" + userId + "&key=" + secretKey);

// 将 sign 拼接在请求地址后面
String res = HttpUtil.request("http://b.com/api/addMoney" +
        "?userId=" + userId + "&money=" + money + "&nonce=" + nonce + "&timestamp=" + timestamp + "&sign=" + sign);

在 B 系统检测这个 timestamp 是否超出了允许的范围

// 为指定用户添加指定余额
@RequestMapping("addMoney")
public JsonResult addMoney(long userId, long money, long timestamp, String nonce, String sign) {

    // 1、检查 timestamp 是否超出允许的范围(此处假定最大允许15分钟差距)
    long timestampDisparity = System.currentTimeMillis() - timestamp; // 实际的时间差
    if(timestampDisparity > 1000 * 60 * 15) {
        return JsonResult.error("timestamp 时间差超出允许的范围,请求无效");
    }

    // 2、检查此 nonce 是否已被使用过了
    // 代码同上,不再赘述

    // 3、验证签名
    // 代码同上,不再赘述

    // 4、将 nonce 记入缓存,ttl 有效期和 allowDisparity 允许时间差一致 
    CacheUtil.set("nonce_" + nonce, "1", 1000 * 60 * 15);

    // 5、业务代码 ...

    // 6、返回
    return JsonResult.ok();
}

至此,抓包者:

  • 如果在 15 分钟内重放攻击,nonce 参数不答应:缓存中可以查出 nonce 值,直接拒绝响应请求。
  • 如果在 15 分钟后重放攻击,timestamp 参数不答应:超出了允许的 timestamp 时间差,直接拒绝响应请求。

7、服务器的时钟差异造成安全问题

以上的代码,均假设 A 系统服务器与 B 系统服务器的时钟一致,才可以正常完成安全校验,但在实际的开发场景中,有些服务器会存在时钟不准确的问题。

假设 A 服务器与 B 服务器的时钟差异为 10 分钟,即:在 A 服务器为 8:00 的时候,B 服务器为 7:50。

A 系统发起请求,其生成的时间戳也是代表 8:00。
B 系统接受到请求后,完成业务处理,此时 nonce 的 ttl 为 15分钟,到期时间为 7:50 + 15分 = 8:05。

8.05 后,nonce 缓存消失,抓包者重放请求攻击:

  • timestamp 校验通过:因为时间戳差距仅有 8.05 - 8.00 = 5分钟,小于 15 分钟,校验通过。
  • nonce 校验通过:因为此时 nonce 缓存已经消失,可以通过校验。
  • sign 校验通过:因为这本来就是由 A 系统构建的一个合法签名。

攻击完成。

要解决上述问题,有两种方案:

  • 方案一:修改服务器时钟,使两个服务器时钟保持一致。
  • 方案二:在代码层面兼容时钟不一致的场景。

要采用方案一的同学可自行搜索一下同步时钟的方法,在此暂不赘述,此处详细阐述一下方案二。

我们只需简单修改一下,B 系统校验参数的代码即可:

// 为指定用户添加指定余额
@RequestMapping("addMoney")
public JsonResult addMoney(long userId, long money, long timestamp, String nonce, String sign) {

    // 1、检查 timestamp 是否超出允许的范围 (重点一:此处需要取绝对值)
    long timestampDisparity = Math.abs(System.currentTimeMillis() - timestamp);
    if(timestampDisparity > 1000 * 60 * 15) {
        return JsonResult.error("timestamp 时间差超出允许的范围,请求无效");
    }

    // 2、检查此 nonce 是否已被使用过了
    // 代码同上,不再赘述 

    // 3、验证签名
    // 代码同上,不再赘述 

    // 4、将 nonce 记入缓存,防止重复使用(重点二:此处需要将 ttl 设定为允许 timestamp 时间差的值 x 2 )
    CacheUtil.set("nonce_" + nonce, "1", (1000 * 60 * 15) * 2);

    // 5、业务代码 ...

    // 6、返回
    return JsonResult.ok();
}

8、最终版方案

此处再贴一下完整的代码。

A 系统(发起请求端):

// 声明变量
long userId = 10001;
long money = 1000;
String nonce = RandomUtil.getRandomString(32); // 随机32位字符串
long timestamp = System.currentTimeMillis(); // 当前时间戳
String secretKey = "xxxxxxxxxxxxxxxxxxxx";

// 计算 sign 参数
String sign = md5("money=" + money + "&nonce=" + nonce + "&timestamp=" + timestamp + "&userId=" + userId + "&key=" + secretKey);

// 将 sign 拼接在请求地址后面
String res = HttpUtil.request("http://b.com/api/addMoney" +
        "?userId=" + userId + "&money=" + money + "&nonce=" + nonce + "&timestamp=" + timestamp + "&sign=" + sign);

B 系统(接收请求端):

// 为指定用户添加指定余额
@RequestMapping("addMoney")
public JsonResult addMoney(long userId, long money, long timestamp, String nonce, String sign) {

    // 1、检查 timestamp 是否超出允许的范围
    long allowDisparity = 1000 * 60 * 15;    // 允许的时间差:15分钟
    long timestampDisparity = Math.abs(System.currentTimeMillis() - timestamp); // 实际的时间差
    if(timestampDisparity > allowDisparity) {
        return JsonResult.error("timestamp 时间差超出允许的范围,请求无效");
    }

    // 2、检查此 nonce 是否已被使用过了
    if(CacheUtil.get("nonce_" + nonce) != null) {
        return JsonResult.error("此 nonce 已被使用过了,请求无效");
    }

    // 3、验证签名
    String sign2 = md5("money=" + money + "&nonce=" + nonce + "&timestamp=" + timestamp + "&userId=" + userId + "&key=" + secretKey);
    if( ! sign2.equals(sign)) {
        return JsonResult.error("无效 sign,无法响应请求");
    }

    // 4、将 nonce 记入缓存,防止重复使用,注意此处需要将 ttl 设定为允许 timestamp 时间差的值 x 2  
    CacheUtil.set("nonce_" + nonce, "1", allowDisparity * 2);

    // 5、业务代码 ...

    // 6、返回
    return JsonResult.ok();
}

如您在阅读中发现不足,欢迎留言!!!

  • 5
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 5
    评论
### 回答1: 对于您的问题,我可以回答。要添加接口鉴权代码,您需要做以下几个步骤: 1. 在您的代码中定义一个密钥,用于对请求进行签名和验证。 2. 在您的请求中添加一个名为“signature”的参数,其值为对请求进行签名后得到的结果。 3. 在接收到请求时,您需要验证请求中的签名是否正确,如果不正确则拒绝请求。 4. 如果请求中的签名正确,您可以继续处理该请求。 具体实现的代码因为要结合具体的语言和框架,因此无法在此提供。但是,您可以参考具体语言和框架的官方文档或者搜索相关教程来学习如何实现接口鉴权代码。 ### 回答2: 添加接口鉴权代码是为了确保只有经过授权的用户才能使用某个接口。下面给出了一种实现方法: 首先,需要为每个用户生成一个访问令牌(token),token是一个具有一定时效性和唯一性的字符串,用于标识用户的身份。可以使用常见的加密算法(如HMAC)来生成token。同时,需要为每个用户设定访问权限,即用户能够访问的接口。 在Magic-API中,可以创建一个中间件(middleware),用于进行接口鉴权。中间件是位于请求和响应处理之间的一个处理器,可以对请求进行一些预处理和验证操作。 在中间件的代码中,首先需要获取请求中的token,可以从header、参数或cookie中获取。然后,需要对token进行校验与解析。校验时需要检查token是否存在以及是否有效,可以使用加密算法对token进行解密运算,并与用户的访问权限进行比对。 如果token校验通过,并且用户具有访问接口的权限,那么请求会继续传递给下一个处理器进行处理。否则,会返回一个错误响应,提示用户未经授权或权限不足。 以下是一个简单的中间件示例代码: ```python def authentication_middleware(request, response): token = request.headers.get('Authorization') if not token: return json_response({'error': '未提供访问令牌'}, status=401) user = validate_token(token) if not user: return json_response({'error': '无效的访问令牌'}, status=403) if not user.has_permission(request.path): return json_response({'error': '权限不足'}, status=403) # 继续处理下一个中间件或者请求处理器 return response app.add_middleware(authentication_middleware) ``` 上述代码中的validate_token函数用于校验token的有效性,并返回对应的用户对象。has_permission函数用于检查用户是否具有访问请求路径的权限。json_response函数用于返回响应结果。 以上是一个简单的Magic-API添加接口鉴权代码的演示,具体实现可能还需要根据具体情况进行调整。 ### 回答3: 在编写Magic API接口鉴权代码时,可以按照以下步骤进行: 首先,需要获取API接口请求的相关信息,包括请求头、请求参数等。在鉴权过程中,这些信息将被用于验证身份和权限。 接下来,需要确定一种合适的鉴权方式,例如常用的使用API密钥或令牌进行身份验证。根据选择的鉴权方式,可以按照以下步骤进行代码编写: 1. 首先,获取API密钥或令牌。可以将密钥或令牌存储在配置文件或数据库中,并在代码中进行读取。 2. 在接口调用之前,编写一个鉴权函数或中间件,用于验证用户的身份和权限。可以在这个函数中进行身份验证、权限验证以及其他自定义的鉴权逻辑。 3. 通过请求头或参数API密钥或令牌传递给鉴权函数。可以根据实际情况选择合适的传递方式。 4. 在鉴权函数中,将传递的密钥或令牌与存储的合法密钥或令牌进行比较,以确认用户的身份和权限。可以使用加密算法对密钥或令牌进行加密和解密操作。 5. 根据鉴权结果,决定是否允许继续访问接口。如果验证通过,则接口调用正常进行;如果验证失败,则返回相应的鉴权错误信息。 6. 需要注意的是,鉴权函数中不仅要验证身份和权限,还可以添加其他的安全检查,如请求频率限制、防御攻击等。 以上是关于如何编写Magic API接口鉴权代码的一般步骤,具体的实现方式会根据具体的开发框架和技术选择而有所不同。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

Asurplus

学如逆水行舟,不进则退

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

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

打赏作者

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

抵扣说明:

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

余额充值