在传统web开发B-S模型中,用户登陆后创建一个sessionId返回给Browser(User-Agent);在Browser每次请求后端Server时,根据sessionId获取用户登陆时的客户信息,从而实现安全认证(Authentication)。进入移动互联网时代我们需要对这个模型进行升级,从而实现更广泛的安全认证。
在移动互联网开发中我们遇到的问题首先是RESTful无状态的架构风格变换,简单来说就是去session。第二个问题在于原生APP和webview中h5访问的混合式开发的cookie处理复杂度。目前主流采用OAuth2.0+JWT实现。本文专注于具体如何使用OAuth2.0+JWT,以及各种场景下的问题和解决。
安全认证的场景
下图是一个典型的互联网公司前台架构。
下图展示了移动APP混合式开发安全认证的SSO主要场景
OAuth2.0最初的核心目的是解决企业间客户授权联合登陆的问题。用户账号密码登陆是登陆的一个特例,并不匹配企业自身互联网登陆认证的所有场景。本文不考虑用户使用何种凭证登陆。在使用OAuth2.0的auth server中,登陆后会得到以下数据:
{
"access_token": "2e17505e-1c34-4ea6-a901-40e49ba786fa",
"token_type": "bearer",
"refresh_token": "e5f19364-862d-4212-ad14-9d6275ab1a62",
"expires_in": 59,
"scope": "read write",
}
OAuth2.0同时返回了两个token,access_token用来访问受保护的资源,refresh_token在access_token失效后用来获取access_token。
客户端(User-Agent)需要将这些信息保存起来,从而在后续的接口访问中使用。如果是原生APP登陆,需要持久化到本地存储中。如果是web登陆,需要保存在sessionStorage中。
既然通过refresh_token可以获得access_token,为什么不直接把access_token的失效时间设置得和refresh_token一样,这样不就不需要刷新了吗?个人认为有以下考量:
用户请求后端受保护的资源时,需要将access_token在报文头中传递到后端。请求报文通常是如下格式:
GET /resource HTTP/1.1
Host: server.example.com
Authorization: Bearer mF_9.B5f-4.1JqM
第三行报文头“Authorization: Bearer mF_9.B5f-4.1JqM”Bearer后有个空格,空格后是登陆返回参数中的“access_token”的值。在H5开发中,可以通过axios的拦截器里面添加。
超时自动登陆即access_token过期,通过refresh_token重新获得access_token。在访问受保护资源时,如果access_token过期,后端将返回401认证失败。在不同场景中技术方案又有所差异:
原生接口请求401可以通过拦截网络组件,自动请求/oauth/token接口用本地存储的refresh_token换取新的token后重新发起请求。
web页面接口请求401也可以拦截网络请求组件,尝试刷新access_token后重新发起请求。在SSO场景中刷新token需要做一些特殊处理。
客户在当前domain登陆后,sessionStorage保存了refresh_token,在拦截到401报文时,可以直接用存储的refresh_token刷新access_token。
H5可以根据与原生开发的约定,通过webview的user-agent属性判断是否在指定app中,在需要刷新token时,通过jsBridge委托给原始APP刷新token后重新发起请求
在公司内部一级域名下web页面跳转的sso场景下,token超时可以通过postMessage和iframe实现委托刷新。
常见的web 页面从m.a.com域名跳转m1.a.com域名。如果公司web服务由不同的二级域名提供,这个时候页面跳转就存在单点登陆SSO的问题。由于web浏览器sessionStorage的同源策略,在新域名下的js无法获取到用户登陆时存储的token。我们可以按如下方案做联合登陆:
原则上这个方案也适合不同的一级域名的SSO,比如从a.com跳转到b.com;但这个方案在刷新token时将受信域扩大化了,带来安全隐患。同时公司不同的域名下一般应该使用不同的认证中心auth-server,应该使用企业间联合登陆方案。
以上方案前提假设是server端有一个统一的auth-server,access_token在不同的后端服务器间都可以用同一个密钥验签,从而达到认证的效果。这也是在分布式服务中使用JWT的好处,可以脱离session和集中式存储做安全认证。
APP打开新的webview时,直接在url地址后面增加参数access_token。H5页面统一按SSO方案处理。
APP关闭后重新打开,用户不需要重新登陆就可以直接访问。由于APP持久化了首次登陆的token信息,所以可以直接通过refresh_token刷新access_token实现自动登陆。
web登陆信息被保存到了sessionStorage,在浏览器关闭时sessionStorage的数据就丢失了。如果需要实现这个场景下的自动登陆,需要前后端都做处理。参考:spring-security-oauth2-remember-me
Cookie cookie = new Cookie("refreshToken", refreshToken);
cookie.setHttpOnly(true);
cookie.setPath(ctx.getRequest().getContextPath() + "/oauth/token");
cookie.setMaxAge(2592000); // 30 days
ctx.getResponse().addCookie(cookie);
localstorage能够永久保存数据,和APP持久化数据效果是一样的。为什么在APP中永久保存,在桌面web就要搞得这么麻烦呢?个人认为有以下原因:
总之,用localstorage长期保存token带来的坏处远远大于好处。
如果企业间都是使用OAuth2.0的auth-server,建议直接使用OAuth2.0的授权码模式做联合登陆。
在互信的企业间(或者集团公司内部子公司,如淘宝和支付宝),可以跳过用户确认的环节,实现无感的SSO跳转。
以上官方流程过于抽象,在实际操作中可以按如下步骤理解:
在整个SSO过程中,为了满足多次自动重定向,targetUrl组装比较复杂。假设a.com服务下认证服务器域名为"a.com/auth-server",b.com服务下认证服务器域名为"b.com/auth-server"。a.com下的页面希望跳转到"b.com/resouce",请求地址格式为:
GET /auth-server/authorize?response_type=code&client_id=s6BhdRkqt3&state=xyz
&redirect_uri=${targetUrl} HTTP/1.1
Host: a.com
url地址中targetUrl的组装方式为:
urlEncode(
//”/signin/sso“是b.com实现的联合登陆接口,根据实际情况调整。
"b.com/auth-server/signin/sso?redirect_uri="
+urlEncode("b.com/resouce?a=x&b=y")
)
按以上包装后,第4步重定向的目标是:b.com/auth-server/signin/sso?redirect_uri=${encodedUrl}&code=xxxx
encodedUrl即urlEncode(“b.com/resouce?a=x&b=y”)的结果,也是第6步重定向的目标
第6步重定向的目标是:b.com/resouce?a=x&b=y&access_token=yyyyyyyy