OIDC与SSO在 ASP-NET Core 中的实践指南

深度解析 OIDC 与 SSO:在 ASP-NET Core 中的优雅实践指南

1. 核心概念解析

在深入代码之前,我们需要清晰地界定几个容易混淆的概念:SSO、OAuth 2.0、OIDC 和 JWT。

1.1 什么是 SSO (Single Sign-On)?

单点登录 (SSO) 是一种身份验证方案,允许用户使用 一组凭据(如用户名和密码)登录单个身份提供者,从而获得对 多个 相互关联但独立的软件系统的访问权限。

  • 通俗比喻: 你在游乐园门口买了一张通票(登录一次),之后进入园内的过山车、摩天轮、鬼屋(不同的应用)都不需要再买票,只需要出示通票即可。

1.2 什么是 OAuth 2.0?

OAuth 2.0 是一个 授权 (Authorization) 框架,而不是身份验证协议。它的核心是允许第三方应用在用户授权下访问其资源,而无需获取用户的账号密码。

  • 核心: 它颁发的是“访问令牌 (Access Token)”,就像一把“钥匙”。
  • 局限: OAuth 2.0 本身并不告诉客户端“用户是谁”,它只告诉服务端“持有这把钥匙的人能干什么”。

1.3 什么是 OIDC (OpenID Connect)?

OIDC 是构建在 OAuth 2.0 之上的 身份验证 (Authentication) 层。它解决了 OAuth 2.0 无法标准地获取用户身份信息的问题。

  • 核心: OIDC 在 OAuth 的基础上增加了一个 ID Token
  • 公式: OIDC = OAuth 2.0 + ID Token (用户身份信息) + UserInfo Endpoint (标准用户信息接口)
  • 为什么 SSO 偏爱 OIDC: 因为它标准化了身份认证流程,现代 SSO 系统(如 Auth0, Keycloak, Azure AD, IdentityServer)几乎都基于 OIDC。

1.4 什么是 JWT (JSON Web Token)?

JWT 是一种开放标准 (RFC 7519),定义了一种紧凑且自包含的方式,用于在各方之间作为 JSON 对象安全地传输信息。

  • 在 OIDC 中的角色:
    • ID Token: 必须是 JWT 格式。
    • Access Token: 通常也是 JWT 格式(虽然 OAuth 规范没强制,但在 .NET 生态中几乎都是 JWT)。

2. 架构设计:OIDC 在微服务/分布式系统中的流向

在 ASP-NET Core 实践中,我们通常涉及三个角色:

  1. Identity Provider (IdP): 认证中心(如 Duende IdentityServer, Keycloak, Azure AD)。
  2. Client (Relying Party): 客户端应用(如 MVC 网站, Blazor, React/Vue)。
  3. API (Resource Server): 受保护的后端接口。

认证流程图解

sequenceDiagram
    participant User as 用户
    participant Client as 客户端应用 (ASP-NET Core MVC)
    participant IdP as 认证中心 (OIDC Provider)
    participant API as受保护 API

    User->>Client: 1. 访问受保护页面
    Client->>IdP: 2. 重定向到 IdP 登录 (OIDC Authorize Request)
    User->>IdP: 3. 输入用户名/密码
    IdP->>Client: 4. 验证通过,返回 Code (Authorization Code Flow)
    Client->>IdP: 5. 使用 Code 换取 Tokens (ID Token + Access Token)
    IdP->>Client: 6. 返回 Tokens (JWT)
    Client->>Client: 7. 验证 ID Token (确认用户身份,建立会话)
    Client->>API: 8. 请求 API (携带 Authorization: Bearer Access_Token)
    API->>API: 9. 验证 Access Token (JWT 签名验证)
    API->>Client: 10. 返回数据

3. ASP-NET Core 中的优雅实践

