架构设计LaravelPHP

浅谈API的设计及其安全性

2018-05-07  本文已影响43人  Gundy_

看起来好像前后端分离是个浪潮,原来只有APP客户端会考虑这些,现在连Web都要考虑前后端分离 。
这里面不得不谈的就是API的设计和安全性,这些个问题不解决好,将会给服务器安全和性能带来很大威胁 。
下面我也是根据自己的一些经历和经验说下自己的一些心得 。
API的设计中,主要考虑两大方面的问题 :

由于HTTP协议是无状态的,所以在做MVC Web的时候,无论是Java Web还是PHP等,大多数都是依靠session/cookie来完成的用户标识的(如果你不了解session相关内容,请自行搜索)。但在前后端分离的开发模式中,session/cookie模式就显得不太合适,尤其是APP客户端,是不太可能用session/cookie的,业界广泛采用的方式就是采用token。

我们用楼、楼管和租户来做个类比。我们的系统是一栋楼,一名管理员负责管理这栋楼和租户,有100个租户,每个租户都被分配了属于自己的一个房间而且租户不可以随意进入其他租户的房间。当租户第一次来登记的时候,楼管就会要求租户出具身份证(用户名和密码),在核验完毕身份证后(验证密码)后,管理员会把一件房的钥匙(token)给租户并同时自己也记录下钥匙和房间号。自此之后,用户每次进出自己房间只需要用自己的钥匙即可。但后来楼管觉得用户长期持有同一把钥匙有些不安全,比如钥匙被别人克隆了一把又或者钥匙丢了让别人捡了,这样会比较危险,所以楼管又决定给每把分配出去的钥匙有效时间,比如30天。当钥匙到期后,就不可以再打开门锁,必须只能再找楼管换一把新钥匙。

回到正文中,我们引入两个API来说明具体开发中流程。现在有两个API:

TIPS :

客户端需要根据服务端API文档首先实现登录页面,假如文档要求以POST方式json协议提交数据,伪代码演示如下:
http.post( 'http://host.com/api/account/login', { "account":"zhangsan", "password":"123456" } )
服务端收到数据后,从数据库中验证用户名和密码(检查租户身份证),如果错误,返回错误提示,如果正确,就要生成一个token(颁发钥匙)给用户并同时自己也要记录下token是代表了哪位用户(记录下钥匙给了哪个用户)。假如用户的uid是8,生成的token是abcdefg,那么也就是说abcdefg这个token分配给了8号用户(8号租户持有钥匙abcdefg),客户端自己需要保存住这个token(租户自己持有钥匙)。

当用户需要访问自己订单的时候,也就是需要访问API http://host.com/api/order/list 的时候,就要带上token,因为服务端是记录了token和用户uid对应关系的,所以服务器就可以根据token得知当前访问的用户是谁并返回给该用户其订单内容,伪代码演示如下:
http.post( 'http://host.com/api/order/list', { "token" : "abcdefg" } );

这样就基本上已经实现了客户端和服务端通信了,但实际上仅仅这样还是有很大的安全风险。如果任何一个人通过抓包等方式得知了服务器API的地址,就意味着他可以任意调用API了,我们的API会被盗用。为了避免这种被盗用,引入签名机制。也就说访问任何一个API的时候,都需要验证签名,只有签名通过了才可以继续下去,否则就会弹出错误信息。伪代码演示如下:

  1. // 这里可以看到签名的机制就是将api使用md5 hash一下
  2. // 访问帐号登录API
  3. http.post( 'http://host.com/api/account/login', { "account":"zhangsan", "password":"123456" } ).signature( 'api/account/login' )
  4. // 访问我的订单API
  5. http.post( 'http://host.com/api/order/list', { "token" : "abcdefg" } ).signature( 'api/order/list' )

服务端收到数据后,也用相同的签名方式运算出签名与客户端传递来的签名进行对比,伪代码演示如下:

  1. server_signature = md5( 'api/account/login' )
  2. client_signature = http.get_post( 'signature' )
  3. if ( server_signature != client_signature ) {
  4. return 'signature error';
  5. }

在具备这种签名机制后,如果客户端被反编译了,签名机制就会被人得知。所以,在签名机制中引入另外一个新的重要元素:时间戳。时间戳的引入有两个重要作用:

假如某次访问 http://host.com/api/order/list 的时候timestamp值为123456789,签名为”xyz”,有恶意用户记录下该所有的数据然后反复调用。如果我们在服务端对比服务器时间和用户提交过来的时间戳,两者相差巨大超出一天或者半个小时,那么就可以直接返回一些诸如“过期的API访问”等等错误提示。

事情做到这里看起来已经基本比较完善了,这样的签名制度看起来能够抵挡相当大一部分恶意调用了。实际上,在真正完善的API设计中,API都会由API网关来实现,API网关中有一项功能就是防刷限流,可以根据不同维度比如用户、IP地址、设备ID来限制其每秒钟内对某个API的最多访问次数。

截止到目前为止,对于敏感数据包括token在内,我们都是明文传输的。我们需要对敏感数据加密,假如此时产品经理提出了第三个要求:添加银行卡,银行卡号算是敏感信息吧。

至于数据保密性的问题,我们第一点想起的自然是https了。但是,https在面对charles等抓包工具时,其实并没有什么卵用,只要配置一下根证书瞬间可以看到一切明文,所以,除了必要的https外,我们还需要额外的加密机制。

假如这个API是http://host.com/api/bankcard/create ,那么加密的要求就当用户添加银行卡时如果数据被拦截后至少不能赤裸裸地将银行卡号暴露出去。我们需要引入一套加密方案,对敏感数据实现加密。

加密方案不在本文讨论范围,所以我就直接选择AES高级加密方式,AES对内容进行需要一个加密密码,伪代码演示一下:

  1. // 加密密码
  2. password = '123456'
  3. // 需要加密的内容
  4. message = 'Hello World!'
  5. // 利用加密密码对内容进行加密
  6. enc_message = encrypt( password, message )
  7. // 解密
  8. dec_message = decrypt ( password, enc_message )
  9. print dec_message // Hello World!

下面我们将引入加密机制后业务逻辑流程完整走一遍,估计有些同学可能已经晕头了:

// 第一步,客户端执行登录
http.post( 'http://host.com/api/account/login', { "account":"zhengsan", "password":"123456" } ).signature( "api/account/login"+"timestamp" )

  1. // 第二步,服务端收到登录需要,再对比完签名和timestamp时间有效性后,执行登录业务逻辑server_timestamp = get_timestamp()
  2. client_timestamp = get_post( 'timestamp' )
  3. if( server_timestamp - cilent_timestamp > 30 ){
  4. return '过期API访问'
  5. }
  6. server_signature = signature( 'api/account/login' + client_timestamp )
  7. client_signature = get_post( 'signature' )
  8. if( server_signature != client_signature ){
  9. return '签名错误'
  10. }
  11. // 验证密码并返回token
  12. password = get_post( 'password' )
  13. account = get_post( 'account' )
  14. server_password = get_password_by_account( 'account' )
  15. if( password == server_password ){
  16. // 生成一个AES加密密码
  17. enc_password = "1a2b3c4d5f6g7h8i9j0k"
  18. // 生成原始的token
  19. token = "0k9j8h7i6g5f4c3b2c1az9y8x7"
  20. // 服务端记录token与uid对应关系
  21. set( token, uid )
  22. // 最后一步很重要,要将aes加密密码 和 token返回给客户端
  23. return enc_password,token
  24. }

// 第三步,客户端收到登录后的数据:加解密密码 和 token,然后保存起来
token = get( 'token' )
enc_password = get('enc_password')
// 将这两项保存起来
save( token, enc_password )
// 先对银行卡号进行加密,然后再进行提交
bank_card = '666777888999'
enc_bank_card = encrypt( enc_password, bank_card )
http.post( "http://host.com/api/bankcard/create", { enc_bank_card, enc_password, token } ).signature( 'api/bankcard/create'+timestamp )

  1. // 第四步,服务端收到数据后,验证API签名和timestamp时效性,最后解密数据,入库
  2. // 验证signature和timestamp时效性伪代码略过...
  3. // 获取客户端传来的token enc_bankcard enc_password
  4. token = get_post( 'token' )
  5. enc_bankcard = get( 'enc_bankcard' )
  6. enc_password = get( 'enc_password' )
  7. bankcard = decrypt( enc_password, enc_bankcard )
  8. // 根据对应关系,用token找到uid
  9. uid = get_uid_by_token( 'token' )
  10. // 将uid和bankcard入库
  11. save( uid, bankcard )

总结:

FAQ:

上一篇下一篇

猜你喜欢

热点阅读