# 第十一章:单点登录(SSO)与联邦身份 > "一次登录,处处访问 — 这是 SSO 的承诺,也是安全工程的挑战。" ```{mermaid} 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 的世界里,用户需要为每个应用维护独立的账号和密码: ```{mermaid} 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 的代价。 ```{mermaid} 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 实现方式 ### 基于 Cookie 的 SSO(同域) 适用场景:同一顶级域名下的多个子应用,例如 `app1.example.com`、`app2.example.com`。 ```{mermaid} sequenceDiagram participant U as 用户浏览器 participant Auth as auth.example.com participant App1 as app1.example.com participant App2 as app2.example.com U->>Auth: 1. 登录请求 Auth-->>U: 2. Set-Cookie: session=xxx
Domain=.example.com U->>App1: 3. 访问(Cookie 自动携带) App1-->>U: 4. ✅ 已认证 U->>App2: 5. 访问(Cookie 自动携带) App2-->>U: 6. ✅ 已认证 ``` **局限**:只能在同一顶级域名下使用。 ### 基于 Token 的 SSO(跨域) 适用场景:不同域名的应用,使用 OIDC 或 OAuth2 实现。 ```{mermaid} 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 流程 ```{mermaid} 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 | ```{mermaid} flowchart TB subgraph SAML["SAML 2.0 流程"] direction LR SA[用户] -->|访问| SSP[SP] SSP -->|AuthnRequest XML| SIdP[IdP] SIdP -->|SAML Response XML
签名的 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 联邦身份 联邦身份允许不同组织之间建立信任关系,实现跨组织的身份认证。 ```{mermaid} flowchart TB subgraph OrgA["组织 A"] IdPA["IdP A
(Azure AD)"] UserA["用户 A"] IdPA --- UserA end subgraph OrgB["组织 B"] IdPB["IdP B
(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 互相信任 | 行业联盟 | | 信任框架 | 基于标准的多方信任 | 政府/教育 | ```{mermaid} flowchart TB subgraph HubSpoke["Hub-and-Spoke 模型"] Hub["中心 IdP
(集团总部)"] SP1["SP 1
子公司 A"] SP2["SP 2
子公司 B"] SP3["SP 3
合作伙伴"] 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 的核心认证协议: ```{mermaid} 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 部署 ```bash # 快速启动 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 ``` ```yaml # 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) ```python """ 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 实现 ```python """ 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": "", "privateKey": "", }, "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": "", }, "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 配置 ```java /** * 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 roles = principal.getAttribute("Role"); List 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 配置 ```java /** * 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 oidcUserService() { OidcUserService delegate = new OidcUserService(); return userRequest -> { OidcUser oidcUser = delegate.loadUser(userRequest); // 从 Keycloak 的 realm_access.roles 中提取角色 Map realmAccess = oidcUser.getClaimAsMap("realm_access"); Collection authorities = new HashSet<>(); if (realmAccess != null && realmAccess.containsKey("roles")) { @SuppressWarnings("unchecked") List roles = (List) 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 ```java /** * 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 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 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 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 /* * 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, `

SAML SP Demo

Login`) }) // 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 /* * 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, `

Go OIDC SSO Demo

Login with Keycloak`) } 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 ```yaml # 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) ```bash #!/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 签名 | `apt install xmlsec1` | ```bash # 解码 Base64 编码的 SAML Response echo "" | 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 工具 | `cargo install jwt-cli` | | **oidc-client-ts** | 前端 OIDC 调试库 | npm 包 | ```bash # 使用 jwt-cli 解码 Token jwt decode # 手动请求 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=" | 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 方案时需考虑:协议支持、部署方式、成本、扩展性