我们将场景设定为:

  • IdP: 假设是一个符合 OIDC 标准的服务器(地址: https://idp.example.com)。
  • API: 一个受保护的 ASP-NET Core Web API。
  • Client: 一个 ASP-NET Core MVC 应用,需要登录并调用 API。

3.1 服务端 (API) 的优雅实践:集成 JWT Bearer

API 不关心登录页面,它只关心请求头里有没有合法的 Token。

NuGet 包: Microsoft.AspNetCore.Authentication.JwtBearer

代码配置 (Program.cs):

var builder = WebApplication.CreateBuilder(args);

// 1. 注册认证服务
builder.Services.AddAuthentication("Bearer") // 设置默认方案为 Bearer
    .AddJwtBearer("Bearer", options =>
    {
        // IdP 的地址(用于获取公钥来验证签名)
        options.Authority = "https://idp.example.com";
        
        // 定义这个 API 的名字(Audience),Token 必须包含这个 aud 才是给我的
        options.Audience = "my_api_resource";

        // 生产环境必须为 true,开发环境如果是自签名证书可暂时设为 false
        options.RequireHttpsMetadata = true;

        // 优雅实践:自定义 Token 验证逻辑(可选)
        options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
        {
            ValidateAudience = true,
            ValidateIssuer = true,
            // 容忍时钟偏差(默认5分钟,建议设为0或更短,严格控制过期)
            ClockSkew = TimeSpan.Zero 
        };
    });

// 2. 注册授权策略 (优雅的权限管理)
builder.Services.AddAuthorization(options =>
{
    // 定义一个策略:这就叫“优雅”,不要在 Controller 里写 if (role == "admin")
    options.AddPolicy("AdminOnly", policy => 
        policy.RequireClaim("role", "admin"));
        
    options.AddPolicy("ApiScope", policy =>
    {
        policy.RequireAuthenticatedUser();
        policy.RequireClaim("scope", "my_api.read");
    });
});

var app = builder.Build();

// 3. 启用中间件(顺序很重要!)
app.UseAuthentication(); // 先验票
app.UseAuthorization();  // 再看能不能进

app.MapControllers();
app.Run();

在 Controller 中使用:

[ApiController]
[Route("api/[controller]")]
[Authorize(Policy = "ApiScope")] // 应用策略
public class OrderController : ControllerBase
{
    [HttpGet]
    public IActionResult Get()
    {
        // 获取当前 Token 中的 UserID (sub claim)
        var userId = User.FindFirst("sub")?.Value;
        return Ok(new { message = $"Hello user {userId}, data secured!" });
    }
}

3.2 客户端 (MVC/Blazor Server) 的优雅实践:集成 OIDC

客户端负责引导用户去 IdP 登录,并保存 Token。

NuGet 包: Microsoft.AspNetCore.Authentication.OpenIdConnect

代码配置 (Program.cs):

using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllersWithViews();

builder.Services.AddAuthentication(options =>
{
    // 客户端的主身份验证方案是 Cookie (因为浏览器和 MVC App 之间通过 Cookie 维持会话)
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    // 挑战方案是 OIDC (当用户没登录时,跳转去哪里)
    options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme)
.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
{
    // 1. 基础配置
    options.Authority = "https://idp.example.com";
    options.ClientId = "mvc_client";
    options.ClientSecret = "super_secret_password"; // 生产环境应从 KeyVault 读取
    options.ResponseType = "code"; // 必须使用 Authorization Code 流程 (最安全)

    // 2. 优雅配置:Token 保存
    // 将 AccessToken 和 RefreshToken 保存到 Cookie 中,方便后续调用 API 取出
    options.SaveTokens = true; 

    // 3. Scope 配置 (我要请求什么权限)
    options.Scope.Clear();
    options.Scope.Add("openid");
    options.Scope.Add("profile");
    options.Scope.Add("my_api.read"); // 请求访问 API 的权限
    options.Scope.Add("offline_access"); // 请求刷新令牌 (Refresh Token)

    // 4. 获取用户详细信息
    options.GetClaimsFromUserInfoEndpoint = true;
    
    // 5. 映射 Claim (可选,将 IdP 的 claim 映射为 .NET 标准 ClaimTypes)
    options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
    {
        NameClaimType = "name",
        RoleClaimType = "role"
    };
});

var app = builder.Build();

app.UseStaticFiles();
app.UseRouting();

app.UseAuthentication(); // 必须在 Authorization 之前
app.UseAuthorization();

app.MapDefaultControllerRoute();
app.Run();

在 Controller 中调用 API (如何使用 Token):

public class HomeController : Controller
{
    public async Task<IActionResult> CallApi()
    {
        // 优雅实践:使用 HttpContext.GetTokenAsync 获取保存的 Access Token
        var accessToken = await HttpContext.GetTokenAsync("access_token");
        
        var client = new HttpClient();
        // 将 Token 放入 Authorization Header
        client.SetBearerToken(accessToken); 
        
        var response = await client.GetAsync("https://api.example.com/api/order");
        // ... 处理结果
        return View();
    }
}

4. 进阶:如何做到“最优雅”?

仅仅跑通流程是不够的,以下是生产环境的“优雅”标准。

4.1 Token 管理自动化 (自动刷新 Token)

Access Token 有效期通常很短(如 1 小时)。过期了怎么办?让用户重新登录体验极差。

优雅方案: 使用 Refresh Token 自动换取新的 Access Token。

  • 推荐库: IdentityModel.AspNetCore (由 Duende 团队维护)。

  • 做法:

    // 注册服务
    builder.Services.AddAccessTokenManagement();
    
    // 在 HttpClient 中直接使用
    builder.Services.AddHttpClient<IOrderService, OrderService>(client => 
    {
        client.BaseAddress = new Uri("https://api.example.com");
    })
    .AddUserAccessTokenHandler(); // 魔法发生在这里:它会自动附加 Token,并在过期时自动刷新
    

4.2 BFF 模式 (Backend for Frontend) —— 针对 SPA

如果你的客户端是 React/Vue/Angular,不要 在前端 JS 中存储 Token (localStorage/sessionStorage),这容易受到 XSS 攻击。

优雅方案: BFF 模式。

  • 前端只与自己的后端 (BFF) 通信。
  • BFF 与 IdP 进行 OIDC 交互。
  • BFF 将 Token 保存在服务端的 Session/Cookie 中(HttpOnly, Secure, SameSite)。
  • 前端只持有加密的 Cookie,不接触 JWT。
  • 实现: 可以使用 Duende.BFF 库或 YARP 反向代理来实现。

4.3 策略授权 (Policy-based Authorization)

不要在代码里到处写 User.IsInRole("Admin")。业务逻辑会变,角色名称会变。

优雅方案:

  1. 定义策略:CanDeleteOrder
  2. 配置策略:options.AddPolicy("CanDeleteOrder", ...)
  3. 使用策略:[Authorize(Policy = "CanDeleteOrder")]

这样如果以后规则变成“Admin 或 Manager 且工龄大于3年”,你只需要改 Startup 配置,不需要改 Controller 代码。

4.4 必须使用 HTTPS

OIDC 和 OAuth 2.0 严重依赖 TLS/SSL。如果不用 HTTPS,Token 就像在互联网上裸奔的密码。在生产环境中强制实施 HSTS。

5. 总结

在 ASP-NET Core 中实现 OIDC 和 SSO 的最佳实践总结如下:

  1. 分离关注点: IdP 负责发证,API 负责验资,Client 负责引导。
  2. 利用标准库: 不要手写 HTTP 请求去验证 Token,使用微软官方提供的中间件 (JwtBearer, OpenIdConnect)。
  3. 使用 JWT: 作为 Token 的载体,轻量且自包含。
  4. 自动化管理: 引入 IdentityModel.AspNetCore 处理 Token 的存储、传递和刷新。
  5. 安全优先: 始终校验 AudienceIssuer,SPA 应用采用 BFF 模式避免 Token 泄露。

通过以上架构,你构建的不仅仅是一个登录功能,而是一个符合国际标准、安全、可扩展的现代身份认证体系。

单点登出(Single Sign-Out,简称 SLO)是 SSO 系统中最复杂的部分之一。相比于登录(建立信任),登出(销毁信任)需要协调多个分散的系统,确保“一处注销,处处注销”。

如果处理不当,用户以为自己退出了,但实际上他在 IdP 或其他应用中的会话仍然有效,这会带来严重的安全隐患(尤其是公共电脑场景)。

在 OIDC 和 ASP-NET Core 中,主要有三种层级的注销,以及两种主流的通知机制。

OIDC 单点登出 (SLO) 深度实践指南

6. 单点登出的三个层级

要实现完整的 SLO,必须按顺序完成以下三个步骤:

  1. 本地注销 (Local Logout):
    • 清除当前应用(App A)的 Cookie/Session。
    • 用户无法再访问 App A 的受保护页面。
  2. IdP 注销 (Upstream Logout):
    • App A 重定向浏览器到 IdP 的结束会话端点(End Session Endpoint)。
    • IdP 清除自己在用户浏览器中的 SSO Cookie。
    • 用户无法再通过 SSO 自动登录任何新应用。
  3. 联合注销/下游注销 (Federated/Downstream Logout):
    • IdP 通知所有 其他 已登录的应用(App B, App C…)清除它们的本地会话。

7. 两种主流的通知机制

OIDC 协议定义了两种方式让 IdP 通知其他应用进行注销:

7.1 前端通道注销 (Front-Channel Logout) - 最常用

利用用户的浏览器作为“中转站”。

  • 原理:
    1. 用户在 App A 点击退出。
    2. App A 跳转到 IdP 的登出页面。
    3. IdP 的登出页面上包含多个隐藏的 <iframe><img> 标签。
    4. 这些标签的 src 指向 App B 和 App C 的注销 URL。
    5. 浏览器加载这些 iframe,从而触发 App B 和 App C 的 Cookie 清除逻辑。
  • 优点: 实现简单,通过浏览器 Cookie 机制自然工作。
  • 缺点:
    • 依赖浏览器:如果用户关闭浏览器太快,或浏览器拦截了第三方 Cookie/iframe,App B 可能没来得及退出。
    • 脆弱性:只要链路中一个环节断了,后续的注销可能失败。

7.2 后端通道注销 (Back-Channel Logout) - 更可靠

利用服务器之间的直接通信。

  • 原理:
    1. 用户在 App A 点击退出,跳转到 IdP。
    2. IdP 收到注销请求后,在服务器端直接向 App B 和 App C 注册的 “Logout Webhook URL” 发送 HTTP POST 请求(携带 Logout Token)。
    3. App B 和 C 收到请求后,销毁对应的服务端 Session 或将 Session ID 加入黑名单。
  • 优点: 不依赖用户浏览器,更可靠,适合移动端或无浏览器环境。
  • 缺点:
    • 实现复杂:需要应用维护 Session 存储(如 Redis),因为服务器收到请求时没有浏览器的 Cookie 上下文。
    • 网络要求:IdP 必须能访问到内网中的 App B/C(或者 App 必须暴露公网接口)。

8. ASP-NET Core 中的优雅实践 (代码实现)

通常我们默认使用 前端通道注销 ,因为它与 ASP-NET Core 的 Cookie 认证集成最顺畅。

8.1 客户端应用 (MVC/Blazor) 的实现

AccountController 或类似的控制器中,你需要同时触发“本地注销”和“IdP 注销”。

public class AccountController : Controller
{
    [HttpPost] // 推荐使用 POST 防止 CSRF 攻击
    public IActionResult Logout()
    {
        // SignOut 接受两个参数:
        // 1. CookieAuthenticationDefaults.AuthenticationScheme: 
        //    清除本地 Cookie,实现“本地注销”。
        // 2. OpenIdConnectDefaults.AuthenticationScheme: 
        //    触发重定向到 IdP 的 EndSessionEndpoint,实现“IdP 注销”。
        
        return SignOut(
            new AuthenticationProperties
            {
                // 注销完成后,IdP 将用户重定向回来的地址
                RedirectUri = Url.Action("Index", "Home", null, Request.Scheme) 
            },
            CookieAuthenticationDefaults.AuthenticationScheme,
            OpenIdConnectDefaults.AuthenticationScheme);
    }
}

8.2 配置 Program.cs 以支持完整流程

你需要告诉 OIDC 中间件,当 IdP 需要通知我(当前应用)退出时,我的地址是什么。

// 在 AddOpenIdConnect 中配置
.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
{
    options.Authority = "https://idp.example.com";
    options.ClientId = "mvc_client";
    options.ClientSecret = "secret";
    
    // ... 其他配置 ...

    // [关键配置 1]:退出时,携带 id_token_hint
    // IdP 需要这个 token 来验证是谁在请求退出,防止恶意登出攻击
    options.SaveTokens = true; 
    
    // [关键配置 2]:前端通道注销 URL
    // 这是 IdP 用 iframe 调用的地址,ASP-NET Core 默认处理这个路径
    // 默认值为 "/signout-oidc",通常不需要修改,但在 IdP 注册 Client 时必须填对
    options.RemoteSignOutPath = "/signout-oidc"; 
    
    // [关键配置 3]:退出后的跳转
    // 告诉 IdP 退出完成后把用户踢回哪里
    options.SignedOutRedirectUri = "https://myapp.com/home";
})

