二次登录问题

经常碰到一个困惑,打开一个应用,明明已经登录了,但是在点开某些页面后,又被要求登录。这种体验很令人困惑,本以为是应用作者技术不行,但是这些情况,竟然在大厂应用中也会遇见。且称这种问题为二次登录问题吧。研究了一下,发现这个问题出现在从应用中打开内嵌式的 web view 时容易出现。

1685540920000 c50272b2 e1e5 4713 a26d 51c0b8a45ff8

场景

细分一下,可能存在几种场景,都会出现再次要求登录的现象。

  • 在手机浏览器上登录了某个站点,打开相同开发者开发的原生应用时,再次要求登录
  • 在手机应用上登录了某个应用,从应用内打开相同开发者开发的网页时,被再次要求登录(如上图所示)
  • 在手机上登录了某个应用,再次打开另一个相同开发者开发的应用时,被要求再次登录

以上几种场景,纯粹从技术上看相当合理,毕竟是在一处建立了会话,在另一处自然要求用户重新提供凭据,建立新的会话。但是从用户角度,会觉得相当困惑,毕竟都是在用相同开发者的产品,为什么不能记住我?

如果能够为用户提供一种在原生应用世界里的单点登录体验,将会让用户感到更加满意,并且对开发者产生更多的信任(容易认为这样的开发者技术更强,做的产品更棒)。

网站的单点登录解决方案

关于在浏览器的不同网站之间的单点登录,已经有各种成熟的方案将这个问题解决得很好了。这些方案中,绝大多数都是依赖 Cookie,通过使用各种技术在不同的网站间共享 Cookie,并且结合一些网页的重定向,通过 URL 传递临时令牌的方式,最终实现了同一用户会话在不同的网站之间得以共享或者重建。但是,这一切都建立在浏览器这个介质上,也就是说,虽然对用户来讲,看起来是在不同的网站之间实现了会话共享,但是前提是该用户始终使用了同一个浏览器。如果用户打开了新的浏览器,或者同一浏览器使用了隐身模式打开,那么即使是输入同一网址,仍然需要重新登录以建立新的会话。

可能的解决方案

如上所述,在网站单点登录中,实际上只是实现了跨站点的会话传递或者共享,该会话始终处于同一浏览器中。而我们现在试图解决的是,跨应用间的会话共享。浏览器是一个应用,而原生应用是另一个应用,它们很可能没有一个共享的存储,从而这种跨应用的单点登录,比纯网站的单点登录更有挑战性。

仅以上述场景中的第二种为例,阐述几种可能的解决方案。

结论

这里列举了几种可以解决问题的方案,都能够“解决”用户的单点登录体验问题。但是,安全性由弱到强,而实现的难度也是从简单到复杂。最简单的方案由于没有安全性可言,不建议采用。而最复杂的方案,很可能由于各种原因不能实施,因此有一些折中的方案可以采用,比如 nonce 方案,token exchange 方案。这两种方案都将通过 URL 来传递一些临时令牌类的信息,将用户的会话从一个地方传到另一个地方。和网站间的单点登录不同,这个传递不在同一个介质中,因此安全性比网站间的单点登录方案略弱,但是实际上也很难被攻击到,所以用户体验足够重要,而又没有能力实施最复杂的方案时,建议采用这两种方案。其中 nonce 方案又更简单,而 token exchange 方案的用途更广,可以扩展到非单点登录的场景中使用。

以下分别从易到难列举一些可能的“解决”方案:

一个幼稚的实现方案

因为要解决的问题是将 App 中的用户身份信息,传递给 App 中的 web view,而一个用户的身份,一般都有一个标识,比如说 userId,或者是一个身份令牌,即 id_token。由于没有更多的信息共享方式,一个很自然的想法是,将用户的身份信息直接通过 URL 查询参数传递给 web view。比如在从 App 中打开 web view 前,在目标 URL 中添加一个额外的查询参数,将用户的身份令牌传递过去,而在接收方,即 webview 中的站点,从查询参数上解析用户的身份令牌,并信任这个令牌,以该用户的身份建立会话。

1685618506364 6c535083 52f7 4c94 8018 e6acda0ba8f9

