目录
- SSO(单点登录):基本概念、核心协议、代码实战教学与企业项目落地
-
- 写在前面
- [1. 痛点场景描述:那些让人崩溃的瞬间](#1. 痛点场景描述:那些让人崩溃的瞬间)
- [2. 痛点解决方案:SSO来了](#2. 痛点解决方案:SSO来了)
- [3. 是什么------极简概念与原理](#3. 是什么——极简概念与原理)
-
- [3.1 SSO的核心定义与本质](#3.1 SSO的核心定义与本质)
- [3.2 四大主流协议对比](#3.2 四大主流协议对比)
- [3.3 CAS协议的票证流转全过程](#3.3 CAS协议的票证流转全过程)
- [3.4 JWT与Session双令牌联合方案](#3.4 JWT与Session双令牌联合方案)
- 大白话理解
- [4. 为什么用------核心优势与对比](#4. 为什么用——核心优势与对比)
-
- [4.1 SSO对企业和用户的双重价值](#4.1 SSO对企业和用户的双重价值)
- [4.2 量化对比:集中认证 vs 各系统独立认证](#4.2 量化对比:集中认证 vs 各系统独立认证)
- [4.3 选型指南:OIDC/OAuth 2.0 vs SAML 2.0](#4.3 选型指南:OIDC/OAuth 2.0 vs SAML 2.0)
- [4.4 2026年SSO安全趋势](#4.4 2026年SSO安全趋势)
- [5. 怎么用------保姆级基础教学(含代码示例)](#5. 怎么用——保姆级基础教学(含代码示例))
-
- [5.1 环境准备](#5.1 环境准备)
- [5.2 Python Flask + 自建简易CAS服务器](#5.2 Python Flask + 自建简易CAS服务器)
- [5.3 Python Flask + Authlib对接OIDC提供商](#5.3 Python Flask + Authlib对接OIDC提供商)
- [5.4 Java Spring Boot + Keycloak实现SSO](#5.4 Java Spring Boot + Keycloak实现SSO)
- [5.5 Go Gin + go-oidc实现OIDC登录](#5.5 Go Gin + go-oidc实现OIDC登录)
- [6. 进阶------企业级架构与落地](#6. 进阶——企业级架构与落地)
-
- [6.1 多域名单点登录的跨域方案](#6.1 多域名单点登录的跨域方案)
- [6.2 JWT与Session双令牌联合方案的生产级实践](#6.2 JWT与Session双令牌联合方案的生产级实践)
- [6.3 企业统一身份认证平台选型对比](#6.3 企业统一身份认证平台选型对比)
- [6.4 零信任架构下的SSO演进](#6.4 零信任架构下的SSO演进)
- [7. 避坑指南与生产经验](#7. 避坑指南与生产经验)
-
- [7.1 Token存储位置选择](#7.1 Token存储位置选择)
- [7.2 全局登出(SLO)的坑](#7.2 全局登出(SLO)的坑)
- [7.3 Token泄露应急方案](#7.3 Token泄露应急方案)
- [7.4 微服务间认证:mTLS + Service Mesh](#7.4 微服务间认证:mTLS + Service Mesh)
- [7.5 CORS配置常见翻车](#7.5 CORS配置常见翻车)
- [8. 总结与展望](#8. 总结与展望)
- [9. 互动引导 + 转载声明 + 参考链接](#9. 互动引导 + 转载声明 + 参考链接)
-
- 互动引导
- [📢 转载声明](#📢 转载声明)
- [📚 参考链接](#📚 参考链接)
SSO(单点登录):基本概念、核心协议、代码实战教学与企业项目落地

写在前面
想象一下,你去游乐园玩,手里攥着一叠纸质门票。每个项目都要排队验票,轮到你时才发现门票皱了、票根撕坏了、某张票压根没带------那种崩溃感,你品,你细品。
现在把"游乐园"换成"公司办公系统"------OA、CRM、邮箱、代码仓库、项目管理、财务系统......每个都要单独登录,每个都要记密码。
恭喜你,这就是无数企业正在经历的"密码内耗"日常。
今天,我们就来聊聊那个能让你"一票畅玩所有项目"的神器------SSO 单点登录(Single Sign-On)。
1. 痛点场景描述:那些让人崩溃的瞬间
场景一:重复登录之痛------"我到底是在工作还是在验证身份?"
周一早上,你打开电脑,准备开始一天的工作。打开邮箱要登录一次,打开OA审批要登录一次,打开CRM查客户要登录一次,打开代码仓库提交代码又要登录一次......
一天输 20 遍密码,我到底是在工作还是在验证身份?
更绝的是,每个系统的密码规则还各不相同:
- 邮箱要求:8位以上,包含大小写和特殊字符
- OA要求:12位以上,不能和用户名相同
- 代码仓库要求:不能和上次密码相同
- 财务系统要求:每90天必须更换
于是你把密码记在便利贴上,便利贴贴在显示器上,"密码.xlsx"存在桌面正中间------恭喜你,成功为黑客降低了攻击成本。
场景二:安全漏洞之痛------"每个系统自己管登录,安全水位参差不齐"
公司收购了一家初创团队,对方技术团队虽然代码写得溜,但安全意识约等于零。他们的用户系统还在用明文存储密码(对,你没看错,密码原文存数据库)。
某天,这个系统被拖库了。黑客拿着这批账号密码,去撞你公司其他系统的登录页面。
一个系统沦陷,多个系统集体"开门揖盗"------因为员工往往在多个系统使用相同密码。
这就是典型的"木桶效应":你企业安全水平取决于那块最短的木板。
场景三:微服务架构之痛------"20个服务,我该怎么确认这个请求来自谁?"
公司技术架构从单体应用转向微服务,一口气拆出了20个服务。前端Vue、后端Java、Python数据分析、Go实时处理......
问题来了:
- 用户在前端登录了,但后端Java服务怎么知道这个请求是谁发的?
- 服务间互相调用,怎样确认"调用方"的身份?
- Token存前端会被XSS攻击,存后端又增加复杂度......
微服务把业务拆开了,却把"身份认证"这道必答题甩给了每个团队自己去解。
场景四:离职漏删之痛------"员工离职了,但还有5个系统的账号没关"
小王是公司的销冠,今天正式离职。HR在OA系统关闭了他的账号,一切看起来很顺利。
但现实是:
- 邮箱账号还在,邮件还在被访问
- 代码仓库权限还在,代码还在被clone
- 客户CRM权限还在,客户数据还在被查看
- 第三方SaaS系统权限还在,订单还在被操作
"员工离职了,但他的数字身份还活着。"
更可怕的是,离职员工往往掌握着一些"特权账号"------服务器登录权限、数据库访问权限。一旦这些账号失控,后果不堪设想。
💡 核心矛盾
身份信息分散在各个系统中,缺乏统一的认证中心和令牌管理策略。每个系统都在"重复造轮子",安全水平参差不齐,用户体验一塌糊涂。
SSO的根本使命,就是用一个统一的、安全的、可控的身份认证中心,终结这场"密码混战"。
2. 痛点解决方案:SSO来了
SSO如何解决这个问题?
SSO(Single Sign-On,单点登录)让用户使用一套账号密码进行一次登录,即可获得所有受信任的应用系统的访问授权。
就像游乐园的手环------门口刷一次票,手腕绑上手环(令牌),之后所有设施刷手环即可通行。
2026年趋势数据
FIDO 联盟 2026 年行业报告显示(来源:mojoauth.com):
- 全球活跃 Passkeys 已超 50 亿
- 68% 的组织正在部署 Passkeys
- 82% 的企业目标是完全无密码环境
- 28% 的组织已完全消除密码
Gartner 2026年预测:
- 到2026年底,超过 30% 的企业将把身份威胁检测和响应能力作为安全运营的核心组件
核心公式
SSO = 统一身份认证中心 + 令牌签发与管理 + 受信应用注册 + 安全协议实现
3. 是什么------极简概念与原理
3.1 SSO的核心定义与本质
SSO是一种身份认证机制,允许用户使用一套账号密码进行一次登录,即可获得多个相互信任的应用系统的访问授权。
⚠️ 重要区分:认证 vs 授权
认证(Authentication):验证"你是谁",回答"你确实是你声称的那个人"。
授权(Authorization):验证"你能做什么",回答"你被允许访问这个资源"。
SSO是认证,不是授权! SSO解决的是"证明你是谁"的问题,而不是"你能访问什么"的问题。授权通常由各个应用系统自己控制。
3.2 四大主流协议对比
| 协议 | 定位 | 票据形式 | 传送方式 | 适用场景 | 安全特性 | 复杂度 | 移动端支持 |
|---|---|---|---|---|---|---|---|
| CAS | 专为SSO设计的轻量协议 | TGT + ST | URL参数 | 单体Web应用 | 基础 | ⭐ 低 | 一般 |
| OAuth 2.0 | 授权框架(不是认证协议) | Access Token | Bearer Header | 第三方授权 | 较高 | ⭐⭐ 中 | 优秀 |
| OIDC | OAuth 2.0 + 身份层 | ID Token + Access Token | JWT | 前后端分离、移动端 | 高 | ⭐⭐ 中 | 优秀 |
| SAML 2.0 | 企业级联合身份标准 | SAML断言(XML) | POST/重定向 | 企业SaaS、政府 | 高 | ⭐⭐⭐ 高 | 一般 |
各协议特点速览:
-
CAS:最轻量、最纯粹的SSO协议,专为Web应用设计。认证与票据分离,但CAS Client和Server之间缺乏加密/签名机制。CAS 3.0引入基于SAML的ST校验。⚠️ 社区活跃度低,已基本停滞。
-
OAuth 2.0 :授权框架,设计用于第三方应用获取资源访问权限。四种授权模式:授权码、隐式、密码凭证、客户端凭证。⚠️ 不是认证协议,不能直接用于用户身份验证。
-
OIDC :建立在OAuth 2.0之上,引入ID Token(JWT格式)。是目前前后端分离、移动端应用的首选方案。支持backchannel_logout。OIDC = OAuth 2.0 + 身份信息标准(ID Token)。
-
SAML 2.0:基于XML的企业级联合身份认证标准,通过SAML断言实现认证,广泛应用于企业SaaS系统和政府机构。配置相对繁琐,报文体积大。SP不需要回连IdP校验,通过预先交换的公钥直接解析XML签名。
3.3 CAS协议的票证流转全过程
CAS(Central Authentication Service)是专门为SSO设计的协议,其核心是票据(Ticket)机制:

关键票据说明:
| 票据 | 全称 | 作用 | 生命周期 |
|---|---|---|---|
| TGT | Ticket Granting Ticket | 长期票据,证明用户已登录 | 会话级(可配置) |
| ST | Service Ticket | 短期一次性票据,用于访问具体服务 | 一次性(秒级) |
| PGT | Proxy Granting Ticket | 允许服务代理用户访问其他服务 | 会话级 |
登出回调流程:
- 用户请求登出,CAS Server销毁TGT
- CAS Server向已注册的服务发送
logout回调 - 各服务自行清理本地会话
3.4 JWT与Session双令牌联合方案
现代SSO系统通常采用双令牌机制:

Access Token(访问令牌):
- 格式:JWT
- 有效期:15分钟 - 2小时
- 特点:无状态验证,服务端不需要存储
- 用途:访问API资源
Refresh Token(刷新令牌):
- 格式:随机字符串
- 有效期:7天 - 30天
- 特点:持久化存储在数据库/Redis,支持主动失效
- 用途:获取新的Access Token
大白话理解
SSO就像一张"乐园通票"
门口刷一次票,手腕绑上手环(令牌),之后所有设施刷手环即可。手环丢了?去门口补办一张,所有设施立即生效。
JWT就像一张"防伪身份证"盖了公安局钢印(数字签名),服务方只需要看钢印就能确认真假,不需要打电话给公安局核实。
无密码Passkeys就像"指纹钥匙"系统自动为每个网站生成唯一加密钥匙对,私钥只存在于你的设备里。网站只存公钥,黑客拿到公钥也没用。
4. 为什么用------核心优势与对比
4.1 SSO对企业和用户的双重价值
对用户:
| 价值点 | 说明 |
|---|---|
| 效率提升 | 登录次数从N次降为1次,每天节省10-30分钟 |
| 记忆减负 | 只需记住1套密码,降低密码疲劳 |
| 体验一致 | 统一身份标识,多设备无缝切换 |
对企业:
| 价值点 | 说明 |
|---|---|
| 安全提升 | 密码策略统一管理,高风险密码不复存在 |
| 成本降低 | 密码重置工单减少60%+,IT运维压力骤降 |
| 审计完善 | 统一身份中心,访问日志可追溯 |
| 敏捷扩展 | 新应用接入SSO只需配置,无需开发登录模块 |
4.2 量化对比:集中认证 vs 各系统独立认证
| 指标 | 独立认证 | SSO集中认证 | 提升幅度 |
|---|---|---|---|
| 密码重置工单 | 每系统每月100+ | 统一平台统一处理 | 降低 60%+ |
| 员工登录总时间 | 每天30分钟 | 每天5分钟 | 节省 85% |
| 密码复用率 | 70%+ | 0%(单密码) | 彻底杜绝 |
| 离职账号漏删风险 | 不可避免 | 集中管理 | 风险趋近于零 |
| 新应用接入时间 | 3-5天 | 30分钟 | 提升 90% |
4.3 选型指南:OIDC/OAuth 2.0 vs SAML 2.0

选型建议:
| 场景 | 推荐协议 | 原因 |
|---|---|---|
| 新建前后端分离项目 | OIDC | 协议现代、JWT生态完善、便于SPA/移动端 |
| 移动端App | OIDC + PKCE | PKCE增强安全性,适合无后端场景 |
| 企业内部系统集成 | SAML 2.0 | 大量企业SaaS原生支持 |
| 混合场景(新旧并存) | Keycloak | 同时支持OIDC/OAuth2/SAML |
4.4 2026年SSO安全趋势
Passkeys无密码化加速(来源:微软2026无密码化加速新闻)
- 微软短信验证码正逐步淘汰
- 通行密钥成为个人账户默认登录方式
- 企业Entra ID的Passkeys已进入公测阶段
零信任架构成为必选项(来源:CSDN零信任架构文章)
- 持续验证取代默认信任
- 自适应认证引擎基于实时风险评分动态调整认证要求
- 2026年零信任已从"可选项"变为"必选项"
协议演进
- CAS从3.6版本起已支持简单的MFA集成
- OAuth 2.1持续收紧安全建议,逐步淘汰隐式授权和密码凭证模式
5. 怎么用------保姆级基础教学(含代码示例)
5.1 环境准备
Python方案依赖:
bash
pip install flask requests authlib python-jose cryptography
Java方案依赖(Spring Boot 3 + Spring Security):
xml
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
Go方案依赖:
bash
go get golang.org/x/oauth2
go get github.com/coreos/go-oidc/v3
5.2 Python Flask + 自建简易CAS服务器
下面是一个约100行精简版CAS Server,展示TGT签发、ST签发和票据验证的核心流程:
python
#!/usr/bin/env python3
"""
简易CAS Server实现 - 用于教学演示
⚠️ 生产环境请使用专业CAS Server(如Apereo CAS)
"""
from flask import Flask, request, redirect, jsonify, make_response
import uuid
import time
from functools import wraps
app = Flask(__name__)
# ============ 模拟数据库存储 ============
# TGT存储: {tgt_id: {username, create_time, service_tickets: []}}
tgt_storage = {}
# 用户数据: {username: password}
users_db = {
"admin": "123456",
"developer": "dev2024",
"tester": "test123"
}
# 允许访问的服务列表: {service_url: service_name}
allowed_services = {
"http://app1.example.com": "应用系统1",
"http://app2.example.com": "应用系统2"
}
# ============ 工具函数 ============
def generate_ticket():
"""生成唯一的TGT"""
return f"TGT-{uuid.uuid4().hex}"
def generate_st():
"""生成一次性的ST"""
return f"ST-{uuid.uuid4().hex}"
def get_tgt_from_cookie():
"""从Cookie中获取TGT"""
return request.cookies.get('CASTGC')
def is_tgt_valid(tgt_id):
"""验证TGT是否有效"""
if not tgt_id or tgt_id not in tgt_storage:
return False
tgt_data = tgt_storage[tgt_id]
# 检查是否过期(默认8小时)
if time.time() - tgt_data['create_time'] > 8 * 3600:
del tgt_storage[tgt_id]
return False
return True
# ============ 认证路由 ============
@app.route('/login', methods=['GET', 'POST'])
def login():
"""
登录入口
- GET: 显示登录页面或处理service参数重定向
- POST: 处理登录表单提交
"""
# 如果已经登录,直接返回
tgt_id = get_tgt_from_cookie()
if is_tgt_valid(tgt_id):
username = tgt_storage[tgt_id]['username']
service = request.args.get('service')
if service and service in allowed_services:
# 已登录且有service参数,直接签发ST
st = generate_st()
tgt_storage[tgt_id]['service_tickets'].append({
'st': st,
'service': service,
'create_time': time.time()
})
# 重定向到服务,并携带ST
return redirect(f"{service}?ticket={st}")
return jsonify({"message": f"欢迎回来,{username}!"})
# 处理POST登录
if request.method == 'POST':
data = request.get_json() or request.form.to_dict()
username = data.get('username')
password = data.get('password')
service = data.get('service')
# 验证用户凭证
if username in users_db and users_db[username] == password:
# 签发TGT
tgt_id = generate_ticket()
tgt_storage[tgt_id] = {
'username': username,
'create_time': time.time(),
'service_tickets': []
}
response = make_response(jsonify({
"success": True,
"message": "登录成功",
"username": username
}))
# 设置Cookie(HttpOnly安全Cookie)
response.set_cookie('CASTGC', tgt_id, httponly=True, samesite='Lax')
# 如果有service参数,签发ST
if service and service in allowed_services:
st = generate_st()
tgt_storage[tgt_id]['service_tickets'].append({
'st': st,
'service': service,
'create_time': time.time()
})
response.headers['X-Service-Ticket'] = st
return response
return jsonify({"success": False, "message": "用户名或密码错误"}), 401
# GET请求显示登录页面
return jsonify({
"message": "请登录",
"allowed_services": list(allowed_services.keys()),
"usage": {
"login": "POST /login with {\"username\": \"admin\", \"password\": \"123456\", \"service\": \"http://app1.example.com\"}",
"validate": "GET /serviceValidate?ticket=ST-xxx&service=http://app1.example.com"
}
})
@app.route('/serviceValidate')
def service_validate():
"""
ST票据验证
客户端使用此接口验证ST是否有效
"""
ticket = request.args.get('ticket')
service = request.args.get('service')
if not ticket:
return jsonify({"success": False, "message": "缺少ticket参数"}), 400
# 遍历所有TGT,查找对应的ST
for tgt_id, tgt_data in tgt_storage.items():
for st_data in tgt_data['service_tickets']:
if st_data['st'] == ticket:
# 检查ST是否过期(默认5分钟)
if time.time() - st_data['create_time'] > 300:
return jsonify({"success": False, "message": "ST已过期"}), 401
# 检查service是否匹配
if service and st_data['service'] != service:
return jsonify({"success": False, "message": "Service不匹配"}), 401
# 验证成功,返回用户信息
# 生产环境应该返回标准CAS响应格式
return jsonify({
"success": True,
"user": tgt_data['username'],
"ticket": ticket,
"service": st_data.get('service', 'unknown')
})
return jsonify({"success": False, "message": "无效的ticket"}), 401
@app.route('/logout')
def logout():
"""
登出
销毁TGT,并触发SLO回调
"""
tgt_id = get_tgt_from_cookie()
if tgt_id and tgt_id in tgt_storage:
# 获取所有注册的服务,准备发送SLO回调
services = set(st['service'] for st in tgt_storage[tgt_id]['service_tickets'])
username = tgt_storage[tgt_id]['username']
# 销毁TGT
del tgt_storage[tgt_id]
response = make_response(jsonify({
"success": True,
"message": f"用户 {username} 已登出",
"slo_callbacks": list(services)
}))
# 清除Cookie
response.delete_cookie('CASTGC')
return response
return jsonify({"success": True, "message": "无活动会话"})
@app.route('/p3/serviceValidate')
def service_validate_saml():
"""
SAML格式的ST验证(CAS 3.0+支持)
返回SAML格式的响应
"""
ticket = request.args.get('ticket')
service = request.args.get('service')
# 这里简化处理,返回类SAML格式的XML
for tgt_id, tgt_data in tgt_storage.items():
for st_data in tgt_data['service_tickets']:
if st_data['st'] == ticket:
if time.time() - st_data['create_time'] > 300:
return "<saml:StatusCode>TicketExpired</saml:StatusCode>", 401
saml_response = f"""<?xml version="1.0"?>
<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:1.0:assertion">
<saml:AuthenticationStatement>
<saml:Subject>
<saml:NameIdentifier>{tgt_data['username']}</saml:NameIdentifier>
</saml:Subject>
</saml:AuthenticationStatement>
</saml:Assertion>"""
return saml_response, 200, {'Content-Type': 'text/xml'}
return "<saml:StatusCode>InvalidTicket</saml:StatusCode>", 401
@app.route('/status')
def status():
"""健康检查"""
return jsonify({
"status": "running",
"tgt_count": len(tgt_storage),
"users": list(users_db.keys())
})
# ============ 启动服务 ============
if __name__ == '__main__':
print("=" * 50)
print("简易CAS Server已启动")
print("=" * 50)
print("测试账号:")
print(" - admin / 123456")
print(" - developer / dev2024")
print(" - tester / test123")
print("=" * 50)
app.run(host='0.0.0.0', port=8080, debug=True)
5.3 Python Flask + Authlib对接OIDC提供商
使用Authlib库快速对接OIDC Provider(如Keycloak、Google、Authing等):
python
#!/usr/bin/env python3
"""
Flask + Authlib 对接 OIDC Provider
支持登录、回调、获取用户信息、登出
"""
from flask import Flask, redirect, session, jsonify, url_for, request
from authlib.integrations.flask_client import OAuth
from authlib.oidc.core import CodeIDToken
import secrets
app = Flask(__name__)
app.secret_key = secrets.token_hex(32) # 生产环境请使用安全的随机密钥
# ============ OAuth配置 ============
oauth = OAuth(app)
# 配置OIDC Provider(以Keycloak为例)
# 其他Provider类似,只需修改server_metadata_url和client配置
oauth.register(
name='oidc',
client_id='your-client-id',
client_secret='your-client-secret',
server_metadata_url='http://localhost:8080/realms/your-realm/.well-known/openid-configuration',
client_kwargs={
'scope': 'openid email profile',
# PKCE增强安全性(OIDC推荐)
'code_challenge_method': 'S256'
}
)
# 如果使用其他Provider,可参考以下配置:
#
# Google:
# oauth.register('google', ...)
# server_metadata_url = 'https://accounts.google.com/.well-known/openid-configuration'
#
# Authing:
# oauth.register('authing', ...)
# server_metadata_url = 'https://your-domain.authing.cn/oidc/.well-known/openid-configuration'
# ============ 会话管理 ============
@app.before_request
def before_request():
"""请求前置处理:会话初始化"""
session.permanent = True
# ============ 路由定义 ============
@app.route('/')
def index():
"""首页"""
user = session.get('user')
if user:
return f"""
<h1>欢迎回来,{user.get('name', 'User')}!</h1>
<p>Email: {user.get('email', 'N/A')}</p>
<p>Subject: {user.get('sub', 'N/A')}</p>
<p><a href="/logout">退出登录</a></p>
"""
return """
<h1>SSO Demo - Flask + OIDC</h1>
<p><a href="/login">使用OIDC登录</a></p>
"""
@app.route('/login')
def login():
"""
发起OIDC登录
1. 生成state和nonce(防CSRF)
2. 重定向到IdP授权页面
"""
redirect_uri = url_for('callback', _external=True)
# 使用OIDC的授权码流程
return oauth.oidc.authorize_redirect(redirect_uri)
@app.route('/callback')
def callback():
"""
处理IdP回调
1. 交换授权码获取Token
2. 验证ID Token
3. 获取用户信息
"""
# 交换授权码获取Token
token = oauth.oidc.authorize_access_token()
# 验证ID Token(Authlib自动处理)
# nonce会在验证后从ID Token中移除
user_info = token.get('userinfo')
if user_info:
# 存储用户信息到会话
session['user'] = {
'sub': user_info.get('sub'), # 用户唯一标识
'name': user_info.get('name'),
'email': user_info.get('email'),
'picture': user_info.get('picture'),
'preferred_username': user_info.get('preferred_username')
}
session['access_token'] = token.get('access_token')
session['refresh_token'] = token.get('refresh_token')
return redirect(url_for('index'))
return jsonify({"error": "Failed to retrieve user info"}), 400
@app.route('/userinfo')
def user_info():
"""
获取用户信息(受保护的API)
演示如何调用userinfo endpoint
"""
access_token = session.get('access_token')
if not access_token:
return jsonify({"error": "Not authenticated"}), 401
# 调用IdP的userinfo endpoint
user_info = oauth.oidc.userinfo(token=access_token)
return jsonify(user_info)
@app.route('/logout')
def logout():
"""
登出
1. 清除本地会话
2. 可选:发起OIDC SLO(Single Logout)
"""
# 清除本地会话
session.clear()
# 如果IdP支持SLO,可以重定向到IdP的登出端点
# logout_url = oauth.oidc.load_server_metadata()['end_session_endpoint']
# return redirect(logout_url + f"?post_logout_redirect_uri={url_for('index', _external=True)}")
return redirect(url_for('index'))
@app.route('/api/protected')
def protected_api():
"""
受保护的API示例
验证Token后方可访问
"""
access_token = session.get('access_token')
if not access_token:
return jsonify({"error": "Unauthorized"}), 401
# Token验证(实际生产中应在中间件层处理)
# 这里仅演示如何在业务逻辑中验证Token
return jsonify({
"message": "访问成功",
"data": {"secret": "这是受保护的数据"}
})
# ============ OIDC Server Metadata 端点(开发测试用)========
@app.route('/.well-known/openid-configuration')
def oidc_metadata():
"""
返回OIDC配置元数据
生产环境由实际的IdP提供
"""
return jsonify({
"issuer": "http://localhost:5000",
"authorization_endpoint": url_for('login', _external=True),
"token_endpoint": url_for('callback', _external=True),
"userinfo_endpoint": url_for('user_info', _external=True),
"end_session_endpoint": url_for('logout', _external=True),
"jwks_uri": url_for('jwks', _external=True)
})
@app.route('/.well-known/jwks.json')
def jwks():
"""
返回JSON Web Key Set
生产环境由实际的IdP提供
"""
return jsonify({
"keys": []
})
# ============ 启动 ============
if __name__ == '__main__':
print("=" * 50)
print("Flask OIDC Demo 已启动")
print("访问 http://localhost:5000 开始测试")
print("=" * 50)
app.run(host='0.0.0.0', port=5000, debug=True)
5.4 Java Spring Boot + Keycloak实现SSO
Step 1: Docker启动Keycloak
bash
# 启动Keycloak(生产环境建议使用官方镜像)
docker run -d \
--name keycloak \
-p 8080:8080 \
-e KEYCLOAK_ADMIN=admin \
-e KEYCLOAK_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:23.0 start-dev
Step 2: application.yml配置
yaml
# application.yml
spring:
application:
name: sso-demo-app
security:
oauth2:
client:
registration:
keycloak:
client-id: sso-demo-client
client-secret: your-client-secret
scope: openid,profile,email
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
provider:
keycloak:
issuer-uri: http://localhost:8080/realms/your-realm
user-name-attribute: preferred_username
server:
port: 8081
# 如果使用resource-server模式(JWT验证)
# spring.security.oauth2.resourceserver.jwt.issuer-uri: http://localhost:8080/realms/your-realm
Step 3: SecurityConfig配置
java
package com.example.sso.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// 配置授权规则
.authorizeHttpRequests(authz -> authz
// 公开路径
.requestMatchers("/", "/public/**", "/login/**").permitAll()
// 需要认证的路径
.requestMatchers("/api/protected/**").authenticated()
// 其他请求需要认证
.anyRequest().authenticated()
)
// 配置OAuth2登录
.oauth2Login(oauth2 -> oauth2
.loginPage("/oauth2/authorization/keycloak")
.defaultSuccessUrl("/userinfo", true)
.failureUrl("/?error")
)
// 配置OAuth2资源服务器(JWT验证)
// 启用此模式后,所有/api/**请求都需要携带有效JWT
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
)
// 登出配置
.logout(logout -> logout
.logoutSuccessUrl("http://localhost:8080/realms/your-realm/protocol/openid-connect/logout?redirect_uri=http://localhost:8081/")
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID")
)
// 禁用CSRF(API场景)或配置正确的CSRF token
.csrf(csrf -> csrf.disable());
return http.build();
}
@Bean
public org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter jwtAuthenticationConverter() {
var converter = new org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter();
converter.setPrincipalClaimName("preferred_username");
return converter;
}
}
Step 4: 受保护的Controller示例
java
package com.example.sso.controller;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api")
public class UserController {
/**
* 获取当前用户信息(OAuth2 Login模式)
*/
@GetMapping("/userinfo")
public Map<String, Object> getUserInfo(@AuthenticationPrincipal Jwt jwt) {
Map<String, Object> userInfo = new HashMap<>();
userInfo.put("sub", jwt.getSubject());
userInfo.put("username", jwt.getClaimAsString("preferred_username"));
userInfo.put("email", jwt.getClaimAsString("email"));
userInfo.put("name", jwt.getClaimAsString("name"));
return userInfo;
}
/**
* 受保护的API示例
*/
@GetMapping("/protected/data")
public Map<String, String> getProtectedData() {
Map<String, String> data = new HashMap<>();
data.put("message", "这是一条受保护的数据");
data.put("timestamp", String.valueOf(System.currentTimeMillis()));
return data;
}
/**
* 检查认证状态
*/
@GetMapping("/status")
public Map<String, Object> getStatus(@AuthenticationPrincipal Jwt jwt) {
Map<String, Object> status = new HashMap<>();
status.put("authenticated", jwt != null);
if (jwt != null) {
status.put("user", jwt.getSubject());
status.put("issuer", jwt.getIssuer().toString());
status.put("expiresAt", jwt.getExpiresAt());
}
return status;
}
}
5.5 Go Gin + go-oidc实现OIDC登录
go
package main
import (
"context"
"crypto/rand"
"encoding/base64"
"fmt"
"log"
"net/http"
"strings"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/gin-gonic/gin"
"golang.org/x/oauth2"
)
// ============ 全局变量 ============
var (
// OIDC Provider配置
provider *oidc.Provider
verifier *oidc.IDTokenVerifier
oauth2Config oauth2.Config
stateStore = make(map[string]string) // 简单的内存状态存储,生产用Redis
)
// ============ 初始化 ============
func initOIDC() error {
ctx := context.Background()
// 初始化OIDC Provider
// Keycloak示例: "http://localhost:8080/realms/your-realm"
var err error
provider, err = oidc.NewProvider(ctx, "http://localhost:8080/realms/your-realm")
if err != nil {
return fmt.Errorf("failed to create OIDC provider: %w", err)
}
// 创建ID Token验证器
verifier = provider.Verifier(&oidc.Config{
ClientID: "your-client-id",
})
// 初始化OAuth2配置
oauth2Config = oauth2.Config{
ClientID: "your-client-id",
ClientSecret: "your-client-secret",
RedirectURL: "http://localhost:8082/callback",
Endpoint: provider.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
return nil
}
// ============ 工具函数 ============
func generateState() string {
b := make([]byte, 32)
rand.Read(b)
return base64.URLEncoding.EncodeToString(b)
}
func generateNonce() string {
b := make([]byte, 32)
rand.Read(b)
return base64.URLEncoding.EncodeToString(b)
}
// ============ Gin中间件 ============
func authMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// 从Cookie或Header获取access_token
tokenString := c.GetHeader("Authorization")
if tokenString != "" {
tokenString = strings.TrimPrefix(tokenString, "Bearer ")
}
if tokenString == "" {
cookie, err := c.Cookie("access_token")
if err == nil {
tokenString = cookie
}
}
if tokenString == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
c.Abort()
return
}
// 验证Token(简化版,直接存储token到context)
// 生产环境应该在这里验证JWT
c.Set("access_token", tokenString)
c.Next()
}
}
// ============ 路由处理 ============
// 首页
func indexHandler(c *gin.Context) {
token, _ := c.Cookie("access_token")
if token != "" {
c.HTML(http.StatusOK, "index.html", gin.H{
"authenticated": true,
"message": "已登录",
})
return
}
c.HTML(http.StatusOK, "index.html", gin.H{
"authenticated": false,
"message": "请登录",
})
}
// 发起OIDC登录
func loginHandler(c *gin.Context) {
state := generateState()
nonce := generateNonce()
// 存储state和nonce(生产用Redis)
stateStore[state] = nonce
// 构建授权URL
authURL := oauth2Config.AuthCodeURL(state, oauth2.Nonce(nonce))
c.Redirect(http.StatusFound, authURL)
}
// 处理OIDC回调
func callbackHandler(c *gin.Context) {
ctx := context.Background()
// 验证state
state := c.Query("state")
storedNonce, ok := stateStore[state]
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid state"})
return
}
delete(stateStore, state)
// 交换授权码获取Token
code := c.Query("code")
token, err := oauth2Config.Exchange(ctx, code)
if err != nil {
log.Printf("token exchange failed: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "token exchange failed"})
return
}
// 提取ID Token并验证
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "missing id_token"})
return
}
// 验证ID Token(包含nonce验证)
idToken, err := verifier.Verify(ctx, rawIDToken)
if err != nil {
log.Printf("ID token verification failed: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "ID token verification failed"})
return
}
// 验证nonce
if idToken.Nonce != storedNonce {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid nonce"})
return
}
// 提取用户信息
var userInfo struct {
Sub string `json:"sub"`
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
Email string `json:"email"`
}
if err := idToken.Claims(&userInfo); err != nil {
log.Printf("failed to parse user info: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse user info"})
return
}
// 设置Cookie(HttpOnly安全)
c.SetCookie("access_token", rawIDToken, int(token.Expiry.Sub(time.Now()).Seconds()), "/", "", false, true)
c.SetCookie("user_id", userInfo.Sub, 86400*7, "/", "", false, false)
c.HTML(http.StatusOK, "callback.html", gin.H{
"message": fmt.Sprintf("登录成功: %s", userInfo.Name),
})
}
// 获取用户信息API
func userInfoHandler(c *gin.Context) {
ctx := context.Background()
// 从Cookie获取Token
tokenString, err := c.Cookie("access_token")
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
return
}
// 验证并解析ID Token
idToken, err := verifier.Verify(ctx, tokenString)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
var userInfo struct {
Sub string `json:"sub"`
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
Email string `json:"email"`
Picture string `json:"picture"`
}
if err := idToken.Claims(&userInfo); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get user info"})
return
}
c.JSON(http.StatusOK, userInfo)
}
// 登出
func logoutHandler(c *gin.Context) {
// 清除Cookie
c.SetCookie("access_token", "", -1, "/", "", false, true)
c.SetCookie("user_id", "", -1, "/", "", false, false)
c.HTML(http.StatusOK, "index.html", gin.H{
"authenticated": false,
"message": "已登出",
})
}
// ============ 主函数 ============
func main() {
// 初始化OIDC
if err := initOIDC(); err != nil {
log.Fatalf("Failed to initialize OIDC: %v", err)
}
// 初始化Gin
gin.SetMode(gin.ReleaseMode)
r := gin.Default()
// 加载HTML模板
r.LoadHTMLGlob("templates/*.html")
// 路由
r.GET("/", indexHandler)
r.GET("/login", loginHandler)
r.GET("/callback", callbackHandler)
r.GET("/logout", logoutHandler)
r.GET("/api/userinfo", authMiddleware(), userInfoHandler)
// 静态文件
r.Static("/static", "./static")
log.Println("Go OIDC Demo 已启动,访问 http://localhost:8082")
r.Run(":8082")
}
6. 进阶------企业级架构与落地
6.1 多域名单点登录的跨域方案
实际企业环境中,应用系统往往分布在不同的域名下,跨域问题是SSO落地的核心技术挑战。

Nginx反向代理配置示例:
nginx
# nginx.conf
upstream sso_backend {
server 127.0.0.1:8080; # SSO认证中心
}
upstream app_backend {
server 127.0.0.1:8081; # 业务应用
}
server {
listen 80;
server_name sso.example.com; # SSO统一入口
# SSO认证相关请求
location / {
proxy_pass http://sso_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# 重要:传递原始域名给后端
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
# Cookie配置
proxy_cookie_path / /;
proxy_cookie_domain sso.example.com .example.com;
}
}
server {
listen 80;
server_name app.example.com; # 业务应用域名
location / {
proxy_pass http://app_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
# SSO集成:将登录请求转发到SSO中心
# 业务应用可以检查Cookie中的会话标识,
# 如无则重定向到SSO登录
}
}
6.2 JWT与Session双令牌联合方案的生产级实践

Token存储策略:
| Token类型 | 存储位置 | 理由 |
|---|---|---|
| Access Token | 内存变量 | XSS攻击无法读取JS变量 |
| Refresh Token | HttpOnly Cookie + Redis | 防XSS + 支持主动失效 |
Token Rotation机制(OAuth 2.1标准):
python
# Refresh Token轮换示例
async def refresh_token(refresh_token_value):
# 1. 检查旧Token是否存在
old_token_data = await redis.get(f"refresh_token:{refresh_token_value}")
if not old_token_data:
raise TokenExpiredError("Token已被撤销")
# 2. 删除旧Token(防止Replay攻击)
await redis.delete(f"refresh_token:{refresh_token_value}")
# 3. 生成新Token
new_access_token = generate_jwt(...)
new_refresh_token = generate_random_string(...)
# 4. 存储新Token
await redis.setex(
f"refresh_token:{new_refresh_token}",
timedelta(days=7),
json.dumps({
"user_id": old_token_data["user_id"],
"device_id": old_token_data.get("device_id")
})
)
return new_access_token, new_refresh_token
并发刷新问题------刷新锁机制:
python
# 刷新锁 + 请求队列
async def refresh_token_with_lock(user_id: str):
lock_key = f"refresh_lock:{user_id}"
# 获取锁(最多等待5秒)
lock_acquired = await redis.set(lock_key, "1", nx=True, ex=5)
if not lock_acquired:
# 等待其他请求完成刷新
await asyncio.sleep(0.5)
return await get_tokens_from_cache(user_id) # 从缓存获取
try:
# 执行刷新
tokens = await perform_refresh(user_id)
# 存入缓存,给其他并发请求使用
await cache_tokens(user_id, tokens)
return tokens
finally:
await redis.delete(lock_key)
⚠️ JWT黑名单是"反模式"
如果你需要查Redis来判断Token是否被吊销,为什么不直接用Session?
JWT的价值在于无状态验证,为它加上黑名单等于自废武功。
正确做法:
- Access Token短期有效(15分钟),安全窗口可控
- Refresh Token存Redis,支持主动失效
- 真正的敏感操作,加上二次验证
6.3 企业统一身份认证平台选型对比
| 平台 | 部署方式 | 协议支持 | 核心优势 | 适用场景 | 国内生态 |
|---|---|---|---|---|---|
| Keycloak | 开源自托管 | OIDC/OAuth2/SAML/LDAP | 协议全家桶、用户联邦、MFA | 技术团队强 + 数据主权要求 | 英文界面,部署复杂 |
| Okta | SaaS云服务 | OIDC/SAML/SCIM | 2026 Forrester Wave领导者,异构SaaS集成强 | 异构SaaS环境 | 国际企业首选 |
| Authing | SaaS/私有化 | OIDC/OAuth2/SAML | 中文友好,国内生态适配好 | 国内企业 | 钉钉/飞书/企微原生 |
| MaxKey | 国产开源 | OIDC/SAML/CAS | Spring Boot技术栈,国产化适配 | 政府/国企 | 国产OS/中间件兼容 |
选型决策建议:

6.4 零信任架构下的SSO演进
持续自适应认证(Continuous Adaptive Authentication):
Passkeys与SSO的结合(FIDO2/WebAuthn):
- FIDO2认证器(手机指纹、硬件Key)作为SSO的认证因子
- 优势:
- 消除密码钓鱼攻击
- 设备绑定,隐私保护
- 用户体验革命性提升

SCIM自动用户生命周期管理:

7. 避坑指南与生产经验
7.1 Token存储位置选择
| 存储位置 | Access Token | Refresh Token | 理由 |
|---|---|---|---|
| 内存变量 | ✅ 推荐 | ❌ 不适合 | XSS攻击无法读取JS变量 |
| HttpOnly Cookie | ⚠️ 可选 | ✅ 必须 | 防止XSS窃取,同时防止CSRF |
| localStorage | ❌ 危险 | ❌ 危险 | XSS可轻松读取 |
| sessionStorage | ❌ 危险 | ❌ 危险 | XSS可轻松读取 |
7.2 全局登出(SLO)的坑
| 类型 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| Frontchannel Logout | IdP通过隐藏iframe回调各SP | 兼容性较好 | 需要各SP配合配置,延迟不可控 |
| Backchannel Logout | IdP通过服务端POST请求调用各SP | 可靠、可即时 | 各SP需暴露logout endpoint |
⚠️ SLO不是银弹:即使实现了SLO,用户在IdP登出后,各SP的本地会话可能仍然有效。
最佳实践:SP的会话有效期 <= Access Token有效期,主动验证Token有效性
7.3 Token泄露应急方案
python
# 应急响应流程
async def handle_token_leak(user_id: str, token_type: str):
"""Token泄露时的紧急响应"""
# 1. 立即撤销所有Refresh Token
if token_type in ["refresh", "all"]:
await redis.delete_pattern(f"refresh_token:user:{user_id}:*")
# 2. 将用户加入风控黑名单(短期)
await redis.setex(f"risk:blacklist:{user_id}", 3600, "token_leak")
# 3. 强制用户重新认证
await redis.delete(f"session:{user_id}")
# 4. 记录审计日志
await audit_log.log(
event="token_revoke",
user_id=user_id,
reason="security_incident",
timestamp=datetime.now()
)
# 5. 通知用户(邮件/短信)
await notification_service.send_security_alert(user_id)
7.4 微服务间认证:mTLS + Service Mesh

7.5 CORS配置常见翻车
⚠️ Access-Control-Allow-Origin只能设置一次!
错误配置:
python
# 错误!重复设置CORS头
@app.after_request
def add_cors_headers(response):
response.headers['Access-Control-Allow-Origin'] = 'https://app.example.com'
response.headers.add('Access-Control-Allow-Origin', 'https://admin.example.com') # ❌ 覆盖了!
return response
正确配置:
python
# 正确:使用逗号分隔或动态判断
ALLOWED_ORIGINS = [
"https://app.example.com",
"https://admin.example.com",
"https://mobile.example.com"
]
@app.after_request
def add_cors_headers(response):
origin = request.headers.get('Origin')
if origin in ALLOWED_ORIGINS:
response.headers['Access-Control-Allow-Origin'] = origin
response.headers['Access-Control-Allow-Credentials'] = 'true'
return response
# 或者使用库(Flask-CORS)
from flask_cors import CORS
CORS(app, origins=ALLOWED_ORIGINS, supports_credentials=True)
8. 总结与展望
SSO演进路径

2026年趋势展望
| 趋势 | 影响 | 开发者应对 |
|---|---|---|
| 无密码化加速 | Passkeys覆盖50亿设备 | 优先实现FIDO2/WebAuthn支持 |
| 零信任成为标配 | 持续验证取代一次认证 | 接入自适应认证引擎 |
| AI威胁检测 | 身份安全智能化 | 关注IDR(Identity Threat Detection) |
给开发者的三条建议
1️⃣ 新项目优先选 OIDC + 成熟 IdP
不要自己造轮子写认证协议。Keycloak、Authing、Okta都有成熟实现,站在巨人肩膀上。
2️⃣ 不要自己实现认证协议CAS/OAuth/OIDC都有复杂的攻击向量和安全考量。专业的事交给专业的人(库/平台)。
3️⃣ 安全是持续运营,不是一次性项目SSO上线只是开始,持续的安全监控、Token审计、漏洞响应才是长期工作。
9. 互动引导 + 转载声明 + 参考链接
互动引导
恭喜你! 到这里,你已经掌握了SSO的核心概念、协议原理、企业级架构和生产避坑指南。
现在,是时候检验学习成果了:
-
思考题:你们公司的SSO落地了吗?遇到过什么坑?欢迎在评论区分享!
-
动手题:试着运行本文的Flask CAS Server示例,完整走一遍登录-获取ST-验证ST的流程。
-
进阶题:如果要在微服务架构中实现SSO,你会选择哪种方案(网关拦截/Service Mesh/各服务独立验证)?为什么?
📢 评论区见! 如果你觉得这篇文章有帮助,欢迎点赞、转发给需要的小伙伴!
📢 转载声明
本文欢迎转载,但请务必保留以下信息:
- 作者:Java后端的Ai之路
- 来源:CSDN
- 微信转载需联系作者授权
商业转载请联系作者获取授权,非商业转载请注明出处并保持原文完整。
📚 参考链接
官方文档:
- CAS Protocol 3.0 Specification
- OAuth 2.0 RFC 6749
- OpenID Connect Core 1.0
- SAML 2.0 OASIS Standard
- WebAuthn W3C Recommendation
技术文档:
行业报告:
- FIDO Alliance 2026 Industry Report - Passkeys覆盖超50亿设备数据来源
- Gartner 2026 Zero Trust Prediction - 零信任安全趋势
- Forrester Wave: Workforce Identity Security 2026 - Okta领导者象限
优秀博客: