第十一章:单点登录(SSO)与联邦身份
“一次登录,处处访问 — 这是 SSO 的承诺,也是安全工程的挑战。”
mindmap
root((SSO 与联邦身份))
SSO 方式
Cookie SSO
Token SSO
SAML SSO
OIDC SSO
SAML 2.0
Assertion
SP-Initiated
IdP-Initiated
联邦身份
信任关系
身份映射
跨组织
方案对比
Keycloak
Okta
Azure AD
11.1 SSO 解决什么问题
在没有 SSO 的世界里,用户需要为每个应用维护独立的账号和密码:
flowchart LR
subgraph 没有SSO
U1[用户] -->|用户名/密码 A| A1[App1]
U1 -->|用户名/密码 B| A2[App2]
U1 -->|用户名/密码 C| A3[App3]
end
style U1 fill:#f96,stroke:#333
style A1 fill:#fcc,stroke:#333
style A2 fill:#fcc,stroke:#333
style A3 fill:#fcc,stroke:#333
密码疲劳、弱密码、密码复用 — 这是没有 SSO 的代价。
flowchart LR
subgraph 有了SSO
U2[用户] -->|一次登录| IdP[IdP 身份提供者]
IdP -->|✅| B1[App1]
IdP -->|✅| B2[App2]
IdP -->|✅| B3[App3]
end
style U2 fill:#6f9,stroke:#333
style IdP fill:#69f,stroke:#333
style B1 fill:#cfc,stroke:#333
style B2 fill:#cfc,stroke:#333
style B3 fill:#cfc,stroke:#333
一套凭证、统一管理、更好的安全性。
11.2 SSO 实现方式
基于 Token 的 SSO(跨域)
适用场景:不同域名的应用,使用 OIDC 或 OAuth2 实现。
sequenceDiagram
participant U as 用户浏览器
participant IdP as IdP (Keycloak)
participant A1 as app1.com
participant A2 as app2.org
participant A3 as app3.io
U->>IdP: 1. 登录
IdP-->>U: 2. 返回 id_token
U->>A1: 3. 携带 Token 访问
A1-->>U: 4. ✅
U->>A2: 5. 携带 Token 访问
A2-->>U: 6. ✅
U->>A3: 7. 携带 Token 访问
A3-->>U: 8. ✅
11.3 SAML 2.0
SAML(Security Assertion Markup Language)是企业级 SSO 的标准协议。
核心概念
概念 |
说明 |
|---|---|
IdP(Identity Provider) |
身份提供者,负责认证用户 |
SP(Service Provider) |
服务提供者,依赖 IdP 的认证结果 |
Assertion |
身份断言,包含用户身份信息 |
Binding |
消息传输方式(HTTP-POST、HTTP-Redirect) |
Metadata |
IdP 和 SP 的配置信息(XML 格式) |
SP-Initiated 流程
sequenceDiagram
participant U as 用户浏览器
participant SP as SP (应用)
participant IdP as IdP (认证)
U->>SP: 1. 访问受保护资源
SP-->>U: 2. 302 重定向(携带 AuthnRequest)
U->>IdP: 3. 重定向到 IdP
IdP-->>U: 4. 显示登录页面
U->>IdP: 5. 提交凭证
IdP->>IdP: 6. 验证凭证
IdP-->>U: 7. 返回 SAML Response(HTTP-POST)
U->>SP: 8. POST SAML Response
SP->>SP: 9. 验证签名 & 解析 Assertion
SP-->>U: 10. ✅ 登录成功,建立会话
SAML vs OIDC 对比
维度 |
SAML 2.0 |
OIDC |
|---|---|---|
数据格式 |
XML |
JSON/JWT |
传输方式 |
HTTP-POST/Redirect |
HTTP REST |
Token 大小 |
大(XML) |
小(JWT) |
移动端支持 |
差 |
好 |
实现复杂度 |
高 |
低 |
适用场景 |
企业 SSO |
Web/Mobile/API |
标准组织 |
OASIS |
OpenID Foundation |
年代 |
2005 |
2014 |
flowchart TB
subgraph SAML["SAML 2.0 流程"]
direction LR
SA[用户] -->|访问| SSP[SP]
SSP -->|AuthnRequest XML| SIdP[IdP]
SIdP -->|SAML Response XML<br/>签名的 Assertion| SSP
end
subgraph OIDC["OIDC 流程"]
direction LR
OA[用户] -->|访问| ORP[RP 应用]
ORP -->|Authorization Request| OOP[OP 提供者]
OOP -->|Authorization Code| ORP
ORP -->|Token Request| OOP
OOP -->|id_token JWT + access_token| ORP
end
style SAML fill:#ffe0b2,stroke:#e65100
style OIDC fill:#b3e5fc,stroke:#01579b
11.4 联邦身份
联邦身份允许不同组织之间建立信任关系,实现跨组织的身份认证。
flowchart TB
subgraph OrgA["组织 A"]
IdPA["IdP A<br/>(Azure AD)"]
UserA["用户 A"]
IdPA --- UserA
end
subgraph OrgB["组织 B"]
IdPB["IdP B<br/>(Okta)"]
UserB["用户 B"]
IdPB --- UserB
end
IdPA <-->|"🤝 联邦信任关系"| IdPB
UserA -->|"访问组织 B 资源"| OrgB
UserB -->|"访问组织 A 资源"| OrgA
style OrgA fill:#e3f2fd,stroke:#1565c0
style OrgB fill:#fce4ec,stroke:#c62828
联邦信任模型
模型 |
描述 |
适用场景 |
|---|---|---|
点对点 |
两个组织直接建立信任 |
合作伙伴 |
Hub-and-Spoke |
中心 IdP 连接多个 SP |
企业集团 |
网状 |
多个 IdP 互相信任 |
行业联盟 |
信任框架 |
基于标准的多方信任 |
政府/教育 |
flowchart TB
subgraph HubSpoke["Hub-and-Spoke 模型"]
Hub["中心 IdP<br/>(集团总部)"]
SP1["SP 1<br/>子公司 A"]
SP2["SP 2<br/>子公司 B"]
SP3["SP 3<br/>合作伙伴"]
Hub --> SP1
Hub --> SP2
Hub --> SP3
end
subgraph Mesh["网状模型"]
IdP1["IdP 1"] <--> IdP2["IdP 2"]
IdP2 <--> IdP3["IdP 3"]
IdP1 <--> IdP3
end
style HubSpoke fill:#e8f5e9,stroke:#2e7d32
style Mesh fill:#fff3e0,stroke:#e65100
11.5 Kerberos 认证协议
Kerberos 是 Windows Active Directory 的核心认证协议:
sequenceDiagram
participant C as 客户端
participant KDC as KDC (密钥分发中心)
participant S as 服务器
C->>KDC: 1. AS-REQ (用户名)
KDC-->>C: 2. AS-REP (TGT)
C->>KDC: 3. TGS-REQ (TGT + 服务名)
KDC-->>C: 4. TGS-REP (Service Ticket)
C->>S: 5. AP-REQ (Service Ticket)
S-->>C: 6. AP-REP (认证成功)
11.6 企业 SSO 方案对比
特性 |
Keycloak |
Okta |
Azure AD |
|---|---|---|---|
类型 |
开源自托管 |
SaaS |
SaaS |
协议 |
OIDC/SAML/OAuth2 |
OIDC/SAML |
OIDC/SAML/WS-Fed |
成本 |
免费 |
按用户付费 |
Microsoft 365 含 |
自定义 |
高(SPI 扩展) |
中 |
中 |
用户联邦 |
LDAP/AD/Social |
广泛 |
AD/B2B/B2C |
MFA |
内置 |
内置 |
内置 |
部署 |
Docker/K8s |
云 |
云 |
Keycloak Docker 部署
# 快速启动 Keycloak
docker run -d --name keycloak \
-p 8080:8080 \
-e KEYCLOAK_ADMIN=admin \
-e KEYCLOAK_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:latest \
start-dev
# 生产部署(PostgreSQL + HTTPS)
docker compose up -d
# docker-compose.yml
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: keycloak_password
volumes:
- pgdata:/var/lib/postgresql/data
keycloak:
image: quay.io/keycloak/keycloak:latest
command: start
environment:
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: keycloak_password
KC_HOSTNAME: auth.example.com
KC_HTTPS_CERTIFICATE_FILE: /opt/keycloak/conf/cert.pem
KC_HTTPS_CERTIFICATE_KEY_FILE: /opt/keycloak/conf/key.pem
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: secure_password
ports:
- "8443:8443"
depends_on:
- postgres
volumes:
pgdata:
11.7 SSO 方案选型指南
SAML vs OIDC vs CAS
维度 |
SAML 2.0 |
OIDC |
CAS |
|---|---|---|---|
协议类型 |
基于 XML 的断言 |
基于 OAuth2 的身份层 |
基于 Ticket 的认证 |
数据格式 |
XML |
JSON / JWT |
XML / JSON |
适用场景 |
企业 B2B SSO |
Web / Mobile / SPA |
高校 / 内部系统 |
移动端友好 |
❌ 差 |
✅ 好 |
⚠️ 一般 |
API 友好 |
❌ 差 |
✅ 好 |
⚠️ 一般 |
实现复杂度 |
高 |
低 |
中 |
社区活跃度 |
稳定(成熟) |
非常活跃 |
活跃(Apereo) |
典型 IdP |
ADFS, Shibboleth |
Keycloak, Auth0 |
Apereo CAS |
选型建议:
新项目首选 OIDC:轻量、现代、移动端友好,生态丰富
企业 B2B 集成选 SAML:大量企业 IdP(ADFS、Okta)仍以 SAML 为主
高校/教育选 CAS:Apereo CAS 在教育行业有深厚积累
混合场景:Keycloak 同时支持 SAML + OIDC,可作为协议桥接
11.8 SSO 集成实战
Python:Authlib OIDC SSO 集成(FastAPI)
"""
FastAPI + Authlib OIDC SSO 集成
依赖: pip install fastapi uvicorn authlib httpx itsdangerous
"""
from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse
from authlib.integrations.starlette_client import OAuth
from starlette.middleware.sessions import SessionMiddleware
app = FastAPI(title="OIDC SSO Demo")
app.add_middleware(SessionMiddleware, secret_key="change-me-in-production")
# ── 配置 OIDC Provider ──────────────────────────────────
oauth = OAuth()
oauth.register(
name="keycloak",
client_id="my-fastapi-app",
client_secret="my-client-secret",
server_metadata_url=(
"http://localhost:8080/realms/my-realm/"
".well-known/openid-configuration"
),
client_kwargs={"scope": "openid email profile"},
)
@app.get("/")
async def homepage(request: Request):
user = request.session.get("user")
if user:
return {
"message": f"Hello, {user['name']}!",
"email": user.get("email"),
"sub": user.get("sub"),
}
return {"message": "Not logged in", "login_url": "/login"}
@app.get("/login")
async def login(request: Request):
"""重定向到 IdP 登录页面"""
redirect_uri = request.url_for("auth_callback")
return await oauth.keycloak.authorize_redirect(request, redirect_uri)
@app.get("/callback")
async def auth_callback(request: Request):
"""处理 IdP 回调,交换 Token 并建立会话"""
token = await oauth.keycloak.authorize_access_token(request)
userinfo = token.get("userinfo")
if userinfo is None:
userinfo = await oauth.keycloak.userinfo(request)
# 将用户信息存入 Session
request.session["user"] = {
"sub": userinfo["sub"],
"name": userinfo.get("name", ""),
"email": userinfo.get("email", ""),
}
return RedirectResponse(url="/")
@app.get("/logout")
async def logout(request: Request):
"""登出:清除本地会话 + 重定向到 IdP 登出端点"""
request.session.pop("user", None)
# 可选:重定向到 Keycloak 的 end_session_endpoint
return RedirectResponse(url="/")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=5000)
Python:python3-saml SAML SP 实现
"""
FastAPI + python3-saml SAML SP 实现
依赖: pip install fastapi uvicorn python3-saml
"""
from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse, HTMLResponse
from onelogin.saml2.auth import OneLogin_Saml2_Auth
from onelogin.saml2.utils import OneLogin_Saml2_Utils
app = FastAPI(title="SAML SP Demo")
# ── SAML 配置 ────────────────────────────────────────────
SAML_SETTINGS = {
"strict": True,
"debug": False,
"sp": {
"entityId": "https://myapp.example.com/metadata",
"assertionConsumerService": {
"url": "https://myapp.example.com/saml/acs",
"binding": (
"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
),
},
"singleLogoutService": {
"url": "https://myapp.example.com/saml/sls",
"binding": (
"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
),
},
"x509cert": "<SP_CERT>",
"privateKey": "<SP_PRIVATE_KEY>",
},
"idp": {
"entityId": "https://idp.example.com/metadata",
"singleSignOnService": {
"url": "https://idp.example.com/sso",
"binding": (
"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
),
},
"x509cert": "<IDP_CERT>",
},
"security": {
"authnRequestsSigned": True,
"wantAssertionsSigned": True,
"wantNameIdEncrypted": False,
"signatureAlgorithm": (
"http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"
),
},
}
def _prepare_saml_request(request: Request) -> dict:
"""将 FastAPI Request 转换为 python3-saml 所需格式"""
return {
"https": "on" if request.url.scheme == "https" else "off",
"http_host": request.url.hostname,
"script_name": request.url.path,
"get_data": dict(request.query_params),
"post_data": {}, # POST 数据需异步读取后填入
}
@app.get("/saml/login")
async def saml_login(request: Request):
"""SP-Initiated SSO:生成 AuthnRequest 并重定向到 IdP"""
req = _prepare_saml_request(request)
auth = OneLogin_Saml2_Auth(req, SAML_SETTINGS)
sso_url = auth.login()
return RedirectResponse(url=sso_url)
@app.post("/saml/acs")
async def saml_acs(request: Request):
"""Assertion Consumer Service:处理 IdP 返回的 SAML Response"""
form_data = await request.form()
req = _prepare_saml_request(request)
req["post_data"] = dict(form_data)
auth = OneLogin_Saml2_Auth(req, SAML_SETTINGS)
auth.process_response()
errors = auth.get_errors()
if errors:
return HTMLResponse(
f"SAML Error: {', '.join(errors)}", status_code=400
)
if not auth.is_authenticated():
return HTMLResponse("Authentication failed", status_code=401)
# 提取用户属性
attributes = auth.get_attributes()
name_id = auth.get_nameid()
return {
"authenticated": True,
"name_id": name_id,
"attributes": attributes,
"session_index": auth.get_session_index(),
}
@app.get("/saml/metadata")
async def saml_metadata(request: Request):
"""暴露 SP Metadata 供 IdP 导入"""
req = _prepare_saml_request(request)
auth = OneLogin_Saml2_Auth(req, SAML_SETTINGS)
metadata = auth.get_settings().get_sp_metadata()
errors = auth.get_settings().validate_metadata(metadata)
if errors:
return HTMLResponse(
f"Metadata Error: {', '.join(errors)}", status_code=500
)
return HTMLResponse(content=metadata, media_type="application/xml")
Java:Spring Security SAML2 配置
/**
* Spring Boot 3 + Spring Security SAML2 SSO 配置
* 依赖: spring-boot-starter-security, spring-security-saml2-service-provider
*/
// ── application.yml ──
/*
spring:
security:
saml2:
relyingparty:
registration:
keycloak:
entity-id: my-saml-sp
assertingparty:
metadata-uri: >-
http://localhost:8080/realms/my-realm/protocol/saml/descriptor
signing:
credentials:
- private-key-location: classpath:saml/sp-private.key
certificate-location: classpath:saml/sp-cert.pem
acs:
location: "{baseUrl}/login/saml2/sso/{registrationId}"
*/
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.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.saml2.provider.service.authentication.OpenSaml4AuthenticationProvider;
import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal;
import org.springframework.security.web.SecurityFilterChain;
import java.util.List;
import java.util.stream.Collectors;
@Configuration
@EnableWebSecurity
public class Saml2SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// 自定义 SAML 认证提供者:从 Assertion 属性中提取角色
OpenSaml4AuthenticationProvider authProvider =
new OpenSaml4AuthenticationProvider();
authProvider.setResponseAuthenticationConverter(responseToken -> {
// 默认转换
var authentication = OpenSaml4AuthenticationProvider
.createDefaultResponseAuthenticationConverter()
.convert(responseToken);
// 从 SAML 属性中提取角色
Saml2AuthenticatedPrincipal principal =
(Saml2AuthenticatedPrincipal) authentication.getPrincipal();
List<String> roles = principal.getAttribute("Role");
List<GrantedAuthority> authorities = roles != null
? roles.stream()
.map(r -> new SimpleGrantedAuthority("ROLE_" + r))
.collect(Collectors.toList())
: List.of(new SimpleGrantedAuthority("ROLE_USER"));
return new org.springframework.security.saml2.provider.service
.authentication.Saml2Authentication(
principal,
authentication.getSaml2Response(),
authorities
);
});
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/public/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.saml2Login(saml2 -> saml2
.authenticationManager(
new org.springframework.security.authentication
.ProviderManager(authProvider)
)
)
.saml2Logout(logout -> {});
return http.build();
}
}
Java:Spring Security OIDC SSO 配置
/**
* Spring Boot 3 + Spring Security OIDC SSO 配置
* 依赖: spring-boot-starter-oauth2-client
*/
// ── application.yml ──
/*
spring:
security:
oauth2:
client:
registration:
keycloak:
client-id: my-spring-app
client-secret: my-client-secret
scope: openid, email, profile
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
provider:
keycloak:
issuer-uri: http://localhost:8080/realms/my-realm
*/
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.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.web.SecurityFilterChain;
import java.util.*;
import java.util.stream.Collectors;
@Configuration
@EnableWebSecurity
public class OidcSecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/public/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.oauth2Login(oauth2 -> oauth2
.userInfoEndpoint(userInfo -> userInfo
.oidcUserService(oidcUserService())
)
)
.logout(logout -> logout
.logoutSuccessUrl("/")
);
return http.build();
}
/**
* 自定义 OIDC UserService:从 Keycloak Token 中提取 realm_roles
*/
@Bean
public OAuth2UserService<OidcUserRequest, OidcUser> oidcUserService() {
OidcUserService delegate = new OidcUserService();
return userRequest -> {
OidcUser oidcUser = delegate.loadUser(userRequest);
// 从 Keycloak 的 realm_access.roles 中提取角色
Map<String, Object> realmAccess =
oidcUser.getClaimAsMap("realm_access");
Collection<GrantedAuthority> authorities = new HashSet<>();
if (realmAccess != null && realmAccess.containsKey("roles")) {
@SuppressWarnings("unchecked")
List<String> roles = (List<String>) realmAccess.get("roles");
authorities = roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.collect(Collectors.toSet());
}
return new DefaultOidcUser(
authorities,
oidcUser.getIdToken(),
oidcUser.getUserInfo()
);
};
}
}
Java:Keycloak Admin Client
/**
* Keycloak Admin Client — 创建 Realm、Client、User
* 依赖: org.keycloak:keycloak-admin-client:24.0.0
*/
import org.keycloak.admin.client.Keycloak;
import org.keycloak.admin.client.KeycloakBuilder;
import org.keycloak.representations.idm.*;
import jakarta.ws.rs.core.Response;
import java.util.List;
import java.util.Map;
public class KeycloakAdminExample {
private final Keycloak keycloak;
public KeycloakAdminExample(String serverUrl, String adminUser,
String adminPassword) {
this.keycloak = KeycloakBuilder.builder()
.serverUrl(serverUrl)
.realm("master")
.clientId("admin-cli")
.username(adminUser)
.password(adminPassword)
.build();
}
/**
* 创建新 Realm
*/
public void createRealm(String realmName) {
RealmRepresentation realm = new RealmRepresentation();
realm.setRealm(realmName);
realm.setEnabled(true);
realm.setDisplayName(realmName + " Realm");
realm.setRegistrationAllowed(false);
realm.setSslRequired("external");
realm.setLoginWithEmailAllowed(true);
// Token 配置
realm.setAccessTokenLifespan(300); // 5 分钟
realm.setSsoSessionIdleTimeout(1800); // 30 分钟
realm.setSsoSessionMaxLifespan(36000); // 10 小时
keycloak.realms().create(realm);
System.out.println("Realm created: " + realmName);
}
/**
* 创建 OIDC Client
*/
public void createOidcClient(String realmName, String clientId,
String clientSecret,
List<String> redirectUris) {
ClientRepresentation client = new ClientRepresentation();
client.setClientId(clientId);
client.setSecret(clientSecret);
client.setEnabled(true);
client.setProtocol("openid-connect");
client.setPublicClient(false);
client.setDirectAccessGrantsEnabled(false);
client.setRedirectUris(redirectUris);
client.setWebOrigins(List.of("+"));
client.setStandardFlowEnabled(true);
// 在 Token 中包含角色
client.setDefaultClientScopes(
List.of("openid", "email", "profile", "roles")
);
try (Response response = keycloak.realm(realmName)
.clients().create(client)) {
if (response.getStatus() == 201) {
System.out.println("Client created: " + clientId);
} else {
System.err.println("Failed: " + response.getStatusInfo());
}
}
}
/**
* 创建用户并设置密码
*/
public void createUser(String realmName, String username,
String email, String password,
List<String> roles) {
// 创建用户
UserRepresentation user = new UserRepresentation();
user.setUsername(username);
user.setEmail(email);
user.setEnabled(true);
user.setEmailVerified(true);
try (Response response = keycloak.realm(realmName)
.users().create(user)) {
if (response.getStatus() != 201) {
System.err.println("Failed to create user: "
+ response.getStatusInfo());
return;
}
}
// 获取用户 ID
List<UserRepresentation> users = keycloak.realm(realmName)
.users().searchByUsername(username, true);
String userId = users.get(0).getId();
// 设置密码
CredentialRepresentation credential = new CredentialRepresentation();
credential.setType(CredentialRepresentation.PASSWORD);
credential.setValue(password);
credential.setTemporary(false);
keycloak.realm(realmName).users().get(userId)
.resetPassword(credential);
// 分配 Realm 角色
for (String roleName : roles) {
RoleRepresentation role = keycloak.realm(realmName)
.roles().get(roleName).toRepresentation();
keycloak.realm(realmName).users().get(userId)
.roles().realmLevel().add(List.of(role));
}
System.out.println("User created: " + username
+ " with roles: " + roles);
}
// ── 使用示例 ──
public static void main(String[] args) {
var admin = new KeycloakAdminExample(
"http://localhost:8080", "admin", "admin");
// 1. 创建 Realm
admin.createRealm("my-app-realm");
// 2. 创建 Client
admin.createOidcClient("my-app-realm", "my-web-app",
"super-secret",
List.of("http://localhost:3000/callback"));
// 3. 创建用户
admin.createUser("my-app-realm", "alice",
"alice@example.com", "password123",
List.of("admin"));
admin.createUser("my-app-realm", "bob",
"bob@example.com", "password123",
List.of("user"));
}
}
Go:crewjam/saml SAML SP 实现
/*
* Go SAML SP 实现 — 使用 crewjam/saml
* 依赖: go get github.com/crewjam/saml
*/
package main
import (
"context"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"fmt"
"log"
"net/http"
"net/url"
"os"
"github.com/crewjam/saml/samlsp"
)
func main() {
// 加载 SP 证书和私钥
keyPair, err := tls.LoadX509KeyPair("sp-cert.pem", "sp-key.pem")
if err != nil {
log.Fatalf("Failed to load key pair: %v", err)
}
keyPair.Leaf, err = x509.ParseCertificate(keyPair.Certificate[0])
if err != nil {
log.Fatalf("Failed to parse certificate: %v", err)
}
// IdP Metadata URL
idpMetadataURL, _ := url.Parse(
"http://localhost:8080/realms/my-realm/protocol/saml/descriptor",
)
idpMetadata, err := samlsp.FetchMetadata(
context.Background(),
http.DefaultClient,
*idpMetadataURL,
)
if err != nil {
log.Fatalf("Failed to fetch IdP metadata: %v", err)
}
// SP 根 URL
rootURL, _ := url.Parse("http://localhost:8000")
// 创建 SAML 中间件
samlSP, err := samlsp.New(samlsp.Options{
URL: *rootURL,
Key: keyPair.PrivateKey.(*rsa.PrivateKey),
Certificate: keyPair.Leaf,
IDPMetadata: idpMetadata,
})
if err != nil {
log.Fatalf("Failed to create SAML SP: %v", err)
}
// 受保护的路由
protectedHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 从 SAML Session 中获取用户信息
session := samlsp.SessionFromContext(r.Context())
if session == nil {
http.Error(w, "No session", http.StatusUnauthorized)
return
}
sa, ok := session.(samlsp.SessionWithAttributes)
if !ok {
http.Error(w, "Invalid session type", http.StatusInternalServerError)
return
}
attrs := sa.GetAttributes()
fmt.Fprintf(w, "Hello, %s!\n", attrs.Get("displayName"))
fmt.Fprintf(w, "Email: %s\n", attrs.Get("email"))
fmt.Fprintf(w, "Roles: %v\n", attrs["Role"])
})
// 公开路由
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `<h1>SAML SP Demo</h1><a href="/protected">Login</a>`)
})
// SAML 端点(/saml/acs, /saml/metadata 等由中间件自动处理)
http.Handle("/saml/", samlSP)
// 受保护路由使用 SAML 中间件
http.Handle("/protected",
samlSP.RequireAccount(protectedHandler))
log.Println("SAML SP listening on :8000")
log.Fatal(http.ListenAndServe(":8000", nil))
}
Go:go-oidc SSO 集成
/*
* Go OIDC SSO 集成 — 使用 coreos/go-oidc + oauth2
* 依赖:
* go get github.com/coreos/go-oidc/v3
* go get golang.org/x/oauth2
*/
package main
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
var (
issuerURL = "http://localhost:8080/realms/my-realm"
clientID = "my-go-app"
clientSecret = "my-client-secret"
redirectURL = "http://localhost:8080/callback"
provider *oidc.Provider
oauth2Config oauth2.Config
verifier *oidc.IDTokenVerifier
)
func init() {
ctx := context.Background()
var err error
// 发现 OIDC Provider 配置
provider, err = oidc.NewProvider(ctx, issuerURL)
if err != nil {
log.Fatalf("Failed to discover OIDC provider: %v", err)
}
oauth2Config = oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: redirectURL,
Endpoint: provider.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "email", "profile"},
}
verifier = provider.Verifier(&oidc.Config{ClientID: clientID})
}
// 生成随机 state 防止 CSRF
func generateState() string {
b := make([]byte, 16)
rand.Read(b)
return base64.URLEncoding.EncodeToString(b)
}
func handleLogin(w http.ResponseWriter, r *http.Request) {
state := generateState()
// 实际项目中应将 state 存入 session 或加密 cookie
http.SetCookie(w, &http.Cookie{
Name: "oauth_state",
Value: state,
Path: "/",
MaxAge: 300,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, oauth2Config.AuthCodeURL(state), http.StatusFound)
}
func handleCallback(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// 验证 state
stateCookie, err := r.Cookie("oauth_state")
if err != nil || r.URL.Query().Get("state") != stateCookie.Value {
http.Error(w, "Invalid state", http.StatusBadRequest)
return
}
// 用 authorization code 交换 token
code := r.URL.Query().Get("code")
token, err := oauth2Config.Exchange(ctx, code)
if err != nil {
http.Error(w, "Token exchange failed: "+err.Error(),
http.StatusInternalServerError)
return
}
// 提取并验证 id_token
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
http.Error(w, "No id_token in response",
http.StatusInternalServerError)
return
}
idToken, err := verifier.Verify(ctx, rawIDToken)
if err != nil {
http.Error(w, "ID token verification failed: "+err.Error(),
http.StatusUnauthorized)
return
}
// 解析 Claims
var claims struct {
Sub string `json:"sub"`
Name string `json:"name"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
}
if err := idToken.Claims(&claims); err != nil {
http.Error(w, "Failed to parse claims", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Login successful!",
"sub": claims.Sub,
"name": claims.Name,
"email": claims.Email,
"email_verified": claims.EmailVerified,
"access_token": token.AccessToken,
"token_expiry": token.Expiry.Format(time.RFC3339),
})
}
func handleHome(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `<h1>Go OIDC SSO Demo</h1>
<a href="/login">Login with Keycloak</a>`)
}
func main() {
http.HandleFunc("/", handleHome)
http.HandleFunc("/login", handleLogin)
http.HandleFunc("/callback", handleCallback)
log.Println("OIDC SSO demo listening on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
11.9 Keycloak 快速部署
生产级 docker-compose.yml
# docker-compose.yml — Keycloak 生产部署
version: "3.9"
services:
postgres:
image: postgres:16-alpine
container_name: keycloak-db
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: ${KC_DB_PASSWORD:-keycloak_secret}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U keycloak"]
interval: 10s
timeout: 5s
retries: 5
networks:
- keycloak-net
keycloak:
image: quay.io/keycloak/keycloak:24.0
container_name: keycloak
command: start --optimized
environment:
# 数据库
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: ${KC_DB_PASSWORD:-keycloak_secret}
# 主机名
KC_HOSTNAME: ${KC_HOSTNAME:-auth.example.com}
KC_HOSTNAME_STRICT: "true"
KC_PROXY: edge
# HTTPS(由反向代理终止 TLS 时可省略)
# KC_HTTPS_CERTIFICATE_FILE: /opt/keycloak/conf/cert.pem
# KC_HTTPS_CERTIFICATE_KEY_FILE: /opt/keycloak/conf/key.pem
# 管理员
KEYCLOAK_ADMIN: ${KC_ADMIN:-admin}
KEYCLOAK_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD:-change_me}
# 性能
KC_HEALTH_ENABLED: "true"
KC_METRICS_ENABLED: "true"
ports:
- "8080:8080"
- "8443:8443"
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/health/ready || exit 1"]
interval: 30s
timeout: 10s
retries: 3
networks:
- keycloak-net
volumes:
pgdata:
networks:
keycloak-net:
driver: bridge
初始化脚本(通过 Admin REST API)
#!/bin/bash
# keycloak-init.sh — 初始化 Realm、Client、角色和测试用户
set -euo pipefail
KC_URL="${KC_URL:-http://localhost:8080}"
KC_ADMIN="${KC_ADMIN:-admin}"
KC_ADMIN_PASSWORD="${KC_ADMIN_PASSWORD:-change_me}"
REALM="my-app"
echo "⏳ Waiting for Keycloak to be ready..."
until curl -sf "${KC_URL}/health/ready" > /dev/null 2>&1; do
sleep 2
done
echo "✅ Keycloak is ready"
# 获取 Admin Token
TOKEN=$(curl -s -X POST "${KC_URL}/realms/master/protocol/openid-connect/token" \
-d "client_id=admin-cli" \
-d "username=${KC_ADMIN}" \
-d "password=${KC_ADMIN_PASSWORD}" \
-d "grant_type=password" | jq -r '.access_token')
AUTH="Authorization: Bearer ${TOKEN}"
# 1. 创建 Realm
echo "📦 Creating realm: ${REALM}"
curl -s -X POST "${KC_URL}/admin/realms" \
-H "${AUTH}" \
-H "Content-Type: application/json" \
-d '{
"realm": "'"${REALM}"'",
"enabled": true,
"registrationAllowed": false,
"loginWithEmailAllowed": true,
"accessTokenLifespan": 300,
"ssoSessionIdleTimeout": 1800
}'
# 2. 创建角色
for ROLE in admin editor viewer; do
echo "🔑 Creating role: ${ROLE}"
curl -s -X POST "${KC_URL}/admin/realms/${REALM}/roles" \
-H "${AUTH}" \
-H "Content-Type: application/json" \
-d '{"name": "'"${ROLE}"'"}'
done
# 3. 创建 OIDC Client
echo "🔧 Creating OIDC client: my-web-app"
curl -s -X POST "${KC_URL}/admin/realms/${REALM}/clients" \
-H "${AUTH}" \
-H "Content-Type: application/json" \
-d '{
"clientId": "my-web-app",
"secret": "my-client-secret",
"enabled": true,
"protocol": "openid-connect",
"publicClient": false,
"standardFlowEnabled": true,
"directAccessGrantsEnabled": false,
"redirectUris": ["http://localhost:3000/callback", "http://localhost:5000/callback"],
"webOrigins": ["+"]
}'
# 4. 创建测试用户
create_user() {
local USERNAME=$1 EMAIL=$2 PASSWORD=$3 ROLE=$4
echo "👤 Creating user: ${USERNAME}"
curl -s -X POST "${KC_URL}/admin/realms/${REALM}/users" \
-H "${AUTH}" \
-H "Content-Type: application/json" \
-d '{
"username": "'"${USERNAME}"'",
"email": "'"${EMAIL}"'",
"enabled": true,
"emailVerified": true,
"credentials": [{"type": "password", "value": "'"${PASSWORD}"'", "temporary": false}]
}'
# 获取用户 ID
USER_ID=$(curl -s "${KC_URL}/admin/realms/${REALM}/users?username=${USERNAME}" \
-H "${AUTH}" | jq -r '.[0].id')
# 获取角色 ID
ROLE_JSON=$(curl -s "${KC_URL}/admin/realms/${REALM}/roles/${ROLE}" \
-H "${AUTH}")
# 分配角色
curl -s -X POST "${KC_URL}/admin/realms/${REALM}/users/${USER_ID}/role-mappings/realm" \
-H "${AUTH}" \
-H "Content-Type: application/json" \
-d "[${ROLE_JSON}]"
echo " → Assigned role: ${ROLE}"
}
create_user "alice" "alice@example.com" "password123" "admin"
create_user "bob" "bob@example.com" "password123" "editor"
create_user "carol" "carol@example.com" "password123" "viewer"
echo ""
echo "🎉 Keycloak initialization complete!"
echo " Realm: ${REALM}"
echo " URL: ${KC_URL}/realms/${REALM}/.well-known/openid-configuration"
echo " Users: alice (admin), bob (editor), carol (viewer)"
11.10 SSO 调试技巧
SAML 调试
工具 |
用途 |
安装方式 |
|---|---|---|
SAML Tracer |
浏览器扩展,捕获 SAML Request/Response |
Firefox/Chrome 扩展商店 |
SAML Developer Tools |
在线解码 SAML XML |
samltool.com |
xmlsec1 |
命令行验证 SAML 签名 |
|
# 解码 Base64 编码的 SAML Response
echo "<base64_saml_response>" | base64 -d | xmllint --format -
# 验证 SAML 签名
xmlsec1 --verify --pubkey-cert-pem idp-cert.pem saml-response.xml
OIDC / JWT 调试
工具 |
用途 |
地址 |
|---|---|---|
jwt.io |
在线解码和验证 JWT |
https://jwt.io |
jwt-cli |
命令行 JWT 工具 |
|
oidc-client-ts |
前端 OIDC 调试库 |
npm 包 |
# 使用 jwt-cli 解码 Token
jwt decode <token>
# 手动请求 Token(Resource Owner Password — 仅调试用)
curl -s -X POST "http://localhost:8080/realms/my-realm/protocol/openid-connect/token" \
-d "client_id=my-web-app" \
-d "client_secret=my-client-secret" \
-d "username=alice" \
-d "password=password123" \
-d "grant_type=password" \
-d "scope=openid email profile" | jq .
# 查看 OIDC Discovery 文档
curl -s "http://localhost:8080/realms/my-realm/.well-known/openid-configuration" | jq .
# 内省 Token
curl -s -X POST "http://localhost:8080/realms/my-realm/protocol/openid-connect/token/introspect" \
-d "client_id=my-web-app" \
-d "client_secret=my-client-secret" \
-d "token=<access_token>" | jq .
常见问题排查清单
问题 |
可能原因 |
排查方法 |
|---|---|---|
SAML 签名验证失败 |
IdP 证书过期或不匹配 |
检查 SP 配置中的 IdP 证书 |
OIDC redirect_uri 不匹配 |
Client 配置的 redirect_uri 与实际不一致 |
检查 IdP Client 配置 |
Token 过期太快 |
access_token_lifespan 设置过短 |
调整 Realm Token 配置 |
CORS 错误 |
Web Origins 未配置 |
在 Client 中添加 Web Origins |
登出后仍可访问 |
未清除 IdP Session |
使用 Front-Channel / Back-Channel Logout |
时钟偏差导致验证失败 |
服务器时间不同步 |
配置 NTP,增加 clockSkew 容忍度 |
11.11 小结
SSO 通过一次登录访问多个应用,提升用户体验和安全性
SAML 2.0 是企业级 SSO 标准,但较重;OIDC 更现代、更轻量
联邦身份 实现跨组织的身份互信
Keycloak 是最流行的开源 SSO 方案,支持 OIDC/SAML/OAuth2
方案选型:新项目首选 OIDC,企业 B2B 用 SAML,高校用 CAS
调试工具:SAML Tracer 抓包、jwt.io 解码、Discovery 端点验证配置
选择 SSO 方案时需考虑:协议支持、部署方式、成本、扩展性