本文档详细阐述旧版 WinForm 桌面应用如何对接公司现有 Keycloak 统一认证平台,实现单点登录(SSO)。


一、背景与目标

1.1 现状

  • 公司已部署 Keycloak 统一认证平台,多个系统已完成对接
  • 现有 WinForm 桌面应用采用独立的用户名/密码登录
  • 需要将 WinForm 纳入 SSO 体系,实现"一处登录,处处通行"

1.2 目标

目标 说明
统一认证 WinForm 使用公司统一账号登录
用户体验 如果用户已在浏览器登录过其他系统,WinForm 可自动完成登录(免输密码)
安全合规 采用业界标准 OAuth 2.0 + PKCE 协议,满足安全审计要求

二、技术方案概述

2.1 选用协议:OAuth 2.0 + OIDC + PKCE

对于桌面应用,业界标准推荐使用 Authorization Code Flow with PKCE

┌─────────────────────────────────────────────────────────────────────────┐
│                           为什么选择 PKCE?                              │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  传统 Web 应用可以使用 client_secret(存在服务器端),但桌面应用不行:     │
│                                                                         │
│  ❌ 桌面应用的代码可被反编译,client_secret 无法保密                      │
│  ❌ 传统 Implicit Flow 已被 OAuth 2.1 废弃(不安全)                      │
│                                                                         │
│  ✅ PKCE 方案:每次登录生成临时密钥对,无需存储长期密钥                    │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

2.2 架构图

    ┌─────────────┐                              ┌─────────────────────┐
    │  WinForm    │                              │  Keycloak Server    │
    │  桌面应用    │                              │  (公司已部署)        │
    └──────┬──────┘                              └──────────┬──────────┘
           │                                                │
           │  ① 用户点击登录 → 打开系统浏览器                  │
           │ ───────────────────────────────────────────────>│
           │                                                │
           │  ② 用户在浏览器登录(输入用户名密码/扫码/SSO)     │
           │                                                │
           │  ③ 登录成功 → 浏览器跳转回本地回调地址             │
           │ <───────────────────────────────────────────────│
           │                                                │
           │  ④ WinForm 用授权码换取 Token                    │
           │ ───────────────────────────────────────────────>│
           │                                                │
           │  ⑤ 返回 access_token、refresh_token             │
           │ <───────────────────────────────────────────────│
           │                                                │
           │  ⑥ 携带 Token 调用业务 API                       │
           │ ─────────────────────────────────────────────────────────────>
           │                                                              │
           │                                      ┌────────────────────────┐
           │                                      │      后端 API 服务      │
           │ <─────────────────────────────────────────────────────────────│
           │  ⑦ 返回业务数据                       └────────────────────────┘

三、服务端配置(Keycloak 管理员操作)

注意:Keycloak 服务端已部署完成,只需新增一个 Client 配置即可。

3.1 新建 Client

在 Keycloak 管理控制台创建新客户端:

配置项 说明
Client ID winform-legacy-app 客户端唯一标识
Client Protocol openid-connect 使用 OIDC 协议
Access Type public 公开客户端(桌面应用无法保密)
Standard Flow Enabled ✅ ON 启用授权码流程
Valid Redirect URIs http://localhost:18080/* 本地回调地址(端口可自定)
PKCE Code Challenge Method S256 必须启用

3.2 获取关键端点

从 Keycloak 发现文档获取(无需手动配置):

发现文档地址:
https://{keycloak-host}/realms/{realm}/.well-known/openid-configuration

常用端点:
├── 授权端点: /realms/{realm}/protocol/openid-connect/auth
├── Token端点: /realms/{realm}/protocol/openid-connect/token
├── 用户信息: /realms/{realm}/protocol/openid-connect/userinfo
└── 登出端点: /realms/{realm}/protocol/openid-connect/logout

四、完整交互流程详解

