第十一章:单点登录(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 签名

apt install xmlsec1

# 解码 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 工具

cargo install jwt-cli

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 方案时需考虑:协议支持、部署方式、成本、扩展性