服务器间API通信时的身份验证

在企业级应用中,将服务拆分解耦是很常见的,所以也就有了服务器间调用API的场景。

一般会将提供基础能力的服务独立部署,然后前端业务应用通过API去调用这些基础能力。由于前端业务应用和基础服务一般是多对一的关系,故在调用API的时候,前端业务应用需要标识身份,以便基础服务能够针对性地提供服务。

设定个场景

先具象化的设定一个场景,后面比较容易说清楚:

服务S提供了一个短信发送的API,即调用此服务可以实现给指定号码发送短信。有A、B、C业务应用会使用这个服务,且服务S需要知道哪些业务调用了它。

这个服务的API调用方式是通过HTTP的GET方式:(不要吐槽这个,这是确实可行的)

http://service.domain.com/sms?
  number=17012345678&
  content=helloworld

简单的方式

如果A、B、C和S在同一个私网内,且API访问仅限此网内,A、B、C也均可信可控,那么根本不用麻烦,只要加上一个标识参数告知S即可。看起来就像这样:

http://service.domain.com/sms?
  number=17012345678&
  content=helloworld&
  appId=appNameA

使用Token

如果业务部门比较分散,导致A、B、C并不完全可信,不排除会出现B使用A的appId的这类冒名的情况。

那么S可以给A、B、C分别预先生成一个Token,要求在请求时一并发送,并会校验appId和token是否匹配。看起来就像这样:

http://service.domain.com/sms?
  number=17012345678&
  content=helloworld&
  appId=appNameA&
  token=0UW2m6Cpu9JdrM4muXHVBTOQMb4MG9nJ

这样,各业务就不能冒用标识了。

使用Signature (签名)

Token相当于是一个密码,那么上述的方式等于将密码明文传输了,不是太妥当。所以可以再改进一下:

  • 将appId和token作为字符串连接,进行一次SHA1计算(MD5也行),生成一个signature;

  • 不再传输token,而是传输appId和signature;

  • S收到请求后,通过appId和token的作同样计算,校验signature是否一致。

所以请求就变成这样:(这里用了SHA1计算)

http://service.domain.com/sms?
  number=17012345678&
  content=helloworld&
  appId=appNameA&
  signature=6f3db6934eeb685cfdb2295c35856f00ebea29a3

加入时间戳

如果私网内存在不可控的服务器或者干脆就是在公网上通信,那目前实际上仍然不是非常安全,因为上述的appId和signature在每次调用的时候是不变的,如果被非法调用者得知,仍然可以冒名。再进一步改进:

  • API增加一个必选参数timestamp,即当前时间的Unix时间戳,单位到秒;

  • 同时,要求使用appId、timestamp、token三者相接计算signature;

  • S收到请求后,不仅校验signature是否一致,还校验时间是否为当前时间。(由于各服务器时间存在误差,所以这里实际是比较时间戳和当前时间是否在一个范围内,在此设为1分钟)

请求就进化为:

http://service.domain.com/sms?
  number=17012345678&
  content=helloworld&
  appId=appNameA&
  timestamp=1502610966&
  signature=ff0447ab272947edd965df6d2ef19576eabb3fe9

这样,即使签名被非法得知,也仅仅能在设定的几分钟范围内被调用,大大降低了风险。

限制Signature重复

当然,最好的情况是完全杜绝被非法调用。可以进一步处理:

  • 在S暂存每次请求的signature,保证不能重复使用。每个signature暂存时间为之前设定的时间范围1分钟。

  • 如果是低频API,每秒调用最多调用一次的,不受影响。

  • 如果是高频API,则需要保证每次的签名不同,不然在同一秒的请求会被受限;可以再增加一个noise的字段,值为随机字符串(一般为4位字符),并加入到signature计算中。

所以,像这样请求:

http://service.domain.com/sms?
  number=17012345678&
  content=helloworld&
  appId=appNameA&
  timestamp=1502610966&
  noise=xWk2&
  signature=c2b7e467a7bd14bf2ef768702be1c7f6f95a2d09

再加上S上做的限制signature重复使用,可以保证signature泄露的时候不会造成非法调用。

参数防篡改

还有一种更糟的情况,就是A、B、C发往S的请求被劫持,劫持者修改了手机号码和短信内容,再发往S。这样,signature是不会重复使用的,仍然能够通过校验。

所以更好的办法是,把业务参数即number和content的值也加入到signature计算中。这里需要注意的是,为了更通用以及确保字符串连接的顺序一致,须按照参数名对除signature以外的所有参数(包括token)进行一个排序,然后将其值连接。

拿例子来说,排序好是这样:

http://service.domain.com/sms?
  appId=appNameA&
  content=helloworld&
  noise=xWk2
  number=17012345678&
  timestamp=1502610966&
  (token=0UW2m6Cpu9JdrM4muXHVBTOQMb4MG9nJ)

然后将

appNamehelloworldxWk21701234567815026109660UW2m6Cpu9JdrM4muXHVBTOQMb4MG9nJ

这样一个字符串做SHA1计算,得到最终的请求:

http://service.domain.com/sms?
  appId=appNameA&
  content=helloworld&
  noise=xWk2
  number=17012345678&
  timestamp=1502610966&
  signature=76168273fd018b89df674d5275a6c16f3daf9b10

大杀器

如果A、B、C三者的网路环境不复杂,可以固定IP的话,在S上通过IP来验证即可。可以无视上面的步骤,轻松加愉快。

上述内容中一些计算方法:(NodeJS)

//计算token和noise
function generateToken(len, radix) {
  var chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'.split('');
  var token = [], i;
  radix = radix || chars.length;
  if(!len) len = 32;
  for (i = 0; i < len; i++) token[i] = chars[0 | Math.random()*radix];
  return token.join('');
}
//计算签名
const crypto = require('crypto');
function sha1(input){
  return crypto.createHash('sha1').update(input).digest('hex')
}