我是如何编写一个单点登录系统的

我是如何编写一个单点登录系统的

本文将从“后端逻辑”及”前端“两个层面介绍我是如何设计一个单点登录系统的。
除有特殊说明外,本文所描述的所有逻辑均在单点登录系统中(后端、前端授权页)实现,请不要混淆将其用于第三方应用程序中。

对于单点登录系统,目前有很多商业公司都提供成熟的解决方案,如国外的 Okta、Auth0、Duo 和国内公司的 Authing,这些商业化的解决方案都非常成熟,适合有足够预算和对安全性、可用性高的企业使用。
单点登录不仅仅可以用于企业内部对员工身份的鉴权(B2E),也可以用于对客户身份的鉴权(B2C)
我第一次尝试设计单点登录系统是在 2020年年末,当时我第一次有了身份认证的需求,我所设计的第一版单点登录系统后端是基于 Python Flask 的,前端非常简陋,仅仅有一些基本功能:


从第二版起,我便把认证系统的后端技术栈改为了 Nodejs,因为其相对于 Python 而言有着更优秀的性能,在代码结构上也更加现代化,更适合构建大型项目(比如 Python 的缩进控制相比于大括号对于一些复杂逻辑真的不友好)。
最新版(第三版)的单点登录页则对所有旧版本的前端都进行了重构,整体页面设计更加现代化,功能分布也更加合理:

逻辑

基本逻辑

单点登录与普通的单应用认证不同。简单的来讲,用户账户的管理权不在各个应用上,而是统一由一个中心化的认证服务管理,即实际上各个应用一般没有独立完整的鉴权系统。
一般情况下用户的密码不会直接向用户欲访问的服务传输,而是要求应用将用户重定向至一个受信的单点登录登陆页,在用户确认登陆后再将其重定向至指定的受信回调地址,同时,在重定向时会同时附带名为”Token”的 GET 参数以便应用程序进行下一步的鉴权。

图:Muna Identity 的登陆页面

同时,单点登录页面一般还需要同时实现“记住密码”等必要功能。

上图:单点登录页首次登陆流程

令牌逻辑

在一个完整的鉴权过程中,服务器至少需要接收来自两方的数据请求,一方为客户端,另外一方则为用户所登录的应用程序。
在此过程中,我们通常会使用 Token 和 Session 来实现,你可以简单的理解为 Token 是在请求过程中传递的,而传递 Token 的作用是为了得到一个持久化的 Session(即会话)。
假设 A 程序需要对用户鉴权,用户只需要向应用程序发送其有效的 Session 字符串,应用程序会通过 API 验证该 Session 是否有效并同时得到 Session 所属用户的相关信息,若有效则认证成功,响应请求。

此外,若用户重置了密码,其 Session 也会被挂起以等待下次登陆时重新生成并启用,在此期间旧的 Session 将无法使用,故使用 Session 登陆可以确保用户的账号密码等敏感数据不被传输至第三方应用,同时还可以对整个认证过程进行更加精细化的控制。
不过你可能还会发现,如果每个用户只有一个 Session 的话,似乎无法登出某个特定的应用程序,只能选择全部登出。为了解决该问题,我在编写代码时使用了 主Session + 应用Session 结合的方式,每一个用户有一个主Session 和 无数个应用Session,在获取应用Session时,程序会将新生成的应用Session、主Session和应用识别码一同写入数据库;在校验应用 Session 时,会先检查当时写入的主 Session 和用户目前的 主Session 是否一致,若一致则再进行下一步操作。同时因为用户在进行修改密码、登出全部设备等操作时会重置主Session,而应用Session又依赖于主Session,如此就达到了我们的目的。
另外,在某些认证中,ObjectID 也是过程中重要的一环,该字符串在整个数据库的用户表中必须是唯一的,你可以将其简单理解为是用户的识别代码。