这个方案,显然是可以解决用户的问题的,立即优化了用户的单点登录体验。但是,通过 URL 传递了用户的信息,基本上没有安全可言。比如开发者可以从应用的访问日志中看到所有的 URL,从而可以重放请求,极易伪装成别的用户。同时,一旦这些 URL 通过某种方式(比如 Excel)发送给更多的受众,而一些正常用户不慎点击了这些链接,就造成了用户身份混乱。

之所以列出这个方案在这里,其实是为了起警示作用。一些缺乏经验的程序员,很容易想到这个方案来“解决”用户的问题。更糟糕的是,甚至传递的不是身份令牌,而是 userId。这样,不仅有所有的上述风险,还允许攻击者随意猜测 userId,通过 URL 遍历用户。

简化的授权码模式

这个模式比上述幼稚方案更安全,但基本思想还是通过 URL 来传递用户的身份信息。只是不直接传递用户的唯一标识符,也不传递其身份令牌,而是传递一个没有任何含义的随机字符串作为授权码。但是这个随机字符串却是可以定位到用户的,并且和 OAuth 2.0 中的 authorization code flow 有点像,有效时限很短,并且只能被使用一次。

在 web view 收到这个授权码之后,由其被认证过的后端服务去换取用户的身份令牌。

这个方案之所以更安全,是假设 authCode 从 App 传递给 web view 之后,很快被正常消费掉了。即使开发者从应用访问日志里看到 authCode,重放时会因为 authCode 已被使用从而不能继续。除非攻击者能够先于用户自己拿到这个 authCode,并且抢先消费它,否则用户的身份很难被别人伪装冒充。但这个方案,有些定制化开发,并不是一个普遍公认的标准。

如果这个 App 正好是微信,而 web view 是微信小程序中的,那么之前的《<font style=color:rgb(18, 18, 18);>webview 复用微信小程序获取用户信息的解决方案 - Jeff Tian的文章 - 知乎 》正好是这个方案的一个特例。

Nonce 模式

思想和简化的授权码模式比较类似,但是 Nonce 模式更加标准化。其时序图如下,浅黄色标注区域和简化的授权码模式的思想是一致的,而后面的流程中复用和扩展了标准 OAuth 2.0 流程中的授权接口。

1685632763255 d562986a 6c59 42ff 969a e52f655e29d0

令牌交换流程

令牌交换流程本身并不是为了解决跨应用的单点登录体验问题的,它既是一个标准的流程,又可以用在更多场景。只是,恰好也能利用它的能力来优化跨应用的单点登录体验。

其时序图如下:

1685633092068 bd7122d4 445b 4d52 b7f2 ab3202984d06

如果有可以共享的存储以供 App 和 Web View 来获取 device_secret,那么安全性也是比 Nonce 模式更强的。详细讨论见《<font style=color:rgb(18, 18, 18);>令牌交换流程初体验 - Jeff Tian的文章 - 知乎 》。

系统自带浏览器方案

这个方案,即 App 不使用 web view 来打开网页,而是直接通过系统自带的浏览器。这个方案可以说几乎没有什么工作量,就能让用户享受单点登录带来的好处。然而,这个方案会使得用户从 App 离开,切换到浏览器,不知道一般来说产品经理是否愿意。而且,如果是开发微信小程序,这个方案也不可行,因为小程序的开发者并不能在小程序里直接跳转到浏览器。

如果系统自带的浏览器方案能够被采用,当在某些场景下用户需要跳转回应用的话,可以使用 Scheme URL 来完成。在认证过程中使用 Scheme URL 跳转回应用时,有可能有恶意的应用故意使用和你的应用同样的 Scheme URL,以截获授权码等等。要预防这种情况,可以为客户端开启 PKCE 模式。

系统特定视图控制器

这个方案与上一个方案一样,也是抛弃 web view。但是为了不离开应用本身,可以采用系统特定的视图控制器,从而用户体验上更像是在应用里打开 web view,但是不像 web view 那样被沙箱隔离。它可以和应用共享 cookie 存储,从而拥有单点登录的体验。看上去,这个方案也不需要网页前端和后端做太多改造,但是却需要应用端做很大的修改,并且同样地,不适用于微信小程序这种场景,因为微信给到开发者的网页浏览器,只有 web view,而且只开放给企业级小程序。

系统特定视图控制器,openid 组织分别提供了 Android 版和 iOS 版:

1685634199965 084e0b0b 74b2 4d51 ae52 3c457e394cb9 1685634232163 25dbb692 2534 4e2a a6da 806f20f1e400

从网上的资料看,通过使用这个 SDK,不仅可以让同一个 App 里的不同嵌入式网页享受单点登录的体验,还能实现复杂的连锁式单点登录体验:

1685634517066 84eaf187 e302 405d 88ae 21562e1e5573

总结

<font style=color:rgb(23, 43, 77);> 通过 URL 传递身份 <font style=color:rgb(23, 43, 77);>简化授权码/Nonce 模式 <font style=color:rgb(23, 43, 77);>令牌交换流程 <font style=color:rgb(23, 43, 77);>系统自带浏览器 <font style=color:rgb(23, 43, 77);>系统特定视图控制器
<font style=color:rgb(23, 43, 77);>Description 直接将用户身份作为查询字符串放在 URL 里 <font style=color:rgb(23, 43, 77);>用户在原生应用中认证完成后,授权服务器为其生成一个随机值,并通过某种方式返回给应用。
<font style=color:rgb(23, 43, 77);>当应用将该随机值传递给web view 之后,web view 用它结合其他服务器认证信息来交换用户的令牌。
<font style=color:rgb(23, 43, 77);>和 Nonce 模式类似,当用户在原生应用中完成了身份认证,授权服务器为其生成一个 device_secret,并随同 id_token 一起返回给应用。
<font style=color:rgb(23, 43, 77);>后面 web view 或者其他应用可以使用这个 device_secret 来交换新的令牌。
<font style=color:rgb(23, 43, 77);>这需要一种方式在应用和 web view 之间共享这个 device_secret。iOS 中如果用了 UIWebView 或者 WKWebView 的话,可以使用 NSHTTPCookieStorage。在其他情况下,如果找不到一个合适的共享存储,则只能通过 URL 来传递这个 device_secret,好在它可以是动态生成的,一次性的。
<font style=color:rgb(23, 43, 77);>当用户在原生应用中进行身份认证时,原生应用打开系统自带的浏览器来让用户登录。当需要从原生应用打开网页时,应用直接使用系统浏览器来打开该网站的 URL,这样由于 cookie 已经存在,就和纯粹浏览器中的单点登录的情况一样了。
<font style=color:rgb(23, 43, 77);>
<font style=color:rgb(23, 43, 77);>OpenID 分别为 iOS 和 Android 创建了特定的 SDK,通过使用这些 SDK,cookies 可以在原生应用与嵌入式站点安全共享,从而实现单点登录体验。
<font style=color:rgb(23, 43, 77);>好处 + 易于理解
+ 易于实现
+ 不用原生应用做太多改动
+ 易于理解
+ <font style=color:rgb(23, 43, 77);>易于实现
+ <font style=color:rgb(23, 43, 77);>不用原生应用做太多改动
+ 标准
+ <font style=color:rgb(23, 43, 77);>可以扩展为其他场景使用,所以更强大
+ 安全
+ <font style=color:rgb(23, 43, 77);>授权服务器不需要修改
+ <font style=color:rgb(23, 43, 77);>内嵌网站也不用修改
+ <font style=color:rgb(23, 43, 77);>原生应用也不用大改
+ 安全
+ <font style=color:rgb(23, 43, 77);>授权服务器不需要修改
+ <font style=color:rgb(23, 43, 77);>内嵌网站也不用修改
坏处 + 非常不安全 + 较不安全
+ 授权服务器与嵌入式网站都得改动
+ 略不安全
+ 授权服务器与嵌入式网站都得改动
+ 原生应用也得改动
+ 用户体验较差 + 原生应用改动很大
+ 原生应用中引入了额外的依赖
<font style=color:rgb(23, 43, 77);>风险 + PII 信息泄露
+ 容易被伪装
+ 仍然可以从应用访问日志中看到 URL 中的 nonce + <font style=color:rgb(23, 43, 77);>如果没有安全的共享方式,device_secrets 和令牌信息能够从应用访问日志中查看到
<font style=color:rgb(23, 43, 77);>工程量 后端: 轻微
前端: 轻微
原生应用: 无
后端: 中等
前端: 中等
原生应用: 小
后端: 中等
前端: 中等
原生应用: 小
后端: 无
前端: 无
原生应用: 小
后端: 无
前端: 无
原生应用: 较大