4.1 流程时序图

  ┌──────────┐       ┌──────────┐       ┌──────────┐       ┌──────────┐
  │ WinForm  │       │ 系统浏览器 │       │ Keycloak │       │ 后端API  │
  └────┬─────┘       └────┬─────┘       └────┬─────┘       └────┬─────┘
       │                  │                  │                  │
  【第1步】用户点击"登录"按钮
       │ 生成 PKCE 密钥对   │                  │                  │
       │ (code_verifier,   │                  │                  │
       │  code_challenge)  │                  │                  │
       │                  │                  │                  │
  【第2步】启动本地 HTTP 监听 + 打开浏览器
       │ 启动 HttpListener │                  │                  │
       │ 监听 localhost    │                  │                  │
       │ ────打开浏览器───>│                  │                  │
       │                  │ ──GET /auth─────>│                  │
       │                  │  (带 PKCE 公钥)   │                  │
       │                  │                  │                  │
  【第3步】Keycloak 展示登录页面
       │                  │ <──返回登录页────│                  │
       │                  │                  │                  │
       │      【用户操作】在浏览器中输入用户名密码,点击登录         │
       │                  │                  │                  │
       │                  │ ──POST 登录─────>│                  │
       │                  │                  │ 验证凭据          │
       │                  │                  │ 创建 Session      │
       │                  │                  │ 生成授权码        │
       │                  │ <──302 重定向────│                  │
       │                  │                  │                  │
  【第4步】浏览器跳转到本地回调地址
       │ <─GET /callback──│                  │                  │
       │  ?code=xxx       │                  │                  │
       │ 返回"登录成功"页面─>│                  │                  │
       │                  │                  │                  │
  【第5步】用授权码 + PKCE私钥换取 Token(核心安全验证)
       │ ────────────POST /token────────────>│                  │
       │   code=xxx                          │                  │
       │   code_verifier=原始PKCE私钥 ←←←←←←│ PKCE验证          │
       │ <───────────返回 Token──────────────│                  │
       │                  │                  │                  │
  【第6步】携带 Token 调用业务 API
       │ ─────────────────GET /api/data─────────────────────────>│
       │   Authorization: Bearer {access_token}                  │
       │ <─────────────────返回业务数据───────────────────────────│
       │                  │                  │                  │

4.2 各步骤详解

第1步:生成 PKCE 密钥对

PKCE 的核心是一次性密钥对,每次登录重新生成:

┌────────────────────────────────────────────────────────────────────┐
│                         PKCE 密钥对                                 │
├────────────────────────────────────────────────────────────────────┤
│                                                                    │
│  code_verifier(私钥,保密!仅存于内存)                             │
│  ────────────────────────────────────                              │
│  随机字符串,例如: dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk      │
│                                                                    │
│                    │                                               │
│                    │ SHA256 哈希 + Base64URL 编码                   │
│                    ▼                                               │
│                                                                    │
│  code_challenge(公钥,发送给 Keycloak)                            │
│  ─────────────────────────────────────                             │
│  哈希结果,例如: E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM        │
│                                                                    │
│  ┌────────────────────────────────────────────────────────────┐    │
│  │ 【安全原理】                                                │    │
│  │ code_verifier 始终在 WinForm 内存中,从不经过网络传输        │    │
│  │ 即使攻击者截获 code_challenge,也无法反推出 code_verifier    │    │
│  │ (SHA256 是单向哈希,不可逆)                                │    │
│  └────────────────────────────────────────────────────────────┘    │
│                                                                    │
└────────────────────────────────────────────────────────────────────┘

第2步:构建授权 URL

https://keycloak.company.com/realms/corp/protocol/openid-connect/auth
    ?client_id=winform-legacy-app              ← 客户端标识
    &response_type=code                        ← 要求返回授权码
    &redirect_uri=http://localhost:18080/callback  ← 回调地址
    &scope=openid profile email                ← 请求的权限
    &state=随机字符串                           ← 防CSRF攻击
    &code_challenge=E9Melhoa2OwvFrEMTJgu...    ← PKCE公钥
    &code_challenge_method=S256                ← 哈希算法

第3步:用户在浏览器中操作

用户看到的界面(公司统一登录页):

