深度解析 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 实践中,我们通常涉及三个角色:
- Identity Provider (IdP): 认证中心(如 Duende IdentityServer, Keycloak, Azure AD)。
- Client (Relying Party): 客户端应用(如 MVC 网站, Blazor, React/Vue)。
- 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")。业务逻辑会变,角色名称会变。
优雅方案:
- 定义策略:
CanDeleteOrder。 - 配置策略:
options.AddPolicy("CanDeleteOrder", ...)。 - 使用策略:
[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 的最佳实践总结如下:
- 分离关注点: IdP 负责发证,API 负责验资,Client 负责引导。
- 利用标准库: 不要手写 HTTP 请求去验证 Token,使用微软官方提供的中间件 (
JwtBearer,OpenIdConnect)。 - 使用 JWT: 作为 Token 的载体,轻量且自包含。
- 自动化管理: 引入
IdentityModel.AspNetCore处理 Token 的存储、传递和刷新。 - 安全优先: 始终校验
Audience和Issuer,SPA 应用采用 BFF 模式避免 Token 泄露。
通过以上架构,你构建的不仅仅是一个登录功能,而是一个符合国际标准、安全、可扩展的现代身份认证体系。
单点登出(Single Sign-Out,简称 SLO)是 SSO 系统中最复杂的部分之一。相比于登录(建立信任),登出(销毁信任)需要协调多个分散的系统,确保“一处注销,处处注销”。
如果处理不当,用户以为自己退出了,但实际上他在 IdP 或其他应用中的会话仍然有效,这会带来严重的安全隐患(尤其是公共电脑场景)。
在 OIDC 和 ASP-NET Core 中,主要有三种层级的注销,以及两种主流的通知机制。
OIDC 单点登出 (SLO) 深度实践指南
6. 单点登出的三个层级
要实现完整的 SLO,必须按顺序完成以下三个步骤:
- 本地注销 (Local Logout):
- 清除当前应用(App A)的 Cookie/Session。
- 用户无法再访问 App A 的受保护页面。
- IdP 注销 (Upstream Logout):
- App A 重定向浏览器到 IdP 的结束会话端点(End Session Endpoint)。
- IdP 清除自己在用户浏览器中的 SSO Cookie。
- 用户无法再通过 SSO 自动登录任何新应用。
- 联合注销/下游注销 (Federated/Downstream Logout):
- IdP 通知所有 其他 已登录的应用(App B, App C…)清除它们的本地会话。
7. 两种主流的通知机制
OIDC 协议定义了两种方式让 IdP 通知其他应用进行注销:
7.1 前端通道注销 (Front-Channel Logout) - 最常用
利用用户的浏览器作为“中转站”。
- 原理:
- 用户在 App A 点击退出。
- App A 跳转到 IdP 的登出页面。
- IdP 的登出页面上包含多个隐藏的
<iframe>或<img>标签。 - 这些标签的
src指向 App B 和 App C 的注销 URL。 - 浏览器加载这些 iframe,从而触发 App B 和 App C 的 Cookie 清除逻辑。
- 优点: 实现简单,通过浏览器 Cookie 机制自然工作。
- 缺点:
- 依赖浏览器:如果用户关闭浏览器太快,或浏览器拦截了第三方 Cookie/iframe,App B 可能没来得及退出。
- 脆弱性:只要链路中一个环节断了,后续的注销可能失败。
7.2 后端通道注销 (Back-Channel Logout) - 更可靠
利用服务器之间的直接通信。
- 原理:
- 用户在 App A 点击退出,跳转到 IdP。
- IdP 收到注销请求后,在服务器端直接向 App B 和 App C 注册的 “Logout Webhook URL” 发送 HTTP POST 请求(携带 Logout Token)。
- 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. 总结与最佳实践建议
-
必须做的事:
- 在 Controller 中同时调用
SignOut("Cookies")和SignOut("oidc")。 - 确保
id_token被保存 (SaveTokens = true),否则无法优雅地重定向到 IdP 进行注销。 - 在 IdP 的 Client 配置中,正确填写
FrontChannelLogoutUri(通常是https://your-app/signout-oidc)。
- 在 Controller 中同时调用
-
关于 Token 撤销:
- 不要试图去“物理删除”客户端已经拿走的 JWT。
- 使用 短 Access Token (5分钟) + Refresh Token 策略。
- 当检测到注销事件时,在 IdP 端撤销对应的 Refresh Token。
-
调试技巧:
- 使用 Chrome 开发者工具的 Network 标签,勾选 “Preserve log”。
- 观察跳转过程:App A → IdP → (IdP 加载 iframe) → App B → IdP → App A/Index。
- 检查 Cookie 是否真的被删除了。