8.3 流程图解 (前端通道)

sequenceDiagram
    participant User as 用户
    participant AppA as 应用 A (发起注销)
    participant IdP as 认证中心 (IdentityServer/Keycloak)
    participant AppB as 应用 B (被动注销)

    User->>AppA: 点击 "退出"
    AppA->>AppA: 1. 清除 AppA 本地 Cookie
    AppA->>IdP: 2. 重定向到 /connect/endsession (携带 id_token_hint)
    IdP->>IdP: 3. 清除 IdP 的 SSO Cookie
    IdP-->>AppB: 4. 浏览器加载隐形 iframe (src=AppB/signout-oidc)
    AppB->>AppB: 5. 中间件拦截请求,清除 AppB Cookie
    IdP->>User: 6. 显示 "您已安全退出" 或重定向回 AppA 首页

9. JWT Token 的失效问题 (核心难点)

只要涉及 SSO,就会有人问:“我注销了,但之前的 Access Token (JWT) 还没过期,别人捡到了是不是还能用?”

答案是:是的,标准的 JWT 一旦签发,在过期前无法撤销。

这是 JWT 的特性(无状态)决定的。如何解决?

优雅解决方案

方案 A:短生命周期 + Refresh Token (推荐)

  • 做法: Access Token 有效期设得很短(例如 5-10 分钟)。
  • 原理: 应用必须频繁使用 Refresh Token 去 IdP 换新 Access Token。
  • 注销生效: 当用户注销时,IdP 撤销 Refresh Token 的权限。
  • 结果: 旧 Access Token 最多只能活几分钟,之后应用去刷新时会被 IdP 拒绝,从而强制应用端下线。