同时,我们还需要对来自应用程序的API请求进行鉴权,不过该部分鉴权比较简单,同普通需要鉴权的API一样,我们只需要要求应用程序在请求API时附带“AppID“和”AppKey“的请求头或请求体即可完成鉴权。
又因为应用Session的设计(即每个应用程序只能使用自己请求用户登陆授权而生成的应用Session),故我们可以非常精确的对每个用户的授权及使用情况进行溯源。

单点登录页(及记住密码)逻辑

在单点登录页中,我们显然不能将用户的账号密码直接存储在浏览器的Cookie或存储中,故我们需要引入一个新的概念——”rememberToken“来代表用户的登陆状态
为了方便理解,你可以将“rememberToken”理解为一个拥有更大权限、更长生命周期的 Session,即将单点登录页也理解为一个应用程序,该应用程序拥有与其他程序不同的权限——”即生成可供其他应用程序登陆使用的 Token“,而要获取 rememberToken,则需要用户向认证服务的后端发送其账号密码。
同”应用Session“一致,“rememberToken“也应该依赖于主Session以便在用户修改密码或登出设备后失效。

理论上而言,该登陆逻辑对于仅登陆一次的临时登陆场景较不友好,故没有将“不记住密码”作为额外选项向用户提供,但因现代浏览器普遍提供了“访客”登陆模式,且该模式对于临时登陆用户而言更加安全,故如用户有“不记住密码”的需求,可以通过前端指引用户打开其浏览器的“访客”或“无痕浏览”模式(案例:Google Account)。

此外,你可能已经注意到了图中的“SafeKey“参数,该参数将用于校验二步验证,详见下文。

权限控制

从应用程序的层面来讲,只需要维护一个用户ObjectID的数据表并在每次鉴权时与请求用户的 ObjectID进行比对即可完成对特定用户进行黑白名单的权限控制。
但从鉴权系统的角度而言,我们可以在用户登陆应用程序和应用程序获取用户信息时对用户的权限进行控制,如 U1 用户没有访问 A 应用的权限,则其尝试登陆时则无法返回对应应用程序的 Token,即无法登陆;同时,如 U1 用户在其权限被修改前就已经登陆了 A 应用,则当 A 应用使用 U1 用户所提供的 应用Session 鉴权时,会因权限不足而导致鉴权失败,即无法登录。

信息授权

一般情况下,后端数据库中会存储很多用户数据,如用户的 Email 地址、真实姓名及职务等,但这些数据对于一些应用程序来讲并不是必要的,故我们可以要求应用开发者自行选择其需要用户向其授权访问那些数据。
从逻辑上来讲,应用程序不能访问其没有请求授权的任何数据,对于一般应用而言,仅需 ObjectID即可对用户完成基本的鉴权。
同时,应用所请求的数据应该在用户登陆该服务时向用户展示:

需要注意的是,在这期间很容易存在一个安全漏洞,即用户在应用请求 A 权限时登陆了应用,但应用在用户登陆后修改了其需要的权限为 A、B 权限,这时应用则可以使用之前只授权了 A 权限的应用 Session 访问 A和B 两个权限。
对于上述问题,可以通过一个数据库自增字段“appVer”(应用版本)来解决,当获取应用Session时,同时存入现在应用的“appVer”,当应用使用应用Session获取用户数据时,校验数据库中的“appVer”与当前的“appVer”是否一致,如不一致则为应用已更新,则挂起 Session 直至重新授权。

安全逻辑

防重放、防篡改的复合加密算法

这是一种复合加密算法,密文由对称加密(AES-256-CBC)与非对称加密(RAS)组合加密而成,可以通过非对称加密的方式同服务端协商 AES 加密密钥,通过对称加密的方式加密请求体中的大数据,同时在服务端回传数据时也会使用请求时所协商的 AES 密钥进行返回数据的加密。通过此套加密算法,可以实现数据往返加密,且每次加密所使用的密钥均为随机的 32 位字符串。

通过与 Nonce 的结合,该请求方式可以做到防篡改、防重放、防劫持,即使中间人劫持了网络请求,因数据经过加密。故其无法获取请求信息也无法获取返回数据的明文,同时因为结合的 Nonce 的校验,该请求无法被任何人重放。

