浅谈API的设计及其安全性
看起来好像前后端分离是个浪潮,原来只有APP客户端会考虑这些,现在连Web都要考虑前后端分离 。
这里面不得不谈的就是API的设计和安全性,这些个问题不解决好,将会给服务器安全和性能带来很大威胁 。
下面我也是根据自己的一些经历和经验说下自己的一些心得 。
API的设计中,主要考虑两大方面的问题 :
- 防止API被恶意调用
- API通信中数据加密的问题
由于HTTP协议是无状态的,所以在做MVC Web的时候,无论是Java Web还是PHP等,大多数都是依靠session/cookie来完成的用户标识的(如果你不了解session相关内容,请自行搜索)。但在前后端分离的开发模式中,session/cookie模式就显得不太合适,尤其是APP客户端,是不太可能用session/cookie的,业界广泛采用的方式就是采用token。
我们用楼、楼管和租户来做个类比。我们的系统是一栋楼,一名管理员负责管理这栋楼和租户,有100个租户,每个租户都被分配了属于自己的一个房间而且租户不可以随意进入其他租户的房间。当租户第一次来登记的时候,楼管就会要求租户出具身份证(用户名和密码),在核验完毕身份证后(验证密码)后,管理员会把一件房的钥匙(token)给租户并同时自己也记录下钥匙和房间号。自此之后,用户每次进出自己房间只需要用自己的钥匙即可。但后来楼管觉得用户长期持有同一把钥匙有些不安全,比如钥匙被别人克隆了一把又或者钥匙丢了让别人捡了,这样会比较危险,所以楼管又决定给每把分配出去的钥匙有效时间,比如30天。当钥匙到期后,就不可以再打开门锁,必须只能再找楼管换一把新钥匙。
回到正文中,我们引入两个API来说明具体开发中流程。现在有两个API:
- http://host.com/api/account/login 用户登录API
- http://host.com/api/order/list 用户订单列表
TIPS :
- 下面流程仅仅是大体流程,不处理具体细节。方案都是为了说明问题才用简单粗暴,可以根据大概原理自己加入更丰富的元素
- 伪代码中出现的post不表示使用POST方式提交数据,仅仅表示人们口中常说的PO数据
- 本文仅为API设计提出大体方案,比如签名机制,但具体签名机制采取多少数据项才、用md5或者sha1可自主决定
客户端需要根据服务端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的时候,都需要验证签名,只有签名通过了才可以继续下去,否则就会弹出错误信息。伪代码演示如下:
// 这里可以看到签名的机制就是将api使用md5 hash一下
// 访问帐号登录API
http.post( 'http://host.com/api/account/login', { "account":"zhangsan", "password":"123456" } ).signature( 'api/account/login' )
// 访问我的订单API
http.post( 'http://host.com/api/order/list', { "token" : "abcdefg" } ).signature( 'api/order/list' )
服务端收到数据后,也用相同的签名方式运算出签名与客户端传递来的签名进行对比,伪代码演示如下:
server_signature = md5( 'api/account/login' )
client_signature = http.get_post( 'signature' )
if ( server_signature != client_signature ) {
return 'signature error';
}
在具备这种签名机制后,如果客户端被反编译了,签名机制就会被人得知。所以,在签名机制中引入另外一个新的重要元素:时间戳。时间戳的引入有两个重要作用:
- 判定某次API访问的时效性
- 参与签名运算
假如某次访问 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对内容进行需要一个加密密码,伪代码演示一下:
// 加密密码
password = '123456'
// 需要加密的内容
message = 'Hello World!'
// 利用加密密码对内容进行加密
enc_message = encrypt( password, message )
// 解密
dec_message = decrypt ( password, enc_message )
print dec_message // Hello World!
下面我们将引入加密机制后业务逻辑流程完整走一遍,估计有些同学可能已经晕头了:
// 第一步,客户端执行登录
http.post( 'http://host.com/api/account/login', { "account":"zhengsan", "password":"123456" } ).signature( "api/account/login"+"timestamp" )
// 第二步,服务端收到登录需要,再对比完签名和timestamp时间有效性后,执行登录业务逻辑server_timestamp = get_timestamp()
client_timestamp = get_post( 'timestamp' )
if( server_timestamp - cilent_timestamp > 30 ){
return '过期API访问'
}
server_signature = signature( 'api/account/login' + client_timestamp )
client_signature = get_post( 'signature' )
if( server_signature != client_signature ){
return '签名错误'
}
// 验证密码并返回token
password = get_post( 'password' )
account = get_post( 'account' )
server_password = get_password_by_account( 'account' )
if( password == server_password ){
// 生成一个AES加密密码
enc_password = "1a2b3c4d5f6g7h8i9j0k"
// 生成原始的token
token = "0k9j8h7i6g5f4c3b2c1az9y8x7"
// 服务端记录token与uid对应关系
set( token, uid )
// 最后一步很重要,要将aes加密密码 和 token返回给客户端
return enc_password,token
}
// 第三步,客户端收到登录后的数据:加解密密码 和 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 )
// 第四步,服务端收到数据后,验证API签名和timestamp时效性,最后解密数据,入库
// 验证signature和timestamp时效性伪代码略过...
// 获取客户端传来的token enc_bankcard enc_password
token = get_post( 'token' )
enc_bankcard = get( 'enc_bankcard' )
enc_password = get( 'enc_password' )
bankcard = decrypt( enc_password, enc_bankcard )
// 根据对应关系,用token找到uid
uid = get_uid_by_token( 'token' )
// 将uid和bankcard入库
save( uid, bankcard )
总结:
- 签名机制是为了防止API被恶意调用,包括API
- 加密是为了保证敏感数据,敏感数据可以包括token
- token本身与加密无关,只是token本身的含义总是跟加密似乎带点儿关系,但实际上token仅仅是个用户身份识别器
- 只要客户端被反编译了,加密方式和签名机制都会暴露出来,所以安全是需要双方配合的
FAQ:
-
token和uid对应关系如何实现?
通过redis处理,你可以考虑一个hash类型数据结构,key就用token,hash中保存完整的用户信息 -
token或者aes加解密密码如何传递?
最好不用GET方式,建议走POST方式,也可以将这些信息放到http header中,也可以放到http body中。我自己一般习惯将signature、token、timestamp、enc_password这些信息放在http header中,API参数放在http body中 -
我怎么感觉enc_password这样明文传好危险
其实,你可以不用完整的enc_password,你可以和客户端协商制定一个规则,比如去掉enc_password的前三位和后两位,用剩下的做加解密密码 -
token本身需要加密吗?有没有必要所有api提交的参数都加密?
可以加密,你甚至用解密后的token参与签名运算,制作出更复杂的签名规则。至于提交参数是否都加密,实际上是可以的。如果任何api参数都加密,抓包者是无法通过抓包分析你api接受参数的名称的,比如原来是明文提交{“account”:”zhangsan”},如果该api加密提交,那么这个json被加密成”abcdekkadadfad==”之流,抓包者由于无法得知确切的参数名称account就无法很容易写出一些脚本