还在用旧的认证授权方案?快来试试现代化的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; } = "test1@example.com"; // 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 = "test1@example.com",
                PasswordHash = BC.HashPassword("Password123!"), // Hash the password!
                Mobile = "110",
                Remark = "Initial test user 1"
            },
            new User
            {
                UserName = "test2",
                Email = "test2@example.com",
                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. 输入凭据 : 输入用户名和密码(如 test1@example.com / 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 的工作原理,并在实际项目中应用这些知识。如有任何问题或建议,欢迎在评论区留言交流!

相关推荐
冰橙子id几秒前
linux系统安全
linux·安全·系统安全
stark张宇5 分钟前
VMware 虚拟机装 Linux Centos 7.9 保姆级教程(附资源包)
linux·后端
亚力山大抵1 小时前
实验六-使用PyMySQL数据存储的Flask登录系统-实验七-集成Flask-SocketIO的实时通信系统
后端·python·flask
超级小忍1 小时前
Spring Boot 中常用的工具类库及其使用示例(完整版)
spring boot·后端
上海锝秉工控2 小时前
防爆拉线位移传感器:工业安全的“隐形守护者”
大数据·人工智能·安全
CHENWENFEIc2 小时前
SpringBoot论坛系统安全测试实战报告
spring boot·后端·程序人生·spring·系统安全·安全测试
重庆小透明2 小时前
力扣刷题记录【1】146.LRU缓存
java·后端·学习·算法·leetcode·缓存
博观而约取3 小时前
Django 数据迁移全解析:makemigrations & migrate 常见错误与解决方案
后端·python·django
你不知道我是谁?3 小时前
AI 应用于进攻性安全
人工智能·安全
寻月隐君4 小时前
Rust 异步编程实践:从 Tokio 基础到阻塞任务处理模式
后端·rust·github