ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从零自建统一身份认证服务:OAuth2.0与单点登录的工程实践

从零自建统一身份认证服务:OAuth2.0与单点登录的工程实践 做了这么多年后端最烦的往往不是业务本身而是每个项目都要把登录注册、找回密码、权限校验这套东西从零重写一遍。Spruce ID 这个项目算是我实在受不了这种重复劳动之后逼自己把一套统一身份认证服务真正落地的一次实践。它解决的核心问题很简单一套账号体系接走所有内部应用新项目上线不用再关心“用户怎么来、密码怎么存、会话怎么管”。这篇文章把整个项目的设计思路、授权流程、核心实现和安全加固过程都摊开讲包括我踩过的坑和排查方向希望对准备自建 ID 系统、做单点登录的技术同行有点参考价值。1. 项目缘起为什么我会自己造一个“ID 系统”1.1 到处复制登录代码的痛过去两三年我所在的团队一共维护了六个业务系统包括后台管理、小程序 API、对外平台和几个内部工具。早期各自独立开发每个系统都有自己的用户表、自己的注册流程、自己的 token 校验逻辑。表面看相安无事实际上问题越攒越多同一个员工在不同系统里可能是完全不同的账号密码策略五花八门有的系统甚至还在用明文存 MD5。每次做一个新应用最消耗时间的就是把那套“注册—登录—改密—校验”的代码从旧项目里拷过来再改表名、改字段、改 Redis 键名一整套搞下来没有两三天下不来。这不是个别团队的困境。只要系统一多账号割裂就会带来三重代价首先是用户体验差用户要在不同平台反复注册和记忆密码其次是安全隐患大老系统没人愿意动密码存储和会话管理还停留在十年前的水平最后是管理成本高离职人员的账号在一个系统里删了另一个系统里还留着等出事了才发现。Spruce ID 的出发点就是把这些重复劳动统一收口让身份认证变成一项独立的基础设施。1.2 为什么不直接用现成平台当时也认真评估过直接用第三方认证平台或者开源套件。现成方案确实成熟协议标准、社交登录、管理后台都是现成的能省掉不少工作量。但最终决定自己写有几个很现实的原因。一是数据控制。用户信息、登录日志、密码散列这些属于核心敏感数据团队希望全部保存在自己的数据库里不经过任何第三方链路。二是定制自由度。我们的权限模型比较特殊包括多组织、数据权限和审批流联动这些在通用认证平台里往往只能做“半定制”遇到需求变更是要等对方排期的。三是内网隔离场景。部分系统跑在隔离网络里根本无法访问外部的认证服务自建一套反而是最稳妥的选择。当然这里不是劝所有人都自建而是说在数据敏感、定制多、网络受限的场景下自建统一认证确实有不可替代的价值。如果你们只是个普通官网或者纯 To C 产品第三方的 OAuth 登录反而更省心。做这个决策时一定要看到底卡在什么地方。提示自建 ID 系统的前提是你们有专职后端负责安全维护。人员不足、又要快速上线的团队建议先用成熟的认证平台把业务跑起来后期再考虑迁移。1.3 Spruce ID 的目标边界这个项目从第一天起就定了三条边界。第一只做认证不做“用户全生命周期管理”注册、登录、改密、找回、令牌颁发与校验是全部核心功能像 CRM 里的用户画像、行为轨迹、积分体系都不碰。第二用标准协议对外提供服务以 OAuth 2.0 和 OpenID Connect 为基准让新应用可以用标准方式接入不搞私有协议。第三单机部署也能跑通但不牺牲安全底线该做的加密、限流、审计一个都不能少。这三条边界对后续开发帮助极大每次遇到需求摇摆我都会拿这三条出来对照这个功能算不算认证不符合协议怎么做变通只有一台机器能不能撑住答案不合适就果断砍掉或延后。事实证明边界清晰是个人项目能按期落地的最大保障。如果一开始就想做一个“囊括一切用户能力”的大而全系统我估计现在还在写需求文档。2. 技术选型与整体架构设计2.1 技术栈为什么这么定Spruce ID 的技术栈最终选定了 Node.js 和 TypeScript搭配 Express数据层用 PostgreSQL 和 Redis。选择 Node.js 不是因为它是最潮流的技术而是整个团队对 JS 全栈最熟其他业务系统也都是 TS 写的身份认证服务作为基础设施代码的可读性和可维护性优先级最高。TypeScript 在这里带来的价值远不止类型提示它能把 OAuth 请求上下文中那些容易写错的联合类型在编译期就拦住比如授权类型、令牌类型、错误码这些字段跑测试之前就能排除一大批低级 bug。PostgreSQL 在这个项目里不只是普通关系库在用它的 JSONB 类型对存储第三方应用的配置信息非常友好。每个接入 Spruce ID 的客户端应用要支持哪些回调地址、哪些授权类型、哪些权限范围用 JSONB 一存既直观又灵活不必为每个新特性加一张关联表。Redis 则承担了三件事验证码和限流计数的临时存储、刷新令牌的画像存储、以及分布式环境下会话中心的快速访问。这三类数据的共同点是时效性强、不需要持久化事务放进 Redis 再合适不过。2.2 核心数据模型怎么设计用户表是认证服务的基本盘主要字段包括用户 ID、登录名、邮箱、手机号、密码散列、状态、多因素密钥、最后登录 IP 和时间。这里值得强调的是登录名与用户 ID 的分离登录名允许修改但用户 ID 一旦生成就不变所有应用对外的用户标识都应该用用户 ID否则会出现改个邮箱导致关联数据错乱的惨剧。客户端应用表是 OAuth 体系里的重头戏。每条记录代表一个接入方字段包含客户端 ID、客户端密钥、应用名称、类型第一方还是第三方、回调地址白名单、授权类型、权限范围、头像地址和管理员邮箱。客户端 ID 一般用随机生成的字符串客户端密钥则用足够长的随机序列并且只能完整显示一次后面就是“看过即焚”。密码散列存储同样适用于客户端密钥不能把明文直接放进数据库。令牌相关的表设计是我反复权衡过的点。JWT 格式的访问令牌自带过期时间和用户信息服务端不落库校验时验签即可天然支持横向扩展。刷新令牌则必须落库因为它生命周期长需要能被撤销和轮换。授权码也必须短期落库因为它要绑定具体用户、具体客户端、具体跳转地址任何一步对不上都不能换令牌。最后再加上审计日志表记录每次登录、登出、授权、失败的请求信息这是事后追溯和安全分析的基本素材。2.3 一次登录背后的完整流程Spruce ID 的授权流程走的是 OAuth 2.0 里的授权码模式这是目前 Web 场景里最稳的一种。用户在小程序或网页发起登录该页面拼好授权链接后跳转到 Spruce IDSpruce ID 确认用户登录态未登录就先让它登录用户看到授权确认页明确选择同意给某个应用开放哪些信息Spruce ID 生成一个一次性授权码通过重定向返回给调用方调用方拿授权码在后端换访问令牌和刷新令牌之后访问令牌随着 API 请求走。这个流程可以用门卫和访客手环来类比你用户走进园区门卫Spruce ID先确认你是谁你想进的办公楼业务系统要验证有没有权限门卫给的不是万能钥匙而是一张限定楼层的临时手环授权码你只能通过这个手环去前台换正式的通行证令牌。每次通过授权码换令牌时后台都会校验授权码是否过期、是否属于当前客户端、回调地址是否白名单内任何一个不匹配整个流程直接中断。这套流程最大的好处是一开始各种敏感信息就不在浏览器端流动令牌只在后端之间传递被截获的窗口被压缩到很小。3. 核心实现认证功能是怎么一步步跑通的3.1 注册、密码存储与找回的安全底线注册逻辑本身并不复杂但有几处细节一漏就是大坑。我在 Spruce ID 里对用户名和邮箱做了双重唯一约束数据库层面加唯一索引应用层再校验一次防的是并发请求下两套校验互相“绕过去”。注册接口必须做验证码校验当时做的是图形验证码加邮件验证码双保险后来从运维角度看邮件验证码的实际拦截效果远好于图形验证码至少挡住了绝大多数脚本批量注册。密码存储这一块我直接选的 bcrypt代价设为 12。关于代价参数多说一句代价太低GPU 跑字典攻击分分钟破给你看代价太高合法用户的登录延迟又会超过可接受范围。在我们测试机上cost12 的单次哈希耗时大约在 180 到 250 毫秒这个延迟在登录场景完全能接受还能有效拖慢暴力破解。bcrypt 一个天然优势是内置随机盐相同的密码在不同用户记录里的散列值完全不同不会出现彩虹表一把梭的情况。有条件上 argon2id 的话更稳它在抗 GPU 并行破解上比 bcrypt 更强但对部署环境有一定要求。密码找回是我单独花了一整天做的模块核心原则是“不让修改密码的入口成为账号被篡改的突破口”。做法是用户提交邮箱后系统生成一次性重置令牌带 30 分钟过期时间只有拿到这个令牌的人才能进入设置新密码的页面。重置成功后立即吊销该用户在所有会话里的刷新令牌并要求重新登录。这么做是为了防止一种攻击场景攻击者在用户重置密码之前已经偷到了一个有效会话如果重置后不吊销旧令牌攻击者就能继续使用那个会话。很多系统漏掉的就是这一步。注意凡是涉及密码修改、密码重置、邮箱换绑这类敏感操作成功后必须强制撤销旧会话否则你的重置流程等于给攻击者留了一个后门。3.2 双令牌体系Access Token 和 Refresh TokenSpruce ID 的访问令牌采用 JWT 格式有效期设为 15 分钟。时间这么短的原因很简单JWT 一旦签发在它的自然过期之前服务器无法主动让它失效如果把有效期设成 7 天就意味着用户万一手机丢了攻击者拿到的令牌能继续用一个星期。短有效期让令牌泄漏这个风险的影响面被压缩到很小。每个 JWT 里我固定放了这些 claim用户 ID、客户端 ID、会话唯一标识、权限范围、签发时间和过期时间。特别注意不把邮箱、手机号等敏感信息塞进 JWT因为 JWT 默认只是 Base64 编码不是加密任何人拿到都能直接解出来看加签只能防篡改不能防泄露。刷新令牌的有效期我设为 14 天并实现强制轮换每次用刷新令牌换取新的访问令牌时旧的刷新令牌立即失效下发一个新的刷新令牌。这样如果攻击者偷走了刷新令牌而合法用户下一次刷新访问令牌时系统会发现这个刷新令牌已经因为轮换失效就能及时判为“盗用”拒绝后续请求并撤销整个会话。刷新令牌在服务端保存时我存的是 SHA-256 哈希值不是明文本身防止数据库泄露后刷新令牌被直接批量利用。整个双令牌体系跑通后我还做了一件事将会话统一登出。用户退出时不仅删除本地刷新令牌记录还要做一次“会话级退出”即把该用户所有应用下的会话全部吊销。这靠的是一个隐藏的会话 ID系统里所有关键动作都和这个 ID 绑定。很多系统的退出只是删一下本地的 token结果用户在 A 应用退出登录了B 应用里还是登录状态这在我们内部场景是不能接受的。3.3 单点登录与跨域会话打通单点登录是 Spruce ID 最直观的价值体现。用户在任何一个接入应用里完成登录之后再访问另外的应用就不需要重复输入账号密码。原理说起来不复杂登录成功后Spruce ID 在统一域名下种一个会话 Cookie后续所有接入应用跳转回 Spruce ID 时这个 Cookie 会被携带上认证服务据此判断“这个浏览器里已经有了登录态”直接放发授权码。但这里有个容易踩的细节Cookie 的域和路径必须规划好。内部系统如果都跑在同一个主域下面比如 a.example.com 和 b.example.com那 Cookie 的域可以设成 .example.com实现跨子域共享。如果应用域名完全不一样比如一个是 example.com一个是 example.org那就没法靠 Cookie 来实现单点登录只能靠刷新令牌在服务端关联登录态要等到用户去对方应用点击登录时再回来验证一次。我们在 Spruce ID 里两种场景都做了处理统一域名的走 Cookie 自动识别跨域名的走刷新令牌交叉校验。会话 Cookie 本身的安全参数也值得反复检查。HttpOnly 必须开这样脚本拿不到Secure 必须开强制 HTTPS 传输SameSite 我设置成 Lax兼顾单点登录跳转兼容性和 CSRF 防护。为了做一个完整的单点登录我还实现了登录态自动续期用户只要在任何一个接入应用里活跃整个统一登录态的过期时间就会顺延避免用户在用着 A 应用时B 应用因为 Cookie 过期突然要重新登录。3.4 新应用接入有多省事接入方这一端Spruce ID 提供的是标准 OAuth 中间件。可以用一个通用 SDK 包SDK 内部封装了授权链接生成、回调处理、令牌刷新和用户信息获取接入方只需要配置客户端 ID、客户端密钥和回调地址即可。整个接入过程已经在我们内部实践过多次一个新应用从开始接入到跑通登录平均大概只花一个小时。集成时的核心代码如下能看到无非就是三板斧import { SpruceClient } from spruce/sdk; const client new SpruceClient({ clientId: process.env.SPRUCE_CLIENT_ID, clientSecret: process.env.SPRUCE_CLIENT_SECRET, redirectUri: https://app.example.com/auth/callback, scope: openid profile email }); // 第一步构造跳转登录的URL app.get(/login, (req, res) { res.redirect(client.getAuthorizationUrl()); }); // 第二步在回调中换令牌并读取用户信息 app.get(/auth/callback, async (req, res) { const token await client.exchangeCode(req.query.code); const userInfo await client.getUserInfo(token.accessToken); // 拿到 userInfo 后建立应用本地会话 }); // 第三步需要退出时统一登出 app.get(/logout, (req, res) { res.redirect(client.getLogoutUrl()); });SDK 之所以能压到这么简单的接入体验是因为复杂逻辑都收敛到了认证服务端。中间件内部实现了授权码自动过期检查、令牌自动续期、刷新令牌丢失后的重建甚至处理了用户在被授权后又被封禁的边界情况。接入方拿到的 userInfo 永远是标准结构包括用户唯一 ID、显示名称、邮箱、头像其他字段一律不给从协议层面限制数据越权获取。4. 安全加固与实战踩坑4.1 暴力破解防护和令牌盗用识别凡是认证服务暴力破解都是第一道要防的墙。Spruce ID 在登录接口上做了两层防护第一层是 IP 维度限流同一个 IP 一分钟内最多允许 15 次登录尝试超过就触发图形验证码第二层是账号维度锁定同一个用户名一天内连续失败 10 次就锁定 15 分钟期间无论密码正确与否都拒绝登录。锁定的提示要模糊不能直接告诉攻击者“账号存在但锁定了”要说“用户名或密码错误”否则等于免费帮攻击者枚举账号。令牌盗用识别是我后期加的一个关键功能。因为双令牌做了刷新令牌轮换攻击者拿到刷新令牌后只有两种结局要么赶在合法用户刷新之前用掉要么在合法用户刷新之后触发“重用检测”。重用检测的逻辑是每个刷新令牌都有一个家族 ID当系统接收到的刷新令牌已经不在最新一代时就把这个令牌所属的整个家族全部标记为风险所有关联会话立即撤销同时要求用户重新登录。这个机制上线后我们实际捕获过几次从日志里看到的可疑轮换行为基本都是测试脚本干的但足以证明规则有效。4.2 开放重定向与回调地址校验OAuth 系统里有个非常经典但容易被忽略的漏洞开放重定向。攻击者构造一个授权链接把回调地址指向自己的钓鱼服务器用户一旦点击授权码就会从 Spruce ID 转发到钓鱼服务器上然后攻击者就能换上用户的令牌。Spruce ID 的防护措施是在注册客户端时强制登记回调地址白名单位匹配协议上不能是任意第三方域名协议必须匹配域名必须是完全匹配白名单中的整条 URL。我把回调地址校验实现成“前缀白名单 协议强制 域名强制”三层。第一层请求带来的回调地址必须命中白名单前缀比如白名单里放的是https://app.example.com/callback那么回调地址必须完完全全等于这条 URL不允许加子路径或改参数。第二层协议强制只能是 HTTPS开发环境可配置宽松但生产环境直接拦截 HTTP。第三层如果发现回调地址指向内网地址比如http://127.0.0.1直接拒绝对应的客户端。这三层配合起来后开放重定向和 SSRF 类攻击基本没有操作空间。4.3 我实际踩过的三个深坑第一个坑是 JWT 时间校验的时钟偏差。有一次内部系统突然弹出一批“令牌无效”的报错排查半天才发现是认证服务器的系统时钟比应用服务器的快了大概 30 秒。JWT 校验时如果签发时间和当前时间对不上会返回“令牌未生效”的异常看起来就像是令牌被篡改。后来我在 JWT 校验库配置里加了 60 秒的时钟偏差容差同时在部署文档里写清楚各服务器必须同步 NTP 时间。第二个坑是 Cookie 容量限制。最开始的版本曾经尝试把整个 JWT 直接塞进会话 Cookie结果用户权限范围一多JWT 长度迅速膨胀很快超过了浏览器 Cookie 单条 4096 字节的容量上限整个会话静默失效。最后不得已改了方案Cookie 里只放一个随机会话 IDJWT 存 Redis用会话 ID 去换虽然每次请求要查一次 Redis但换来的是稳定性和灵活度。这个取舍我认为非常值。第三个坑是刷新令牌轮换初版做得太机械忘记考虑并发刷新。手机端多请求同时刷新时两个并发请求拿同一个旧刷新令牌去换新令牌其中一个必然失败导致用户被强行登出。后来引入了刷新令牌的原子替换机制并发场景下允许短时间内同一个旧令牌被换取两次但立即标记为可疑正常请求不受影响可疑请求重新要求登录。这种“容忍但不放过”的策略比一刀切拒掉要好得多。4.4 常见问题速查现象可能原因排查与解决授权码换令牌失败授权码已过期、被重复使用授权码有效期设 10 分钟一次性使用看日志里是否有相同 code 二次提交登录后回调地址不匹配白名单配置少写了端口或路径核对客户端白名单是否和生成授权链接时完全一致某一时刻大量会话失效服务器时钟不同步检查 NTP 同步状态校验库配置 clock skew刷新令牌报“已在别处登录”并发刷新触发轮换冲突启用原子替换机制允许短时间并发但标记可疑应用回调里拿不到用户信息scope 没包含 openid授权链接必须带 openid scope否则不返回标准用户信息会话一直无法单点登录Cookie 域配置不一致确认所有应用和认证服务的 Cookie 域是同一主域且 SameSite 设置合理5. 部署运维与后续演进5.1 单机也能跑容器化是标配Spruce ID 从一开始就为容器化部署做好了准备。docker-compose 编排了四个服务认证服务本体、PostgreSQL、Redis、Nginx 网关。Nginx 负责终结 TLS把请求转发给认证服务同时承担一部分简单的频控。整个系统对硬件要求不算高日常日均几万次认证请求的场景两核 CPU 和 4GB 内存完全够用。services: spruce-auth: image: spruce-id/server:1.4.0 environment: DATABASE_URL: postgres://spruce:secretdb:5432/spruce REDIS_URL: redis://redis:6379/2 JWT_PRIVATE_KEY_FILE: /run/secrets/jwt_private.pem JWT_PUBLIC_KEY_FILE: /run/secrets/jwt_public.pem COOKIE_DOMAIN: .example.com depends_on: - db - redis db: image: postgres:16-alpine environment: POSTGRES_DB: spruce POSTGRES_USER: spruce POSTGRES_PASSWORD: secret redis: image: redis:7-alpine gateway: image: nginx:1.26-alpine ports: - 443:443有几个配置细节值得特别说明。JWT 的签名密钥我采用的是 RSA 非对称密钥对私钥放在认证服务里公钥分发给需要验签的接入方。密钥文件不通过环境变量直接传而是走 Docker Secrets这样密钥不会出现在容器配置明文里。生产环境还有一个必须开的开关强制 HTTPS所有到认证服务的 HTTP 请求都 301 到 HTTPS。如果部署负载均衡器之后忘了配转发协议头可能会导致回调地址被判定为 HTTP进而出各种诡异问题。5.2 高可用与多实例扩展等到认证服务要支撑多个集群应用时单实例部署是会遇到瓶颈的。Spruce ID 的多实例横向扩展依赖一个前提认证状态全部外置到 Redis 和 PostgreSQL业务进程本身不保留用户状态。满足这个前提后后面挂多少个实例都无所谓Nginx 或负载均衡器按轮询分发即可。不过要留意一个细节JWT 的公钥分发不能每个实例一套要用共享密钥或集中分发否则不同实例验签结果会互相矛盾。Redis 在这时候就不再只是缓存了它是整个会话体系的中心。为了可用性我给 Redis 加上了主从和哨兵认证服务的 Redis 客户端配置了自动故障切换。数据库层同样做了一个最简但有效的高可用方案PostgreSQL 主从加流复制主库故障时手动切换切换后应用层自动重连新主库。这套方案虽然没有做到自动故障转移但结合我们内部的监控告警已经把认证服务整体可用性拉到了 99.9% 以上。提示身份认证服务的扩展不等于无脑加机器。先把 Redis 和数据库的高可用搞定再谈应用实例的横向扩展顺序反了会越扩越乱。5.3 后续演进的方向Spruce ID 目前已经能支撑日常业务但我的规划里还有一些明确的演进方向。第一步是多因素认证近期计划优先支持 TOTP 动态验证码下一阶段再考虑 WebAuthn 硬件密钥。多因素认证对自建 ID 系统来说不是可选项尤其是管理员账号必须强制开启不然认证服务一旦被突破后面所有系统都会跟着沦陷。第二步是精细化权限范围管理。目前权限范围还停留在粗粒度后面要做成资源级别的授权模型让接入方可以精确申请“只读用户资料”“只写用户昵称”等细粒度权限。第三步是社交登录互联。既然都做了统一认证再让它承担起“聚合登录”的职能是顺理成章的也就是让用户可以绑定第三方账号之后既能直接用第三方的身份登录 Spruce ID也能在同一个体系内继续使用邮箱密码登录。最后整个系统后面要补充一套更完善的管理控制台让运维人员可以查看实时登录状态、吊销指定会话、查询审计日志而不是只能靠命令行操作数据库。最后再分享一点个人体会。自建身份认证服务最难的从来不是代码实现而是每一步都要把“万一被攻破会怎样”放进考量里。密码散列用强算法、刷新令牌做轮换、回调地址死守白名单、敏感操作后清掉旧会话这些看起来都是小事但组合在一起就是一套 ID 系统能不能在真实环境安全运行的底气。Spruce ID 离完美还很远但如果它能让你在设计自己的统一登录方案时多留一个心眼这篇内容就没白写。
返回列表