浅入浅出 SSO 系统

avatar
FE @字节跳动
  1. 前言

在企业内部通常会有多套系统,为了解决多套系统重复登录的问题,通常会引入 SSO(Single Sign-On)系统用于实现统一登录。本文主要从登录/认证、登录态共享、登录态共享三个核心模块介绍 SSO 系统相关实现。

在本文中涉及到基础的编码、签名、加密、摘要等概念,如果不了解它们的作用或者区别可以先阅读附录章节:编码、签名、加密、摘要

  1. 登录/认证

这一步是向系统证明“我就是我”的过程,避免其他人冒充“我”的身份访问一些敏感的资源、执行一些敏感的操作。一般来讲,有以下几种身份认证因素:

  1. 知识:用户知道什么(如密码、安全问题)
  2. 所有物:用户拥有什么(如 TOTP 的密钥、U盾)
  3. 固有特质:用户是什么(人的物理属性,如指纹、人脸)

通过将不同的验证因素相组合,可以避免因密码泄漏或手机丢失而造成的账号被盗问题,这也就是我们常看到的多因素身份认证(常见的如两步认证)。

  1. 密码

这种登录方式是最常见的,几乎每个人都使用过这种方式。相对而言,它也是比较容易出问题的一种方式。因此在设计方案时需要考虑到以下几点:

  1. 密码本身的复杂度,避免被人猜出来
  2. 在传输过程中可以使用密码的摘要代替密码,避免在传输链路中导致密码被泄漏
  3. 在数据库中存储时可加盐(随机字符串)计算摘要后再存储,避免被“拖库”后通过“撞库”的方式泄漏其他网站的用户信息。
  4. 接口频控,避免暴力破解

  1. 一次性密码(OTP)

这也是比较常见的一种登陆方式,比方说短信验证码、TOTP 之类的。从验证码如何生成来区分一般可以分为两种:

在线生成

这种方案一般是后端随机生成一个 6 位的数字,并通过短信/邮件/App(如 AppleID 的登录)之类的方式推送给用户。这种方案需要注意:

  1. 由于验证码只有 6 位,所以需要给验证码增加有效期。
  2. 接口频控(会话层面,如果针对 ip 频控会被爆破)

个人使用 Tips:

离线生成

这种方案依赖用户和服务端采用相同的算法计算出一个验证码,比较常见的是 TOTP。

TOTP 主要核心原理是摘要算法,它会根据共享密钥(绑定时扫二维码的内容)和当前时间(一般 30s 为一个窗口)两个输入来生成 hash 进而计算出验证码。如果用户和后端持有相同的密钥,则在同一时间窗口生成的验证码是相同的。

TOTP(K, TC) = Truncate(HMAC-SHA-1(K, TC))
TC = (T - T0) / T1;
  • K: 共享密钥
  • T:当前时间戳
  • T0: 起始时间
  • T1:时间间隔/时间窗口
  1. WebAuthn

这是一种比较新的登录方式,同时支持的网站也比较少。它最大的优势是不用输入密码,其次它的安全性相对来说也更好一些:服务端只保存了公钥,就算泄漏了也问题不大。

WebAuthn 的认证流程跟 SSH 公钥认证类似。核心原理是签名或者说非对称加密。签名一般是用于验证信息是有指定发布者发布的,利用这个特性:如果一个用户可以对指定内容签出合法的签名,那就能证明该用户的身份,因为私钥只有这个用户持有。WebAuthn 的流程如下:

  1. 浏览器发起登录请求

  2. 后端返回 challenge code(可理解为随机字符串)

  3. 调用浏览器 API 使用安全芯片中的私钥签名

  4. 后端通过注册时保存的公钥验签

用户私钥的保存应该是整个环节中最容易出问题的地方了,一旦泄漏会导致多个网站被盗。通常情况下,私钥可能存在 T2 芯片、TPM 模块或者 Yubikey 或者其他安全芯片中。目前从我的使用体验来看,私钥的启用都做到了双因素认证(用户持有私钥 + 设备的生物认证/ PIN 认证),正常使用不用太过担心安全问题。