方案 B:Reference Tokens (引用令牌)

  • 做法: IdP 不发 JWT,发一个随机字符串(Reference Token)。
  • 原理: API 收到 Token 后,必须每次都去 IdP 的 Introspection Endpoint(内省端点)验证 Token 是否有效。
  • 注销生效: 用户注销,IdP 标记该 Token 无效。API 下一次验证时立刻失败。
  • 代价: 性能损耗大(每次 API 调用都多一次 HTTP 请求去 IdP)。适合对安全性极高、流量不大的场景。

方案 C:黑名单机制 (分布式缓存)

  • 做法: 注销时,将 Token 的 jti (唯一ID) 放入 Redis,并设置过期时间等于 Token 的剩余寿命。
  • 原理: API 中间件收到请求先查 Redis 黑名单。
  • 代价: 违背了 JWT 无状态的初衷,增加了架构复杂度。

10. 总结与最佳实践建议

  1. 必须做的事:

    • 在 Controller 中同时调用 SignOut("Cookies")SignOut("oidc")
    • 确保 id_token 被保存 (SaveTokens = true),否则无法优雅地重定向到 IdP 进行注销。
    • 在 IdP 的 Client 配置中,正确填写 FrontChannelLogoutUri (通常是 https://your-app/signout-oidc)。
  2. 关于 Token 撤销:

    • 不要试图去“物理删除”客户端已经拿走的 JWT。
    • 使用 短 Access Token (5分钟) + Refresh Token 策略。
    • 当检测到注销事件时,在 IdP 端撤销对应的 Refresh Token。
  3. 调试技巧:

    • 使用 Chrome 开发者工具的 Network 标签,勾选 “Preserve log”。
    • 观察跳转过程:App A → IdP → (IdP 加载 iframe) → App B → IdP → App A/Index。
    • 检查 Cookie 是否真的被删除了。