┌──────────────────────────────────────────────────────────────┐
│  🔒 https://keycloak.company.com/realms/corp/login           │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│            ┌────────────────────────────────┐                │
│            │      🏢 XX公司统一登录平台      │                │
│            │                                │                │
│            │  ┌────────────────────────┐    │                │
│            │  │ 用户名/工号            │    │                │
│            │  └────────────────────────┘    │                │
│            │  ┌────────────────────────┐    │                │
│            │  │ 密码                   │    │                │
│            │  └────────────────────────┘    │                │
│            │                                │                │
│            │  ┌────────────────────────┐    │                │
│            │  │        登  录          │    │                │
│            │  └────────────────────────┘    │                │
│            │                                │                │
│            │  ─────── 或使用 ───────        │                │
│            │  🟢 企业微信扫码  📱 钉钉      │                │
│            │                                │                │
│            └────────────────────────────────┘                │
│                                                              │
└──────────────────────────────────────────────────────────────┘

SSO 优势体现

  • 如果用户已在浏览器中登录过 OA/CRM 等其他系统,此步骤自动跳过
  • 浏览器会直接携带已有的 Keycloak Session 完成认证

第4~5步:PKCE 信任验证(核心安全机制)

┌──────────────────────────────────────────────────────────────────┐
│                    为什么 PKCE 能证明请求可信?                    │
├──────────────────────────────────────────────────────────────────┤
│                                                                  │
│   【第2步】WinForm 生成密钥对                                     │
│   ───────────────────────────                                    │
│   code_verifier = "dBjftJeZ4CVP..."  ← 保存在内存                │
│   code_challenge = SHA256(code_verifier) = "E9Melhoa2Owv..."    │
│                              ↓                                   │
│                     发送给 Keycloak 存储                          │
│                                                                  │
│   【第5步】换 Token 时验证                                        │
│   ────────────────────────                                       │
│   WinForm 发送: code_verifier = "dBjftJeZ4CVP..."                │
│                              ↓                                   │
│   Keycloak 计算: SHA256("dBjftJeZ4CVP...") = "E9Melhoa2Owv..."   │
│                              ↓                                   │
│   对比: 计算结果 == 存储的 code_challenge ?                       │
│                              ↓                                   │
│   ✅ 匹配 → 证明是同一个客户端发起的请求 → 颁发 Token              │
│   ❌ 不匹配 → 拒绝(可能是攻击者窃取了 code)                      │
│                                                                  │
│   ┌────────────────────────────────────────────────────────┐     │
│   │ 【关键结论】                                            │     │
│   │ 即使攻击者在网络中截获了 authorization_code,            │     │
│   │ 由于不知道 code_verifier,也无法通过 PKCE 验证。         │     │
│   │ code_verifier 从未在网络上传输过!                       │     │
│   └────────────────────────────────────────────────────────┘     │
│                                                                  │
└──────────────────────────────────────────────────────────────────┘

五、Token 说明

5.1 Keycloak 返回的 Token 结构

{
    "access_token": "eyJhbGciOiJSUzI1NiIs...",   // 访问令牌
    "expires_in": 300,                           // 有效期 5 分钟
    "refresh_expires_in": 1800,                  // 刷新令牌有效期 30 分钟
    "refresh_token": "eyJhbGciOiJIUzI1...",     // 刷新令牌
    "token_type": "Bearer",                      // 令牌类型
    "id_token": "eyJhbGciOiJSUzI1NiIs...",      // 身份令牌
    "session_state": "a1b2c3-...",              // 会话ID
    "scope": "openid profile email"              // 授权范围
}

5.2 三种 Token 的用途

Token 类型 用途 说明
access_token 调用后端 API 放在 HTTP Header 中:Authorization: Bearer xxx
id_token 获取用户信息 解析 JWT 可得到用户名、邮箱、角色等
refresh_token 刷新 access_token access_token 过期后,用 refresh_token 换新的

5.3 id_token 解析示例

id_token 是 JWT 格式,Base64 解码后:

{
    "sub": "f8a7b6c5-1234-5678-9abc-def012345678",  // 用户唯一ID
    "preferred_username": "zhangsan",               // 用户名
    "name": "张三",                                  // 显示名
    "email": "zhangsan@company.com",                // 邮箱
    "realm_access": {
        "roles": ["员工", "项目经理"]                // 用户角色
    }
}

