本文基于笔者2023年的博客,使用magentic-ui重新编辑排版后笔者又稍微调整润色了一下,篇幅较长 ,原文地址:xie.infoq.cn/article/7f7...;
开篇碎语
时间飞逝,转眼已是2025年中。回顾过去,技术浪潮奔涌不息,我们开发者也需不断学习,紧跟时代步伐。 2023年曾在infoq发表过一篇关于"认证授权"的文章,2年过去,有些内容可能已经不再适用了,今天就回首一下,再来聊聊认证授权这个老生常谈却又至关重要的话题。
一、前言:认证授权,应用安全的基石
谈及认证(Authentication)与授权(Authorization),我们最先想到的便是登录模块。如今,几乎所有的应用程序(网站、App、小程序等)都离不开它。
幸运的是,主流开发框架(如 ASP.NET Core)通常都内置或提供了集成认证授权模块的方案。尽管相关概念(OAuth 2.0, OpenID Connect)有些复杂,但借助现代框架,开发一个功能完善的认证授权中心已不再是难事。我们只需遵循OAuth 2.0 和 OpenID Connect (OIDC) 标准,进行一些定制化开发即可。

图片来源: https://blog.goodsxx.cn/assets/1682168801108-10b72855.png
简单来说:
- OAuth 2.0 :一个授权框架,允许第三方应用在用户授权的前提下,安全地访问用户在服务提供商上的受保护资源,而无需获取用户的密码。
- OpenID Connect (OIDC) :构建在 OAuth 2.0 之上的一个身份认证层。它允许客户端应用验证用户的身份,并获取用户的基本信息。
本文不会过多纠缠于纯粹的概念,相关资料繁多且易混淆。相信许多开发者和笔者一样,对此处于"似懂非懂"的状态。关键在于阅读高质量资料并动手实践。文末会附上官方站点及优质参考文章。本文的核心在于 OpenIddict 的实战接入。
二、为什么选择 OpenIddict?
在 .NET 领域,选择认证授权库时,我们曾有 IdentityServer4 这个优秀选项。然而,自 Duende Software 接手后,IdentityServer 已转为商业收费模式(价目表),对于许多项目而言成本不菲。虽然 IdentityServer4 的旧版本仍可使用,但已停止维护,存在安全风险。

此时,OpenIddict 脱颖而出。它是一个开源、免费、功能强大且高度灵活的 .NET 认证授权解决方案,由 Kévin Chalet 大佬维护,社区活跃,更新及时。
OpenIddict 的优势:
- 完全开源免费:无商业许可限制。
- 高度灵活:提供底层构建块,易于定制和扩展。
- 标准兼容:完全支持 OAuth 2.0 和 OpenID Connect 核心规范及多种扩展。
- 与 ASP.NET Core 深度集成:无缝融入 .NET 生态。
- 支持多种数据库:Entity Framework Core (SQL Server, PostgreSQL, MySQL, SQLite), MongoDB 等。
- 持续更新 :紧跟 .NET 版本迭代,目前最新稳定版 6.x 已全面支持 .NET 8 和 .NET 9。
- 功能丰富:包含服务端、客户端和令牌验证三大组件,支持多种授权流程(授权码、客户端凭证、设备码等)、令牌类型、密钥管理、作用域和资源管理等。
因此,对于新项目或寻求从 IdentityServer 迁移的团队,OpenIddict 是一个极具吸引力的选择。
三、实战接入 OpenIddict 6.x (.NET 8/9)
接下来,我们将分步演示如何使用 OpenIddict 6.x 和 .NET 8/9 构建一个认证授权中心(Server)以及两种不同类型的客户端(MVC Web App 和 Web API)。
3.1 开始之前:环境准备
- .NET SDK: 确保安装了 .NET 8 或 .NET 9 SDK。
- IDE: Visual Studio 2022 或 VS Code。
- 数据库: 本例选用 PostgreSQL,你可以根据需要选择其他数据库(如 SQL Server, SQLite 等)。确保已安装并运行相应的数据库服务。
关于 ASP.NET Core Identity: Microsoft.AspNetCore.Identity
。这是一个功能完备的用户管理系统(包含用户注册、登录、密码管理等)。在实际项目中,你可以根据需求决定是否集成它。
- 集成 Identity: 可以利用其现成的用户管理功能和 UI,简化认证部分的开发。OpenIddict 可以与 Identity 无缝协作。
- 不集成 Identity : 如果你有自己的用户系统,或者想完全自定义认证逻辑,可以不集成 Identity,像一样自己实现用户存储和验证逻辑。本文采用不集成 Identity 的方式,手动创建
User
模型和登录逻辑,以便更清晰地展示 OpenIddict 的核心用法。
3.2 构建授权中心 (Server)
授权服务器负责验证用户身份、处理用户授权,并向客户端颁发访问令牌(Access Token)和身份令牌(ID Token)。
1) 创建项目与引入依赖
使用 dotnet new web -n AuthServer
命令创建一个新的 ASP.NET Core Web API 项目(或通过 VS 创建)。然后,引入必要的 NuGet 包:
xml
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.x"> <!-- Use latest 8.x or 9.x -->
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.x" /> <!-- Use latest 8.x or 9.x -->
<PackageReference Include="OpenIddict.AspNetCore" Version="6.0.0" /> <!-- Use latest 6.x -->
<PackageReference Include="OpenIddict.EntityFrameworkCore" Version="6.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Cookies" Version="8.0.x" /> <!-- Match your .NET version -->
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="8.0.x" /> <!-- Optional, for Razor Pages dev experience -->
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.x.x" /> <!-- Optional, for API testing -->
</ItemGroup>
OpenIddict.AspNetCore
: OpenIddict 与 ASP.NET Core 的集成。OpenIddict.EntityFrameworkCore
: 使用 EF Core 存储 OpenIddict 数据(Applications, Scopes, Tokens, Authorizations)。Npgsql.EntityFrameworkCore.PostgreSQL
: EF Core 的 PostgreSQL 提供程序。Microsoft.AspNetCore.Authentication.Cookies
: 用于实现基于 Cookie 的用户登录认证。Microsoft.EntityFrameworkCore.Design
: 用于 EF Core 数据库迁移。Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation
: (可选)方便 Razor 页面开发时运行时编译。Swashbuckle.AspNetCore
: (可选)集成 Swagger UI,方便 API 测试。
2) 创建数据模型与上下文
定义我们自己的用户模型 User.cs
:
csharp
// Models/User.cs
using System.ComponentModel.DataAnnotations;
public class User
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
public string UserName { get; set; }
public string Email { get; set; }
public string PasswordHash { get; set; } // Store hashed password
public string? Mobile { get; set; }
public string? Remark { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
创建数据库上下文 ApplicationDbContext.cs
,继承自 DbContext
:
csharp
// Data/ApplicationDbContext.cs
using Microsoft.EntityFrameworkCore;
public class ApplicationDbContext : DbContext
{
public DbSet<User> Users { get; set; }
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
// Optional: If not using DI for options
// protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
// {
// if (!optionsBuilder.IsConfigured)
// {
// // Replace with your actual connection string
// optionsBuilder.UseNpgsql("Host=localhost;Port=5432;Database=AuthServerDb;Username=postgres;Password=yourpassword");
// optionsBuilder.UseOpenIddict(); // Important!
// }
// }
}
注意: 我们没有显式定义 OpenIddict 的模型(OpenIddictEntityFrameworkCoreApplication
, OpenIddictEntityFrameworkCoreScope
等),因为 UseOpenIddict()
扩展方法会自动将它们包含在模型中。
3) 创建辅助服务与常量
封装一些通用逻辑到 AuthorizationService.cs
:
csharp
// Services/AuthorizationService.cs
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Primitives;
using OpenIddict.Abstractions;
using System.Security.Claims;
using static OpenIddict.Abstractions.OpenIddictConstants;
public class AuthorizationService
{
// 解析 OAuth 请求参数 (Form or Query)
public IDictionary<string, StringValues> ParseOAuthParameters(HttpContext httpContext, List<string>? excluding = null)
{
excluding ??= new List<string>();
var parameters = new Dictionary<string, StringValues>();
if (httpContext.Request.HasFormContentType)
{
parameters = httpContext.Request.Form
.Where(pair => !excluding.Contains(pair.Key))
.ToDictionary(pair => pair.Key, pair => pair.Value);
}
else
{
parameters = httpContext.Request.Query
.Where(pair => !excluding.Contains(pair.Key))
.ToDictionary(pair => pair.Key, pair => pair.Value);
}
return parameters;
}
// 构建重定向 URL
public string BuildRedirectUrl(HttpRequest request, IDictionary<string, StringValues> oAuthParameters)
{
var queryString = QueryString.Create(oAuthParameters);
// Combine PathBase, Path, and the new QueryString
var url = request.PathBase + request.Path + queryString;
return url;
}
// 检查用户认证状态和 MaxAge
public bool IsUserAuthenticated(AuthenticateResult authenticateResult, OpenIddictRequest request)
{
if (!authenticateResult.Succeeded || authenticateResult.Principal == null)
{
return false;
}
// Check MaxAge
if (request.MaxAge.HasValue && authenticateResult.Properties?.IssuedUtc != null)
{
var maxAgeSeconds = TimeSpan.FromSeconds(request.MaxAge.Value);
var authenticationDate = authenticateResult.Properties.IssuedUtc.Value;
if (DateTimeOffset.UtcNow - authenticationDate > maxAgeSeconds)
{
return false; // Authentication is too old
}
}
return true;
}
// 决定声明的目标 (Destination)
public static IEnumerable<string> GetDestinations(ClaimsIdentity identity, Claim claim)
{
// By default, claims are not issued in the access token nor in the identity token.
// Ask OpenIddict to issue the claim in the identity token only if the "openid" scope was granted
// and if the user controller corresponding to the claim is listed as an OIDC claim.
if (claim.Type is Claims.Name or Claims.Email or Claims.Role)
{
yield return Destinations.AccessToken;
if (identity.HasScope(Scopes.OpenId))
{
// Only add to ID token if 'openid' scope is present
if (claim.Type is Claims.Name && identity.HasScope(Scopes.Profile))
yield return Destinations.IdentityToken;
if (claim.Type is Claims.Email && identity.HasScope(Scopes.Email))
yield return Destinations.IdentityToken;
if (claim.Type is Claims.Role && identity.HasScope(Scopes.Roles))
yield return Destinations.IdentityToken;
}
}
// Never include the security stamp in the access token, as it's a secret value.
else if (claim.Type is "aspnet.identity.securitystamp")
{
yield break;
}
else
{
// Default behavior: add to access token if relevant scope is granted
if (identity.HasScope(Scopes.Profile) && claim.Type is Claims.PreferredUsername or Claims.GivenName or Claims.FamilyName)
yield return Destinations.AccessToken;
// Add other claims to access token if needed based on scopes
// Example: if (identity.HasScope("custom_scope") && claim.Type == "my_custom_claim") yield return Destinations.AccessToken;
// By default, claims are not added to the identity token.
// To add claims to the identity token, ensure the 'openid' scope is granted
// and map the claim type to a standard OIDC claim or define a custom scope.
}
}
}
定义常量 Consts.cs
:
csharp
// Helpers/Consts.cs
public static class Consts
{
public const string Email = "email";
public const string Password = "password";
public const string ConsentNaming = "consent"; // Used for consent claim
public const string GrantAccessValue = "Grant";
public const string DenyAccessValue = "Deny";
}
4) 创建登录页面 (Authenticate)
我们需要一个页面让用户输入凭据。这里使用 Razor Page。
创建 Pages/Authenticate.cshtml
和 Pages/Authenticate.cshtml.cs
。
Authenticate.cshtml (简化版):
html
@page
@model AuthServer.Pages.AuthenticateModel
@{ ViewData["Title"] = "Login"; }
<h2>Login</h2>
<form method="post">
<input type="hidden" asp-for="ReturnUrl" />
<div>
<label asp-for="Email"></label>
<input asp-for="Email" />
</div>
<div>
<label asp-for="Password"></label>
<input type="password" asp-for="Password" />
</div>
@if (!string.IsNullOrEmpty(Model.AuthStatus))
{
<div style="color:red;">@Model.AuthStatus</div>
}
<button type="submit">Log in</button>
</form>
Authenticate.cshtml.cs:
csharp
// Pages/Authenticate.cshtml.cs
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using BC = BCrypt.Net.BCrypt; // Using BCrypt.Net for password hashing
namespace AuthServer.Pages
{
public class AuthenticateModel : PageModel
{
private readonly ApplicationDbContext _db;
public AuthenticateModel(ApplicationDbContext db)
{
_db = db;
}
[BindProperty]
[Required]
[EmailAddress]
public string Email { get; set; } = "[email protected]"; // Default for testing
[BindProperty]
[Required]
[DataType(DataType.Password)]
public string Password { get; set; } = "Password123!"; // Default for testing
[BindProperty(SupportsGet = true)] // Bind from query string on GET
public string? ReturnUrl { get; set; }
public string AuthStatus { get; set; } = "";
public IActionResult OnGet(string? returnUrl = null)
{
ReturnUrl = returnUrl;
// If user is already authenticated, redirect directly if ReturnUrl is present
if (User.Identity?.IsAuthenticated == true && !string.IsNullOrEmpty(ReturnUrl))
{
// Potentially dangerous if ReturnUrl is not validated, but OpenIddict handles this later
// return Redirect(ReturnUrl);
}
return Page();
}
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
{
return Page();
}
var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == Email);
if (user == null || !BC.Verify(Password, user.PasswordHash))
{
AuthStatus = "Invalid username or password.";
return Page();
}
// --- User is authenticated, create claims principal ---
var claims = new List<Claim>
{
// IMPORTANT: The ClaimTypes.NameIdentifier is the unique ID for the user
// Using email here for simplicity, but user.Id (Guid) is usually better.
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.UserName),
new Claim(ClaimTypes.Email, user.Email),
// Add other claims like roles if needed
// new Claim(ClaimTypes.Role, "Admin"),
};
var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
var authProperties = new AuthenticationProperties
{
// Allow refresh
IsPersistent = true,
//ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(10) // Example expiration
};
await HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(claimsIdentity),
authProperties);
AuthStatus = "Authentication successful.";
// Redirect back to the OpenIddict authorize endpoint or a default page
if (!string.IsNullOrEmpty(ReturnUrl) && Url.IsLocalUrl(ReturnUrl)) // Security check
{
return Redirect(ReturnUrl);
}
else
{
// Maybe redirect to a user profile page or home page
return RedirectToPage("/Index");
}
}
}
}
注意:
- 使用了
BCrypt.Net
(需要Install-Package BCrypt.Net-Next
) 进行密码哈希存储和验证,这比我之前自己实现的PasswordHasher
更标准。 ClaimTypes.NameIdentifier
通常应使用用户的唯一 ID(如user.Id.ToString()
),而不是邮箱。- 添加了
Url.IsLocalUrl()
检查以防止开放重定向攻击。
登录页示例图:

