第八章:OpenID Connect 身份认证

“OAuth 2.0 告诉你’这个人授权了’,OIDC 告诉你’这个人是谁’。”

        mindmap
  root((OpenID Connect))
    核心概念
      ID Token
      UserInfo
      Claims
    流程
      Authorization Code
      Implicit
      Hybrid
    发现
      Discovery
      JWKS
    提供商
      Keycloak
      Auth0
      Okta
    

8.1 OIDC 与 OAuth 2.0 的关系

OIDC(OpenID Connect)是建立在 OAuth 2.0 之上的身份认证层

        block-beta
  columns 1
  block:oidc["OpenID Connect\n(身份认证 — 你是谁?)\nID Token · UserInfo · Claims"]
  end
  block:oauth["OAuth 2.0\n(授权 — 你能做什么?)\nAccess Token · Scope · Grant"]
  end
  block:http["HTTP / TLS"]
  end

  style oidc fill:#e0f0ff,stroke:#3388cc
  style oauth fill:#fff3e0,stroke:#cc8833
  style http fill:#f0f0f0,stroke:#999999
    

维度

OAuth 2.0

OIDC

目的

授权(访问资源)

认证(确认身份)

核心 Token

Access Token

ID Token

用户信息

不标准

UserInfo Endpoint

发现机制

.well-known/openid-configuration

Scope

自定义

openid, profile, email

8.2 ID Token

ID Token 是一个 JWT,包含用户身份信息:

{
  "iss": "https://auth.example.com",
  "sub": "user-123",
  "aud": "my-client-app",
  "exp": 1709510400,
  "iat": 1709506800,
  "nonce": "abc123",
  "name": "Walter Fan",
  "email": "walter@example.com",
  "email_verified": true,
  "picture": "https://example.com/photo.jpg"
}

标准 Claims

Claim

说明

必须

iss

签发者

sub

用户唯一标识

aud

受众(Client ID)

exp

过期时间

iat

签发时间

nonce

防重放攻击

条件必须

name

用户全名

email

邮箱

picture

头像 URL

ID Token 验证流程

客户端收到 ID Token 后,必须按以下步骤验证,任何一步失败都应拒绝该 Token:

        flowchart TD
    A["收到 ID Token(JWT)"] --> B{"解码 JWT Header\n获取 kid 和 alg"}
    B --> C{"从 JWKS 端点\n获取对应公钥"}
    C --> D{"验证签名\n(RS256 / ES256)"}
    D -->|失败| REJECT["❌ 拒绝 Token"]
    D -->|成功| E{"检查 iss\n是否匹配预期签发者"}
    E -->|不匹配| REJECT
    E -->|匹配| F{"检查 aud\n是否包含本客户端 Client ID"}
    F -->|不包含| REJECT
    F -->|包含| G{"检查 exp\n是否已过期"}
    G -->|已过期| REJECT
    G -->|未过期| H{"检查 iat\n签发时间是否合理"}
    H -->|异常| REJECT
    H -->|合理| I{"检查 nonce\n是否匹配会话中存储的值"}
    I -->|不匹配| REJECT
    I -->|匹配| J["✅ Token 有效\n提取用户 Claims"]

    style REJECT fill:#ffcccc,stroke:#cc3333
    style J fill:#ccffcc,stroke:#33cc33
    

验证要点说明:

  1. 签名验证:从 jwks_uri 获取公钥集合,根据 JWT Header 中的 kid 选择对应公钥,验证签名完整性

  2. iss 检查:必须与 OIDC Discovery 中的 issuer 完全一致(包括尾部斜杠)

  3. aud 检查aud 可以是字符串或数组,必须包含本客户端的 client_id;如果 aud 包含多个值,还应检查 azp(Authorized Party)

  4. exp 检查:当前时间不得超过 exp,建议允许几秒的时钟偏差(clock skew)

  5. nonce 检查:仅在认证请求中发送了 nonce 时必须检查,用于防止重放攻击

8.3 OIDC 授权码流程

        sequenceDiagram
    autonumber
    participant U as 用户(浏览器)
    participant C as Client(Web App)
    participant IdP as OIDC Provider(IdP)

    U->>C: 访问受保护资源
    C->>U: 302 重定向到 IdP<br/>authorize?response_type=code<br/>&client_id=...&scope=openid profile email<br/>&redirect_uri=...&state=...&nonce=...
    U->>IdP: 跟随重定向,打开登录页
    IdP->>U: 显示登录 + 授权同意页面
    U->>IdP: 提交用户名/密码,同意授权
    IdP->>U: 302 重定向回 Client<br/>callback?code=AUTH_CODE&state=...
    U->>C: 跟随重定向,携带 code 和 state

    Note over C: 验证 state 参数防 CSRF

    C->>IdP: POST /token<br/>grant_type=authorization_code<br/>&code=AUTH_CODE<br/>&client_id=...&client_secret=...
    IdP->>C: 返回 id_token + access_token + refresh_token

    Note over C: 验证 id_token(签名、iss、aud、exp、nonce)

    C->>IdP: GET /userinfo<br/>Authorization: Bearer access_token
    IdP->>C: 返回用户详细信息(name, email, picture...)

    C->>U: 登录成功,建立会话
    

关键安全参数:

  • state:随机值,绑定到用户会话,防止 CSRF 攻击

  • nonce:随机值,嵌入 ID Token,防止重放攻击

  • PKCE(code_verifier / code_challenge):推荐用于所有客户端,防止授权码拦截攻击

8.4 OIDC Discovery

每个 OIDC 提供商都有一个发现端点(Discovery Endpoint),客户端可以通过它自动获取所有必要的配置信息:

GET https://auth.example.com/.well-known/openid-configuration

{
  "issuer": "https://auth.example.com",
  "authorization_endpoint": "https://auth.example.com/authorize",
  "token_endpoint": "https://auth.example.com/oauth/token",
  "userinfo_endpoint": "https://auth.example.com/userinfo",
  "jwks_uri": "https://auth.example.com/.well-known/jwks.json",
  "scopes_supported": ["openid", "profile", "email"],
  "response_types_supported": ["code", "id_token", "token id_token"],
  "id_token_signing_alg_values_supported": ["RS256", "ES256"],
  "subject_types_supported": ["public", "pairwise"]
}

Discovery 完整流程

        sequenceDiagram
    autonumber
    participant C as Client
    participant D as Discovery Endpoint
    participant J as JWKS Endpoint

    C->>D: GET /.well-known/openid-configuration
    D->>C: 返回 JSON 配置文档

    Note over C: 解析并缓存配置:<br/>authorization_endpoint<br/>token_endpoint<br/>userinfo_endpoint<br/>jwks_uri

    C->>J: GET /jwks.json(从 jwks_uri)
    J->>C: 返回公钥集合(JWK Set)

    Note over C: 缓存公钥,用于验证 ID Token 签名<br/>定期刷新(建议缓存 24h)
    

Discovery 文档关键字段说明:

字段

说明

issuer

OIDC 提供商的唯一标识,必须与 ID Token 的 iss 一致

authorization_endpoint

用户授权端点,浏览器重定向目标

token_endpoint

用授权码换取 Token 的端点

userinfo_endpoint

用 Access Token 获取用户信息的端点

jwks_uri

JSON Web Key Set 端点,包含验证签名的公钥

scopes_supported

支持的 scope 列表

response_types_supported

支持的 response_type(code, id_token 等)

id_token_signing_alg_values_supported

ID Token 签名算法(RS256, ES256 等)

token_endpoint_auth_methods_supported

Token 端点支持的认证方式

claims_supported

支持的 Claims 列表

8.5 OIDC 提供商对比

特性

Keycloak

Auth0

Okta

Google

类型

开源自托管

SaaS

SaaS

SaaS

协议

OIDC/SAML/OAuth2

OIDC/SAML

OIDC/SAML

OIDC

定价

免费

免费层+付费

付费

免费

自定义

企业功能

完整

完整

完整

有限

部署

自托管/K8s

OIDC 常见配置对比

以下表格对比了四大 OIDC 提供商在实际接入时的配置差异,帮助开发者快速上手:

配置项

Keycloak

Auth0

Okta

Google

Discovery URL

https://{host}/realms/{realm}/.well-known/openid-configuration

https://{tenant}.auth0.com/.well-known/openid-configuration

https://{domain}.okta.com/.well-known/openid-configuration

https://accounts.google.com/.well-known/openid-configuration

Issuer 格式

https://{host}/realms/{realm}

https://{tenant}.auth0.com/

https://{domain}.okta.com

https://accounts.google.com

Client 注册

Admin Console → Clients

Dashboard → Applications

Admin Console → Applications

Google Cloud Console → Credentials

Client Secret 位置

Credentials Tab

Settings → Client Secret

General → Client Credentials

OAuth 2.0 Client IDs → Download JSON

默认签名算法

RS256

RS256

RS256

RS256

PKCE 支持

✅ 默认支持

✅ 默认支持

✅ 默认支持

✅ 默认支持

自定义 Claims

Protocol Mappers

Rules / Actions

Authorization Server Claims

不支持自定义

Logout Endpoint

/realms/{realm}/protocol/openid-connect/logout

/v2/logout

/oauth2/v1/logout

无标准 RP-Initiated Logout

Token 有效期配置

Realm Settings → Tokens

API → Token Settings

Authorization Server → Access Policies

固定(1h access, 不可调)

多租户支持

通过 Realm 隔离

每个 Tenant 独立

通过 Authorization Server

单一 Google 账号体系

用户联邦/社交登录

Identity Providers 配置

Connections

Identity Providers

仅 Google 账号

免费额度

无限(自托管)

7,500 MAU

15,000 MAU(Developer)

无限(仅 Google 账号)

接入建议:

  • 内部系统 / 私有化部署:首选 Keycloak,完全可控,功能最全

  • SaaS 产品快速上线:首选 Auth0,开发体验最好,文档完善

  • 企业级 / 合规要求高:首选 Okta,SOC2/ISO27001 认证齐全

  • 仅需 Google 账号登录:直接用 Google OIDC,零成本

8.6 Python OIDC 客户端

基础 OIDC 登录(Authlib + FastAPI)

from authlib.integrations.starlette_client import OAuth
from starlette.config import Config
from fastapi import FastAPI, Request
from starlette.middleware.sessions import SessionMiddleware

app = FastAPI()
app.add_middleware(SessionMiddleware, secret_key="session-secret")

oauth = OAuth()
oauth.register(
    name='keycloak',
    server_metadata_url='https://auth.example.com/realms/myrealm/.well-known/openid-configuration',
    client_id='my-app',
    client_secret='my-secret',
    client_kwargs={'scope': 'openid profile email'},
)

@app.get('/login')
async def login(request: Request):
    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):
    token = await oauth.keycloak.authorize_access_token(request)
    id_token = token.get('id_token')
    userinfo = token.get('userinfo')
    
    # 用户已认证
    return {
        "sub": userinfo["sub"],
        "name": userinfo.get("name"),
        "email": userinfo.get("email"),
    }

OIDC Discovery 自动配置 + ID Token 验证 + UserInfo 调用

"""
完整的 OIDC 客户端实现:
- 自动从 Discovery 端点获取配置
- ID Token 验证(含 nonce 检查)
- UserInfo 端点调用
- 完善的错误处理

依赖安装:pip install authlib httpx fastapi uvicorn itsdangerous
"""

import secrets
import time
import logging
from typing import Optional

from authlib.integrations.starlette_client import OAuth, OAuthError
from authlib.jose import jwt, JsonWebKey
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import RedirectResponse
from starlette.middleware.sessions import SessionMiddleware
import httpx

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

# ============================================================
# 配置
# ============================================================
OIDC_ISSUER = "https://auth.example.com/realms/myrealm"
OIDC_DISCOVERY_URL = f"{OIDC_ISSUER}/.well-known/openid-configuration"
CLIENT_ID = "my-app"
CLIENT_SECRET = "my-secret"
REDIRECT_URI = "http://localhost:8000/callback"
# 允许的时钟偏差(秒),用于 exp/iat 验证
CLOCK_SKEW = 5

app = FastAPI(title="OIDC Client Demo")
app.add_middleware(
    SessionMiddleware,
    secret_key="change-this-to-a-random-secret-in-production",
)

# ============================================================
# 1. OIDC Discovery 自动配置
# ============================================================
# Authlib 的 OAuth 客户端会自动从 server_metadata_url 获取
# 所有 OIDC 端点配置(authorization_endpoint, token_endpoint,
# jwks_uri, userinfo_endpoint 等),无需手动指定。
oauth = OAuth()
oauth.register(
    name="oidc",
    server_metadata_url=OIDC_DISCOVERY_URL,
    client_id=CLIENT_ID,
    client_secret=CLIENT_SECRET,
    client_kwargs={
        "scope": "openid profile email",
        # 指定 token 端点的认证方式
        "token_endpoint_auth_method": "client_secret_post",
    },
)


# ============================================================
# 2. 手动 ID Token 验证(演示底层原理)
# ============================================================
# 缓存 JWKS 公钥集合
_jwks_cache: Optional[dict] = None
_jwks_cache_time: float = 0
JWKS_CACHE_TTL = 3600  # 缓存 1 小时


async def fetch_jwks(jwks_uri: str) -> dict:
    """从 JWKS 端点获取公钥集合,带缓存。"""
    global _jwks_cache, _jwks_cache_time

    if _jwks_cache and (time.time() - _jwks_cache_time < JWKS_CACHE_TTL):
        return _jwks_cache

    async with httpx.AsyncClient() as client:
        resp = await client.get(jwks_uri)
        resp.raise_for_status()
        _jwks_cache = resp.json()
        _jwks_cache_time = time.time()
        logger.info("已刷新 JWKS 公钥缓存,共 %d 个密钥", len(_jwks_cache.get("keys", [])))
        return _jwks_cache


def verify_id_token(
    id_token_str: str,
    jwks: dict,
    issuer: str,
    client_id: str,
    nonce: Optional[str] = None,
) -> dict:
    """
    手动验证 ID Token 的完整流程。

    验证步骤:
    1. 解码 JWT 并验证签名(使用 JWKS 中的公钥)
    2. 检查 iss(签发者)
    3. 检查 aud(受众)
    4. 检查 exp(过期时间)
    5. 检查 iat(签发时间)
    6. 检查 nonce(防重放)

    Args:
        id_token_str: 原始 JWT 字符串
        jwks: JWKS 公钥集合
        issuer: 预期的签发者
        client_id: 本客户端的 Client ID
        nonce: 认证请求中发送的 nonce 值

    Returns:
        解码后的 Claims 字典

    Raises:
        HTTPException: 验证失败时抛出
    """
    try:
        # 步骤 1:验证签名并解码
        claims = jwt.decode(
            id_token_str,
            JsonWebKey.import_key_set(jwks),
        )

        now = time.time()

        # 步骤 2:检查 iss
        if claims.get("iss") != issuer:
            raise ValueError(
                f"iss 不匹配: 期望 '{issuer}', 实际 '{claims.get('iss')}'"
            )

        # 步骤 3:检查 aud
        aud = claims.get("aud")
        if isinstance(aud, str):
            if aud != client_id:
                raise ValueError(f"aud 不匹配: 期望 '{client_id}', 实际 '{aud}'")
        elif isinstance(aud, list):
            if client_id not in aud:
                raise ValueError(f"aud 列表中不包含 '{client_id}'")
            # 多受众时检查 azp
            azp = claims.get("azp")
            if azp and azp != client_id:
                raise ValueError(f"azp 不匹配: 期望 '{client_id}', 实际 '{azp}'")
        else:
            raise ValueError("aud 字段缺失或格式错误")

        # 步骤 4:检查 exp
        exp = claims.get("exp")
        if not exp or now > exp + CLOCK_SKEW:
            raise ValueError("ID Token 已过期")

        # 步骤 5:检查 iat
        iat = claims.get("iat")
        if not iat or iat > now + CLOCK_SKEW:
            raise ValueError("iat 签发时间异常(在未来)")

        # 步骤 6:检查 nonce
        if nonce is not None:
            if claims.get("nonce") != nonce:
                raise ValueError("nonce 不匹配,可能存在重放攻击")

        logger.info("ID Token 验证通过,sub=%s", claims.get("sub"))
        return dict(claims)

    except ValueError as e:
        logger.warning("ID Token 验证失败: %s", e)
        raise HTTPException(status_code=401, detail=f"ID Token 验证失败: {e}")
    except Exception as e:
        logger.error("ID Token 解码异常: %s", e)
        raise HTTPException(status_code=401, detail=f"ID Token 解码失败: {e}")


# ============================================================
# 3. 路由
# ============================================================
@app.get("/login")
async def login(request: Request):
    """发起 OIDC 登录,生成 nonce 并存入会话。"""
    # 生成 nonce 并存入 session
    nonce = secrets.token_urlsafe(32)
    request.session["oidc_nonce"] = nonce

    redirect_uri = str(request.url_for("auth_callback"))
    return await oauth.oidc.authorize_redirect(
        request,
        redirect_uri,
        nonce=nonce,
    )


@app.get("/callback")
async def auth_callback(request: Request):
    """
    OIDC 回调处理:
    1. 用授权码换取 Token
    2. 验证 ID Token(含 nonce)
    3. 调用 UserInfo 端点获取详细信息
    """
    try:
        # 用授权码换取 Token(Authlib 自动完成)
        token = await oauth.oidc.authorize_access_token(request)
    except OAuthError as e:
        logger.error("Token 交换失败: %s", e)
        raise HTTPException(status_code=400, detail=f"认证失败: {e.description}")

    # --- ID Token 验证 ---
    id_token_str = token.get("id_token")
    if not id_token_str:
        raise HTTPException(status_code=400, detail="响应中缺少 id_token")

    # 获取 OIDC 配置和 JWKS
    metadata = await oauth.oidc.load_server_metadata()
    jwks = await fetch_jwks(metadata["jwks_uri"])

    # 从 session 取出 nonce
    expected_nonce = request.session.pop("oidc_nonce", None)

    # 手动验证 ID Token(演示完整流程)
    claims = verify_id_token(
        id_token_str=id_token_str,
        jwks=jwks,
        issuer=metadata["issuer"],
        client_id=CLIENT_ID,
        nonce=expected_nonce,
    )

    # --- 调用 UserInfo 端点 ---
    access_token = token.get("access_token")
    userinfo = None
    if access_token and "userinfo_endpoint" in metadata:
        async with httpx.AsyncClient() as client:
            resp = await client.get(
                metadata["userinfo_endpoint"],
                headers={"Authorization": f"Bearer {access_token}"},
            )
            if resp.status_code == 200:
                userinfo = resp.json()
                logger.info("UserInfo 获取成功: sub=%s", userinfo.get("sub"))
            else:
                logger.warning("UserInfo 请求失败: %d", resp.status_code)

    # 建立应用会话
    request.session["user"] = {
        "sub": claims["sub"],
        "name": claims.get("name") or (userinfo or {}).get("name"),
        "email": claims.get("email") or (userinfo or {}).get("email"),
    }

    return {
        "message": "登录成功",
        "id_token_claims": claims,
        "userinfo": userinfo,
    }


@app.get("/me")
async def get_current_user(request: Request):
    """获取当前登录用户信息。"""
    user = request.session.get("user")
    if not user:
        raise HTTPException(status_code=401, detail="未登录")
    return user


@app.get("/logout")
async def logout(request: Request):
    """登出,清除会话。"""
    request.session.clear()
    return RedirectResponse(url="/")


if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000)

8.7 Java OIDC 客户端(Spring Security)

Spring Security 提供了对 OIDC 的一流支持,通过 spring-boot-starter-oauth2-client 可以快速集成。

application.yml 配置

# Spring Boot OIDC 配置
# Spring Security 会自动从 issuer-uri 获取 Discovery 文档
spring:
  security:
    oauth2:
      client:
        registration:
          # 注册名称,可自定义(用于 URL 路径:/oauth2/authorization/keycloak)
          keycloak:
            client-id: my-app
            client-secret: my-secret
            scope: openid, profile, email
            authorization-grant-type: authorization_code
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
        provider:
          keycloak:
            # Spring 会自动访问 {issuer-uri}/.well-known/openid-configuration
            issuer-uri: https://auth.example.com/realms/myrealm
            # 可选:指定从 ID Token 或 UserInfo 中提取用户名的字段
            user-name-attribute: preferred_username

# 日志配置(调试时开启)
logging:
  level:
    org.springframework.security: DEBUG
    org.springframework.security.oauth2: TRACE

SecurityConfig — OIDC 登录配置

package com.example.oidcdemo.config;

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.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * Spring Security OIDC 登录配置。
 *
 * 功能:
 * - 配置 OIDC 登录流程
 * - 自定义用户信息提取(从 ID Token 中提取角色)
 * - 配置登录/登出行为
 */
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            // 访问控制
            .authorizeHttpRequests(auth -> auth
                // 公开端点
                .requestMatchers("/", "/health", "/error").permitAll()
                // 管理端点需要 ADMIN 角色
                .requestMatchers("/admin/**").hasRole("ADMIN")
                // 其他所有请求需要认证
                .anyRequest().authenticated()
            )
            // OIDC 登录配置
            .oauth2Login(oauth2 -> oauth2
                // 自定义登录页面(可选,默认使用 Spring 生成的页面)
                .loginPage("/oauth2/authorization/keycloak")
                // 自定义用户信息服务,用于从 ID Token 提取角色
                .userInfoEndpoint(userInfo -> userInfo
                    .oidcUserService(oidcUserService())
                )
                // 登录成功后跳转
                .defaultSuccessUrl("/dashboard", true)
            )
            // 登出配置
            .logout(logout -> logout
                .logoutSuccessUrl("/")
                .invalidateHttpSession(true)
                .clearAuthentication(true)
            );

        return http.build();
    }

    /**
     * 自定义 OIDC 用户服务:从 ID Token 的 realm_access.roles 中提取角色。
     * 适用于 Keycloak 等在 ID Token 中嵌入角色信息的提供商。
     */
    @Bean
    public OAuth2UserService<OidcUserRequest, OidcUser> oidcUserService() {
        final OidcUserService delegate = new OidcUserService();

        return userRequest -> {
            // 先调用默认的 OIDC 用户服务(会调用 UserInfo 端点)
            OidcUser oidcUser = delegate.loadUser(userRequest);

            // 从 ID Token 中提取角色(Keycloak 格式)
            Set<GrantedAuthority> authorities = new HashSet<>(oidcUser.getAuthorities());

            Map<String, Object> realmAccess = oidcUser.getIdToken().getClaim("realm_access");
            if (realmAccess != null) {
                @SuppressWarnings("unchecked")
                List<String> roles = (List<String>) realmAccess.get("roles");
                if (roles != null) {
                    roles.forEach(role ->
                        authorities.add(new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
                    );
                }
            }

            // 返回包含角色信息的用户对象
            return new DefaultOidcUser(
                authorities,
                oidcUser.getIdToken(),
                oidcUser.getUserInfo(),
                "preferred_username"  // 用作 principal name 的 claim
            );
        };
    }
}

UserController — ID Token 解析和用户信息提取

package com.example.oidcdemo.controller;

import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.HashMap;
import java.util.Map;

/**
 * 用户信息控制器:演示如何从 OIDC 认证中提取用户信息。
 */
@RestController
public class UserController {

    /**
     * 获取当前登录用户的完整信息。
     * Spring Security 自动将 OIDC 用户注入 @AuthenticationPrincipal。
     */
    @GetMapping("/me")
    public Map<String, Object> getCurrentUser(@AuthenticationPrincipal OidcUser oidcUser) {
        Map<String, Object> userInfo = new HashMap<>();

        // --- 从 ID Token 提取标准 Claims ---
        userInfo.put("sub", oidcUser.getSubject());                    // 用户唯一标识
        userInfo.put("name", oidcUser.getFullName());                  // 全名
        userInfo.put("email", oidcUser.getEmail());                    // 邮箱
        userInfo.put("email_verified", oidcUser.getEmailVerified());   // 邮箱是否验证
        userInfo.put("preferred_username", oidcUser.getPreferredUsername()); // 用户名

        // --- ID Token 元数据 ---
        userInfo.put("issuer", oidcUser.getIssuer().toString());       // 签发者
        userInfo.put("issued_at", oidcUser.getIssuedAt());             // 签发时间
        userInfo.put("expires_at", oidcUser.getExpiresAt());           // 过期时间
        userInfo.put("nonce", oidcUser.getNonce());                    // nonce 值

        // --- 从 UserInfo 端点获取的额外信息 ---
        if (oidcUser.getUserInfo() != null) {
            userInfo.put("userinfo_claims", oidcUser.getUserInfo().getClaims());
        }

        // --- 用户角色 ---
        userInfo.put("authorities", oidcUser.getAuthorities().stream()
            .map(Object::toString)
            .toList());

        return userInfo;
    }

    /**
     * 获取原始 ID Token(JWT 字符串)。
     * 可用于传递给下游微服务。
     */
    @GetMapping("/token")
    public Map<String, Object> getTokenInfo(@AuthenticationPrincipal OidcUser oidcUser) {
        Map<String, Object> tokenInfo = new HashMap<>();
        tokenInfo.put("id_token", oidcUser.getIdToken().getTokenValue());
        tokenInfo.put("claims", oidcUser.getIdToken().getClaims());
        return tokenInfo;
    }

    @GetMapping("/dashboard")
    public Map<String, String> dashboard(@AuthenticationPrincipal OidcUser oidcUser) {
        return Map.of(
            "message", "欢迎, " + oidcUser.getFullName() + "!",
            "email", oidcUser.getEmail() != null ? oidcUser.getEmail() : "未提供"
        );
    }
}

8.8 Go OIDC 客户端

使用 github.com/coreos/go-oidc/v3 库实现完整的 OIDC 登录流程。

/*
完整的 Go OIDC 客户端实现:
- OIDC Provider Discovery(自动获取配置)
- Authorization Code Flow(含 state 和 nonce)
- ID Token 验证(签名、iss、aud、exp、nonce)
- UserInfo 端点调用

依赖安装:
  go get github.com/coreos/go-oidc/v3/oidc
  go get golang.org/x/oauth2
*/

package main

import (
	"context"
	"crypto/rand"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"os"
	"sync"
	"time"

	"github.com/coreos/go-oidc/v3/oidc"
	"golang.org/x/oauth2"
)

// ============================================================
// 配置
// ============================================================

// Config 保存 OIDC 客户端配置
type Config struct {
	IssuerURL    string // OIDC Provider 的 Issuer URL
	ClientID     string // 客户端 ID
	ClientSecret string // 客户端密钥
	RedirectURL  string // 回调 URL
	ListenAddr   string // 监听地址
}

func loadConfig() Config {
	return Config{
		IssuerURL:    getEnv("OIDC_ISSUER_URL", "https://auth.example.com/realms/myrealm"),
		ClientID:     getEnv("OIDC_CLIENT_ID", "my-app"),
		ClientSecret: getEnv("OIDC_CLIENT_SECRET", "my-secret"),
		RedirectURL:  getEnv("OIDC_REDIRECT_URL", "http://localhost:8080/callback"),
		ListenAddr:   getEnv("LISTEN_ADDR", ":8080"),
	}
}

func getEnv(key, fallback string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return fallback
}

// ============================================================
// 会话管理(简化版,生产环境应使用 Redis 等)
// ============================================================

// SessionStore 简单的内存会话存储
type SessionStore struct {
	mu       sync.RWMutex
	sessions map[string]*Session
}

// Session 保存用户会话数据
type Session struct {
	State     string    // OAuth2 state 参数(防 CSRF)
	Nonce     string    // OIDC nonce 参数(防重放)
	User      *UserInfo // 登录后的用户信息
	CreatedAt time.Time
}

// UserInfo 用户信息
type UserInfo struct {
	Sub               string `json:"sub"`
	Name              string `json:"name"`
	Email             string `json:"email"`
	EmailVerified     bool   `json:"email_verified"`
	PreferredUsername  string `json:"preferred_username"`
	Picture           string `json:"picture"`
}

func NewSessionStore() *SessionStore {
	return &SessionStore{sessions: make(map[string]*Session)}
}

func (s *SessionStore) Set(id string, session *Session) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.sessions[id] = session
}

func (s *SessionStore) Get(id string) (*Session, bool) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	sess, ok := s.sessions[id]
	return sess, ok
}

func (s *SessionStore) Delete(id string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	delete(s.sessions, id)
}

// generateRandomString 生成加密安全的随机字符串
func generateRandomString(n int) (string, error) {
	b := make([]byte, n)
	if _, err := rand.Read(b); err != nil {
		return "", fmt.Errorf("生成随机字符串失败: %w", err)
	}
	return base64.URLEncoding.EncodeToString(b), nil
}

// ============================================================
// OIDC 客户端
// ============================================================

// OIDCClient 封装 OIDC 客户端逻辑
type OIDCClient struct {
	provider     *oidc.Provider
	oauth2Config oauth2.Config
	verifier     *oidc.IDTokenVerifier
	sessions     *SessionStore
}

func NewOIDCClient(ctx context.Context, cfg Config) (*OIDCClient, error) {
	// -------------------------------------------------------
	// 1. OIDC Provider Discovery
	//    自动从 {issuerURL}/.well-known/openid-configuration
	//    获取所有端点配置(authorization, token, userinfo, jwks)
	// -------------------------------------------------------
	log.Printf("正在发现 OIDC Provider: %s", cfg.IssuerURL)
	provider, err := oidc.NewProvider(ctx, cfg.IssuerURL)
	if err != nil {
		return nil, fmt.Errorf("OIDC Discovery 失败: %w", err)
	}
	log.Printf("OIDC Discovery 成功,Endpoint: %s", provider.Endpoint().AuthURL)

	// -------------------------------------------------------
	// 2. 配置 OAuth2 客户端
	// -------------------------------------------------------
	oauth2Config := oauth2.Config{
		ClientID:     cfg.ClientID,
		ClientSecret: cfg.ClientSecret,
		RedirectURL:  cfg.RedirectURL,
		Endpoint:     provider.Endpoint(), // 从 Discovery 自动获取
		Scopes:       []string{oidc.ScopeOpenID, "profile", "email"},
	}

	// -------------------------------------------------------
	// 3. 配置 ID Token 验证器
	//    自动从 jwks_uri 获取公钥,验证签名、iss、aud、exp
	// -------------------------------------------------------
	verifier := provider.Verifier(&oidc.Config{
		ClientID: cfg.ClientID,
		// 可选:设置允许的时钟偏差
		// Now: func() time.Time { return time.Now() },
	})

	return &OIDCClient{
		provider:     provider,
		oauth2Config: oauth2Config,
		verifier:     verifier,
		sessions:      NewSessionStore(),
	}, nil
}

// HandleLogin 处理登录请求,重定向到 OIDC Provider
func (c *OIDCClient) HandleLogin(w http.ResponseWriter, r *http.Request) {
	// 生成 state(防 CSRF)和 nonce(防重放)
	state, err := generateRandomString(32)
	if err != nil {
		http.Error(w, "内部错误", http.StatusInternalServerError)
		return
	}
	nonce, err := generateRandomString(32)
	if err != nil {
		http.Error(w, "内部错误", http.StatusInternalServerError)
		return
	}

	// 将 state 和 nonce 存入会话
	sessionID, _ := generateRandomString(16)
	c.sessions.Set(sessionID, &Session{
		State:     state,
		Nonce:     nonce,
		CreatedAt: time.Now(),
	})

	// 设置会话 Cookie
	http.SetCookie(w, &http.Cookie{
		Name:     "session_id",
		Value:    sessionID,
		Path:     "/",
		HttpOnly: true,
		Secure:   r.TLS != nil,
		SameSite: http.SameSiteLaxMode,
		MaxAge:   300, // 5 分钟内完成登录
	})

	// 重定向到 OIDC Provider,附带 nonce 参数
	authURL := c.oauth2Config.AuthCodeURL(
		state,
		oidc.Nonce(nonce),
	)
	log.Printf("重定向到 OIDC Provider: %s", authURL)
	http.Redirect(w, r, authURL, http.StatusFound)
}

// HandleCallback 处理 OIDC 回调
func (c *OIDCClient) HandleCallback(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()

	// --- 获取会话 ---
	cookie, err := r.Cookie("session_id")
	if err != nil {
		http.Error(w, "会话不存在", http.StatusBadRequest)
		return
	}
	session, ok := c.sessions.Get(cookie.Value)
	if !ok {
		http.Error(w, "会话已过期", http.StatusBadRequest)
		return
	}

	// --- 验证 state 参数(防 CSRF) ---
	if r.URL.Query().Get("state") != session.State {
		http.Error(w, "state 不匹配,可能存在 CSRF 攻击", http.StatusBadRequest)
		return
	}

	// --- 检查错误响应 ---
	if errCode := r.URL.Query().Get("error"); errCode != "" {
		errDesc := r.URL.Query().Get("error_description")
		http.Error(w, fmt.Sprintf("认证失败: %s - %s", errCode, errDesc), http.StatusBadRequest)
		return
	}

	// --- 用授权码换取 Token ---
	code := r.URL.Query().Get("code")
	if code == "" {
		http.Error(w, "缺少授权码", http.StatusBadRequest)
		return
	}

	oauth2Token, err := c.oauth2Config.Exchange(ctx, code)
	if err != nil {
		log.Printf("Token 交换失败: %v", err)
		http.Error(w, "Token 交换失败", http.StatusInternalServerError)
		return
	}

	// --- 提取并验证 ID Token ---
	rawIDToken, ok := oauth2Token.Extra("id_token").(string)
	if !ok {
		http.Error(w, "响应中缺少 id_token", http.StatusBadRequest)
		return
	}

	// verifier.Verify 会自动完成:
	// 1. 从 JWKS 获取公钥并验证签名
	// 2. 检查 iss 是否匹配 Provider
	// 3. 检查 aud 是否包含 ClientID
	// 4. 检查 exp 是否过期
	idToken, err := c.verifier.Verify(ctx, rawIDToken)
	if err != nil {
		log.Printf("ID Token 验证失败: %v", err)
		http.Error(w, fmt.Sprintf("ID Token 验证失败: %v", err), http.StatusUnauthorized)
		return
	}

	// --- 验证 nonce(防重放攻击) ---
	if idToken.Nonce != session.Nonce {
		http.Error(w, "nonce 不匹配,可能存在重放攻击", http.StatusUnauthorized)
		return
	}

	// --- 提取用户 Claims ---
	var claims struct {
		Sub               string `json:"sub"`
		Name              string `json:"name"`
		Email             string `json:"email"`
		EmailVerified     bool   `json:"email_verified"`
		PreferredUsername  string `json:"preferred_username"`
		Picture           string `json:"picture"`
	}
	if err := idToken.Claims(&claims); err != nil {
		log.Printf("Claims 解析失败: %v", err)
		http.Error(w, "Claims 解析失败", http.StatusInternalServerError)
		return
	}

	// --- 调用 UserInfo 端点获取更多信息 ---
	userInfoResp, err := c.provider.UserInfo(ctx, oauth2.StaticTokenSource(oauth2Token))
	if err != nil {
		log.Printf("UserInfo 请求失败(非致命): %v", err)
		// UserInfo 失败不阻断登录,ID Token 中已有基本信息
	} else {
		// 用 UserInfo 补充 Claims 中缺失的字段
		var uiClaims struct {
			Name    string `json:"name"`
			Email   string `json:"email"`
			Picture string `json:"picture"`
		}
		if err := userInfoResp.Claims(&uiClaims); err == nil {
			if claims.Name == "" {
				claims.Name = uiClaims.Name
			}
			if claims.Email == "" {
				claims.Email = uiClaims.Email
			}
			if claims.Picture == "" {
				claims.Picture = uiClaims.Picture
			}
		}
	}

	// --- 更新会话 ---
	session.User = &UserInfo{
		Sub:               claims.Sub,
		Name:              claims.Name,
		Email:             claims.Email,
		EmailVerified:     claims.EmailVerified,
		PreferredUsername:  claims.PreferredUsername,
		Picture:           claims.Picture,
	}
	session.State = "" // 清除已使用的 state
	session.Nonce = "" // 清除已使用的 nonce

	log.Printf("用户登录成功: sub=%s, name=%s, email=%s", claims.Sub, claims.Name, claims.Email)

	// 返回用户信息
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]interface{}{
		"message":   "登录成功",
		"sub":       claims.Sub,
		"name":      claims.Name,
		"email":     claims.Email,
		"issuer":    idToken.Issuer,
		"issued_at": idToken.IssuedAt,
		"expiry":    idToken.Expiry,
	})
}

// HandleMe 返回当前登录用户信息
func (c *OIDCClient) HandleMe(w http.ResponseWriter, r *http.Request) {
	cookie, err := r.Cookie("session_id")
	if err != nil {
		http.Error(w, `{"error": "未登录"}`, http.StatusUnauthorized)
		return
	}
	session, ok := c.sessions.Get(cookie.Value)
	if !ok || session.User == nil {
		http.Error(w, `{"error": "未登录"}`, http.StatusUnauthorized)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(session.User)
}

// HandleLogout 登出
func (c *OIDCClient) HandleLogout(w http.ResponseWriter, r *http.Request) {
	if cookie, err := r.Cookie("session_id"); err == nil {
		c.sessions.Delete(cookie.Value)
	}
	// 清除 Cookie
	http.SetCookie(w, &http.Cookie{
		Name:   "session_id",
		Value:  "",
		Path:   "/",
		MaxAge: -1,
	})
	http.Redirect(w, r, "/", http.StatusFound)
}

// ============================================================
// 主函数
// ============================================================

func main() {
	cfg := loadConfig()
	ctx := context.Background()

	// 初始化 OIDC 客户端(包含 Discovery)
	client, err := NewOIDCClient(ctx, cfg)
	if err != nil {
		log.Fatalf("OIDC 客户端初始化失败: %v", err)
	}

	// 注册路由
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/html; charset=utf-8")
		fmt.Fprint(w, `<h1>OIDC Demo</h1>
			<a href="/login">登录</a> |
			<a href="/me">我的信息</a> |
			<a href="/logout">登出</a>`)
	})
	http.HandleFunc("/login", client.HandleLogin)
	http.HandleFunc("/callback", client.HandleCallback)
	http.HandleFunc("/me", client.HandleMe)
	http.HandleFunc("/logout", client.HandleLogout)

	log.Printf("OIDC 客户端启动,监听 %s", cfg.ListenAddr)
	if err := http.ListenAndServe(cfg.ListenAddr, nil); err != nil {
		log.Fatalf("服务器启动失败: %v", err)
	}
}

8.9 小结

  • OIDC 是 OAuth 2.0 之上的身份认证层,提供标准化的用户身份信息

  • ID Token 是 JWT 格式,包含用户身份 Claims

  • ID Token 验证必须检查签名、iss、aud、exp、nonce,缺一不可

  • Discovery 端点使客户端可以自动发现 OIDC 配置,避免硬编码端点地址

  • JWKS 端点提供签名验证所需的公钥,客户端应缓存并定期刷新

  • Keycloak 是最流行的开源 OIDC 提供商,适合自托管场景

  • OIDC 是零信任架构中人类身份认证的首选协议

  • 各语言生态都有成熟的 OIDC 库:Python(Authlib)、Java(Spring Security)、Go(go-oidc)