单点登录 (SSO):统一身份管理
单点登录(Single Sign-On,SSO)是一种身份认证机制,允许用户使用一组凭据登录一次后,即可访问多个相互信任的应用系统,无需重复输入密码。SSO 是企业级身份管理的核心组件,能显著提升用户体验和安全性。
核心概念与价值
什么是 SSO?
SSO 建立在信任关系之上:多个应用系统(服务提供方)信任同一个身份提供商(Identity Provider,IdP)。用户只需在 IdP 完成一次认证,即可访问所有已授权的应用。
为什么需要 SSO?
用户体验提升:
- 一套凭据访问所有系统,无需记忆多组密码
- 减少登录次数,提高工作效率
- 统一的用户身份视图
安全性增强:
- 集中管理认证策略,便于实施密码策略
- 减少密码暴露面,降低钓鱼风险
- 统一的审计日志,便于安全监控
运维效率提升:
- 集中化的用户生命周期管理
- 统一的权限分配和撤销
- 降低密码重置工单量
关键术语
| 术语 | 说明 |
|---|---|
| IdP(Identity Provider) | 身份提供商,负责认证用户身份并颁发令牌/断言 |
| SP(Service Provider) | 服务提供商,依赖 IdP 认证结果的应用系统 |
| RP(Relying Party) | 依赖方,OIDC 中对应 SP 的角色 |
| Assertion | 断言,IdP 签发的身份证明(SAML 中为 XML 格式) |
| ID Token | 身份令牌,OIDC 中用于证明用户身份的 JWT |
SSO 实现协议对比
现代 SSO 主要有两种实现协议:
OIDC vs SAML 详细对比
| 特性 | OpenID Connect | SAML 2.0 |
|---|---|---|
| 数据格式 | JSON / JWT | XML |
| 通信方式 | REST API + HTTP 重定向 | HTTP 重定向 + POST |
| 适用场景 | 现代 Web、移动端、API、SPA | 传统企业应用、HR/ERP 系统 |
| 实现复杂度 | 较低,SDK 丰富 | 较高,需处理 XML 和证书 |
| 元数据发现 | .well-known/openid-configuration | 需手动配置元数据 |
| 密钥管理 | JWKS 自动轮换 | 手动证书管理 |
| 移动端支持 | 原生支持(PKCE) | 不友好 |
| 审计能力 | 依赖实现 | 内置详细审计 |
| 学习曲线 | 平缓 | 陡峭 |
OIDC 实现 SSO
OpenID Connect 是实现现代 SSO 的首选方案,基于 OAuth 2.0 构建,使用 JWT 作为身份令牌。
架构图
┌─────────────┐ ┌─────────────┐
│ 应用 A │ │ 应用 B │
│ (RP/SP) │ │ (RP/SP) │
└──────┬──────┘ └──────┬──────┘
│ │
│ 1. 发现配置 │
│ GET /.well-known/openid-configuration
│ │
└──────────────┬───────────────────┘
│
▼
┌───────────────┐
│ Identity │
│ Provider │
│ (IdP) │
└───────────────┘
│
▼
┌───────────────┐
│ 用户认证 │
│ (登录页面) │
└───────────────┘
认证流程
服务端实现
配置 OIDC 客户端
// Node.js - 使用 passport-azure-ad 实现 OIDC SSO
const passport = require('passport');
const { OIDCStrategy } = require('passport-azure-ad');
const config = {
identityMetadata: 'https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration',
clientID: 'YOUR_CLIENT_ID',
responseType: 'code',
responseMode: 'query',
redirectUrl: 'https://app-a.example.com/auth/callback',
allowHttpForRedirectUrl: false,
clientSecret: 'YOUR_CLIENT_SECRET',
scope: ['openid', 'profile', 'email'],
passReqToCallback: true,
};
passport.use('oidc', new OIDCStrategy(config,
(req, iss, sub, profile, accessToken, refreshToken, done) => {
// 处理用户信息
if (!profile.oid) {
return done(new Error('No oid found'), null);
}
// 查找或创建用户
User.findOrCreate({ oid: profile.oid }, (err, user) => {
return done(err, user);
});
}
));
登录入口
// 发起 OIDC 登录
app.get('/login', passport.authenticate('oidc'));
// OIDC 回调处理
app.get('/auth/callback',
passport.authenticate('oidc', { failureRedirect: '/login' }),
(req, res) => {
// 认证成功,建立本地会话
req.session.userId = req.user.id;
res.redirect('/dashboard');
}
);
// 登出
app.get('/logout', (req, res) => {
req.logout();
// 重定向到 IdP 登出端点(可选:单点登出)
const logoutUrl = `https://login.microsoftonline.com/common/oauth2/v2.0/logout?post_logout_redirect_uri=${encodeURIComponent('https://app-a.example.com')}`;
res.redirect(logoutUrl);
});
验证 ID Token
const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');
// 从 JWKS 端点获取公钥
const client = jwksClient({
jwksUri: 'https://login.microsoftonline.com/common/discovery/v2.0/keys'
});
function getKey(header, callback) {
client.getSigningKey(header.kid, (err, key) => {
const signingKey = key.publicKey || key.rsaPublicKey;
callback(null, signingKey);
});
}
async function verifyIdToken(idToken) {
return new Promise((resolve, reject) => {
jwt.verify(idToken, getKey, {
issuer: 'https://login.microsoftonline.com/{tenant-id}/v2.0',
audience: 'YOUR_CLIENT_ID',
algorithms: ['RS256']
}, (err, decoded) => {
if (err) reject(err);
else resolve(decoded);
});
});
}
Python 实现
from authlib.integrations.flask_client import OAuth
from flask import Flask, redirect, url_for, session
app = Flask(__app__)
app.secret_key = 'your-secret-key'
oauth = OAuth(app)
# 注册 OIDC 提供商
oauth.register(
name='sso',
client_id='YOUR_CLIENT_ID',
client_secret='YOUR_CLIENT_SECRET',
server_metadata_url='https://idp.example.com/.well-known/openid-configuration',
client_kwargs={'scope': 'openid profile email'}
)
@app.route('/login')
def login():
redirect_uri = url_for('callback', _external=True)
return oauth.sso.authorize_redirect(redirect_uri)
@app.route('/callback')
def callback():
# 获取令牌
token = oauth.sso.authorize_access_token()
# 获取用户信息
userinfo = oauth.sso.parse_id_token(token)
# userinfo 包含 sub, name, email 等字段
# 建立本地会话
session['user'] = userinfo
return redirect('/dashboard')
@app.route('/logout')
def logout():
# 清除本地会话
session.pop('user', None)
# 重定向到 IdP 登出(实现单点登出)
logout_url = oauth.sso.server_metadata.get('end_session_endpoint')
if logout_url:
return redirect(f"{logout_url}?post_logout_redirect_uri={url_for('index', _external=True)}")
return redirect('/')
SAML 实现 SSO
SAML 2.0 是企业级 SSO 的传统标准,广泛应用于企业内部系统和 SaaS 应用。
架构角色
┌─────────────────────────────────────────────────────────┐
│ SAML 架构 │
├─────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ 用户 │ ─────── │ Identity │ │
│ │ (User) │ │ Provider │ │
│ └─────────────┘ │ (IdP) │ │
│ │ │ │ │
│ │ │ - 认证用户 │ │
│ │ │ - 签发断言 │ │
│ │ │ - 管理会话 │ │
│ │ └──────┬──────┘ │
│ │ │ │
│ │ SAML Assertion │ │
│ │ (XML + 数字签名) │ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Service │ │ Service │ │
│ │ Provider A │ │ Provider B │ │
│ │ (SP) │ │ (SP) │ │
│ └─────────────┘ └─────────────┘ │
│ │
└─────────────────────────────────────────────────────────┘
认证流程
SAML 支持两种发起方式:
SP 发起(最常见):
- 用户访问应用
- 应用检测未登录,重定向到 IdP
- 用户在 IdP 认证
- IdP 返回 SAML 断言给应用
- 应用验证断言,建立会话
IdP 发起:
- 用户直接访问 IdP 门户
- 用户选择要访问的应用
- IdP 直接发送断言给应用
- 应用验证并建立会话
SAML 断言结构
<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="_abc123"
IssueInstant="2024-03-15T10:30:00Z"
Version="2.0">
<saml:Issuer>https://idp.example.com</saml:Issuer>
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<!-- 数字签名 -->
</ds:Signature>
<saml:Subject>
<saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">
[email protected]
</saml:NameID>
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<saml:SubjectConfirmationData
NotOnOrAfter="2024-03-15T10:35:00Z"
Recipient="https://sp.example.com/saml/acs"/>
</saml:SubjectConfirmation>
</saml:Subject>
<saml:Conditions NotBefore="2024-03-15T10:30:00Z"
NotOnOrAfter="2024-03-15T10:35:00Z">
<saml:AudienceRestriction>
<saml:Audience>https://sp.example.com</saml:Audience>
</saml:AudienceRestriction>
</saml:Conditions>
<saml:AttributeStatement>
<saml:Attribute Name="email">
<saml:AttributeValue>[email protected]</saml:AttributeValue>
</saml:Attribute>
<saml:Attribute Name="role">
<saml:AttributeValue>admin</saml:AttributeValue>
</saml:Attribute>
</saml:AttributeStatement>
</saml:Assertion>
Python 实现(Flask)
from flask import Flask, request, redirect, session
from lxml import etree
from base64 import b64decode
import xmlsec
app = Flask(__name__)
app.secret_key = 'your-secret-key'
# SAML 配置
SAML_CONFIG = {
'sp': {
'entity_id': 'https://sp.example.com/saml/metadata',
'acs_url': 'https://sp.example.com/saml/acs',
'certificate': 'path/to/sp_certificate.pem',
},
'idp': {
'entity_id': 'https://idp.example.com',
'sso_url': 'https://idp.example.com/sso',
'slo_url': 'https://idp.example.com/slo',
'certificate': 'path/to/idp_certificate.pem',
}
}
@app.route('/login')
def login():
"""发起 SAML 认证请求"""
# 生成 SAML Request
saml_request = generate_saml_request()
# 重定向到 IdP
sso_url = SAML_CONFIG['idp']['sso_url']
return redirect(f"{sso_url}?SAMLRequest={saml_request}")
@app.route('/saml/acs', methods=['POST'])
def saml_acs():
"""处理 SAML 断言(Assertion Consumer Service)"""
# 获取 SAML Response
saml_response = request.form.get('SAMLResponse')
# Base64 解码
xml_response = b64decode(saml_response)
# 解析 XML
root = etree.fromstring(xml_response)
# 验证签名
if not verify_saml_signature(root):
return 'Invalid signature', 401
# 提取用户信息
name_id = root.find('.//saml:NameID', namespaces={'saml': 'urn:oasis:names:tc:SAML:2.0:assertion'})
attributes = root.findall('.//saml:Attribute', namespaces={'saml': 'urn:oasis:names:tc:SAML:2.0:assertion'})
user_info = {
'name_id': name_id.text,
'attributes': {attr.get('Name'): attr[0].text for attr in attributes}
}
# 建立本地会话
session['user'] = user_info
return redirect('/dashboard')
def verify_saml_signature(root):
"""验证 SAML 断言签名"""
# 使用 IdP 公钥验证签名
signature_node = xmlsec.tree.find_node(root, xmlsec.Node.SIGNATURE)
ctx = xmlsec.SignatureContext()
# 加载 IdP 证书
with open(SAML_CONFIG['idp']['certificate'], 'rb') as f:
key = xmlsec.Key.from_memory(f.read(), xmlsec.KeyFormat.CERT_PEM, None)
ctx.key = key
try:
ctx.verify(signature_node)
return True
except Exception:
return False
Java 实现(Spring Security)
@Configuration
@EnableWebSecurity
public class SamlSecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.saml2Login(saml2 -> saml2
.loginPage("/login")
.defaultSuccessUrl("/dashboard")
)
.logout(logout -> logout
.logoutSuccessUrl("/")
);
return http.build();
}
@Bean
public RelyingPartyRegistrationRepository relyingPartyRegistrations() {
RelyingPartyRegistration registration = RelyingPartyRegistrations
.fromMetadataLocation("https://idp.example.com/metadata")
.registrationId("my-idp")
.entityId("https://sp.example.com/saml/metadata")
.assertionConsumerServiceLocation("https://sp.example.com/login/saml2/sso/my-idp")
.build();
return new InMemoryRelyingPartyRegistrationRepository(registration);
}
}
单点登出 (SLO)
单点登出(Single Logout,SLO)确保用户在一个应用登出后,所有相关应用的会话都被清除。
OIDC 单点登出
// 前端登出
async function logout() {
// 清除本地会话
await fetch('/api/logout', { method: 'POST' });
// 重定向到 IdP 登出端点
const idToken = localStorage.getItem('id_token');
const logoutUrl = new URL('https://idp.example.com/connect/logout');
logoutUrl.searchParams.set('id_token_hint', idToken);
logoutUrl.searchParams.set('post_logout_redirect_uri', window.location.origin);
window.location.href = logoutUrl.toString();
}
SAML 单点登出
@app.route('/logout')
def logout():
# 生成 SAML Logout Request
logout_request = generate_saml_logout_request(session['user']['name_id'])
# 清除本地会话
session.clear()
# 重定向到 IdP 登出
slo_url = SAML_CONFIG['idp']['slo_url']
return redirect(f"{slo_url}?SAMLRequest={logout_request}")
SSO 集成最佳实践
1. 会话管理
// 会话刷新策略
class SessionManager {
constructor() {
this.refreshThreshold = 5 * 60 * 1000; // 5 分钟
}
// 检查会话是否即将过期
async checkSession() {
const session = await this.getSession();
if (!session) return false;
const expiresAt = session.expires_at;
const now = Date.now();
if (expiresAt - now < this.refreshThreshold) {
return this.refreshSession();
}
return true;
}
// 静默刷新会话
async refreshSession() {
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include'
});
return response.ok;
} catch {
return false;
}
}
}
2. 用户映射策略
def map_user_from_sso(sso_user_info):
"""
将 SSO 用户映射到本地用户模型
"""
# 方案 1: 使用唯一标识符映射
user = User.find_by(sso_id=sso_user_info['sub'])
if user:
# 更新信息
user.email = sso_user_info.get('email')
user.name = sso_user_info.get('name')
user.save()
else:
# 创建新用户(JIT Provisioning)
user = User.create(
sso_id=sso_user_info['sub'],
email=sso_user_info.get('email'),
name=sso_user_info.get('name'),
is_active=True
)
return user
3. 权限同步
// 从 SSO 同步权限
async function syncPermissions(userId, ssoGroups) {
// 将 SSO 组映射到应用角色
const roleMapping = {
'admin-group': 'admin',
'dev-group': 'developer',
'user-group': 'user'
};
const roles = ssoGroups
.map(group => roleMapping[group])
.filter(Boolean);
await updateUserRoles(userId, roles);
}
4. 安全配置检查清单
- HTTPS 强制:所有 SSO 端点必须使用 HTTPS
- 重定向 URI 验证:严格验证重定向地址,防止开放重定向
- State 参数:使用 state 参数防 CSRF(OIDC)
- 签名验证:验证所有断言/令牌的签名
- 时效检查:验证断言/令牌的有效期
- 受众验证:确认断言/令牌是发给本应用的
- 证书轮换:定期更新签名证书并通知相关方
选型决策指南
选择 OIDC 的场景
- 开发现代 Web 应用(SPA)或移动应用
- API 优先架构、微服务架构
- 需要 CIAM(客户身份管理)
- 团队熟悉 OAuth 2.0
- 需要快速集成社交登录
选择 SAML 的场景
- 集成传统企业应用(ERP、HR 系统)
- 强监管行业,需要详细审计日志
- 客户使用 ADFS、Ping Identity 等 SAML IdP
- 需要细粒度的属性传递
- 现有基础设施已基于 SAML
混合策略
大型组织通常采用混合策略:对新应用使用 OIDC,对遗留系统保留 SAML。许多 IdP(如 Okta、Azure AD)同时支持两种协议,可作为桥梁。