该内容来自 《Muna Identity 安全白皮书》

密码逻辑

通常而言,任何安全系统都不是无懈可击的,故对用户的一些敏感数据(特别是密码)进行不可逆的加密后再进行存储在任何时候都应该是必须的,因为这样可以在数据库泄露时将用户损失降至最低。
你可能会问,我的项目使用Z不可逆的加密算法(如MD5)来加密我用户的密码,为什么我还需要用到“”?这是因为随着现代计算机的算力提升,较短的 MD5 密文已经被破解的差不多了,即通过暴力枚举的方法将几乎所有字符排列组合成的字符串都存储到一个数据表中,并将其密文和明文对应起来(即彩虹表),以达成通过密文查询明文的要求。
但如果我们使用了“”,那么上面的彩虹表对于我们而言就几乎失效了,当然这取决于“盐”的长度,比如下面就是一个无效“盐”的示例。

这是因为上图所采用的盐位数过短,导致攻击者可以连同salt一起在彩虹表中匹配,虽然有时攻击者可能无法获取到我们所使用的盐,但面对大量的用户密码数据,其总可以在其中找到盐与盐的规律。
故对于一般服务而言,对每一个用户都使用不同的 Salt 是一个最佳实践,因为如果所有用户均使用同一个 Salt且有几位用户的密码是相同的,那么其密码的密文也是相同的,这可能会被黑客利用。
如果你目前希望开发新项目,那么**盐位数的最佳值是 16字节(即128个字符)**,因为128字符的盐对于现在以及可预见的将来而言是绝对安全的。
同时,在撒盐时,一般有如下两种常用方法:

当盐足够长时,两种撒盐方式的安全性几乎是一致的,当盐的位数较小时,采用交叉撒盐是最安全的方式;对于交叉撒盐而言,因为各用户密码长度不一,故将密码计算成32字符的MD5后再进行撒盐是解决该问题的一个方式。
在“Muna Identity”的设置密码流程中,用户的密码一共被计算了两次哈希值,其中原始密码计算哈希后再撒盐是为了使后续用户登陆时可以不再传输密码明文,而是先在本地计算密码的哈希值后再传入服务器。

上图:设置密码流程

因为在设置密码时需要校验密码是否合法,故必须传入明文密码。
此前腾讯QQ因设置密码时只在前端进行校验,故出现了可以设置空白密码的BUG。

上图:密码校验流程

二步验证

二步验证就是我们俗称的“验证码”,其可以通过要求用户在新设备上登陆时进行额外的验证来达到保护账号安全的作用。
一般我们可以向用户提供 TOTP(基于时间的动态验证码,又可以细分为:电子邮件验证码、手机验证码、基于身份认证器的离线验证码)、或Passkey(安全密钥、Windows Hello 或 Android Passkey)等认证方式,一些场景中也可以通过客户的 IP 地址来确认是否允许跳过二步验证(如企业内可信IP地址)。
在“Muna Identity”中,二步验证通过一个单独的 API 来实现,当用户输入账号密码试图登陆账户时,若账户开启了二步验证功能,API 将会返回特定的响应码并附带当前账户支持的二步验证方式,此时前端将会指引用户选择并输入相关验证码(如为邮件或手机等需要发送的验证码,会有请求发送的额外步骤),二步验证完成校验后前端会在相应用户的 Profile Cookie 中存储一个由后端返回的“SafeKey”参数,此参数将会在今后登陆时同“rememberToken”一同传输至后端以证明当前客户端通过了二步验证。

前端

国际化