WebAuthn 体验地址:

www.passkeys.io/

Bitwarden 前段时间推出了相关产品 passwordless.dev,开发者可以通过 SDK 及 API 屏蔽掉底层的细节,可以很简单的给网站接入 WebAuthn 相关的能力。

  1. SSO

虽然本文的主题是一个 SSO 系统,不过它也可以依赖其他的 SSO 系统进行登录(禁止套娃.jpg),比方说通过 GitHub 的 OAuth 进行登录。具体的细节会在登陆态共享章节介绍。

  1. 生物认证

常见的有支付宝的人脸识别,一般在 App 中用的比较多,不过浏览器也有摄像头相关的 API 也能实现相关的功能,目前好像没见到哪个网站支持生物认证登录的。

  1. 其他设备的登录态

这常见的有扫码登录、Microsoft 登录时让用户在 Authenticator 上点击指定数字以及 Google 登录时会让用户在其他已登录设备上点击确定之类的。

  1. 登录态保持

由于 HTTP 协议是无状态的,所以在完成登录之后我们需要某种方案保持住用户的登录态,以便在后续的请求中知道请求是由谁发出的。根据是否需要在后端保存数据,通常有两种方案:

  1. Cookie-Session

依托于浏览器的 Cookie 机制,后端可以通过 Set-Cookie 在浏览器写下 key-value 的数据,浏览器在请求对应接口时会将 key-value 的数据带上发送给后端。

在这套方案中,核心的有两部分内容:

  1. 完成登录后拿到的包含有用户信息的对象,这里我们先称之为 SessionObject。
  2. 与 SessionObject 相关联的没有实际意义的不重复随机字符串,这里我们先称之为 SessionID。也就是在 cookie 中保存的内容。

相关流程如下:

  1. 在完成登录后后端会得到一个 SessionObject,同时通过 UUID、SHA 之类的方法创建一个随机字符串 SessionID。
  2. 保存 SessionID 和 SessionObject 的关联关系到 SessionCache (能保存 kv 结构数据的工具)中,SessionCache 可以是一个形如 Record<SessionID, SessionObject> 的对象,也可以是 Redis 或者 DB。
  3. 通过 Set-Cookie 将 SessionID 保存到浏览器的 Cookie 中。
  4. 在后续的请求中,后端服务先从 Cookie 中拿到 SessionID,然后再从 SessionCache 中根据 SessionID 获取 SessionObject。如果能获取到说明用户已登录,否则认为用户未登录。

过期机制:

浏览器的 Cookie 本身就是有过期机制的,后端保存的 SessionObject 可以搭配 exp 字段或者 Redis 的 EXPIRE 命令实现过期机制。因此可以两者相搭配来保障用户账号安全:e.g. Cookie 30 天有效期,SessionObject 每次请求后刷新有效期为 1 天,这样可以让活跃用户不需要频繁的登录,长时间未使用的用户需要走一遍登录流程验证用户身份。

  1. JWT(JSON Web Token)

JWT 是一个很长的字符串,由 . 分割为3 段:Header.Payload.Secrect

它的特点是用户信息直接放在字符串中返回给前端,依靠签名机制来确保没有被伪造。

Header

该部分是一个 JSON 对象经过 Base64URL 编码后的字符串。JSON 对象格式如下:

{
  "alg": "RS256",  // 签名算法,RS256/HS256 等等
  "typ": "JWT"     // 固定为 JWT 
}

Payload

该部分也是一个 JSON 对象 Base64URL 编码后的字符串。用来存放实际需要传递的数据。JWT 规定了 7 个官方字段可选用,除了官方字段外,还可以自定义私有字段,用户信息就是存放在这里。

  • iss (issuer):签发人
  • exp (expiration time):过期时间(通过该字段验证 JWT 是否过期)
  • sub (subject):主题
  • aud (audience):受众
  • nbf (Not Before):生效时间
  • iat (Issued At):签发时间
  • jti (JWT ID):编号

