还在用旧的认证授权方案?快来试试现代化的OpenIddict!

本文基于笔者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.0OpenID 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 的旧版本仍可使用,但已停止维护,存在安全风险。

图片来源 https://andreyka26.com/assets/2023-02-19-oauth-authorization-code-using-openiddict-and-dot-net/image1.png

此时,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.cshtmlPages/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.cshtmlPages/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 -> EndSessionUserinfo -> 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 : 将 profileapi1 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 and UseOpenIddict.
  • 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 the OpenIddictClientAspNetCoreDefaults.AuthenticationScheme, triggering the redirect to the auth server.
  • /logout: Signs out the local cookie and then calls SignOut 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 项目。

  1. 访问客户端 : 打开 https://localhost:7003 (MvcClient 的地址)。你会看到未登录状态。
  1. 点击登录: 点击

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

  1. 输入凭据 : 输入用户名和密码(如 [email protected] / Password123!)。
  2. 授权确认: 登录成功后,会显示授权确认页面。点击 "Grant Access"。
  1. 重定向回客户端: 授权成功后,会重定向回 MVC 客户端,显示用户信息和令牌。

  1. 登出: 点击 "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。
  • 中间件顺序 : 确保 UseAuthenticationUseAuthorization 在正确的位置。

4) 牛刀小试 (API 资源服务器)

现在,同时运行 AuthServer 和 ApiServer 项目。

  1. 访问 Swagger UI : 打开 https://localhost:7002/swagger (ApiServer 的 Swagger UI 地址)。
  1. 未授权访问 : 尝试直接调用 /resources 端点,会返回 401 Unauthorized。
  1. 授权访问 :
    • 点击 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 流程。

  1. 打开 OIDC Debugger : 访问 oidcdebugger.com/
  2. 配置参数 :
    • 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: 勾选 (推荐)
  1. 发送请求: 点击 "Send Request" 按钮。
  2. 登录和授权: 如果未登录,会重定向到授权服务器的登录页面。登录并授权后,会重定向回 OIDC Debugger。
  3. 查看授权码: OIDC Debugger 会显示授权码和其他参数。

服务端的控制台也会输出相关信息

2) Postman

获取授权码后,我们可以使用 Postman 来交换令牌。

  1. 创建 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)
  1. 发送请求: 点击 "Send" 按钮。
  2. 查看令牌 : 响应中会包含访问令牌、ID 令牌和刷新令牌(如果请求了 offline_access 作用域)。

3) 验证 JWT 令牌

获取令牌后,我们可以使用 JWT.io 来解析和验证 JWT 令牌。

  1. 打开 JWT.io : 访问 jwt.io/
  2. 粘贴令牌: 将访问令牌或 ID 令牌粘贴到 "Encoded" 文本框中。
  3. 查看解析结果: 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 官方资源

5.2 学习资源

5.3 第三方登录参考

结语

认证与授权是现代应用程序的基础设施,选择合适的框架和实践方式至关重要。OpenIddict 作为一个开源、免费、功能强大的解决方案,为 .NET 开发者提供了构建安全、标准化认证授权系统的绝佳选择。

希望本文能帮助你理解 OpenIddict 的工作原理,并在实际项目中应用这些知识。如有任何问题或建议,欢迎在评论区留言交流!

相关推荐
寻月隐君2 分钟前
Web3实战:Solana CPI全解析,从Anchor封装到PDA转账
后端·web3·github
sky_ph12 分钟前
JAVA-GC浅析(一)
java·后端
LaoZhangAI17 分钟前
Claude Code完全指南:2025年最强AI编程助手深度评测
前端·后端
LaoZhangAI20 分钟前
FLUX.1 Kontext vs GPT-4o图像编辑全面对比:2025年最全评测指南
前端·后端
LaoZhangAI21 分钟前
2025最全Supabase MCP使用指南:一键连接AI助手与数据库【实战教程】
前端·javascript·后端
天天摸鱼的java工程师27 分钟前
@Autowired 注入失效?
java·后端
随缘而动,随遇而安1 小时前
第七十四篇 高并发场景下的Java并发容器:用生活案例讲透技术原理
java·大数据·后端
汪子熙1 小时前
Cursor 中代码库索引(codebase indexing)功能背后的核心技术实现原理
人工智能·后端
weixin_436525071 小时前
Spring Boot 实现流式响应(兼容 2.7.x)
java·spring boot·后端
源码超级联盟1 小时前
分享一个空指针的bug
java·后端