对于大部分网站而言,在开发时同时设计一个英文版本是必要的。
对于前端的国际化适配,有很多实现方法,Muna Identity 通过多文件的方法来进行国际化适配(如英文网站为 /en,日文网站为 /ja)这种方法可能会使每次前端更新变得比较繁琐,但其可以保证网站更快的加载速度,还可以针对不同语言(或传统)的人群设计不同的网页操作逻辑和网站设计,如在每年的圣诞节,英文网站可以增添圣诞元素;或在春节为简体中文网站增添新年元素。
不过需要注意的是,如果仅仅为了对更多语言的支持而不注重翻译质量,对于用户而言是没有意义的
除了网页本身的语言外,一些接口所响应的提示信息也需要被翻译为当地语言,对于此需求,Muna Identity 维护了一张专门存储 API 响应各语言译本的数据表,如下图:

当前端收到 API 的响应请求后,会将其响应体中的 “code” 发送至该 API 以获取用户语言的响应信息译本:

为了减少对数据库的占用和响应延迟,这些热点数据应该被放在Redis等基于内存的数据库中并设定较长的 TTL 或永不过期以确保用户在访问时API的响应速度。

无障碍设计

Web 从根本上是为所有人设计的,无论他们的硬件、软件、语言、位置或能力如何。当 web 达到这一目标时,具有不同听力、运动、视觉和认知能力的人都可以访问它。—— 源:《Accessibility - W3C》 | 译:《Accessibility - Mozilla developer》

在编写前端页面时,“无障碍设计”是一门重要的学科,以下是一些对无障碍开发有帮助的链接:
https://www.w3.org/mission/accessibility/
https://developer.mozilla.org/zh-CN/docs/Web/Accessibility
https://developer.chrome.com/docs/lighthouse/accessibility/scoring
https://www.woshipm.com/pd/5838324.html
如果你希望对网站的无障碍设计进行评估,你可以使用浏览器开发者工具里 Lighthouse 中的 Accessibility 选项对网站的无障碍设计进行全面、自动的评估。
无障碍的网站设计可以使视力较差的视弱甚至失明用户也能使用网站,比如为每个图片设定相应的 “alt” html 参数可以帮助一些网页读屏工具读出图片的内容;同时基于无障碍原则的页面设计一般使用较高的文本对比度,可以使普通用户的阅读也更加省力和流畅。
在现代网站的设计中,无障碍设计已经成为评定网站质量的重要标准。

针对欧洲的用户

虽然此前英国已完成脱欧程序,但现阶段为了防止出现法律风险,一般还对英国用户施行与欧盟用户相同的隐私处理原则。

欧盟于 2018 年 5 月 25 日颁布了号称“史上最严隐私保护法案”的《通用数据保护条例(General Data Protection Regulation)》(Regulation (EU) 2016/679),该条例一经实施便有数家大型跨国互联网公司以规避法律风险为理由暂停了对欧盟地区用户的服务直至完成对部分服务的整改,可见该法案之严厉。

或许你在访问一些网站时会发现一些类似于如下样式的 Cookie 使用提示横幅:




这是因为 GDPR 规定了,当网站使用了来自第三方(第三方追踪器、第三方广告分析器等)的 Cookie 时,网站必须向访问者展示一个以“网站使用了 Cookie 技术”为主旨的提示性横幅,该横幅只需要在用户第一次访问网站时向用户展示,并且需要包含服务隐私政策的链接。
更好的一种做法是向用户提供一个单独的设置页面,允许用户自行决定开关哪些 Cookie,但这一般会面临很大的工作量。
目前海外已经有专门做 Cookie 合规方面的公司了,比如“Cookieyes”等服务可以让开发者花费较少的时间来实现上述功能。

结束语

Muna Identity 的开发及该文档的撰写得益于各技术文档、开源项目及专家的帮助,在此深表谢意。
本文的部分流程图、示意图使用”飞书文档”制作,推荐一下(不是广子!)。

Author

芙樱竹

Posted on

2024-01-13

Updated on

2024-06-24

Licensed under

Comments

若您使用我站的"评论"功能发表观点,则代表您已阅读并同意遵守 ICUA协议隐私政策
评论内容支持基本 Markdown 语法及部分 HTML 标签;为保证隐私安全,所有涉及如图片、视频或网页内嵌等外部资源引用的 HTML及Markdown 标签都会被自动删除,所有链接均会被转换为纯文本格式。