Secret

该部分的主要作用是确保前面两段的内容没有被篡改以及验证签发者的身份,验证通过则说明 JWT 是可以信任的。从签发方和验证方是否相同角度来看相关的签发算法可以分为两类:

  1. 签发方和验证方相同:在签发和验证的时候都都可以拿到 secretKey,这时候一般通过摘要算法来生成 secret。

    1. HMACSHA256(
        base64UrlEncode(header) + "." +
        base64UrlEncode(payload),
        your-256-bit-secret
      )
      
  2. 签发方和验证方不同:这个时候一般会有一个统一的认证服务签发,有多个消费方。此时如果只有一个密钥并且共享给其他消费方时很有可能会产生安全问题,所以一般会采用数字签名的方式,即统一认证服务通过私钥签出 secret,消费方通过公钥验证。

  1. 对比

开发成本保密性服务扩展性泄漏后是否支持撤销有效期 & 登录频率
Cookie-Session差:需要额外引入存储服务如 Redis。强:用户信息都保存在后端差。cookie 的验证依赖中心化的服务支持。泄漏后手动删除后端保存的 SessionObject有效期长,进而可降低登录频率
JWT优:信息存在浏览器端,无需存储服务差:用户信息保存在浏览器,敏感信息会泄露强。通过非对称签名的方式验证,验证过程不依赖中心化服务不支持。如果通过中心化服务做黑名单会引入中心化依赖短:出于安全考虑,有效期不会太长

  1. 登录态共享