5) 创建授权页面 (Consent)
对于需要用户明确同意(ConsentTypes.Explicit
)的客户端,我们需要一个授权页面。
创建 Pages/Consent.cshtml
和 Pages/Consent.cshtml.cs
。
Consent.cshtml (简化版):
html
@page
@model AuthServer.Pages.ConsentModel
@{ ViewData["Title"] = "Authorize"; }
<h2>Authorize Application</h2>
<p>Application <strong>@Model.ApplicationName</strong> wants to access your resources.</p>
<form method="post">
<input type="hidden" asp-for="ReturnUrl" />
<input type="hidden" asp-for="Parameters" /> <!-- Pass parameters back -->
<p>Do you grant access?</p>
<button type="submit" name="grantButton" value="@Consts.GrantAccessValue">Grant Access</button>
<button type="submit" name="grantButton" value="@Consts.DenyAccessValue">Deny Access</button>
</form>
Consent.cshtml.cs:
csharp
// Pages/Consent.cshtml.cs
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Primitives;
using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore;
using System.Security.Claims;
using System.Text.Json;
using static OpenIddict.Abstractions.OpenIddictConstants;
namespace AuthServer.Pages
{
[Authorize] // Must be logged in to grant consent
public class ConsentModel : PageModel
{
private readonly IOpenIddictApplicationManager _applicationManager;
private readonly AuthorizationService _authService; // Inject our helper service
public ConsentModel(IOpenIddictApplicationManager applicationManager, AuthorizationService authService)
{
_applicationManager = applicationManager;
_authService = authService;
}
public string ApplicationName { get; set; }
[BindProperty] // Bound from the form post
public string? ReturnUrl { get; set; }
// Store parameters needed for the redirect back to Authorize endpoint
[BindProperty]
public string Parameters { get; set; }
public async Task<IActionResult> OnGetAsync(string returnUrl)
{
ReturnUrl = returnUrl;
// Extract parameters from ReturnUrl (which is the original /connect/authorize request)
var requestUri = new Uri(Request.Scheme + "://" + Request.Host + returnUrl);
var parameters = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(requestUri.Query)
.ToDictionary(kvp => kvp.Key, kvp => new StringValues(kvp.Value));
// Store parameters as JSON string for the POST request
Parameters = JsonSerializer.Serialize(parameters);
var request = new OpenIddictRequest(parameters);
var application = await _applicationManager.FindByClientIdAsync(request.ClientId);
if (application == null)
{
// Handle error: client not found
return Page();
}
ApplicationName = await _applicationManager.GetDisplayNameAsync(application) ?? request.ClientId;
return Page();
}
public async Task<IActionResult> OnPostAsync(string grantButton)
{
if (!User.Identity.IsAuthenticated)
{
// Should not happen due to [Authorize], but good practice
return Challenge(CookieAuthenticationDefaults.AuthenticationScheme);
}
// Deserialize parameters back
var parameters = JsonSerializer.Deserialize<Dictionary<string, StringValues>>(Parameters);
var request = new OpenIddictRequest(parameters);
var application = await _applicationManager.FindByClientIdAsync(request.ClientId);
if (application == null)
{
// Handle error
return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
if (grantButton == Consts.GrantAccessValue)
{
// User granted consent. We don't need to store this permanently here,
// OpenIddict will create an authorization record if needed.
// The key is to redirect back to the Authorize endpoint *without* the prompt=consent parameter
// and *with* the user authenticated.
// Rebuild the return URL without prompt=consent
parameters.Remove(Parameters.Prompt);
var redirectUrl = _authService.BuildRedirectUrl(Request, parameters);
// Sign in again to potentially update claims if needed, though usually not necessary here.
// await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, User);
return Redirect(redirectUrl); // Redirect back to Authorize endpoint
}
else // User denied consent
{
// Redirect back to the client with an access_denied error.
var properties = new AuthenticationProperties(new Dictionary<string, string?>
{
[OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.AccessDenied,
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user denied access to the application."
});
// Important: Use Forbid with the OpenIddict scheme to signal denial to OpenIddict
return Forbid(properties, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
}
}
}
注意:
- 页面需要显示请求授权的应用名称 (
ApplicationName
)。 - 通过
Parameters
隐藏字段传递原始请求参数,以便在 POST 时重建请求。 - 用户同意后,关键是重定向回
/connect/authorize
端点 ,此时用户已认证,且没有prompt=consent
参数,OpenIddict 会继续处理并颁发 Code。 - 用户拒绝后,使用
Forbid(properties, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)
将access_denied
错误返回给客户端。
授权页示例图:

6) 实现 OpenIddict 核心端点 (AuthorizationController)
这是授权服务器的核心,处理 /connect/authorize
, /connect/token
, /connect/userinfo
, /connect/logout
等请求。
创建 Controllers/AuthorizationController.cs
:
csharp
// Controllers/AuthorizationController.cs
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Primitives;
using Microsoft.IdentityModel.Tokens;
using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore;
using System.Collections.Immutable;
using System.Security.Claims;
using System.Web;
using static OpenIddict.Abstractions.OpenIddictConstants;
namespace AuthServer.Controllers
{
public class AuthorizationController : Controller
{
private readonly IOpenIddictApplicationManager _applicationManager;
private readonly IOpenIddictAuthorizationManager _authorizationManager;
private readonly IOpenIddictScopeManager _scopeManager;
private readonly AuthorizationService _authService;
private readonly ApplicationDbContext _dbContext; // Inject DbContext if needed for user lookup
public AuthorizationController(
IOpenIddictApplicationManager applicationManager,
IOpenIddictAuthorizationManager authorizationManager,
IOpenIddictScopeManager scopeManager,
AuthorizationService authService,
ApplicationDbContext dbContext)
{
_applicationManager = applicationManager;
_authorizationManager = authorizationManager;
_scopeManager = scopeManager;
_authService = authService;
_dbContext = dbContext;
}
[HttpGet("~/connect/authorize")]
[HttpPost("~/connect/authorize")]
[IgnoreAntiforgeryToken] // Not needed for standard OAuth/OIDC endpoints
public async Task<IActionResult> Authorize()
{
var request = HttpContext.GetOpenIddictServerRequest() ??
throw new InvalidOperationException("The OpenID Connect request cannot be retrieved.");
// Try to retrieve the user principal stored in the authentication cookie.
var result = await HttpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme);
// If the user principal can't be extracted, redirect the user to the login page.
if (!_authService.IsUserAuthenticated(result, request))
{
// Build the parameters dictionary for the challenge
var parameters = _authService.ParseOAuthParameters(HttpContext, new List<string> { Parameters.Prompt });
var returnUrl = _authService.BuildRedirectUrl(HttpContext.Request, parameters);
return Challenge(new AuthenticationProperties
{
RedirectUri = returnUrl // Redirect back here after login
},
CookieAuthenticationDefaults.AuthenticationScheme);
}
// Retrieve the application details from the database.
var application = await _applicationManager.FindByClientIdAsync(request.ClientId) ??
throw new InvalidOperationException("Details concerning the calling client application cannot be found.");
// Retrieve the permanent authorizations associated with the user and the calling client application.
var userId = result.Principal.FindFirstValue(ClaimTypes.NameIdentifier);
var authorizations = await _authorizationManager.FindAsync(
subject: userId,
client: await _applicationManager.GetIdAsync(application),
status: Statuses.Valid,
type: AuthorizationTypes.Permanent,
scopes: request.GetScopes()).ToListAsync();
// Check consent requirements
var consentType = await _applicationManager.GetConsentTypeAsync(application);
switch (consentType)
{
// If the consent is explicit, prompt the user for consent.
case ConsentTypes.Explicit when !authorizations.Any(): // Only prompt if no valid permanent authorization exists
case ConsentTypes.Explicit when request.HasPrompt(Prompts.Consent):
// If the consent is systematic, automatically grant consent without prompting the user.
case ConsentTypes.Systematic:
// Handled below
break;
// If the consent type is unknown or invalid, return an error.
default:
return Forbid(new AuthenticationProperties(new Dictionary<string, string?>
{
[OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidClient,
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "Invalid consent type specified for the client application."
}), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
// If prompt=login was specified, force the user to log in again.
if (request.HasPrompt(Prompts.Login))
{
// To avoid endless login loops, remember the prompt=login parameter was processed.
var prompt = string.Join(" ", request.GetPrompts().Remove(Prompts.Login));
var parameters = _authService.ParseOAuthParameters(HttpContext, new List<string> { Parameters.Prompt });
parameters[Parameters.Prompt] = prompt;
var returnUrl = _authService.BuildRedirectUrl(HttpContext.Request, parameters);
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return Challenge(new AuthenticationProperties { RedirectUri = returnUrl }, CookieAuthenticationDefaults.AuthenticationScheme);
}
// If prompt=consent was specified, or if the client requires explicit consent and no permanent authorization exists,
// redirect the user to the consent page.
if (request.HasPrompt(Prompts.Consent) || (consentType == ConsentTypes.Explicit && !authorizations.Any()))
{
var parameters = _authService.ParseOAuthParameters(HttpContext, new List<string> { Parameters.Prompt });
var returnUrl = _authService.BuildRedirectUrl(HttpContext.Request, parameters);
// Pass the original request URL (including query string) to the Consent page
var consentRedirectUrl = $"/Consent?returnUrl={HttpUtility.UrlEncode(returnUrl)}";
return Redirect(consentRedirectUrl);
}
// --- Consent granted (implicitly or previously) ---
// Create the claims-based identity that will be used by OpenIddict.
var identity = new ClaimsIdentity(
authenticationType: TokenValidationParameters.DefaultAuthenticationType,
nameType: Claims.Name,
roleType: Claims.Role);
// Add claims based on the authenticated user and requested scopes.
var user = await _dbContext.Users.FindAsync(Guid.Parse(userId)); // Fetch user details if needed
if (user == null)
{
return Forbid(new AuthenticationProperties(new Dictionary<string, string?>
{
[OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant,
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user account is no longer available."
}), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
identity.SetClaim(Claims.Subject, userId) // Subject is the unique user ID
.SetClaim(Claims.Email, user.Email)
.SetClaim(Claims.Name, user.UserName);
// Add roles if applicable: .SetClaims(Claims.Role, new[] { "admin", "user" }.ToImmutableArray());
// Set the list of scopes granted to the client application.
identity.SetScopes(request.GetScopes());
identity.SetResources(await _scopeManager.ListResourcesAsync(identity.GetScopes()).ToListAsync());
// Automatically create a permanent authorization to avoid prompting for consent every time
// if the consent type is explicit or systematic.
if (consentType is ConsentTypes.Explicit or ConsentTypes.Systematic)
{
// Find existing or create new authorization
var authorization = authorizations.LastOrDefault() ?? await _authorizationManager.CreateAsync(
identity: identity,
subject : userId,
client : await _applicationManager.GetIdAsync(application),
type : AuthorizationTypes.Permanent,
scopes : identity.GetScopes());
identity.SetAuthorizationId(await _authorizationManager.GetIdAsync(authorization));
}
identity.SetDestinations(claim => AuthorizationService.GetDestinations(identity, claim));
// Returning a SignInResult will ask OpenIddict to issue the appropriate access/identity tokens.
return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
[HttpPost("~/connect/token"), Produces("application/json")]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> Exchange()
{
var request = HttpContext.GetOpenIddictServerRequest() ??
throw new InvalidOperationException("The OpenID Connect request cannot be retrieved.");
if (request.IsAuthorizationCodeGrantType() || request.IsRefreshTokenGrantType())
{
// Retrieve the claims principal stored in the authorization code/refresh token.
var result = await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
var principal = result.Principal;
var userId = principal.GetClaim(Claims.Subject);
if (string.IsNullOrEmpty(userId))
{
return Forbid(new AuthenticationProperties(new Dictionary<string, string?>
{
[OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant,
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The token is no longer valid."
}), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
// Ensure the user account associated with the claims principal is still valid.
var user = await _dbContext.Users.FindAsync(Guid.Parse(userId)); // Validate user exists
if (user == null)
{
return Forbid(new AuthenticationProperties(new Dictionary<string, string?>
{
[OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant,
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user is no longer available."
}), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
// Create a new claims principal based on the refresh/authorization code principal.
var identity = new ClaimsIdentity(principal.Claims,
authenticationType: TokenValidationParameters.DefaultAuthenticationType,
nameType: Claims.Name,
roleType: Claims.Role);
// Override the user claims present in the principal in case they changed since the refresh token was issued.
identity.SetClaim(Claims.Subject, userId)
.SetClaim(Claims.Email, user.Email)
.SetClaim(Claims.Name, user.UserName);
// .SetClaims(Claims.Role, ...);
identity.SetDestinations(claim => AuthorizationService.GetDestinations(identity, claim));
// Returning a SignInResult will ask OpenIddict to issue the appropriate access/identity tokens.
return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
// Handle other grant types like client_credentials if needed
// else if (request.IsClientCredentialsGrantType()) { ... }
throw new InvalidOperationException("The specified grant type is not supported.");
}
// Note: The UserInfo endpoint is protected by the OpenIddict validation handler.
// It requires a valid access token containing the 'openid' scope and the 'sub' claim.
[Authorize(AuthenticationSchemes = OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)]
[HttpGet("~/connect/userinfo"), HttpPost("~/connect/userinfo"), Produces("application/json")]
public async Task<IActionResult> Userinfo()
{
var userId = User.GetClaim(Claims.Subject);
if (string.IsNullOrEmpty(userId))
{
return Challenge(
authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
properties: new AuthenticationProperties(new Dictionary<string, string?>
{
[OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidToken,
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The access token is invalid or doesn't contain a subject claim."
}));
}
var user = await _dbContext.Users.FindAsync(Guid.Parse(userId));
if (user == null)
{
return Challenge(
authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
properties: new AuthenticationProperties(new Dictionary<string, string?>
{
[OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidToken,
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user associated with the access token no longer exists."
}));
}
var claims = new Dictionary<string, object>(StringComparer.Ordinal)
{
// Note: the "sub" claim is a mandatory claim and must be included in the JSON response.
[Claims.Subject] = userId
};
if (User.HasScope(Scopes.Profile))
{
claims[Claims.Name] = user.UserName;
// Add other profile claims like 'given_name', 'family_name', etc.
}
if (User.HasScope(Scopes.Email))
{
claims[Claims.Email] = user.Email;
claims[Claims.EmailVerified] = true; // Assuming email is verified
}
if (User.HasScope(Scopes.Roles))
{
// claims[Claims.Role] = ... // Add roles if applicable
}
// Add other claims based on requested scopes
return Ok(claims);
}
[HttpGet("~/connect/logout")]
[HttpPost("~/connect/logout")]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> Logout()
{
// Ask ASP.NET Core Identity to delete the local and external cookies created
// when the user agent is redirected from the external identity provider
// after a successful authentication flow (e.g. Google or Facebook).
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
// Returning a SignOutResult will ask OpenIddict to redirect the user agent
// to the post_logout_redirect_uri specified by the client application or to
// the RedirectUri specified in the authentication properties if none was set.
return SignOut(authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
properties: new AuthenticationProperties
{
// Specify the post-logout redirect URI.
// If null, OpenIddict will try to use the client's PostLogoutRedirectUris.
// If the client has no registered PostLogoutRedirectUris, OpenIddict will display a default page.
RedirectUri = "/"
});
}
}
}
关键更新与说明:
- 控制器逻辑 : 基本沿用笔者之前博客的逻辑,但根据 OpenIddict 6.x 的 API 和最佳实践进行了微调(例如,
FindAsync
的使用,Consent 处理逻辑,Userinfo 端点实现)。 - 端点命名 : OpenIddict 6.x 对某些端点名称进行了标准化(如
Logout
->EndSession
,Userinfo
->UserInfo
),但在Set...EndpointUris
配置时仍可使用旧名称以保持兼容性或自定义。代码中使用了配置时设置的路径 (~/connect/authorize
等)。 - Consent 处理 : 改进了 Consent 逻辑,仅在需要时(首次、
prompt=consent
)跳转到 Consent 页面,并在同意后通过重定向回Authorize
端点来完成流程。 - Userinfo 端点: 实现了标准的 Userinfo 端点,根据请求的 Scope 返回相应的 Claim。
- Logout 端点: 实现了标准的 End Session 端点,先登出本地 Cookie,然后通过 OpenIddict 处理 Post Logout Redirect。
- 错误处理 : 使用
Forbid
结合OpenIddictServerAspNetCoreDefaults.AuthenticationScheme
返回标准的 OAuth/OIDC 错误给客户端。 - 依赖注入 : 确保所有需要的服务(
IOpenIddict...Manager
,AuthorizationService
,ApplicationDbContext
)都已正确注入。
7) 数据种子 (Seeding)
我们需要在应用启动时创建一些初始数据,如客户端应用信息、作用域以及测试用户。
创建 Data/ClientsSeeder.cs
:
csharp
// Data/ClientsSeeder.cs
using Microsoft.EntityFrameworkCore;
using OpenIddict.Abstractions;
using static OpenIddict.Abstractions.OpenIddictConstants;
using BC = BCrypt.Net.BCrypt;
public class ClientsSeeder
{
private readonly ApplicationDbContext _context;
private readonly IOpenIddictApplicationManager _applicationManager;
private readonly IOpenIddictScopeManager _scopeManager;
public ClientsSeeder(ApplicationDbContext context, IOpenIddictApplicationManager appManager, IOpenIddictScopeManager scopeManager)
{
_context = context;
_applicationManager = appManager;
_scopeManager = scopeManager;
}
public async Task SeedAsync()
{
await _context.Database.EnsureCreatedAsync(); // Ensure DB exists
await AddScopesAsync();
await AddWebApiClientAsync();
await AddMvcClientAsync();
await AddOidcDebuggerClientAsync();
await AddInitUsersAsync();
}
private async Task AddScopesAsync()
{
// Standard scopes
if (await _scopeManager.FindByNameAsync(Scopes.OpenId) is null)
await _scopeManager.CreateAsync(new OpenIddictScopeDescriptor { Name = Scopes.OpenId });
if (await _scopeManager.FindByNameAsync(Scopes.Profile) is null)
await _scopeManager.CreateAsync(new OpenIddictScopeDescriptor { Name = Scopes.Profile, Resources = { "resource_server_1" } }); // Link profile scope to resource server
if (await _scopeManager.FindByNameAsync(Scopes.Email) is null)
await _scopeManager.CreateAsync(new OpenIddictScopeDescriptor { Name = Scopes.Email });
if (await _scopeManager.FindByNameAsync(Scopes.Roles) is null)
await _scopeManager.CreateAsync(new OpenIddictScopeDescriptor { Name = Scopes.Roles });
if (await _scopeManager.FindByNameAsync(Scopes.OfflineAccess) is null)
await _scopeManager.CreateAsync(new OpenIddictScopeDescriptor { Name = Scopes.OfflineAccess }); // For Refresh Tokens
// Custom API scope
if (await _scopeManager.FindByNameAsync("api1") is null)
{
await _scopeManager.CreateAsync(new OpenIddictScopeDescriptor
{
Name = "api1",
DisplayName = "API 1 Access",
Resources = { "resource_server_1" } // Associate scope with the resource server audience
});
}
}
private async Task AddWebApiClientAsync() // For Swagger UI / Postman / SPA
{
var clientId = "web-client";
if (await _applicationManager.FindByClientIdAsync(clientId) is not null) return;
await _applicationManager.CreateAsync(new OpenIddictApplicationDescriptor
{
ClientId = clientId,
ClientSecret = "901564A5-E7FE-42CB-B10D-61EF6A8F3654", // Use a strong secret in production!
ConsentType = ConsentTypes.Explicit, // Or Implicit/Systematic depending on trust
DisplayName = "Web API Client (Swagger/SPA)",
RedirectUris =
{
// For Swagger UI
new Uri("https://localhost:7002/swagger/oauth2-redirect.html"),
// Add URIs for your SPA if applicable
// new Uri("http://localhost:4200/callback")
},
PostLogoutRedirectUris =
{
new Uri("https://localhost:7002/resources") // Example redirect after logout
},
Permissions =
{
// Endpoints needed
Permissions.Endpoints.Authorization,
Permissions.Endpoints.Token,
Permissions.Endpoints.Logout,
// Grant types allowed
Permissions.GrantTypes.AuthorizationCode,
Permissions.GrantTypes.RefreshToken, // Allow refresh tokens
// Response types allowed
Permissions.ResponseTypes.Code,
// Scopes allowed
Permissions.Scopes.OpenId,
Permissions.Scopes.Profile,
Permissions.Scopes.Email,
Permissions.Scopes.Roles,
Permissions.Scopes.OfflineAccess, // Needed for refresh tokens
$"{Permissions.Prefixes.Scope}api1" // Custom scope permission
},
Requirements =
{
// Require PKCE for Authorization Code flow (recommended)
Requirements.Features.ProofKeyForCodeExchange
}
});
}
private async Task AddMvcClientAsync()
{
var clientId = "mvc";
if (await _applicationManager.FindByClientIdAsync(clientId) is not null) return;
await _applicationManager.CreateAsync(new OpenIddictApplicationDescriptor
{
ClientId = clientId,
ClientSecret = "901564A5-E7FE-42CB-B10D-61EF6A8F3654", // Use a strong secret!
ConsentType = ConsentTypes.Explicit,
DisplayName = "MVC Client Application",
RedirectUris =
{
new Uri("https://localhost:7003/callback/login/local")
},
PostLogoutRedirectUris =
{
new Uri("https://localhost:7003/callback/logout/local")
},
Permissions =
{
Permissions.Endpoints.Authorization,
Permissions.Endpoints.Token,
Permissions.Endpoints.Logout,
Permissions.GrantTypes.AuthorizationCode,
Permissions.GrantTypes.RefreshToken,
Permissions.ResponseTypes.Code,
Permissions.Scopes.OpenId,
Permissions.Scopes.Profile,
Permissions.Scopes.Email,
Permissions.Scopes.Roles,
Permissions.Scopes.OfflineAccess
// Note: MVC client usually doesn't need direct API scope ('api1')
// It gets user info via ID token and makes API calls on behalf of the user later.
},
Requirements =
{
Requirements.Features.ProofKeyForCodeExchange
}
});
}
private async Task AddOidcDebuggerClientAsync()
{
var clientId = "oidc-debugger";
if (await _applicationManager.FindByClientIdAsync(clientId) is not null) return;
await _applicationManager.CreateAsync(new OpenIddictApplicationDescriptor
{
ClientId = clientId,
// OIDC Debugger often uses implicit flow or code flow without secret for public clients
// For code flow with PKCE (recommended), no secret is needed if client type is public.
// Let's assume code flow with PKCE for this public client.
// ClientSecret = "...", // Not needed for public client with PKCE
ClientType = ClientTypes.Public, // Important for PKCE without secret
ConsentType = ConsentTypes.Explicit,
DisplayName = "OIDC Debugger Client",
RedirectUris = { new Uri("https://oidcdebugger.com/debug") },
PostLogoutRedirectUris = { new Uri("https://oidcdebugger.com/") },
Permissions =
{
Permissions.Endpoints.Authorization,
Permissions.Endpoints.Token,
Permissions.Endpoints.Logout,
Permissions.GrantTypes.AuthorizationCode,
Permissions.ResponseTypes.Code,
Permissions.Scopes.OpenId,
Permissions.Scopes.Profile,
Permissions.Scopes.Email,
Permissions.Scopes.Roles,
$"{Permissions.Prefixes.Scope}api1"
},
Requirements =
{
Requirements.Features.ProofKeyForCodeExchange
}
});
}
private async Task AddInitUsersAsync()
{
if (await _context.Users.AnyAsync()) return; // Only seed if no users exist
await _context.Users.AddRangeAsync(
new User
{
UserName = "test1",
Email = "[email protected]",
PasswordHash = BC.HashPassword("Password123!"), // Hash the password!
Mobile = "110",
Remark = "Initial test user 1"
},
new User
{
UserName = "test2",
Email = "[email protected]",
PasswordHash = BC.HashPassword("Password123!"),
Mobile = "119",
Remark = "Initial test user 2"
}
);
await _context.SaveChangesAsync();
}
}
注意:
- Secrets: 生产环境中绝不能硬编码客户端密钥,应使用配置或密钥管理服务。
- PKCE : 对公共客户端(如 SPA、移动应用、Swagger UI)和机密客户端(如 MVC 应用)都推荐启用 PKCE (
Requirements.Features.ProofKeyForCodeExchange
)。 - Client Types : 为 OIDC Debugger 设置了
ClientTypes.Public
,这样它可以使用 PKCE 而无需客户端密钥。 - Scopes & Resources : 将
profile
和api1
Scope 与资源服务器resource_server_1
关联起来。这是为了正确颁发包含aud
(Audience) 声明的 Access Token。 - Password Hashing : 使用
BCrypt.Net
哈希初始用户密码。
8) 配置服务与中间件 (Program.cs
)
现在,在 Program.cs
中将所有部分组合起来。
csharp
// Program.cs (Minimal API setup for .NET 8/9)
using AuthServer.Data; // For DbContext, Seeder
using AuthServer.Pages; // For Page Models if needed
using AuthServer.Services; // For AuthorizationService
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using OpenIddict.Abstractions;
using Quartz;
using static OpenIddict.Abstractions.OpenIddictConstants;
var builder = WebApplication.CreateBuilder(args);
// --- Configure Services ---
// 1. Database Context
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? "Host=localhost;Port=5432;Database=AuthServerDb;Username=postgres;Password=yourpassword"; // Provide a default or ensure it's in config
builder.Services.AddDbContext<ApplicationDbContext>(options =>
{
options.UseNpgsql(connectionString);
// Register the entity sets needed by OpenIddict.
options.UseOpenIddict();
});
// 2. OpenIddict Configuration
builder.Services.AddOpenIddict()
// Register the OpenIddict core components.
.AddCore(options =>
{
// Configure OpenIddict to use the Entity Framework Core stores and models.
options.UseEntityFrameworkCore()
.UseDbContext<ApplicationDbContext>();
// Enable Quartz.NET integration for scheduled tasks (token cleanup).
options.UseQuartz();
})
// Register the OpenIddict server components.
.AddServer(options =>
{
// Enable the necessary endpoints
options.SetAuthorizationEndpointUris("connect/authorize")
.SetLogoutEndpointUris("connect/logout")
.SetTokenEndpointUris("connect/token")
.SetUserinfoEndpointUris("connect/userinfo");
// .SetIntrospectionEndpointUris("connect/introspect") // Enable if needed
// .SetRevocationEndpointUris("connect/revoke"); // Enable if needed
// Specify allowed flows
options.AllowAuthorizationCodeFlow()
.AllowRefreshTokenFlow();
// .AllowClientCredentialsFlow(); // Enable if needed
// .AllowImplicitFlow(); // Generally discouraged
// Register the signing and encryption credentials.
// IMPORTANT: In production, use robust key management (e.g., X.509 certificates stored securely).
// For development, ephemeral keys or development certificates are convenient.
options.AddDevelopmentEncryptionCertificate()
.AddDevelopmentSigningCertificate();
// Or use AddEncryptionKey / AddSigningKey with SymmetricSecurityKey / X509Certificate2
// Example with symmetric key (ensure it's strong and stored securely!):
// options.AddEncryptionKey(new SymmetricSecurityKey(
// Convert.FromBase64String("DRjd/GnduI3Efzen9V9BvbNUfc/VKgXltV7Kbk9sMkY="))); // DO NOT USE THIS KEY IN PRODUCTION
// Register the ASP.NET Core host and configure the ASP.NET Core-specific options.
options.UseAspNetCore()
.EnableAuthorizationEndpointPassthrough() // Allow custom logic in Authorize endpoint
.EnableLogoutEndpointPassthrough() // Allow custom logic in Logout endpoint
.EnableTokenEndpointPassthrough() // Allow custom logic in Token endpoint
.EnableUserinfoEndpointPassthrough() // Allow custom logic in Userinfo endpoint
.EnableStatusCodePagesIntegration(); // Integrate with UseStatusCodePages
})
// Register the OpenIddict validation components (optional on server, useful for API protection within the server itself).
.AddValidation(options =>
{
// Import the configuration from the local OpenIddict server instance.
options.UseLocalServer();
// Register the ASP.NET Core host.
options.UseAspNetCore();
});
// 3. Authentication (Cookie for user login)
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.LoginPath = "/Authenticate"; // Redirect here if user needs to log in
options.LogoutPath = "/connect/logout"; // Optional: Centralize logout
options.ExpireTimeSpan = TimeSpan.FromMinutes(60);
options.SlidingExpiration = false;
});
// Note: No need to call AddAuthorization here explicitly if using endpoint routing's authorization
// 4. Background Tasks (Quartz.NET for OpenIddict)
builder.Services.AddQuartz(options =>
{
options.UseMicrosoftDependencyInjectionJobFactory();
options.UseSimpleTypeLoader();
options.UseInMemoryStore(); // Use DB store in production if needed
});
// Register the Quartz.NET hosted service.
builder.Services.AddQuartzHostedService(options => options.WaitForJobsToComplete = true);
// 5. Other Services (Controllers, Razor Pages, CORS, Swagger, Custom Services)
builder.Services.AddControllers(); // For AuthorizationController
builder.Services.AddRazorPages()
.AddRazorRuntimeCompilation(); // Optional: For development
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
// Be specific in production!
policy.WithOrigins("https://localhost:7002", "https://localhost:7003", "https://oidcdebugger.com")
.AllowAnyHeader()
.AllowAnyMethod();
});
});
builder.Services.AddSwaggerGen(); // Optional: For API testing
builder.Services.AddTransient<AuthorizationService>(); // Register custom service
builder.Services.AddTransient<ClientsSeeder>(); // Register seeder
// --- Build the App ---
var app = builder.Build();
// --- Configure the HTTP Request Pipeline ---
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
app.UseSwagger();
app.UseSwaggerUI();
}
else
{
app.UseExceptionHandler("/Error");
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting(); // Must be before CORS, AuthN, AuthZ
app.UseCors(); // Apply CORS policy
app.UseAuthentication(); // Enable Authentication middleware
app.UseAuthorization(); // Enable Authorization middleware
// Map endpoints
app.MapControllers();
app.MapRazorPages();
app.MapDefaultControllerRoute(); // Optional: If using conventional routing too
// Seed the database
using (var scope = app.Services.CreateScope())
{
var seeder = scope.ServiceProvider.GetRequiredService<ClientsSeeder>();
await seeder.SeedAsync();
}
app.Run();
关键配置点:
- DbContext : Configured with
UseNpgsql
andUseOpenIddict
. - OpenIddict Core: Configured to use EF Core and Quartz.NET.
- OpenIddict Server: Endpoints enabled, flows allowed (Auth Code, Refresh Token), development certs used (replace in production!), ASP.NET Core integration enabled with passthrough for controllers.
- OpenIddict Validation: Configured to use the local server (useful if APIs within the auth server itself need protection).
- Authentication: Cookie authentication configured as the default scheme for user login.
- Quartz.NET: Integrated for background token cleanup.
- CORS: Configured to allow requests from client applications.
- Middleware Pipeline: Order is important: Routing -> CORS -> Authentication -> Authorization.
- Seeding: Database seeding logic is called at startup.
9) 数据库迁移
打开包管理器控制台或终端,运行:
bash
# Ensure you have dotnet-ef tool installed: dotnet tool install --global dotnet-ef
dotnet ef migrations add InitialCreate -c ApplicationDbContext -o Data/Migrations
dotnet ef database update -c ApplicationDbContext
这会创建 OpenIddict 需要的表(OpenIddictApplications
, OpenIddictAuthorizations
, OpenIddictScopes
, OpenIddictTokens
)以及我们自定义的 Users
表。

(表结构示意图,实际表名可能略有不同)
至此,我们的授权服务器 (Server) 端基本完成!接下来,我们需要客户端来与之交互。
3.3 创建客户端 (MVC Web App)
这个客户端是一个传统的服务器端渲染的 Web 应用,它将用户重定向到授权服务器进行登录和授权,然后接收授权码,并用授权码换取令牌。
1) 创建项目与引入依赖
创建新项目:dotnet new mvc -n MvcClient
。 引入 NuGet 包:
xml
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.Cookies" Version="8.0.x" />
<PackageReference Include="OpenIddict.Client.AspNetCore" Version="6.0.0" />
<!-- Optional: If storing tokens in DB -->
<!-- <PackageReference Include="OpenIddict.EntityFrameworkCore" Version="6.0.0" /> -->
<!-- <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.x" /> -->
<!-- <PackageReference Include="Quartz.Extensions.Hosting" Version="3.x.x" /> -->
</ItemGroup>
OpenIddict.Client.AspNetCore
: OpenIddict 客户端与 ASP.NET Core 的集成。- 之前的博客中 MVC 客户端使用了 EF Core 和 Quartz 来存储令牌,这是一种持久化令牌的方式,但对于简单演示不是必需的。默认情况下,
OpenIddict.Client.AspNetCore
会将令牌存储在身份验证 Cookie 中(如果空间允许)。为了简化,本例暂时不使用 EF Core 和 Quartz 来存储客户端令牌。
2) 创建认证控制器 (AuthenticationController)
这个控制器处理登录、登出以及接收来自授权服务器的回调。
创建 Controllers/AuthenticationController.cs
:
csharp
// Controllers/AuthenticationController.cs
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Mvc;
using OpenIddict.Client.AspNetCore;
using System.Security.Claims;
using static OpenIddict.Abstractions.OpenIddictConstants;
public class AuthenticationController : Controller
{
// Initiates the login process by challenging the OpenIddict client scheme.
[HttpGet("~/login")]
public ActionResult LogIn(string returnUrl = "/")
{
var properties = new AuthenticationProperties
{
// Specify the OIDC provider (issuer) to use.
// This should match a registered provider in Program.cs.
Items = { [OpenIddictClientAspNetCoreConstants.Properties.Issuer] = "https://localhost:7000/" },
RedirectUri = Url.IsLocalUrl(returnUrl) ? returnUrl : "/" // Where to redirect after successful login
};
// Challenge the OpenIddict client scheme, which will trigger redirection to the auth server.
return Challenge(properties, OpenIddictClientAspNetCoreDefaults.AuthenticationScheme);
}
// Initiates the logout process.
[HttpPost("~/logout"), ValidateAntiForgeryToken]
public async Task<ActionResult> LogOut(string returnUrl = "/")
{
// Retrieve the identity token from the authentication properties, if available.
var result = await HttpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme);
var idToken = result?.Properties?.GetTokenValue(OpenIddictClientAspNetCoreConstants.Tokens.BackchannelIdentityToken);
// Sign out from the local cookie scheme.
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
var properties = new AuthenticationProperties(new Dictionary<string, string?>
{
// Specify the OIDC provider (issuer).
[OpenIddictClientAspNetCoreConstants.Properties.Issuer] = "https://localhost:7000/",
// Provide the identity token hint for the OIDC end session endpoint.
[OpenIddictClientAspNetCoreConstants.Properties.IdentityTokenHint] = idToken
})
{
// Specify the post-logout redirect URI.
RedirectUri = Url.IsLocalUrl(returnUrl) ? returnUrl : "/"
};
// Ask the OpenIddict client middleware to redirect the user agent to the OIDC provider's end session endpoint.
return SignOut(properties, OpenIddictClientAspNetCoreDefaults.AuthenticationScheme);
}
// Handles the callback from the authorization server after login.
// Note: the {provider} parameter corresponds to the scheme name (OpenIddictClientAspNetCoreDefaults.AuthenticationScheme)
[HttpGet("~/callback/login/{provider}"), HttpPost("~/callback/login/{provider}"), IgnoreAntiforgeryToken]
public async Task<ActionResult> LogInCallback()
{
// Retrieve the authorization data validated by the OpenIddict client middleware.
var result = await HttpContext.AuthenticateAsync(OpenIddictClientAspNetCoreDefaults.AuthenticationScheme);
// Ensure the login was successful.
if (result?.Principal?.Identity is not ClaimsIdentity { IsAuthenticated: true } identity)
{
throw new InvalidOperationException("The external authorization data cannot be used for authentication.");
}
// --- Build the ClaimsPrincipal for the local authentication cookie ---
// Map claims from the external principal (from ID token/userinfo) to the local principal.
var claims = new List<Claim>(result.Principal.Claims
.Select(claim => claim switch
{
// Map 'sub' claim to NameIdentifier.
{ Type: Claims.Subject } => new Claim(ClaimTypes.NameIdentifier, claim.Value, claim.ValueType, claim.Issuer),
// Map 'name' claim to Name.
{ Type: Claims.Name } => new Claim(ClaimTypes.Name, claim.Value, claim.ValueType, claim.Issuer),
// Keep other essential claims if needed (e.g., roles).
{ Type: Claims.Role } => new Claim(ClaimTypes.Role, claim.Value, claim.ValueType, claim.Issuer),
_ => null // Discard other claims by default
})
.Where(claim => claim is not null));
// Create the identity for the local cookie.
var localIdentity = new ClaimsIdentity(claims,
authenticationType: CookieAuthenticationDefaults.AuthenticationScheme,
nameType: ClaimTypes.Name,
roleType: ClaimTypes.Role);
// Build authentication properties, copying tokens from the external result.
var properties = new AuthenticationProperties(result.Properties.Items);
// Store the tokens received from the authorization server in the local cookie.
// Filter tokens to store only necessary ones (access, refresh, id).
properties.StoreTokens(result.Properties.GetTokens().Where(token => token switch
{
{ Name: OpenIddictClientAspNetCoreConstants.Tokens.BackchannelAccessToken } => true,
{ Name: OpenIddictClientAspNetCoreConstants.Tokens.BackchannelIdentityToken } => true,
{ Name: OpenIddictClientAspNetCoreConstants.Tokens.RefreshToken } => true,
_ => false
}));
// Sign in the user locally using the cookie scheme.
await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(localIdentity), properties);
// Redirect to the originally requested URL or the home page.
return Redirect(properties.RedirectUri ?? "/");
}
// Handles the callback from the authorization server after logout.
[HttpGet("~/callback/logout/{provider}"), HttpPost("~/callback/logout/{provider}"), IgnoreAntiforgeryToken]
public async Task<ActionResult> LogOutCallback()
{
// Retrieve the data forwarded by the OpenIddict client middleware.
var result = await HttpContext.AuthenticateAsync(OpenIddictClientAspNetCoreDefaults.AuthenticationScheme);
// Redirect according to the post-logout redirect URI provided by the server.
return Redirect(result?.Properties?.RedirectUri ?? "/");
}
}
说明:
/login
: Challenges theOpenIddictClientAspNetCoreDefaults.AuthenticationScheme
, triggering the redirect to the auth server./logout
: Signs out the local cookie and then callsSignOut
on the OpenIddict scheme to redirect to the auth server's end session endpoint./callback/login/{provider}
: Handles the redirect back from the auth server after successful authentication. It retrieves the validated principal from OpenIddict, creates a local principal for the cookie, stores tokens, and signs the user in locally./callback/logout/{provider}
: Handles the redirect back from the auth server after logout.
3) 创建视图 (Home/Index)
修改 Views/Home/Index.cshtml
以显示登录/登出状态和用户信息。
html
@using System.Security.Claims
@using Microsoft.AspNetCore.Authentication
@{ ViewData["Title"] = "Home Page"; }
<div class="text-center">
<h1 class="display-4">Welcome</h1>
@if (User.Identity?.IsAuthenticated == true)
{
<p>Hello, @User.Identity.Name!</p>
<p>You are logged in.</p>
<h4>Your Claims:</h4>
<dl>
@foreach (var claim in User.Claims)
{
<dt>@claim.Type</dt>
<dd>@claim.Value</dd>
}
</dl>
<h4>Tokens (from Cookie):</h4>
@{
var authResult = await Context.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme);
var accessToken = authResult?.Properties?.GetTokenValue("access_token");
var idToken = authResult?.Properties?.GetTokenValue("id_token");
var refreshToken = authResult?.Properties?.GetTokenValue("refresh_token");
}
<dl>
<dt>Access Token</dt>
<dd style="word-break: break-all;">@(accessToken ?? "Not found")</dd>
<dt>ID Token</dt>
<dd style="word-break: break-all;">@(idToken ?? "Not found")</dd>
<dt>Refresh Token</dt>
<dd style="word-break: break-all;">@(refreshToken ?? "Not found")</dd>
</dl>
<form asp-action="LogOut" asp-controller="Authentication" method="post">
@Html.AntiForgeryToken()
<button class="btn btn-danger" type="submit">Logout</button>
</form>
}
else
{
<p>You are not logged in.</p>
<a class="btn btn-success" asp-controller="Authentication" asp-action="LogIn">Log in via OIDC</a>
}
</div>
说明:
- 根据
User.Identity.IsAuthenticated
显示不同内容。 - 登录后显示用户名、用户 Claims 和从 Cookie 中提取的 Tokens。
- 提供登录和登出按钮。
4) 配置服务与中间件 (Program.cs
)
csharp
// Program.cs (MvcClient)
using Microsoft.AspNetCore.Authentication.Cookies;
using OpenIddict.Client.AspNetCore;
using static OpenIddict.Abstractions.OpenIddictConstants;
var builder = WebApplication.CreateBuilder(args);
// --- Configure Services ---
// 1. Authentication (Cookie for local session, OpenIddict for external)
builder.Services.AddAuthentication(options =>
{
// Default scheme for local user session
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
// Scheme used to challenge external OIDC provider
options.DefaultChallengeScheme = OpenIddictClientAspNetCoreDefaults.AuthenticationScheme;
})
.AddCookie(options =>
{
options.LoginPath = "/login"; // Redirect here if local cookie is missing
options.LogoutPath = "/logout";
options.ExpireTimeSpan = TimeSpan.FromMinutes(50);
options.SlidingExpiration = false;
});
// 2. OpenIddict Client Configuration
builder.Services.AddOpenIddict()
.AddCore(options => {
// Configure OpenIddict to use the default token storage (in-memory/cookie properties).
// If using EF Core for token storage:
// options.UseEntityFrameworkCore().UseDbContext<ApplicationDbContext>();
// options.UseQuartz(); // If using EF Core
})
.AddClient(options =>
{
// Allow flows used by this client
options.AllowAuthorizationCodeFlow();
options.AllowRefreshTokenFlow(); // Enable if refresh tokens are needed
// Use development certificates for encryption/signing (replace in production).
options.AddDevelopmentEncryptionCertificate()
.AddDevelopmentSigningCertificate();
// Register the ASP.NET Core host and configure the ASP.NET Core-specific options.
options.UseAspNetCore()
.EnableStatusCodePagesIntegration() // Show friendly error pages
.EnableRedirectionEndpointPassthrough() // Allow custom callback URLs
.EnablePostLogoutRedirectionEndpointPassthrough(); // Allow custom post-logout URLs
// Use System.Net.Http integration for backchannel communication.
options.UseSystemNetHttp();
// Register the OIDC provider configuration.
options.AddRegistration(new OpenIddictClientRegistration
{
// Issuer URI of the authorization server.
Issuer = new Uri("https://localhost:7000/", UriKind.Absolute),
// Client ID and secret registered in the authorization server.
ClientId = "mvc",
ClientSecret = "901564A5-E7FE-42CB-B10D-61EF6A8F3654", // Store securely!
// Scopes requested by the client.
Scopes = { Scopes.OpenId, Scopes.Profile, Scopes.Email, Scopes.Roles, Scopes.OfflineAccess },
// Redirect and post-logout redirect URIs registered in the authorization server.
RedirectUri = new Uri("callback/login/local", UriKind.Relative),
PostLogoutRedirectUri = new Uri("callback/logout/local", UriKind.Relative),
// Enable PKCE (recommended)
RequireProofKeyForCodeExchange = true
});
});
// Add HttpClient service needed by OpenIddict client
builder.Services.AddHttpClient();
// Add MVC services.
builder.Services.AddControllersWithViews();
builder.Services.AddRazorPages(); // If using Razor Pages
// Optional: If using EF Core for token storage
// builder.Services.AddDbContext<ApplicationDbContext>(...);
// builder.Services.AddQuartz(...);
// builder.Services.AddQuartzHostedService(...);
// builder.Services.AddHostedService<Worker>(); // Your token cleanup worker
// --- Build the App ---
var app = builder.Build();
// --- Configure the HTTP Request Pipeline ---
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Home/Error");
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
// IMPORTANT: Authentication middleware must be added here.
app.UseAuthentication();
app.UseAuthorization();
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
app.MapRazorPages(); // If using Razor Pages
app.Run();
关键配置点:
- Authentication Schemes :
CookieAuthenticationDefaults.AuthenticationScheme
for local login,OpenIddictClientAspNetCoreDefaults.AuthenticationScheme
for OIDC interactions. - OpenIddict Client : Configured with flows, dev certs, ASP.NET Core integration, System.Net.Http, and the specific OIDC provider registration (
AddRegistration
). - Provider Registration: Contains Issuer URI, Client ID/Secret, Scopes, Redirect URIs, and enables PKCE.
- HttpClient: Required by OpenIddict for backchannel communication.
- Middleware :
UseAuthentication
is crucial.
5) 牛刀小试 (MVC 客户端流程)
现在,同时运行 AuthServer 和 MvcClient 项目。
- 访问客户端 : 打开
https://localhost:7003
(MvcClient 的地址)。你会看到未登录状态。

- 点击登录: 点击
"通过oidc登录" 按钮,会重定向到授权服务器的登录页面。

- 输入凭据 : 输入用户名和密码(如
[email protected]
/Password123!
)。 - 授权确认: 登录成功后,会显示授权确认页面。点击 "Grant Access"。

- 重定向回客户端: 授权成功后,会重定向回 MVC 客户端,显示用户信息和令牌。
- 登出: 点击 "Logout" 按钮,会登出并重定向回客户端的首页。

3.4 创建 API 资源服务器 (Web API)
现在,我们将创建一个 API 资源服务器,它将使用 OpenIddict 验证来自客户端的访问令牌。这适用于前后端分离的架构,如 SPA + API。
1) 创建项目与引入依赖
创建新项目:dotnet new webapi -n ApiServer
。 引入 NuGet 包:
xml
<ItemGroup>
<PackageReference Include="OpenIddict.Validation.AspNetCore" Version="6.0.0" />
<PackageReference Include="OpenIddict.Validation.SystemNetHttp" Version="6.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.x.x" />
</ItemGroup>
OpenIddict.Validation.AspNetCore
: OpenIddict 验证与 ASP.NET Core 的集成。OpenIddict.Validation.SystemNetHttp
: 使用 System.Net.Http 进行令牌验证的网络通信。Swashbuckle.AspNetCore
: 集成 Swagger UI,方便 API 测试和 OAuth 流程演示。
2) 创建 API 控制器
创建一个简单的受保护 API 控制器 Controllers/ResourceController.cs
:
csharp
// Controllers/ResourceController.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace ApiServer.Controllers
{
[ApiController]
[Route("resources")]
public class ResourceController : ControllerBase
{
[HttpGet]
[Authorize] // Requires authentication
public IActionResult GetSecretResources()
{
// Get user info from the validated token
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
var userName = User.FindFirstValue(ClaimTypes.Name) ?? "unknown";
var userEmail = User.FindFirstValue(ClaimTypes.Email) ?? "unknown";
var roles = User.FindAll(ClaimTypes.Role).Select(c => c.Value).ToList();
// Return protected data
return Ok(new
{
Message = "This is a protected resource!",
User = new
{
Id = userId,
Name = userName,
Email = userEmail,
Roles = roles,
Claims = User.Claims.Select(c => new { c.Type, c.Value }).ToList()
},
Timestamp = DateTime.UtcNow
});
}
[HttpGet("public")]
public IActionResult GetPublicResources()
{
// This endpoint is public (no [Authorize] attribute)
return Ok(new
{
Message = "This is a public resource that anyone can access.",
Timestamp = DateTime.UtcNow
});
}
}
}
说明:
/resources
端点需要认证 ([Authorize]
),返回用户信息和受保护数据。/resources/public
端点不需要认证,任何人都可以访问。- 从
User
对象中提取 Claims,展示令牌中包含的用户信息。
3) 配置服务与中间件 (Program.cs
)
csharp
// Program.cs (ApiServer)
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
using OpenIddict.Validation.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
// --- Configure Services ---
// 1. Controllers
builder.Services.AddControllers();
// 2. OpenIddict Validation
builder.Services.AddOpenIddict()
.AddValidation(options =>
{
// Configure the audience (required for JWT validation).
options.SetIssuer("https://localhost:7000/"); // Auth server URL
options.AddAudiences("resource_server_1"); // Must match the audience in the auth server
// If using symmetric encryption key for token validation (must match the auth server key).
options.AddEncryptionKey(new SymmetricSecurityKey(
Convert.FromBase64String("DRjd/GnduI3Efzen9V9BvbNUfc/VKgXltV7Kbk9sMkY="))); // Use secure key management in production!
// Use introspection if needed (for reference tokens).
// options.UseIntrospection()
// .SetClientId("resource_server_1")
// .SetClientSecret("846B62D0-DEF9-4215-A99D-86E6B8DAB342");
// Register the System.Net.Http integration for remote validation/introspection.
options.UseSystemNetHttp();
// Register the ASP.NET Core host.
options.UseAspNetCore();
});
// 3. Authentication & Authorization
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
});
builder.Services.AddAuthorization(options =>
{
// Optional: Define authorization policies
options.AddPolicy("ApiScope", policy =>
{
policy.RequireAuthenticatedUser();
// Optionally require specific scopes
// policy.RequireClaim("scope", "api1");
});
});
// 4. Swagger with OAuth 2.0 support
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "Protected API", Version = "v1" });
// Configure Swagger to use OAuth2 with Authorization Code flow
c.AddSecurityDefinition("oauth2", new OpenApiSecurityScheme
{
Type = SecuritySchemeType.OAuth2,
Flows = new OpenApiOAuthFlows
{
AuthorizationCode = new OpenApiOAuthFlow
{
AuthorizationUrl = new Uri("https://localhost:7000/connect/authorize"),
TokenUrl = new Uri("https://localhost:7000/connect/token"),
Scopes = new Dictionary<string, string>
{
{ "openid", "OpenID Connect" },
{ "profile", "User profile" },
{ "email", "User email" },
{ "api1", "API access" }
}
}
}
});
c.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "oauth2" }
},
new[] { "api1" } // Scopes required for API operations
}
});
});
// 5. CORS for client applications
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.WithOrigins(
"https://localhost:7000", // Auth server
"https://localhost:7003" // MVC client
// Add other client origins as needed
)
.AllowAnyHeader()
.AllowAnyMethod();
});
});
// --- Build the App ---
var app = builder.Build();
// --- Configure the HTTP Request Pipeline ---
// Development-specific middleware
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
// Enable Swagger UI
app.UseSwagger();
app.UseSwaggerUI(c =>
{
c.SwaggerEndpoint("/swagger/v1/swagger.json", "Protected API v1");
// Configure OAuth client for Swagger UI
c.OAuthClientId("web-client"); // Client ID registered in auth server
c.OAuthClientSecret("901564A5-E7FE-42CB-B10D-61EF6A8F3654"); // Client secret
c.OAuthUsePkce(); // Enable PKCE (recommended)
});
}
app.UseHttpsRedirection();
app.UseStaticFiles(); // If serving static files
app.UseRouting();
app.UseCors(); // Apply CORS policy
// IMPORTANT: Authentication & Authorization middleware
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
关键配置点:
- OpenIddict Validation: 配置为验证来自授权服务器的令牌,设置 Issuer 和 Audience,添加加密密钥。
- Authentication : 默认方案设置为
OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme
。 - Swagger UI: 配置 OAuth 2.0 支持,使用授权码流程,方便在 Swagger UI 中测试 API。
- CORS: 允许授权服务器和客户端应用访问 API。
- 中间件顺序 : 确保
UseAuthentication
和UseAuthorization
在正确的位置。
4) 牛刀小试 (API 资源服务器)
现在,同时运行 AuthServer 和 ApiServer 项目。
- 访问 Swagger UI : 打开
https://localhost:7002/swagger
(ApiServer 的 Swagger UI 地址)。

- 未授权访问 : 尝试直接调用
/resources
端点,会返回 401 Unauthorized。

- 授权访问 :
- 点击 Swagger UI 右上角的 "Authorize" 按钮。
- 在弹出的对话框中,选择所需的 Scopes (
openid
,profile
,email
,api1
),然后点击 "Authorize"。 - 这会重定向到授权服务器的登录页面。登录并授权后,会重定向回 Swagger UI。
- 再次调用
/resources
端点,现在应该返回 200 OK 和受保护的资源数据。


3.5 使用 OIDC Debugger 和 Postman 进行调试
除了通过客户端应用程序,我们还可以使用专业工具来调试 OAuth 2.0 和 OpenID Connect 流程。
1) OIDC Debugger
OIDC Debugger 是一个在线工具,可以帮助我们测试 OpenID Connect 流程。
- 打开 OIDC Debugger : 访问 oidcdebugger.com/
- 配置参数 :
- Authorize URI :
https://localhost:7000/connect/authorize
- Redirect URI :
https://oidcdebugger.com/debug
- Client ID :
oidc-debugger
(我们在授权服务器中配置的客户端 ID) - Scope :
openid profile email api1
- Response type :
code
(授权码流程) - Response mode :
query
- Enable PKCE: 勾选 (推荐)
- Authorize URI :

- 发送请求: 点击 "Send Request" 按钮。
- 登录和授权: 如果未登录,会重定向到授权服务器的登录页面。登录并授权后,会重定向回 OIDC Debugger。
- 查看授权码: OIDC Debugger 会显示授权码和其他参数。
服务端的控制台也会输出相关信息

2) Postman
获取授权码后,我们可以使用 Postman 来交换令牌。
- 创建 POST 请求 :
- URL :
https://localhost:7000/connect/token
- Body (x-www-form-urlencoded):
grant_type
:authorization_code
code
: [从 OIDC Debugger 获取的授权码]redirect_uri
:https://oidcdebugger.com/debug
client_id
:oidc-debugger
client_secret
:901564A5-E7FE-42CB-B10D-61EF6A8F3654
(如果是公共客户端,则不需要)code_verifier
: [从 OIDC Debugger 获取的 PKCE 验证码] (如果启用了 PKCE)
- URL :

- 发送请求: 点击 "Send" 按钮。
- 查看令牌 : 响应中会包含访问令牌、ID 令牌和刷新令牌(如果请求了
offline_access
作用域)。

3) 验证 JWT 令牌
获取令牌后,我们可以使用 JWT.io 来解析和验证 JWT 令牌。
- 打开 JWT.io : 访问 jwt.io/
- 粘贴令牌: 将访问令牌或 ID 令牌粘贴到 "Encoded" 文本框中。
- 查看解析结果: JWT.io 会自动解析令牌,显示头部 (Header)、负载 (Payload) 和签名 (Signature)。

四、总结与最佳实践
通过本文,我们详细介绍了如何使用 OpenIddict 6.x 和 .NET 8/9 构建现代化的认证授权系统。以下是一些关键点和最佳实践:
4.1 OpenIddict 的优势
- 开源免费: 相比于商业解决方案 (如 Duende IdentityServer),OpenIddict 完全开源免费,无使用限制。
- 标准兼容: 完全支持 OAuth 2.0 和 OpenID Connect 标准,包括各种授权流程和扩展。
- 高度灵活: 提供丰富的配置选项,可以根据需求定制。
- 活跃维护: 社区活跃,更新频繁,紧跟 .NET 版本迭代。
- 全面组件: 提供服务端、客户端和验证组件,可以单独使用或组合使用。
4.2 安全最佳实践
- 使用授权码流程 + PKCE: 这是目前最安全的授权流程,适用于所有类型的客户端。
- 避免隐式流程: 隐式流程 (Implicit Flow) 存在安全风险,不推荐使用。
- 密钥管理: 在生产环境中,使用安全的密钥管理方案,如 Azure Key Vault、AWS KMS 或 HashiCorp Vault。
- 令牌生命周期: 设置合理的令牌生命周期,访问令牌应短期(如 1 小时),刷新令牌可以长期(如 14 天)。
- 作用域控制: 仅请求和授予必要的作用域,遵循最小权限原则。
- HTTPS: 所有通信必须使用 HTTPS,包括开发环境。
- 密码哈希: 使用强哈希算法(如 BCrypt)存储用户密码。
- 防止开放重定向: 验证重定向 URI,避免开放重定向攻击。
4.3 部署考虑
- 数据库选择: OpenIddict 支持多种数据库,选择适合你的项目的数据库。
- 负载均衡: 确保授权服务器可以水平扩展,使用共享数据存储。
- 监控与日志: 实施全面的监控和日志记录,以便及时发现和解决问题。
- 备份策略: 定期备份数据库,确保数据安全。
- 证书管理: 在生产环境中,使用正式的 SSL 证书,并实施证书轮换策略。
4.4 未来展望
随着 OpenIddict 和 .NET 的不断发展,我们可以期待更多的功能和改进:
- 更多授权流程: 支持更多的 OAuth 2.0 和 OpenID Connect 扩展。
- 更好的性能: 优化令牌处理和验证的性能。
- 更多集成: 与更多的身份提供商和服务集成。
- 更好的开发体验: 提供更多的工具和模板,简化开发流程。
五、相关资源
5.1 官方资源
- OAuth 2.0 : oauth.net/2/
- OAuth 2.0 RFC : www.rfc-editor.org/rfc/rfc6749
- OpenID Connect : openid.net/connect/
- OpenIddict : documentation.openiddict.com/
- OpenIddict GitHub : github.com/openiddict/...
- OpenIddict 示例 : github.com/openiddict/...
5.2 学习资源
- 微软身份平台和 OAuth 2.0 : learn.microsoft.com/zh-cn/azure...
- OpenIddict 博客 : kevinchalet.com/
- 认证授权中文博客 : blog.goodsxx.cn/articles/di...
- OAuth 2.0 与 OpenIddict : andreyka26.com/oauth-autho...
- OpenID Connect 与 OpenIddict : andreyka26.com/openid-conn...
- OIDC 调试工具 : oidcdebugger.com/
- JWT 调试工具 : jwt.io/
5.3 第三方登录参考
- 微信网页授权 : developers.weixin.qq.com/doc/offiacc...
- 微信小程序登录 : developers.weixin.qq.com/miniprogram...
结语
认证与授权是现代应用程序的基础设施,选择合适的框架和实践方式至关重要。OpenIddict 作为一个开源、免费、功能强大的解决方案,为 .NET 开发者提供了构建安全、标准化认证授权系统的绝佳选择。
希望本文能帮助你理解 OpenIddict 的工作原理,并在实际项目中应用这些知识。如有任何问题或建议,欢迎在评论区留言交流!