HTTP API认证授权方案
一.需求背景
在一些商业合作的场景下,合作方有自己的软件系统并且具备开发能力,需要访问我们的数据资源(比如:账号、产品、统计等),一般的技术方案是提供HTTP API给合作方调用。此时为了保证数据的安全性以及对数据访问范围的控制,就必须验证API调用方的身份,然后结合调用方的权限返回对应的资源,对于无法识别身份的调用方,服务端会进行拦截。
二.常用的API认证技术
2.1 App Secret Key + HMAC
这是一种用于给消息签名的技术,我们怕消息在传递的过程中被人修改,所以,我们需要用对消息进行一个MAC算法,得到一个摘要字串,然后,接收方得到消息后,进行同样的计算,然后比较这个MAC字符串,如果一致,则表明没有被修改过(整个过程参看下图)。而HMAC – Hash-based Authenticsation Code,指的是利用Hash技术完成这一工作,比如:SHA-256算法。
以SHA-256算法示例,签名流程:
- 发送方以 Key 作为算法的签名,对消息 Message 进行一个MAC算法,得到一个摘要字串 MAC。
- 接收方 接收消息 Message 后进行同样的计算得到一个摘要字串 MAC。
- 接收方 然后比较这个 MAC 字符串是否一致,如果一致,则表明没有被修改过。
2.2 OAuth 2.0
OAuth 是一个关于授权(authorization)的开放网络标准,在全世界得到广泛应用,目前的版本是2.0版。OAuth 2.0依赖于TLS/SSL的链路加密技术(HTTPS),完全放弃了签名的方式,认证服务器再也不返回什么token secret的密钥了。
2.2.1 Authorization Code Flow
Authorization Code 是最常使用的OAuth 2.0的授权许可类型,它适用于用户给第三方应用授权访问自己信息的场景。其流程图如下:
授权流程:
- 当用户 Resource Owner 访问第三方应用 Client 的时候,第三方应用会把用户带到认证服务器 Authorization Server 上去。
- 当 Authorization Server 收到这个URL请求后,其会通过 client_id 来检查 redirect_uri 和 scope 是否合法,如果合法,则弹出一个页面,让用户授权。(如果用户没有登录,则先让用户登录,登录完成后,出现授权访问页面)
- 当用户授权同意访问以后,Authorization Server 会跳转回 Client ,并以返回一个 Authorization Code。
- 接下来,Client 就可以使用 Authorization Code 获得 Access Token。
- 最后就是用 Access Token 请求 Resource Server 用户的资源。
2.2.2 Client Credential Flow
客户端以自己的名义,而不是以用户的名义,向"服务提供商"进行认证。在这种模式中,用户直接向客户端注册,客户端以自己的名义要求"服务提供商"提供服务,其实不存在授权问题。
授权流程:
- Client 用自己的
client_id
和client_secret
向 Authorization Server 请求 Access Token。 - 然后 Client 使用Access Token访问 Resource Server 相关的资源。
三.业内产品调研
3.1 微信支付
-
微信支付采用 App Secret Key + HMAC 签名,首先介绍一下微信支付的大致原理:
-
微信是支付系统的开发方,掌管整个支付系统,负责记账。
-
商家想要接入微信支付收银,需要向微信支付部门申请商户号。
-
普通用户通过微信点击商家的付款链接,进行付款。
-
微信后台记录一笔用户和商家之间的交易流水,然后通知商家系统支付成功。
好了,现在可以知道,交易过程其实就是商家系统和微信后台的接口互相调用,而且只需要单向的关注商家调用微信后台。
-
-
JSAPI支付-开发文档,签名算法:
假设传递的参数如下:
appid: wxd930ea5d5a258f4f mch_id: 10000100 device_info: 1000 body: test nonce_str: ibuaiVcKdpRxkhJA
第一步,设所有发送或者接收到的数据为集合M,将集合M内非空参数值的参数按照参数名ASCII码从小到大排序(字典序),使用URL键值对的格式(即key1=value1&key2=value2…)拼接成字符串stringA。
stringA="appid=wxd930ea5d5a258f4f&body=test&device_info=1000&mch_id=10000100&nonce_str=ibuaiVcKdpRxkhJA";
第二步,在stringA最后拼接上key得到stringSignTemp字符串,并对stringSignTemp进行MD5运算,再将得到的字符串所有字符转换为大写,得到sign值signValue。
stringSignTemp=stringA+"&key=192006250b4c09247ec02edce69f6a2d" //注:key为商户平台设置的密钥key sign=MD5(stringSignTemp).toUpperCase()="9A0A8659F005D6984697E2CA0A9CF3B7" //注:MD5签名方式 sign=hash_hmac("sha256",stringSignTemp,key).toUpperCase()="6A9AE1657590FD6257D693A078E1C3E4BB6BA4DC30B23E0EE2496E54170DACD6" //注:HMAC-SHA256签名方式,部分语言的hmac方法生成结果二进制结果,需要调对应函数转化为十六进制字符串。
最终发送的数据:
<xml> <appid>wxd930ea5d5a258f4f</appid> <mch_id>10000100</mch_id> <device_info>1000</device_info> <body>test</body> <nonce_str>ibuaiVcKdpRxkhJA</nonce_str> <sign>9A0A8659F005D6984697E2CA0A9CF3B7</sign> </xml>
3.2 微信公众号
-
access_token是公众号的全局唯一接口调用凭据,公众号调用各接口时都需使用access_token。开发者需要进行妥善保存。access_token的存储至少要保留512个字符空间。access_token的有效期目前为2个小时,需定时刷新,重复获取将导致上次获取的access_token失效。
-
接口调用请求说明
GET https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=APPID&secret=APPSECRET
参数说明:
- grant_type:获取access_token填写client_credential
- appid:第三方用户唯一凭证
- secret:第三方用户唯一凭证密钥,即appsecret
返回情况
正常情况下,微信会返回下述JSON数据包给公众号:
{"access_token":"ACCESS_TOKEN","expires_in":7200}
参数说明:
- access_token:获取到的凭证
- expires_in:凭证有效时间,单位:秒
3.3 微信网页授权
-
如果用户在微信客户端中访问第三方网页,公众号可以通过微信网页授权机制,来获取用户基本信息,进而实现业务逻辑。
-
网页授权AccessToken的流程
- 第一步:引导用户进入授权页面同意授权,获取code
- 第二步:通过code换取网页授权access_token
- 第三步:如果需要,开发者可以刷新网页授权access_token,避免过期。
- 第四步:通过网页授权access_token和openid获取用户基本信息
四、如何选择HTTP API鉴权方案
4.1 HTTP API鉴权方式的对比
前面介绍几种常用的API鉴权技术,在产品调研环节分别可以找到其落地场景。
首先拿 微信支付 来看,一笔交易的下单在一个接口中完成,请求的参数包含金额、商户号等,都是非常关键的参数,必须要求严格校验,防止被攻击篡改,同时参数还有时效限制(含时间戳)。此时自然不适合使用OAuth 2.0的鉴权方式,AccessToken的请求不对参数进行校验。
然后看下 微信公众号 AccessToken的场景,可以看到使用AccessToken调用接口(管理公众号菜单、管理账号)都属于一个企业范围内的数据,可以这么理解,这部分信息属于微信授权给企业的一份独立资产,公众号对应的企业有权限管理这份资产。此时使用AccessToken可以很好的控制访问范围。这里不是不能用 App Secret Key + HMAC 的鉴权方式,而是觉得这部分信息安全要求没有支付高。另一方面,不对参数加密,通信也会更加高效(加密有耗时,比如文件上传也不太适合进行加密)。
最后看下 微信网页授权,同理类推,用户的信息属于每个独立的用户,获取的AccessToken的访问范围也只能是当前用户的信息。
4.2 HTTP API鉴权经验分享
上面提到的两种鉴权方式,无论是作为服务方还是调用方,我都在工作中都有使用到。个人觉得 App Secret Key + HMAC 实践起来相对容易,客户端对服务端的调用比较直接,鉴权不通过时可以通过接口的响应及时获得反馈。
另一种,OAuth 2.0的AccessToken的方式,服务端需要维护AccessToken,并且还要控制AccessToken的失效,拿微信公众号来看,新的AccessToken生成后,旧的AccessToken在5分钟之内有效;客户端需要维护一份AccessToken并及时刷新保持有效。再看下业务的交互上,比起 App Secret Key + HMAC 明显多一些环节,环节多了就容易犯错。
4.3 结论
最后,具体选择使用哪一种鉴权方式,我想还是需要结合对应的业务场景来看。比如业务发展的初期,需要快速开发推向市场,这时就没必要纠结,直接选择一种相对而言简单且不容易犯错的 App Secret Key + HMAC 签名鉴权。等到后续用户量大了,业务成熟了,可以参考 微信公众号、AWS s4签名,精细划分每一个AccessToken的访问范围。
五.实践-方案实现
实践案例使用 App Secret Key + HMAC 的鉴权方式,下面会详细介绍 客户端签名 和 服务端验签 的过程。
5.1 分配AppId和AppSecret
在签名之前首先需要分配 AppId 和 AppSecret,落实到业务场景中,这个就是我们作为资源方分配给合作方的租户配置。关于 AppId 和 AppSecret 的生成没有标准规范,每家的生成算法都不一样,也都不会公布出来。本次案例,我们使用32位的uuid作为AppId,以64位的hash串作为AppSecret:
// 生成AppId
private static String generateAppId() {
UUID uuid = UUID.randomUUID();
return uuid.toString().replaceAll("-", "");
}
// 生成AppSecret
private static String generateAppSecret() {
UUID uuid = UUID.randomUUID();
return DigestUtils.sha256Hex(uuid.toString());
}
计算得出:
APPID = "ivv49q404zfp8075ivbcwye4ardqafha"
APP_SECRET = "ut338c829x2yzfnklvy8lezyu3ndsss68dyzo9opt3icbin7lv7p2j4b0i2cvjz8"
5.2 客户端签名
-
假设传递的参数如下:
private static final String APPID = "ivv49q404zfp8075ivbcwye4ardqafha"; /** * 下单请求对象 */ class PlaceOrderForm { String appid; Integer totalAmount; String body; String detail; String nonceStr; } /** * 模拟请求对象 */ private static PlaceOrderForm mockWebForm () { PlaceOrderForm form = new PlaceOrderForm(); form.appid = APPID; form.body = "test"; form.detail = "test"; form.nonceStr = "123456"; form.totalAmount = 88; return form; }
-
第一步,设所有发送或者接收到的数据为集合M,将集合M内非空参数值的参数按照参数名ASCII码从小到大排序(字典序),使用URL键值对的格式(即key1=value1&key2=value2…)拼接成字符串stringA。
/** * TreeMap会根据Key排序 */ private static Map<String, String> confirmToMap(PlaceOrderForm form) throws Exception { Map<String, String> map = new TreeMap<>(); Field[] fields = PlaceOrderForm.class.getDeclaredFields(); for (Field field : fields) { field.setAccessible(true); Object value = field.get(form); if (value != null && !field.getName().equals("sign")) { if (value instanceof String) { map.put(field.getName(), (String) value); } else if (value instanceof Integer) { map.put(field.getName(), String.valueOf(value)); } } } return map; }
-
第二步,在stringA最后拼接上appsecret得到stringSignTemp字符串,并对stringSignTemp进行MD5运算,再将得到的字符串所有字符转换为大写,得到sign值signValue。
private static final String APP_SECRET = "ut338c829x2yzfnklvy8lezyu3ndsss68dyzo9opt3icbin7lv7p2j4b0i2cvjz8"; /** * 生成签名 */ private static String sign(Map<String, String> params) { StringBuilder sb = new StringBuilder(); for (Map.Entry<String, String> entry : params.entrySet()) { sb.append(entry.getKey()); sb.append("="); sb.append(entry.getValue()); sb.append("&"); } sb.append("appsecret="); sb.append(APP_SECRET); return DigestUtils.md5Hex(sb.toString()).toUpperCase(); }
-
最后计算得到摘要
public static void main(String[] args) { PlaceOrderForm form = mockWebForm(); try { Map<String, String> stringStringMap = confirmToMap(form); String sign = sign(stringStringMap); System.out.println(sign); } catch (Exception e) { e.printStackTrace(); } }
5.3 服务端验签
服务端接收请求的参数,使用同样的签名算法计算出摘要 sign 进行比较,如果一致,则说明请求没有被修改。