一、API权限控制范围
1、首先验证web端请求参数:
(1)web请求参数:渠道、ServiceName、版本、Airline、时间戳(yyyyMMddhhmmssSSS)、reqXML、Language、签名
(2)请求不能为空,并且格式是否符合要求
2、验证渠道+IP是否在白名单中:
(1)API项目启动时,将API_CHANNEL_INFO表数据,缓存到内存中
(2)根据Airline+渠道,去内存中取出授权服务器IP列表,并验证请求IP是否在列表中
3、验证签名:
(1)Web请求签名规则:Airline+”|”+渠道+”|”+版本+”|”+ServiceName+”|”+Language+”|”+Token+”|”+时间戳+”|”+reqXML
(2)根据Airline+渠道,去缓存中取出Token,再将Token解密取出来的Token是加密的,需要解密)
(3)API根据签名规则拼字符串,并用SHA256算法加密,然后验证web传过来的签名和API生成的签名是否一致
4、验证并发:
(1)先验证单机总并发量(service级+version的并发)
(2)再验证(渠道+service+version)的并发
(3)只做单机并发限制,默认认为每个服务器分的总并发数都是相同的
5、验证service授权:
(1)API项目启动时,将service授权表数据,缓存到内存中
(3)根据Airline+渠道+ServiceName+版本,去内存里数据中取值,如果取到值,并且值中的STATUS状态是可用,才说明该ServiceName有访问权限
6、登录接口调整:
(1)判断用户名密码是否正确:密码在数据库里是加盐(盐:账号创建时间),再用sha256算法加密)
(2)验证成功后,redis里需要存数据(KEY是渠道+userid,对应的值有logintime+lasttime),并设置超时时间(不同的渠道有自己的redis超时时间,超时时间值在API_CHANNEL_INFO表里存放)
(3)[1]
7、其它接口调整:
(1)API入口需要校验请求的接口是否需要校验登录(在Service授权表中存放了每个接口是否需要校验的标识)
(2)如果接口需要校验登录:要判断userid在redis里是否存在。若不存在,要返回需要登录的提示;若存在,需要更新redis里的lasttime,并且重置超时时间
(3)如果接口不需要验证登录,但是是已经登录的情况下,也要去重置redis超时时间
8、备注:
(1)API需要一个刷缓存接口(刷新所有服务器上的缓存)
(2)需要RES底层提供API_CHANNEL_INFO、API_SERVICE_AUTH、API_SERVICE_INFO这3个表数据的接口
二、数据库
1、渠道信息表(API_CHANNEL_INFO)
AIRLINE | 字符串 | 航空公司二字码,如:CA |
CHANNEL | 字符串 | 渠道 |
CHANNEL_TYPE | 字符串 | 渠道类型 |
TOKEN | 字符串 | TOKEN |
LOGIN_TIME_OUT | 字符串 | 登录超时时间 |
REQUEST_SERVER_IP | 字符串 | 授权服务器IP列表,多个以;分隔 |
注:联合主键是:AIRLINE+CHANNEL
2、Service授权表(API_SERVICE_AUTH)
AIRLINE | 字符串 | 航空公司二字码,如:CA |
CHANNEL | 字符串 | 渠道 |
SERVICE_NAME | 字符串 | 请求操作类型 |
MAX_ASSIGN_NUM | 字符串 | 最大并发数 |
IS_NEED_LOGIN | 字符串 | 请求操作是否需要登录:0或null需要,1不需要 |
VERSION | 字符串 | 授权版本 |
STATUS | 字符串 | 状态(0 可用;1 不可用) |
注:联合主键是:AIRLINE+CHANNEL+SERVICE_NAME+VERSION
3、Service信息表(API_SERVICE_INFO)
AIRLINE | 字符串 | 航空公司二字码,如:CA |
SERVICE_NAME | 字符串 | 请求操作类型 |
MAX_ASSIGN_NUM | 字符串 | 单机最大总并发数 |
VERSION | 字符串 | 授权版本 |
注:联合主键是:AIRLINE+SERVICE_NAME+VERSION
[1] 删除此条变更。因为:假设两个用户ua(用户名:user、密码:pwd)、ub(用户名:userb、密码:pwdb)。a正常登录系统。但b使用用户名user登录系统,则会登录失败。如果此时删除对应的redis缓存的话,将会把a从系统中删除,而不是b)
ps:
https://www.jianshu.com/p/12db60a7062b
看起来好像前后端分离是个浪潮,原来只有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:
- 用户登录API
- 用户订单列表
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 的时候,就要带上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访问的时效性
- 参与签名运算
假如某次访问 的时候timestamp值为123456789,签名为”xyz”,有恶意用户记录下该所有的数据然后反复调用。如果我们在服务端对比服务器时间和用户提交过来的时间戳,两者相差巨大超出一天或者半个小时,那么就可以直接返回一些诸如“过期的API访问”等等错误提示。
事情做到这里看起来已经基本比较完善了,这样的签名制度看起来能够抵挡相当大一部分恶意调用了。实际上,在真正完善的API设计中,API都会由API网关来实现,API网关中有一项功能就是防刷限流,可以根据不同维度比如用户、IP地址、设备ID来限制其每秒钟内对某个API的最多访问次数。
截止到目前为止,对于敏感数据包括token在内,我们都是明文传输的。我们需要对敏感数据加密,假如此时产品经理提出了第三个要求:添加银行卡,银行卡号算是敏感信息吧。
至于数据保密性的问题,我们第一点想起的自然是https了。但是,https在面对charles等抓包工具时,其实并没有什么卵用,只要配置一下根证书瞬间可以看到一切明文,所以,除了必要的https外,我们还需要额外的加密机制。
假如这个API是 ,那么加密的要求就当用户添加银行卡时如果数据被拦截后至少不能赤裸裸地将银行卡号暴露出去。我们需要引入一套加密方案,对敏感数据实现加密。
加密方案不在本文讨论范围,所以我就直接选择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就无法很容易写出一些脚本