ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

基于Cookie的单点登录(SSO)原理、实现与安全实践

基于Cookie的单点登录(SSO)原理、实现与安全实践 1. 从一次登录混乱引发的思考为什么我们需要SSO如果你在一家公司工作大概率会遇到这样的场景早上打开电脑登录OA系统处理请假再打开CRM系统查看客户跟进接着又得登录财务系统去报销昨天的打车费。每进一个系统就要输一次用户名和密码烦不胜烦。更糟的是密码策略要求三个月一换你根本记不住哪个系统用了哪个密码最后只能点“忘记密码”然后花十分钟等邮件重置。这不仅仅是效率问题更是安全风险——为了方便很多人会把所有系统的密码设成一样或者写在便利贴上。这就是“单点登录”要解决的核心痛点。SSO全称Single Sign-On中文叫单点登录它的目标简单而直接一次登录处处通行。你只需要在一个地方比如公司的统一门户完成身份认证之后访问所有接入该SSO体系的应用时就无需再次登录了。这背后不仅仅是用户体验的提升更是企业IT治理和安全管控的基石。想象一下员工离职时管理员只需要在SSO中心注销他的账号就能瞬间切断他对所有内部系统的访问权限这比挨个去几十个系统里删账号要高效和安全得多。在众多实现SSO的技术方案中基于Cookie的SSO是最经典、最直观也是很多自研系统或中小型场景的首选方案。它不依赖于复杂的第三方协议栈其核心逻辑与我们日常的网页浏览体验紧密相连理解它是理解整个Web身份认证与状态管理的基础。今天我们就来彻底拆解这个看似简单实则暗藏玄机的“Cookie SSO”。2. Cookie SSO的核心原理一个“通行证”的故事要理解基于Cookie的SSO我们必须先回到Web的基础HTTP协议是无状态的。服务器无法自动知道两次请求是否来自同一个浏览器。Cookie就是为了解决这个问题而生的它是由服务器发送到用户浏览器并保存在本地的一小块数据。浏览器会在后续向同一服务器的请求中自动携带这个Cookie从而让服务器识别用户身份。基于Cookie的SSO本质上就是利用Cookie的这个“自动携带”特性在多个系统域名间安全地传递用户的登录状态。它的经典架构通常包含一个认证中心和一个或多个业务应用。整个流程可以类比为在一个大型商业园区企业内网里活动认证中心就像园区的总服务台。你第一次进入园区必须在这里登记身份登录服务台核实后会给你一张盖了章的“园区通行证”一个特殊的Cookie。业务应用就像园区里的各个独立店铺OA、CRM、财务系统。它们不负责验明你的身份但都信任总服务台的盖章。通行过程当你试图进入一家店铺时保安业务应用会检查你是否持有“园区通行证”。如果没有他会礼貌地请你先去总服务台办理。你去了总服务台因为之前已经办过服务台直接给你开一张针对这家店铺的“准入条”另一个Cookie你拿着这个“准入条”就能进入店铺了。神奇的是之后你去园区里其他任何店铺都只需要出示“园区通行证”保安看一眼就会放行或者快速地向总服务台确认一下这个过程对你是透明的。把这个比喻翻译成技术流程一个典型的基于Cookie的SSO登录序列如下用户访问业务应用A用户浏览器请求app-a.company.com。应用A检查本地会话应用A检查请求中是否携带了它自己颁发的、标识用户在本应用已登录的Cookie例如app_a_session。如果没有则认为用户未登录。重定向至认证中心由于用户未在应用A登录应用A将用户浏览器重定向到认证中心并携带一个参数表明“登录成功后请回到应用A”。重定向地址类似https://sso-center.company.com/login?redirect_urihttps://app-a.company.com/home。认证中心检查全局会话认证中心检查请求中是否携带了它自己颁发的、标识用户已在全局登录的Cookie例如sso_center_token。如果已存在说明用户已经在其他地方登录过比如刚访问过应用B。认证中心直接进入第6步。如果不存在进入第5步。用户进行认证认证中心向用户展示登录页面用户输入用户名和密码。认证中心验证凭证正确后在服务器端创建全局会话并在用户的浏览器端设置一个由认证中心域名sso-center.company.com颁发的Cookie其值通常是一个加密的、随机的令牌Token用于在后续请求中关联服务器端的全局会话。这是整个SSO体系的“根Cookie”。生成认证票据认证中心知道用户要回到应用A。它会生成一个一次性的、短时效的“票据”Ticket比如一个随机字符串。这个票据与当前用户的全局会话在认证中心服务器内存或缓存中关联起来。重定向回应用A认证中心将用户浏览器重定向回最初请求的应用A并将票据作为参数附加在URL上https://app-a.company.com/home?sso_ticketabc123xyz。应用A验证票据应用A的后端接收到这个带有票据的请求。它不会信任来自前端的票据而是拿着这个票据秘密地向认证中心的一个验证接口发起一次服务器对服务器的后台请求。请求类似https://sso-center.company.com/validate_ticket?ticketabc123xyzapp_secretxxxxxx。认证中心验证并返回用户信息认证中心验证票据的有效性是否存在、是否过期、是否被使用过。如果有效则返回该票据关联的用户唯一标识如User ID、用户名。同时使该票据立即失效防止被重复使用。应用A建立本地会话应用A拿到认证中心返回的用户标识认为用户身份可信。于是它在自己的服务器端为用户创建本地会话并在用户的浏览器端设置一个由应用A自身域名app-a.company.com颁发的Cookie用于维持用户在该应用内的登录状态。登录完成至此用户在应用A的登录完成。同时他的浏览器里已经有了认证中心的Cookiesso_center_token。用户访问应用B当用户再去访问app-b.company.com时重复步骤1-3。在步骤4认证中心检查请求发现浏览器已经携带了sso_center_token这个Cookie于是跳过登录页面直接生成新的票据重定向回应用B。应用B同样通过后台验证票据建立本地会话。用户感知上就是“无需再次输入密码直接进入了应用B”。这个流程的核心安全要点在于业务应用App A/B与认证中心之间的信任是通过后台的、服务器对服务器的票据验证接口建立的。用户浏览器只负责携带Cookie和重定向不参与核心信任的传递。用户密码只在认证中心输入一次之后的所有跨系统认证依赖的是加密的Cookie和一次性的票据。3. 关键实现细节与安全陷阱理解了流程我们来看看实现时有哪些“魔鬼细节”。这些细节处理不好SSO就会变成安全漏洞的集合体。3.1 Cookie的作用域与安全属性Cookie是SSO的载体它的设置至关重要。Domain域名这是实现跨子域SSO的关键。如果公司所有应用都使用同级子域如app-a.company.com,app-b.company.com那么认证中心可以设置在sso-center.company.com。但是默认情况下sso-center.company.com设置的Cookie浏览器在访问app-a.company.com时是不会发送的。为了实现共享我们必须在设置Cookie时指定一个父域。例如将认证中心Cookie的Domain设置为.company.com注意前面的点。这样所有*.company.com下的子域在访问认证中心时都会携带这个Cookie。然而这带来了安全边界扩大的风险需谨慎评估。Path路径通常设置为/表示该域名下的所有路径都携带此Cookie。HttpOnly必须设置为true。这个属性可以防止JavaScript通过document.cookieAPI访问该Cookie这对于防御跨站脚本攻击至关重要。因为SSO的Token是最高权限的凭证绝不能被前端脚本窃取。Secure必须设置为true。这个属性要求浏览器只在HTTPS连接中才发送此Cookie。在当今全站HTTPS的趋势下这能有效防止Cookie在明文传输中被嗅探。SameSite这是现代浏览器引入的、用于防御CSRF和跨站信息泄露的重要属性。它有三个值Strict最严格。浏览器只会在“第一方”上下文即导航来自当前站点中发送Cookie。这意味着从其他网站链接点击过来或者从邮件中点击链接都不会携带Cookie。对于SSO的认证中心Cookie这通常过于严格会导致从外部链接或门户网站跳转到业务应用时SSO流程失败。Lax默认值相对宽松。允许在顶级导航如点击链接且是安全HTTPS的GET请求中发送Cookie。这对于大多数SSO场景是比较合适的因为它平衡了安全性和可用性。用户从公司门户点击应用链接可以正常携带Cookie完成SSO。None允许跨站发送Cookie但必须同时设置Securetrue。这主要用于需要嵌入跨站iframe等特殊场景但会显著增加CSRF风险在SSO中应尽量避免。注意Chrome等浏览器对SameSite的默认策略已从None改为Lax。如果你的老系统依赖跨站携带Cookie必须显式地将SameSite设置为None并确保Securetrue否则SSO会在新版浏览器中失效。这就是热词中“针对chrome对cookie samesite限制的解决方案”所指向的问题。3.2 票据Ticket的设计与验证票据是认证中心向业务应用传递用户身份的临时凭证其设计必须考虑安全。一次性与时效性票据必须在验证后立即失效从缓存中删除或标记为已使用。同时它应该有很短的生存时间TTL比如5-10秒。这限制了攻击者即使截获了票据URL也没时间利用。随机性与不可预测性票据必须使用密码学安全的随机数生成器生成足够长如32字节以上防止被暴力猜测。后台验证这是铁律业务应用必须在服务器端通过一个安全的、内网可达的API将票据发送给认证中心进行验证。绝不可以相信前端传递的任何关于用户身份的信息除了票据本身这个“问题”。验证接口本身也需要认证通常通过预共享的App Key/Secret或IP白名单来实现防止任意系统都能来验证票据。3.3 会话管理全局会话与本地会话认证中心会话全局会话用户在主认证中心登录后创建。这个会话的存活时间决定了用户在整个SSO体系中的登录有效期。通常会将这个会话ID或Token存储在服务端缓存如Redis中并将一个对应的、加密签名的Token作为Cookie发给浏览器。服务端会话的优点是服务端可以主动使其失效如强制下线。业务应用会话本地会话每个业务应用在通过票据验证后独立创建自己的本地会话。这个会话的生命周期可以独立于全局会话进行管理。例如全局会话可能30分钟无操作后过期但某个安全性要求高的应用可以设置自己的本地会话15分钟就过期。当本地会话过期而全局会话仍有效时用户访问该应用会触发一次静默的SSO流程因为认证中心Cookie还在用户无感知地重新建立本地会话。3.4 单点登出Single Logout有登录就有登出。单点登出的目标是用户在一个地方比如认证中心或任何一个应用点击退出所有接入SSO的系统登录状态全部清除。基于Cookie的SSO实现单点登出比较麻烦因为HTTP协议没有提供一种机制让服务器主动通知浏览器删除另一个域下的Cookie。常见的实现方式是用户在应用A点击退出。应用A销毁自己的本地会话并清除自己域下的Cookie。应用A将浏览器重定向到认证中心的登出接口。认证中心销毁全局会话并清除自己域下的SSO Cookiesso_center_token。认证中心需要知道用户登录了哪些应用。因此在每次票据验证成功时认证中心需要记录“用户U登录了应用A”。通常在一个集中的存储里维护一个userId - ListappId的映射。认证中心根据这个列表生成一系列隐藏的iframe或重定向请求指向各个已登录应用的登出接口例如app-a.company.com/logoutapp-b.company.com/logout。用户的浏览器会依次加载这些iframe或发起请求触发各个应用销毁自己的本地会话和Cookie。最后认证中心重定向回一个登出成功页面。这个过程依赖于浏览器能正常发起对各个应用的登出请求并且各应用的登出接口需要能够被认证中心以GET或POST方式触发。在实际中由于浏览器策略如同源策略、第三方Cookie限制和网络环境单点登出并不总是100%可靠常常会退化为“主要系统登出其他系统待会话自然过期”的折中方案。4. 实战构建一个简易的Cookie SSO原型光说不练假把式。我们用一个极度简化的原型来演示核心代码逻辑。这里使用Node.js Express框架因为它足够轻量能清晰表达思想。4.1 项目结构与环境准备假设我们有三个服务sso-center:3000(认证中心)app-a:3001(业务应用A)app-b:3002(业务应用B)为了模拟跨域我们需要在本地hosts文件C:\Windows\System32\drivers\etc\hosts或/etc/hosts中添加127.0.0.1 sso-center.local 127.0.0.1 app-a.local 127.0.0.1 app-b.local然后分别启动三个服务。首先初始化项目并安装依赖# 创建项目目录 mkdir simple-cookie-sso cd simple-cookie-sso mkdir sso-center app-a app-b # 初始化认证中心 cd sso-center npm init -y npm install express redis cookie-parser uuid # 初始化应用A和应用B (步骤类似略)我们使用Redis作为全局会话和票据的存储。确保你已安装并运行Redis。4.2 认证中心实现sso-center/app.js核心代码如下const express require(express); const cookieParser require(cookie-parser); const { v4: uuidv4 } require(uuid); const redis require(redis); const app express(); app.use(express.urlencoded({ extended: true })); app.use(cookieParser()); app.set(view engine, ejs); // 使用EJS模板渲染登录页 // 连接Redis用于存储全局会话和票据 const redisClient redis.createClient({ host: 127.0.0.1, port: 6379 }); redisClient.connect().catch(console.error); // 模拟用户数据库 const users [{ username: alice, password: alice123 }]; // 1. 登录页面 app.get(/login, (req, res) { const { redirect_uri } req.query; // 从业务应用传来的回调地址 res.render(login, { redirect_uri, error: null }); }); // 2. 处理登录表单提交 app.post(/login, async (req, res) { const { username, password, redirect_uri } req.body; const user users.find(u u.username username u.password password); if (!user) { return res.render(login, { redirect_uri, error: 用户名或密码错误 }); } // 创建全局会话 const globalSessionId uuidv4(); await redisClient.setEx(session:${globalSessionId}, 1800, JSON.stringify({ username })); // 30分钟过期 // 设置全局会话Cookie (关键Domain设为 .local 以便子域共享) res.cookie(sso_token, globalSessionId, { httpOnly: true, secure: false, // 本地开发设为false生产环境必须为true domain: .local, // 允许 app-a.local, app-b.local 共享 sameSite: Lax, // 根据实际情况调整 maxAge: 1800000 // 30分钟 }); // 生成一次性票据并重定向回业务应用 const ticket TICKET-${uuidv4()}; await redisClient.setEx(ticket:${ticket}, 60, globalSessionId); // 票据1分钟过期 const redirectUrl new URL(redirect_uri); redirectUrl.searchParams.set(ticket, ticket); res.redirect(redirectUrl.toString()); }); // 3. 票据验证接口 (供业务应用后台调用) app.get(/api/validate, async (req, res) { const { ticket, app_secret } req.query; // 简单验证应用身份 (生产环境应用更安全的方式如HMAC签名) if (app_secret ! SHARED_SECRET_123) { return res.status(403).json({ success: false, message: Invalid app secret }); } if (!ticket) { return res.status(400).json({ success: false, message: Ticket required }); } const sessionId await redisClient.get(ticket:${ticket}); if (!sessionId) { return res.json({ success: false, message: Invalid or expired ticket }); } // 验证成功获取用户信息并立即删除票据 const userInfoStr await redisClient.get(session:${sessionId}); await redisClient.del(ticket:${ticket}); // 使票据失效 if (userInfoStr) { const userInfo JSON.parse(userInfoStr); res.json({ success: true, username: userInfo.username }); } else { res.json({ success: false, message: Global session expired }); } }); // 4. 登出接口 app.get(/logout, async (req, res) { const token req.cookies.sso_token; if (token) { await redisClient.del(session:${token}); } // 清除Cookie res.clearCookie(sso_token, { domain: .local }); // 这里简化处理实际应通知所有已登录应用 res.send(Logged out from SSO Center. a href/loginLogin again/a); }); app.listen(3000, () console.log(SSO Center running on http://sso-center.local:3000));4.3 业务应用实现app-a/app.js核心代码如下 (app-b类似)const express require(express); const cookieParser require(cookie-parser); const axios require(axios); // 用于后台调用SSO验证接口 const app express(); app.use(cookieParser()); // 模拟应用本地会话存储 const localSessions {}; // 首页 - 需要登录 app.get(/home, async (req, res) { const localSessionId req.cookies.app_a_session; const user localSessions[localSessionId]; if (user) { // 本地会话有效直接显示主页 return res.send(h1App A Home/h1pWelcome, ${user.username}!/pa href/logoutLogout/a); } // 本地会话无效检查是否有SSO票据 const { ticket } req.query; if (ticket) { // 步骤8后台验证票据 try { const validationUrl http://sso-center.local:3000/api/validate?ticket${ticket}app_secretSHARED_SECRET_123; const response await axios.get(validationUrl); const data response.data; if (data.success) { // 步骤10票据有效创建本地会话 const newLocalSessionId local-${Date.now()}-${Math.random()}; localSessions[newLocalSessionId] { username: data.username }; // 设置应用自身的Cookie res.cookie(app_a_session, newLocalSessionId, { httpOnly: true, secure: false, // Domain 不设置或设为 app-a.local不共享给其他域 maxAge: 900000 // 15分钟 }); // 重定向到首页清除URL中的ticket参数 return res.redirect(/home); } } catch (err) { console.error(Ticket validation failed:, err); } } // 既无本地会话也无有效票据重定向到认证中心登录 const redirectUri encodeURIComponent(http://app-a.local:3001/home); res.redirect(http://sso-center.local:3000/login?redirect_uri${redirectUri}); }); // 应用自身的登出 app.get(/logout, (req, res) { const localSessionId req.cookies.app_a_session; delete localSessions[localSessionId]; res.clearCookie(app_a_session); // 重定向到认证中心登出实现单点登出 res.redirect(http://sso-center.local:3000/logout); }); app.listen(3001, () console.log(App A running on http://app-a.local:3001));4.4 测试流程启动Redis然后依次启动三个服务。在浏览器中首次访问http://app-a.local:3001/home。浏览器会被重定向到http://sso-center.local:3000/login显示登录页。输入alice/alice123登录。登录后浏览器被重定向回http://app-a.local:3001/home?ticketxxx应用A后台验证票据后设置本地Cookie并跳转到干净的/home显示欢迎页。此时打开新标签页访问http://app-b.local:3002/home。你会发现无需再次登录直接进入了App B的欢迎页。这就是SSO的效果。在任何一个应用点击“Logout”都会跳转到认证中心登出并清除Cookie。这个原型省略了错误处理、HTTPS、更安全的秘钥管理、会话同步等生产级细节但它清晰地勾勒出了基于Cookie的SSO最核心的骨架。你可以在此基础上逐步添加Redis集群、负载均衡、更完善的登录页面、记住我等功能。5. 进阶话题与生产环境考量当你理解了基本原理并跑通原型后就需要面对真实世界的复杂性了。5.1 跨顶级域名的挑战上述方案依赖于共享父域Cookie.company.com。但如果你的应用分布在完全不同的顶级域名下比如oa.company.com和crm.partner.com共享Cookie就失效了。这时基于Cookie的SSO方案会变得非常棘手。常见的变通方法是使用跨域认证携带技术例如JSONP已过时不推荐利用script标签可以跨域的特性让业务应用通过JSONP回调从认证中心获取用户信息。安全性差。Post Message业务应用和认证中心通过window.postMessageAPI进行通信。认证中心登录后将Token通过Post Message发送给嵌入在iframe中或通过window.open打开的业务应用窗口。实现复杂且受浏览器弹窗拦截策略影响。重定向携带Token认证中心登录后直接将Token作为URL hash或参数通过重定向链在多个域之间传递。同样存在Token暴露在URL中的安全风险。当面临跨顶级域时业界更倾向于采用标准的联邦身份协议如OAuth 2.0或SAML 2.0。它们天生为跨域、跨组织的身份联邦设计提供了更标准、更安全的流程。例如你使用谷歌账号登录第三方网站就是OAuth 2.0的典型应用。虽然协议本身比Cookie SSO复杂但有许多成熟的开源实现如Keycloak即热词中的keyclock sso和云服务可以大大降低集成成本。5.2 性能、扩展性与高可用会话存储原型中使用单机Redis。生产环境需要使用Redis集群并考虑持久化策略。会话数据的结构设计也很重要除了用户ID可能还要存储权限、角色等信息。认证中心瓶颈所有票据验证请求都打到认证中心。需要做好认证中心的无状态化水平扩展并通过负载均衡分散压力。验证接口本身要轻量、快速。票据存储票据是短期的读写频繁。可以使用内存缓存如Redis并设置很短的TTL。确保票据的生成和验证操作是原子性的防止并发问题。监控与告警监控认证中心的QPS、响应时间、错误率。监控票据验证的成功/失败率。设置会话数量异常增长的告警。5.3 安全加固防CSRF虽然SameSiteLaxCookie能防御大部分CSRF但关键操作如修改密码、支付仍需使用CSRF Token进行额外保护。防重放攻击确保票据一次性使用且短命。在验证接口中使用Redis的SETEX和DEL命令组合或使用带有原子性检查的Lua脚本确保“验证”和“删除”是一个原子操作。敏感操作二次认证对于高危操作如资金转账、核心配置修改即使有SSO会话也应要求用户再次输入密码或进行MFA多因素认证。定期密钥轮换如果使用了签名/加密Token如JWT或者应用与认证中心之间的通信密钥需要制定定期轮换策略。审计日志详细记录所有登录、票据验证、登出事件包括时间、IP、用户、应用名、结果。这对于安全事件追溯至关重要。6. 常见问题排查与调试心得在实际开发和运维中你会遇到各种各样的问题。以下是一些典型场景和排查思路问题一登录成功但跳转回应用后依然提示未登录。排查链检查浏览器Cookie打开开发者工具F12在Application/Storage - Cookies下查看对应域名下是否有认证中心和应用设置的Cookie。检查它们的Domain、Path、Secure、HttpOnly、SameSite属性是否正确。特别注意Secure属性在HTTPS环境下必须为true在HTTP环境下必须为false否则浏览器不会发送或存储Cookie。检查票据验证请求在Network面板中找到业务应用在收到ticket后向认证中心/api/validate发起的后台请求。查看这个请求的响应是什么。常见错误app_secret不对、网络不通、认证中心服务异常、票据已过期。检查认证中心日志查看认证中心在生成票据和验证票据时的日志确认票据是否被正确生成并存储以及验证时是否成功找到关联的全局会话。检查重定向URI确认应用A重定向到认证中心时传递的redirect_uri参数是否与认证中心重定向回来的地址完全一致包括协议、域名、端口。一个末尾斜杠的差异都可能导致问题。问题二在Chrome新版浏览器中SSO失效但在旧版或Firefox中正常。根因这几乎肯定是SameSiteCookie属性导致的问题。Chrome从某个版本开始将未显式指定SameSite的Cookie默认值从None改为了Lax。解决方案明确设置认证中心Cookie的SameSite属性。如果业务应用和认证中心是同站Same-Site即eTLD1相同设置为Lax通常是安全的。如果确实需要跨站携带例如认证中心是login.company.com业务应用是partner-external.com则必须将SameSite设置为None并且同时必须设置Securetrue即要求全站HTTPS。这就是热词中“针对chrome 100 对 cookie samesite 限制的解决方案”的核心。问题三单点登出不彻底某些应用仍然处于登录状态。排查检查认证中心维护的“用户-已登录应用”列表是否准确。可能在记录或清理时存在并发问题。检查认证中心发出的、通知各应用登出的请求iframe或重定向是否被浏览器成功加载。有些应用如果部署在防火墙后或存在CORS限制可能无法接收到通知。检查各应用的登出接口是否正常工作是否能正确识别来自认证中心的通知请求可能需要一个特定的token或签名进行验证。心得在生产中完全可靠的单点登出很难实现。一个务实的做法是将全局会话的过期时间设置得相对较短如30分钟并告知用户“退出登录后其他系统的会话将在较短时间内自动过期”。同时提供一个“查看所有登录设备/会话并强制下线”的功能作为补充。基于Cookie的SSO是一个理解Web身份认证的绝佳切入点。它直观地展示了状态如何在无状态的HTTP协议中流转以及安全是如何在便利性的夹缝中建立的。虽然它在面对现代复杂的跨域、移动端、微服务架构时显得力不从心进而催生了OAuth 2.0、OpenID Connect等更强大的协议但其核心思想——集中认证、信任传递、会话管理——依然是所有SSO方案的基石。从这个小而美的方案入手再去学习更复杂的协议你会对“身份”这个数字世界的基础设施有更深刻的理解。
返回列表