在上述一系列流程完成登录后,我们到了 SSO(Single Sign-On)系统最核心的部分:将登录态共享给其他系统。在这里我们先略过如在父域名下种 Cookie 之类相对局限的方案,看下如 CAS、OAuth 之类更通用的方案。另外还有诸如 SAML、OpenID 之类的方案比较复杂或已经过时就不再介绍。

  1. CAS(Central Authentication Service

CAS 中的 A 是指认证(Authentication)。

在上文 Cookie-Session 的登录态保持方案中有提到 SessionID 这个概念用于关联到用户信息,在 CAS 中也有类似的概念 Ticket。接入方服务端需要通过某种方式拿到 CAS Server 颁发的 Ticket,然后调用 CAS Server 提供的接口用 Ticket 换取用户信息完成登录。这边的某种方式是“页面重定向+Query”。

  1. OAuth

OAuth 中的 A 是指授权(Authorization),但是授权的前提是知道用户身份,因此也可以用它来做认证。

我们现在所说的 OAuth 通常是指 OAuth 2.0。OAuth 2.0 和 OAuth 1.0 是互不兼容的。后文中的 OAuth 均指 OAuth 2.0。在 OAuth 2.0 中有多种授权模式。其中授权码模式是功能最完整、流程最严密的授权模式。

授权码模式其实跟上文的 CAS 类似,都是通过“重定向+ Query” 获取授权码 code(对应 CAS 中的 Ticket);区别在于 CAS 中用 Ticket 直接换取了用户信息(上图中的 XML Content),而 OAuth 则是用授权码换取了访问令牌(access_token),再通过访问令牌从资源服务器中获取用户信息。简单概括就是 OAuth 相比 CAS 多了一个角色:资源服务器;以及多了一个步骤:用 access_token 从资源服务器换取用户信息。

// 授权码换回的内容
{
  "token_type": "Bearer",
  "access_token": "rx6gxRadCkkr3hEKa31EIwHrpTgx36YsHnCairVZp6PtIy7nA7CAWjFPEE9YCTNcmvPEFeul"
}

OAuth 流程体验:

www.oauth.com/playground/…

  1. OIDC

OIDC 是基于 OAuth2.0 的认证 + 授权协议。可以简单理解为 OAuth 2.0 的超集。区别在于在 OIDC 在通过授权码换取 access_token 时还会多颁发一个 ID Token,这是一个包括用户信息的 JWT。如果 ID Token 中包含的用户信息不够,还可以调用 OIDC 定义的 UserInfo EndPoint 来获取更多的用户信息。

// 授权码换回的内容
{
  "token_type": "Bearer",
  "expires_in": 86400,
  "access_token": "rx6gxRadCkkr3hEKa31EIwHrpTgx36YsHnCairVZp6PtIy7nA7CAWjFPEE9YCTNcmvPEFeul",
  "scope": "openid profile email photo",
  "id_token": "eyJraWQiOiJzMTZ0cVNtODhwREo4VGZCXzdrSEtQUkFQRjg1d1VEVGxteW85SUxUZTdzIiwiYWxnIjoiUlMyNTYifQ.eyJzdWIiOiJjb25jZXJuZWQtbWFtYmFAZXhhbXBsZS5jb20iLCJuYW1lIjoiQ29uY2VybmVkIE1hbWJhIiwiZW1haWwiOiJjb25jZXJuZWQtbWFtYmFAZXhhbXBsZS5jb20iLCJpc3MiOiJodHRwczovL3BrLWRlbW8ub2t0YS5jb20vb2F1dGgyL2RlZmF1bHQiLCJhdWQiOiJiY3h2S0JKcEJiQTAwdjc4UUVyN3ppTTkiLCJpYXQiOjE2ODgyOTY2NDUsImV4cCI6MTY5MDg4ODY0NSwiYW1yIjpbInB3ZCJdfQ.ZoPvZPaomdOnnz2GFRGbgaW7PPWIMFDqSBp0gbN4An4a9F-Bc-4_T9EBGV8aGetyjZYAON0gjNV0p0NGFiwettePWKuxBzusuGCEd9iXWWUO9-WTF5e2AGr3_jkg34dbxfiFXy3KgH7m0czm809cMaiZ_ofLYgJHVD8lqMQoWifhoNhpjPqa19Svc3nCHzSYHUgTXQWvA56NmQvyVPh_OM7GMpc6zHopmihJqt3eREof8N-bOd7FL39jeam2-k1TFSDogyJE513aC0OssRADr_TWvtL8xoaPkXM_7bXYs9_7erXmzF9la0hvmOuasieetpLhOvFeoiOJWCU9xhxj4Q"
}
  1. 附:编码、签名、加密、摘要

  1. 编码

  • 用途:编码是为了更高效(http2的压缩)、准确(不安全字符)地通过信道传输信息。
  • 特点:可逆、区别于加密不需要密钥
  • 常见编码方式:Base64、encodeURIComponent
  • 常见场景:JWT 的 Payload 部分
  1. 摘要

  • 通常也被称为哈希或者散列

  • 用途:验证数据的完整性,防止被篡改

  • 特点:单向(不可逆)、弱碰撞性(不同输入值的散列值极低概率会出现结果相同,防止消息伪造)、雪崩效应(当输入发生最微小的改变时,也会导致输出的不可区分性改变)

  • 常见算法:MD5、SHA。(推荐至少使用 SHA256)

  • 常见场景:

  1. 加密

  • 用途:保护数据免遭窃取或泄露
  • 特点:可逆、需要密钥

对称加密

  • 特点:使用同一个密钥进行加解密
  • 常见算法:DES、AES
  • 使用场景:文件加密、消息传输加密

非对称加密

  • 特点:两个有关联的密钥,一个加密一个解密(公钥加密+私钥解密)、性能较差
  • 常见算法:RSA、ECC
  • 使用场景:密钥交换、消息传输

HTTPS 通过非对称加密算法传输对称密钥 K,然后通过对称加密及密钥 K 来传输数据。

  1. 签名

编码/摘要/加密更多是技术手段 签名更偏业务场景 会使用到几类技术手段实现

  • 用途:验证数据的完整性(摘要)+身份验证(非对称加密)
  • 特点:私钥签名+公钥验签
  • 常见算法:RSA、ECC
  • 常见场景:Git 签名(Linus 删库事件~~~~)SSH 公钥登陆

参考