尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

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

基于Cookie的SSO单点登录:原理、实现与安全实践 1. 项目概述为什么我们还在谈基于Cookie的SSO在分布式系统和微服务架构大行其道的今天单点登录SSO早已不是什么新鲜概念。JWT、OAuth 2.0、OpenID Connect这些协议听起来更“现代”讨论热度也更高。但如果你深入企业内部尤其是那些历史包袱较重、系统迭代周期长的场景你会发现基于Cookie的SSO方案依然有着极其顽强的生命力。它可能不够“炫酷”但足够简单、直接并且在特定边界内非常可靠。这个项目要探讨的正是这个看似“传统”却经久不衰的方案基于Cookie实现的单点登录系统。核心目标很明确用户只需在一个核心系统通常称为认证中心登录一次其登录状态就能通过Cookie安全地传递到其他信任的子系统中实现无缝访问。这背后涉及的核心关键词——SSO、Cookie、Session——构成了我们今天要拆解的全部内容。无论你是需要改造一个老系统还是为一个轻量级的新产品快速搭建认证骨架理解这套方案的里里外外都能让你在技术选型时多一份笃定。2. 核心原理与架构设计拆解2.1 传统Session-Cookie认证模式的瓶颈要理解基于Cookie的SSO必须先看清它要解决什么问题。在单体应用时代我们熟悉的认证流程是用户提交用户名密码服务端验证通过后在服务器内存或Redis中创建一个Session对象其中存储用户ID、角色等信息并生成一个唯一的Session ID。随后服务器通过Set-Cookie响应头将这个Session ID种到用户浏览器的Cookie里。浏览器后续的每一次请求都会自动通过Cookie请求头携带这个ID服务器借此找到对应的Session从而识别用户身份。这套模式简单有效但它有一个致命的前提Session存储和验证必须发生在同一个“域”下。因为浏览器有严格的同源策略A域名下的Cookie不会自动发送到B域名的请求中。当系统拆分为多个独立部署、不同子域甚至完全不同域的应用时每个应用都有自己的Session用户就不得不反复登录。这正是SSO要解决的核心痛点一次登录处处通行。2.2 基于Cookie的SSO核心思想信任与票据传递基于Cookie的SSO方案其核心思想是引入一个独立的、所有子系统都信任的认证中心。这个中心负责统一的用户认证和会话管理。整个流程可以类比为在一个大型园区业务系统群里设立一个总门卫室认证中心。用户首次访问当用户访问业务系统A时系统A检查发现请求中没有有效的登录凭证Cookie于是将用户重定向到认证中心的登录页面。统一认证用户在认证中心完成登录。认证中心验证身份后在其自身的域下创建主会话Global Session并生成一个加密的、有时效的“门票”——我们通常称之为票据。票据传递与验证认证中心将用户重定向回系统A并在URL中附上这张票据。系统A收到票据后需要向认证中心发起一个后台的、服务端到服务端的请求验证这张票据的真实性和有效性。建立本地会话票据验证通过后认证中心会返回用户的基本身份信息。系统A据此在本地自己的服务器上创建一个局部会话Local Session并为用户浏览器设置一个属于系统A自己域下的Cookie。至此用户在系统A的登录完成。访问其他系统当用户再去访问系统B时流程重复上述1-4步。关键在于第2步用户浏览器在访问认证中心时会携带认证中心域下的Cookie。认证中心通过这个Cookie发现用户已经存在主会话于是直接生成新票据并跳转用户无需再次输入密码。整个方案的精髓在于认证中心通过Cookie维持用户的全局登录状态而各个业务系统通过验证认证中心颁发的票据在本地建立信任关系并维护自己的局部会话。浏览器同源策略的限制通过认证中心作为可信中介和票据传递机制被巧妙地绕过。2.3 关键组件与数据流设计一个典型的基于Cookie的SSO架构包含以下关键组件认证中心独立的Web应用负责用户登录/注销、主会话管理、票据颁发与验证。它是整个SSO体系的信任根。业务系统需要接入SSO的各个独立应用。它们需要嵌入一个SSO客户端组件该组件负责拦截未认证请求、重定向到认证中心、接收并验证票据、建立本地会话。共享存储通常是一个Redis集群用于存储认证中心的主会话信息以及颁发的票据。票据必须是一次性的验证后立即失效防止重放攻击。信任关系业务系统与认证中心之间需要预先共享一个密钥或配置证书用于票据的签名验证确保票据不会被伪造。数据流可以概括为以下几个关键步骤拦截与重定向客户端访问受保护资源 → 业务系统拦截请求检查本地Session/Cookie → 若无构造认证中心登录URL并重定向。认证与发券浏览器访问认证中心 → 认证中心检查自身Cookie全局Session→ 若无展示登录页若有直接生成票据 → 将用户重定向回业务系统URL附带票据。后台验证与登录业务系统从URL参数获取票据 → 业务系统后台向认证中心接口发起请求验证票据 → 认证中心验证票据有效性返回用户信息并销毁票据 → 业务系统创建本地Session设置本地Cookie。局部会话建立后续请求浏览器携带业务系统本地Cookie业务系统通过本地Session识别用户。3. 核心细节解析与安全要点3.1 Cookie的作用域与安全属性在这个方案中Cookie扮演了两个角色它们的配置至关重要认证中心Cookie用于维持全局会话。其Domain属性应设置为认证中心的顶级域例如.sso.com这样所有形如app1.sso.com,auth.sso.com的子域都能共享此Cookie。关键安全属性包括HttpOnly:必须设置为true。防止JavaScript通过document.cookie访问有效抵御XSS攻击窃取会话。Secure:在生产环境必须设置为true。仅通过HTTPS传输防止网络嗅探。SameSite: 这是一个现代浏览器的重要安全策略。对于认证中心通常建议设置为Lax或Strict。Lax允许在顶级导航如链接点击时携带Cookie而Strict则完全禁止跨站请求携带Cookie。设置为Strict安全性最高但可能影响从其他域名跳转到认证中心的登录流程需要仔细设计。如果认证中心和业务系统在同一个顶级域下这个问题的影响较小。业务系统本地Cookie用于维持局部会话。其Domain设置为业务系统自身的域。安全属性同样需要设置HttpOnly和Secure。SameSite属性可以根据业务系统的跨站需求灵活设置对于纯后端API交互的业务设置为Strict是安全的。注意Chrome等现代浏览器对SameSite的默认策略已从None变为Lax。这意味着如果你的业务系统需要通过iframe嵌入或由第三方网站发起POST请求来触发SSO流程且需要携带认证中心Cookie你必须显式地将认证中心Cookie的SameSite设置为None并且同时必须设置Securetrue即仅限HTTPS。这是实践中一个非常常见的坑。3.2 票据的设计与验证机制票据是整个流程中跨系统传递信任的载体其设计必须兼顾安全与效率。票据内容通常应包含用户唯一标识、票据颁发时间、过期时间、随机数。绝对不要包含敏感信息如密码。票据格式为了便于在URL中传递通常进行Base64编码。更安全的做法是使用JWT格式将上述信息作为Payload并附上签名。签名与验证认证中心使用私钥或共享密钥对票据内容进行签名如HMAC SHA256。业务系统使用对应的公钥或共享密钥验证签名确保票据未被篡改。一次性与时效性票据必须有很短的过期时间如10秒并且必须在认证中心验证后立即标记为失效或删除。这可以防止票据被截获后重复使用重放攻击。验证票据的接口必须设计为幂等的。一个简单的票据生成与验证示例概念性代码# 认证中心 - 生成票据 import time, hmac, hashlib, base64, json def generate_ticket(user_id, secret_key): ticket_data { uid: user_id, iat: int(time.time()), # 颁发时间 exp: int(time.time()) 10, # 10秒后过期 nonce: os.urandom(16).hex() # 随机数防重放 } # 1. 生成签名字符串 payload_json json.dumps(ticket_data, separators(,, :)) signature hmac.new(secret_key.encode(), payload_json.encode(), hashlib.sha256).hexdigest() # 2. 组合成票据字符串 ticket_string base64.urlsafe_b64encode(payload_json.encode()).decode() . signature return ticket_string # 业务系统 - 验证票据 def verify_ticket(ticket_string, secret_key): try: payload_b64, signature ticket_string.split(., 1) payload_json base64.urlsafe_b64decode(payload_b64).decode() ticket_data json.loads(payload_json) # 验证过期时间 if ticket_data[exp] time.time(): return None, Ticket expired # 验证签名 expected_sig hmac.new(secret_key.encode(), payload_json.encode(), hashlib.sha256).hexdigest() if not hmac.compare_digest(expected_sig, signature): return None, Invalid signature # 验证票据是否已使用需查询共享存储 if is_ticket_used(ticket_data[nonce]): return None, Ticket already used mark_ticket_used(ticket_data[nonce]) # 标记已使用 return ticket_data[uid], None except Exception as e: return None, fVerification error: {e}3.3 全局会话与局部会话的管理全局会话存储在认证中心通常以键值对形式存在于Redis中键是认证中心Cookie的值Session ID值包含用户核心身份信息和会话创建时间。需要设置合理的过期时间并考虑会话续期逻辑。局部会话各个业务系统独立管理。验证票据成功后业务系统可以创建自己的Session也可以直接生成一个自包含的、签名的本地Token如JWT存入Cookie。局部会话的过期时间应小于或等于全局会话的过期时间以确保当用户从认证中心注销时所有子系统的会话都能自然失效或通过其他机制被清理。4. 完整实操流程与核心代码实现4.1 环境准备与依赖配置假设我们使用Java Spring Boot生态来实现。我们需要准备认证中心服务一个独立的Spring Boot应用。业务系统A另一个Spring Boot应用。Redis用于共享会话存储。共享配置业务系统与认证中心共享一个密钥用于票据签名。Maven依赖认证中心与业务系统类似dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.apache.commons/groupId artifactIdcommons-lang3/artifactId /dependency !-- 用于JWT或HMAC签名 -- dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/scope /dependency4.2 认证中心核心实现1. 登录接口与全局会话创建RestController RequestMapping(/auth) public class AuthController { Autowired private StringRedisTemplate redisTemplate; private final String SECRET_KEY your-shared-secret-key; private final long GLOBAL_SESSION_TIMEOUT 1800; // 30分钟 PostMapping(/login) public ResponseEntity? login(RequestBody LoginRequest request, HttpServletResponse response) { // 1. 验证用户名密码略 User user userService.authenticate(request.getUsername(), request.getPassword()); if (user null) { return ResponseEntity.status(401).body(Invalid credentials); } // 2. 创建全局会话ID String globalSessionId UUID.randomUUID().toString(); String sessionKey global:session: globalSessionId; // 存储用户核心信息到Redis MapString, String sessionMap new HashMap(); sessionMap.put(userId, user.getId()); sessionMap.put(username, user.getUsername()); redisTemplate.opsForHash().putAll(sessionKey, sessionMap); redisTemplate.expire(sessionKey, GLOBAL_SESSION_TIMEOUT, TimeUnit.SECONDS); // 3. 设置认证中心Cookie Cookie sessionCookie new Cookie(GSESSIONID, globalSessionId); sessionCookie.setDomain(.sso.com); // 注意顶级域设置 sessionCookie.setPath(/); sessionCookie.setHttpOnly(true); sessionCookie.setSecure(true); // 生产环境启用 sessionCookie.setMaxAge((int) GLOBAL_SESSION_TIMEOUT); // 根据跨站需求设置SameSite这里设为Lax response.addHeader(Set-Cookie, String.format(GSESSIONID%s; Domain.sso.com; Path/; HttpOnly; Secure; SameSiteLax; Max-Age%d, globalSessionId, GLOBAL_SESSION_TIMEOUT)); return ResponseEntity.ok().body(Map.of(message, Login successful)); } }2. 票据颁发接口这个接口通常在用户已登录携带GSESSIONID Cookie后由业务系统重定向触发访问。GetMapping(/ticket) public ResponseEntity? issueTicket(CookieValue(value GSESSIONID, required false) String gsessionid, RequestParam(service) String serviceUrl, HttpServletRequest request) { if (gsessionid null) { // 没有全局会话重定向到登录页并带上回调地址 return ResponseEntity.status(302) .header(Location, /login?redirect URLEncoder.encode(serviceUrl, UTF-8)) .build(); } // 验证全局会话是否存在 String sessionKey global:session: gsessionid; if (!redisTemplate.hasKey(sessionKey)) { // 会话过期同样重定向到登录页 return ResponseEntity.status(302) .header(Location, /login?redirect URLEncoder.encode(serviceUrl, UTF-8)) .build(); } // 获取用户信息 String userId (String) redisTemplate.opsForHash().get(sessionKey, userId); // 生成一次性票据 String ticket generateTicket(userId); // 存储票据用于后续验证设置短时间过期如10秒 String ticketKey ticket: ticket; redisTemplate.opsForValue().set(ticketKey, userId, Duration.ofSeconds(10)); // 重定向回业务系统携带票据 String redirectUrl serviceUrl (serviceUrl.contains(?) ? : ?) ticket URLEncoder.encode(ticket, UTF-8); return ResponseEntity.status(302).header(Location, redirectUrl).build(); } private String generateTicket(String userId) { // 使用JWT或自定义格式这里用简单示例 String ticketId UUID.randomUUID().toString(); // 实际应包含签名 return ticketId; }3. 票据验证接口这是一个供业务系统后台调用的内部API。PostMapping(/validate) public ResponseEntity? validateTicket(RequestBody ValidateRequest request) { String ticket request.getTicket(); String ticketKey ticket: ticket; // 1. 检查票据是否存在且未过期 String userId redisTemplate.opsForValue().get(ticketKey); if (userId null) { return ResponseEntity.status(401).body(Map.of(valid, false, message, Invalid or expired ticket)); } // 2. 获取用户信息从全局会话或用户库 String sessionKey global:session:* userId; // 简化查询实际需维护ticket到session的映射或从用户库查 // 假设我们能通过userId找到会话 // ... MapString, String userInfo new HashMap(); userInfo.put(userId, userId); userInfo.put(username, testUser); // 3. **关键步骤删除票据确保一次性使用** redisTemplate.delete(ticketKey); return ResponseEntity.ok().body(Map.of(valid, true, user, userInfo)); }4.3 业务系统客户端集成业务系统需要实现一个过滤器或拦截器用于拦截未认证的请求。SSO客户端过滤器Component public class SsoClientFilter extends OncePerRequestFilter { Value(${sso.auth-center-url}) private String authCenterUrl; // 认证中心地址如 https://auth.sso.com Value(${sso.app-service-url}) private String appServiceUrl; // 当前业务系统地址如 https://app1.sso.com Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { // 1. 检查当前请求是否已登录存在本地会话 HttpSession session request.getSession(false); if (session ! null session.getAttribute(user) ! null) { chain.doFilter(request, response); return; } // 2. 检查请求中是否携带认证中心发回的票据 String ticket request.getParameter(ticket); if (ticket ! null !ticket.isEmpty()) { // 3. 后台向认证中心验证票据 UserInfo userInfo validateTicketWithAuthCenter(ticket); if (userInfo ! null) { // 4. 票据有效创建本地会话 HttpSession newSession request.getSession(true); newSession.setAttribute(user, userInfo); // 可选设置本地Cookie // 重定向到原始请求去除ticket参数避免URL中残留票据 String originalUrl removeTicketParam(request.getRequestURL().toString(), request.getQueryString()); response.sendRedirect(originalUrl); return; } else { // 票据无效重定向到认证中心登录 redirectToAuthCenter(request, response); return; } } // 5. 既无本地会话也无票据重定向到认证中心 redirectToAuthCenter(request, response); } private UserInfo validateTicketWithAuthCenter(String ticket) { // 使用RestTemplate或HttpClient调用认证中心的 /auth/validate 接口 // 传递共享密钥或使用其他安全机制 // 返回用户信息或null // 示例伪代码 try { RestTemplate restTemplate new RestTemplate(); ValidateRequest req new ValidateRequest(ticket); ResponseEntityValidateResponse resp restTemplate.postForEntity( authCenterUrl /auth/validate, req, ValidateResponse.class ); if (resp.getStatusCode().is2xxSuccessful() resp.getBody().isValid()) { return resp.getBody().getUser(); } } catch (Exception e) { logger.error(Ticket validation failed, e); } return null; } private void redirectToAuthCenter(HttpServletRequest request, HttpServletResponse response) throws IOException { String currentUrl appServiceUrl request.getRequestURI(); String queryString request.getQueryString(); if (queryString ! null) { currentUrl ? queryString; } String encodedUrl URLEncoder.encode(currentUrl, UTF-8); String authUrl authCenterUrl /auth/ticket?service encodedUrl; response.sendRedirect(authUrl); } private String removeTicketParam(String url, String queryString) { // 实现去除ticket参数的逻辑 if (queryString null || !queryString.contains(ticket)) return url; // ... 解析并重组URL return url.split(\\?)[0]; // 简化处理 } }业务系统配置在application.yml中sso: auth-center-url: https://auth.sso.com app-service-url: https://app1.sso.com shared-secret: your-shared-secret-key # 用于签名验证4.4 注销的联动处理单点登录必须配套单点注销。用户在任何一个系统点击退出应该通知认证中心并由认证中心通知所有已登录的业务系统。认证中心注销接口接收业务系统的注销请求根据全局Session ID找到该用户登录的所有业务系统记录需要在用户登录时记录然后向每个业务系统的注销回调接口发送异步通知。业务系统注销回调接口接收认证中心通知根据传递的用户标识销毁本地Session。前端跳转业务系统前端收到退出请求后先调用本地退出接口清除本地Cookie/Session然后重定向到认证中心的全局注销接口认证中心执行上述通知逻辑后再重定向回业务系统或登录页。这是一个相对复杂的分布式事务为了简化很多实现采用“被动过期”策略即业务系统的本地Session设置一个较短的过期时间依赖认证中心的全局Session过期。当用户关闭浏览器或全局Session超时后所有局部会话也会陆续失效。但这并非严格意义上的实时单点注销。5. 常见问题、排查技巧与进阶考量5.1 典型问题与解决方案速查表问题现象可能原因排查步骤与解决方案登录后无限重定向到认证中心1. 票据验证失败。2. 业务系统本地Cookie设置失败SameSite、Secure属性问题。3. 认证中心Cookie未成功种上或域设置错误。1. 检查浏览器开发者工具的Network和Application标签页查看重定向链条和Cookie设置情况。2. 确认认证中心和业务系统的域名关系检查Set-Cookie头中的Domain、Secure、SameSite属性是否符合预期。3. 在业务系统后端日志中查看票据验证接口的调用是否成功解析返回结果。在Chrome等高版本浏览器中跨域登录失败Chrome默认的SameSiteLax策略阻止了跨站POST请求携带Cookie。1. 确保认证中心和业务系统使用HTTPS。2. 将认证中心Cookie的SameSite显式设置为None并必须同时设置Securetrue。3. 考虑将登录表单的提交改为由认证中心域下的页面处理避免跨站POST。票据验证报“无效签名”或“票据已使用”1. 认证中心与业务系统使用的共享密钥不一致。2. 票据验证后未及时删除导致重复验证失败。3. 系统间时间不同步导致票据过期时间判断有误。1. 核对双方的密钥配置。2. 检查认证中心/validate接口中验证成功后删除票据的代码逻辑是否执行。3. 确保所有服务器使用NTP进行时间同步。用户在一个系统退出后其他系统仍能访问单点注销未正确实现。1. 检查认证中心的全局注销逻辑是否触发了对所有业务系统的回调通知。2. 检查业务系统的注销回调接口是否正常工作能否正确销毁本地Session。3. 考虑引入更复杂的会话管理机制如将Session ID存入Redis并设置过期每次请求校验。性能瓶颈出现在票据验证接口每个业务系统的每次登录都需要调用一次认证中心的验证接口高并发下压力大。1. 在业务系统端对验证结果进行短期缓存如缓存5分钟用票据ID作为Key。注意平衡缓存时间与安全性。2. 优化认证中心验证接口的性能如使用更快的签名算法、优化Redis查询。5.2 安全加固要点防CSRF虽然SSO流程本身涉及重定向但业务系统自身的敏感操作仍需配备CSRF Token防护。防重放攻击确保票据一次性使用且超时时间极短如10秒并在验证后立即失效。敏感操作复核对于修改密码、支付等极高风险操作即使处于SSO登录态也应要求用户再次输入密码或进行二次验证。HTTPS强制整个SSO流程包括认证中心和所有业务系统必须全程使用HTTPS防止Cookie和票据在传输中被窃听。监控与审计记录所有登录、票据颁发与验证、注销事件便于安全审计和异常行为分析。5.3 方案局限性及适用场景基于Cookie的SSO方案有其清晰的边界优点原理简单实现直接对浏览器兼容性好除现代SameSite策略需额外处理非常适合同一顶级域下的子系统如app1.company.com,app2.company.com,auth.company.com。缺点跨域限制虽然通过重定向和票据传递解决了登录态共享但Cookie本身受同源策略限制。对于完全不同的域名如www.a.com和www.b.com方案会变得复杂通常需要借助隐藏iframe或前端JavaScript进行跨域通信实现难度和风险增加。对非Web客户端不友好移动端App、桌面客户端、API调用等无法直接利用浏览器Cookie机制需要适配其他方案如OAuth 2.0的授权码模式。注销联动复杂实现完美的实时单点注销需要复杂的通知机制。因此这个方案最适合的场景是企业内部系统、平台型产品的后台管理系统、所有子系统共享同一个父域名的Web应用集群。如果你的系统需要面向公众、跨完全不同的域名、或需要支持多种类型的客户端那么OAuth 2.0/OpenID Connect是更通用和标准的选择。5.4 从简单到复杂的演进路径在实际项目中技术选型往往不是非此即彼。你可以从简单的基于Cookie的SSO开始随着业务发展逐步演进初期所有系统在同一个域下使用上述方案快速落地。发展期部分系统拆分到新域名。此时可以保留认证中心让新域名系统通过前端JavaScript与认证中心跨域通信使用CORS或PostMessage来获取登录态或者采用更标准的OAuth 2.0授权码模式。平台期需要对外开放API或对接第三方登录。此时可以引入完整的OAuth 2.0和OpenID Connect服务将原有的认证中心升级为统一的身份提供商同时兼容老的Cookie SSO协议和新的OIDC协议。理解基于Cookie的SSO不仅是掌握一种具体的实现更是深入理解了Web单点登录最本质的“信任传递”思想。无论后续技术栈如何变化这份对核心流程和安全要点的把握都能让你在构建任何身份认证系统时游刃有余。
返回列表