跳到主要内容

单点登录 (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 ConnectSAML 2.0
数据格式JSON / JWTXML
通信方式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 发起(最常见)

  1. 用户访问应用
  2. 应用检测未登录,重定向到 IdP
  3. 用户在 IdP 认证
  4. IdP 返回 SAML 断言给应用
  5. 应用验证断言,建立会话

IdP 发起

  1. 用户直接访问 IdP 门户
  2. 用户选择要访问的应用
  3. IdP 直接发送断言给应用
  4. 应用验证并建立会话

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)同时支持两种协议,可作为桥梁。

参考资料