六、WinForm 客户端开发要点

6.1 技术选型

组件 推荐方案 备注
OIDC 库 IdentityModel.OidcClient NuGet 包,封装了 PKCE 流程
HTTP 客户端 HttpClient .NET 内置
Token 存储 ProtectedData.Protect() Windows DPAPI 加密
浏览器交互 系统浏览器 + HttpListener 或使用 WebView2 内嵌

6.2 核心代码结构

// 使用 IdentityModel.OidcClient(推荐)
public class KeycloakAuthService
{
    private readonly OidcClient _client;
    
    public KeycloakAuthService()
    {
        var options = new OidcClientOptions
        {
            Authority = "https://keycloak.company.com/realms/corp",
            ClientId = "winform-legacy-app",
            Scope = "openid profile email",
            RedirectUri = "http://localhost:18080/callback",
            Browser = new SystemBrowser(18080)  // 自动处理本地回调
        };
        _client = new OidcClient(options);
    }
    
    public async Task<LoginResult> LoginAsync()
    {
        // 一行代码完成整个 OIDC + PKCE 流程
        return await _client.LoginAsync();
    }
    
    public async Task<RefreshTokenResult> RefreshAsync(string refreshToken)
    {
        return await _client.RefreshTokenAsync(refreshToken);
    }
}

6.3 Token 安全存储

// 使用 Windows DPAPI 加密存储(推荐)
public static void SaveToken(string token)
{
    var data = Encoding.UTF8.GetBytes(token);
    var encrypted = ProtectedData.Protect(data, null, DataProtectionScope.CurrentUser);
    File.WriteAllBytes(tokenPath, encrypted);
}

public static string LoadToken()
{
    var encrypted = File.ReadAllBytes(tokenPath);
    var data = ProtectedData.Unprotect(encrypted, null, DataProtectionScope.CurrentUser);
    return Encoding.UTF8.GetString(data);
}

七、安全性分析

7.1 抵御的攻击类型

攻击类型 防护机制 说明
授权码窃取 PKCE 攻击者没有 code_verifier,无法换取 Token
钓鱼攻击 系统浏览器 + HTTPS 用户可见真实 URL,证书验证
CSRF 攻击 state 参数 WinForm 验证 state 一致性
Token 泄露 短有效期 + HTTPS access_token 仅 5 分钟有效
本地存储泄露 DPAPI 加密 Token 加密存储,与用户账户绑定

7.2 信任链完整性

证明最终拿到 Token 的,一定是最初发起登录的那个 WinForm 程序:

   发起登录的程序                        换 Token 的程序
        │                                    │
        │  持有 code_verifier               │  提供 code_verifier
        │         │                          │         │
        │         ▼                          │         ▼
        │  SHA256 → code_challenge           │  code_verifier
        │         │                          │         │
        │         ▼ 存储于 Keycloak          │         ▼ Keycloak 计算
        │  stored_challenge ════════════════ computed_challenge
        │                                    │
        │                                    ▼
        │                          两者相等? → ✅ 是同一个程序
        │                                    │
        └────────────────────────────────────┘

八、实施计划

8.1 里程碑

image-1767596824077

8.2 依赖项

依赖项 负责人 状态
Keycloak 新建 Client 运维/管理员 待确认
回调端口防火墙策略 网络管理员 待确认
后端 API Token 验证 后端开发 待确认

九、总结

核心要点

  1. 协议选择:采用 OAuth 2.0 + OIDC + PKCE,业界标准,安全可靠
  2. 服务端零开发:Keycloak 已部署,仅需新建 Client 配置
  3. 客户端改造:WinForm 对接标准 OIDC 流程,使用成熟类库
  4. 安全保障:PKCE 机制确保授权码不可被窃取利用

收益

  • ✅ 统一账号体系,与公司其他系统打通
  • ✅ 支持 SSO,提升用户体验
  • ✅ 符合安全合规要求
  • ✅ 后续系统对接成本降低

文档版本:1.0
更新日期:2026-01-05

测试成功 Demo