基于OpenIddict6.4.0搭建授权认证服务
什么是 OpenIddict?
OpenIddict 旨在提供一种多功能解决方案,以在任何 .NET 应用程序中实现OpenID Connect 客户端、服务器和令牌验证支持。
入门
如果您正在寻找不涉及编写自定义逻辑的交钥匙解决方案,请考虑查看ABP Framework和OrchardCore:它们开箱即用地支持 OpenIddict,并且是实现标准 OpenID Connect 服务器的最佳选择(即使您不熟悉该标准!)。
如果您更喜欢实施自定义解决方案,请阅读入门指南。
可以在“集成”中找到其他集成(免费或商业)。
为什么不选择IdentityServer?
identityserver是最灵活且符合标准的 ASP.NET Core OpenID Connect 和 OAuth 2.0 框架。但是由于从 IdentityServer4 的 Apache 2.0 协议转变为 Duende IdentityServer 的商业许可。有功能受限的 Community Edition,以及需要付费的 Business Edition 和 Enterprise Edition。
总结与对比
项目 | 当前状态 | 许可证 | 是否免费? | 是否开源? |
---|---|---|---|---|
IdentityServer4 | 已停止维护 | Apache 2.0 | 是 | 是 |
Duende IdentityServer | 当前官方版本 | 商业许可证 | 有条件免费 (年收入<100万美金) | 否 |
OpenIddict | 当前官方版本 | 开源许可(Apache 2.0)。完全免费用于商业和个人项目 | 是 | 是 |
OpenIddict的实践
预期部署架构
项目 | 许可证 |
---|---|
授权中心服务 | https://xxx.com/passport |
资源API服务 | http://localhost:9000 |
搭建授权中心服务
新增代码入口Program.cs代码
using BigData.Passport.API;
using BigData.Passport.Domain;
using BigData.Passport.Domain.Models;
using BigData.Passport.Domain.Services;
using BigData.SSO.Passport;
using BigData.SSO.Passport.Services;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using OpenIddict.Abstractions;
using OpenIddict.EntityFrameworkCore.Models;
using TrendLib.MiniWeb;var builder = WebApplication.CreateBuilder(args);
var configuration = builder.Configuration;// 数据库上下文配置
builder.Services.AddDbContext<ApplicationDbContext>(options =>
{// Configure the context to use MySQL Server.options.UseMySQL(configuration.GetConnectionString("DefaultConnection"));// Register the entity sets needed by OpenIddict.// Note: use the generic overload if you need// to replace the default OpenIddict entities.options.UseOpenIddict();
});// 配置cookie数据保护(持久化存储),避免服务器容器重启登录失效的问题
builder.Services.AddDataProtection().PersistKeysToDbContext<ApplicationDbContext>().SetApplicationName("Passport").SetDefaultKeyLifetime(TimeSpan.FromDays(90));// Register the Identity services.
builder.Services.AddIdentity<ApplicationUser, ApplicationRole>(options =>
{// 放宽密码策略options.Password.RequireDigit = false; // 不需要数字options.Password.RequiredLength = 6; // 最小长6options.Password.RequireLowercase = false; // 不需要小写字母options.Password.RequireUppercase = false; // 不需要大写字母options.Password.RequireNonAlphanumeric = false; // 不需要特殊字符options.Password.RequiredUniqueChars = 1; // 唯一字符数// 其他配置options.SignIn.RequireConfirmedAccount = false;options.User.RequireUniqueEmail = false;
})
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();// 配置 Application Cookie
builder.Services.ConfigureApplicationCookie(options =>
{options.Cookie.Name = "SSO.Auth";options.Cookie.HttpOnly = true;options.Cookie.SecurePolicy = CookieSecurePolicy.Always;options.Cookie.SameSite = SameSiteMode.Lax;options.LoginPath = "/Account/Login";options.AccessDeniedPath = "/Account/AccessDenied";options.LogoutPath = "/Account/LogOut";options.ExpireTimeSpan = TimeSpan.FromDays(7); // cookie:sso.auth,勾选记住我,可以保留7天,否则就是会话级别cookie options.SlidingExpiration = true;
});// 配置 OpenIddict 核心
builder.Services.AddOpenIddict().AddCore(options =>{// 使用内置的内存存储options.UseEntityFrameworkCore().UseDbContext<ApplicationDbContext>().ReplaceDefaultEntities<OpenIddictApplications, OpenIddictAuthorization, OpenIddictScopes, OpenIddictToken, string>();}).AddServer(options =>{// 设置发行者地址options.SetIssuer(configuration["IdentityServer:Authority"]);// Enable the authorization, introspection and token endpoints.options.SetAuthorizationEndpointUris("connect/authorize").SetTokenEndpointUris("connect/token").SetUserInfoEndpointUris("connect/userinfo").SetEndSessionEndpointUris("connect/logout").SetConfigurationEndpointUris(".well-known/openid-configuration"); // Discovery 端点// Mark the "email", "profile" and "roles" scopes as supported scopes.options.RegisterScopes(OpenIddictConstants.Scopes.OpenId, //// 必须包含 openid 才能返回 id_tokenOpenIddictConstants.Scopes.Profile,OpenIddictConstants.Scopes.Email,OpenIddictConstants.Scopes.Roles,OpenIddictConstants.Scopes.OfflineAccess);// Note: this sample only uses the authorization code and refresh token// flows but you can enable the other flows if you need to support implicit,// password or client credentials.options.AllowAuthorizationCodeFlow().AllowClientCredentialsFlow().AllowPasswordFlow().AllowRefreshTokenFlow().AllowHybridFlow().AllowImplicitFlow();// Register the encryption credentials. This sample uses a symmetric// encryption key that is shared between the server and the Api2 sample// (that performs local token validation instead of using introspection).//// Note: in a real world application, this encryption key should be// stored in a safe place (e.g in Azure KeyVault, stored as a secret).// options.AddEncryptionKey(new SymmetricSecurityKey(Convert.FromBase64String("DRjd/GnduI3Efzen9V9BvbNUfc/VKgXltV7Kbk9sMkY=")));// 加密和签名证书(开发环境)options.AddDevelopmentEncryptionCertificate().AddDevelopmentSigningCertificate();// 配置令牌生命周期options.SetAccessTokenLifetime(TimeSpan.FromDays(1));options.SetRefreshTokenLifetime(TimeSpan.FromDays(7));// 禁用访问令牌加密(开发环境)options.DisableAccessTokenEncryption();// 配置 ASP.NET Core 集成options.UseAspNetCore().EnableAuthorizationEndpointPassthrough().EnableTokenEndpointPassthrough().EnableUserInfoEndpointPassthrough().EnableEndSessionEndpointPassthrough().DisableTransportSecurityRequirement(); // 关键:禁用传输安全要求}).AddValidation(options =>{options.UseLocalServer();options.UseAspNetCore();});// Add services to the container.
builder.Services.AddControllersWithViews(options =>
{// handle BusinessExceptionoptions.Filters.Add<BusinessExceptionFilter>();
});
builder.Services.AddNginx();builder.Services.AddCors(options => options.AddDefaultPolicy(policy =>policy.AllowAnyHeader().AllowAnyMethod().AllowAnyOrigin()));// 配置客户端
builder.Services.AddHostedService<Worker>();var app = builder.Build();// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{app.UseExceptionHandler("/Home/Error");// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.app.UseHsts();
}// Configure the HTTP request pipeline.
app.UseNginx();app.UseHttpsRedirection();
app.UseStaticFiles();app.UseRouting();
app.UseCors();app.UseAuthentication();
app.UseAuthorization();app.MapControllerRoute(name: "default",pattern: "{controller=Home}/{action=Index}/{id?}");app.Run();
新增注册应用appliction,Scope范围域以及User用户信息代码Worker.cs
using BigData.Passport.Domain;
using BigData.Passport.Domain.Models;
using BigData.SSO.Passport.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using OpenIddict.Abstractions;
using Org.BouncyCastle.Asn1.Ocsp;
using System.Text.Json;
using static OpenIddict.Abstractions.OpenIddictConstants;namespace BigData.SSO.Passport
{/// <summary>/// 客户端配置/// </summary>public class Worker : IHostedService{/// <summary>/// /// </summary>private readonly IServiceProvider _serviceProvider;/// <summary>/// /// </summary>/// <param name="serviceProvider"></param>public Worker(IServiceProvider serviceProvider)=> _serviceProvider = serviceProvider;public async Task StartAsync(CancellationToken cancellationToken){using var scope = _serviceProvider.CreateScope();var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();await context.Database.EnsureCreatedAsync();await CreateApplicationsAsync();await CreateScopesAsync();await CreateUserAsync();async Task CreateApplicationsAsync(){var manager = scope.ServiceProvider.GetRequiredService<IOpenIddictApplicationManager>();if (await manager.FindByClientIdAsync("console_app") is null){await manager.CreateAsync(new OpenIddictApplicationDescriptor{ApplicationType = ApplicationTypes.Native,ClientId = "console_app",ClientType = ClientTypes.Public,DisplayName = "console_app名称",RedirectUris ={new Uri("http://localhost/")},Permissions ={Permissions.Endpoints.Authorization,Permissions.Endpoints.Token,Permissions.GrantTypes.AuthorizationCode,Permissions.ResponseTypes.Code,Permissions.Scopes.Email,Permissions.Scopes.Profile,Permissions.Scopes.Roles,Permissions.Prefixes.Scope + "api1",Permissions.Prefixes.Scope + "api2"}});}if (await manager.FindByClientIdAsync("spa") is null){await manager.CreateAsync(new OpenIddictApplicationDescriptor{ClientId = "spa",//ClientSecret = "4cb68986-a64f-11f0-9a7b-005056927c86",ClientType = ClientTypes.Public,DisplayName = "测试",// 关键配置:设置同意类型为隐式同意ConsentType = ConsentTypes.Implicit,// 登入重定向 URIRedirectUris ={new Uri("https://xxx/signin-callback"),new Uri("https://xxx/silent-renew.html") // 静默续订},// 退出后重定向 URIPostLogoutRedirectUris ={new Uri("https://xxx/signout-callback"),},Permissions ={Permissions.Endpoints.Authorization,Permissions.Endpoints.EndSession,Permissions.Endpoints.Token,Permissions.GrantTypes.AuthorizationCode,Permissions.GrantTypes.RefreshToken,Permissions.ResponseTypes.Code,Permissions.Scopes.Email,Permissions.Scopes.Profile,Permissions.Scopes.Roles,Permissions.Scopes.Phone,Permissions.Scopes.Address,Permissions.Prefixes.Scope + "api1"},Requirements ={Requirements.Features.ProofKeyForCodeExchange,},});}if (await manager.FindByClientIdAsync("web") is null){await manager.CreateAsync(new OpenIddictApplicationDescriptor{ApplicationType = ApplicationTypes.Web,ClientId = "web",ClientSecret = "400fe3d8-a64f-11f0-9a7b-005056927c86",//ClientType = ClientTypes.Public,DisplayName = "web前端",// 关键配置:设置同意类型为隐式同意ConsentType = ConsentTypes.Implicit,// 登入重定向 URIRedirectUris ={new Uri("https://xxx/signin-callback"),new Uri("https://xxx/silent-renew.html") // 静默续订},// 退出后重定向 URIPostLogoutRedirectUris ={new Uri("https://xxx/signout-callback"),},Permissions ={Permissions.Endpoints.Authorization,Permissions.Endpoints.EndSession,Permissions.Endpoints.Token,Permissions.GrantTypes.AuthorizationCode,Permissions.GrantTypes.ClientCredentials,Permissions.GrantTypes.Password,Permissions.GrantTypes.RefreshToken,Permissions.ResponseTypes.Code,Permissions.Scopes.Email,Permissions.Scopes.Profile,Permissions.Scopes.Roles,Permissions.Scopes.Phone,Permissions.Scopes.Address,Permissions.Prefixes.Scope + "api1"},Requirements ={Requirements.Features.ProofKeyForCodeExchange,},});}if (await manager.FindByClientIdAsync("client1") is null){await manager.CreateAsync(new OpenIddictApplicationDescriptor{ClientId = "client1",ClientSecret = "846B62D0-DEF9-4215-A99D-86E6B8DAB342",DisplayName = "第一个应用client1",Permissions ={Permissions.Endpoints.Introspection,Permissions.Endpoints.Token,Permissions.GrantTypes.ClientCredentials,Permissions.GrantTypes.Password,Permissions.Scopes.Email,Permissions.Scopes.Profile,Permissions.Scopes.Roles,Permissions.Scopes.Phone,Permissions.Scopes.Address,Permissions.Prefixes.Scope + "api1",}});}// Note: no client registration is created for resource_server_2// as it uses local token validation instead of introspection.}async Task CreateScopesAsync(){var manager = scope.ServiceProvider.GetRequiredService<IOpenIddictScopeManager>();if (await manager.FindByNameAsync("api1") is null){await manager.CreateAsync(new OpenIddictScopeDescriptor{Name = "api1",Resources ={"resource_server_1"}});}if (await manager.FindByNameAsync("api2") is null){await manager.CreateAsync(new OpenIddictScopeDescriptor{Name = "api2",Resources ={"resource_server_2"}});}}async Task CreateUserAsync(){var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();if (await userManager.FindByNameAsync("admin") is null){var applicationUser = new ApplicationUser{UserName = "admin",NickName = "管理员",Email = "admin@qq.com",PhoneNumber = "1",IsEnabled = true,CreatedTime = DateTime.Now,};var result = await userManager.CreateAsync(applicationUser, "admin123456");}}}public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;}
}
实现一个自定义的授权控制器AuthorizationController.cs
using BigData.SSO.Passport.Models;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Primitives;
using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore;
using System.Collections.Immutable;
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using static OpenIddict.Abstractions.OpenIddictConstants;
using Microsoft.IdentityModel.Tokens;
using BigData.SSO.Passport.Services;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Identity;
using System.Security.Principal;
using Polly;
using BigData.Passport.Domain.Models;namespace BigData.SSO.Passport.Controllers
{public class AuthorizationController : Controller{private readonly ILogger<AuthorizationController> _logger;private readonly ApplicationDbContext _context;private readonly IOpenIddictApplicationManager _applicationManager;private readonly IOpenIddictAuthorizationManager _authorizationManager;private readonly IOpenIddictScopeManager _scopeManager;private readonly SignInManager<ApplicationUser> _signInManager;private readonly UserManager<ApplicationUser> _userManager;// IAuthenticationSchemeProvider _schemeProvider;private readonly IUserService _userService;private const string applicationname = CookieAuthenticationDefaults.AuthenticationScheme;/// <summary>/// 构造函数/// </summary>/// <param name="logger"></param>/// <param name="context"></param>/// <param name="applicationManager"></param>/// <param name="authorizationManager"></param>/// <param name="scopeManager"></param>/// <param name="signInManager"></param>/// <param name="userManager"></param>/// <param name="userService"></param>public AuthorizationController(ILogger<AuthorizationController> logger,ApplicationDbContext context,IOpenIddictApplicationManager applicationManager,IOpenIddictAuthorizationManager authorizationManager,IOpenIddictScopeManager scopeManager,SignInManager<ApplicationUser> signInManager,UserManager<ApplicationUser> userManager,IUserService userService){_logger = logger;_context = context;_applicationManager = applicationManager;_authorizationManager = authorizationManager;_scopeManager = scopeManager;_signInManager = signInManager;_userManager = userManager;_userService = userService;}#region 授权端点的操作 指定路由 这一步自己处理/// <summary>/// 授权/// </summary>/// <returns></returns>/// <exception cref="InvalidOperationException"></exception>[HttpGet("~/connect/authorize")][HttpPost("~/connect/authorize")][IgnoreAntiforgeryToken]public async Task<IActionResult> Authorize(){_logger.LogInformation("开始授权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 and redirect// the user agent to the login page (or to an external provider) in the following cases://// - If the user principal can't be extracted or the cookie is too old.// - If prompt=login was specified by the client application.// - If max_age=0 was specified by the client application (max_age=0 is equivalent to prompt=login).// - If a max_age parameter was provided and the authentication cookie is not considered "fresh" enough.//// For scenarios where the default authentication handler configured in the ASP.NET Core// authentication options shouldn't be used, a specific scheme can be specified here.var result = await HttpContext.AuthenticateAsync();if (result is not { Succeeded: true } ||((request.HasPromptValue(PromptValues.Login) || request.MaxAge is 0 ||(request.MaxAge is not null && result.Properties?.IssuedUtc is not null &&DateTimeOffset.UtcNow - result.Properties.IssuedUtc > TimeSpan.FromSeconds(request.MaxAge.Value))) &&TempData["IgnoreAuthenticationChallenge"] is null or false)){// If the client application requested promptless authentication,// return an error indicating that the user is not logged in.if (request.HasPromptValue(PromptValues.None)){return Forbid(authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,properties: new AuthenticationProperties(new Dictionary<string, string?>{[OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.LoginRequired,[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user is not logged in."}));}// To avoid endless login endpoint -> authorization endpoint redirects, a special temp data entry is// used to skip the challenge if the user agent has already been redirected to the login endpoint.//// Note: this flag doesn't guarantee that the user has accepted to re-authenticate. If such a guarantee// is needed, the existing authentication cookie MUST be deleted AND revoked (e.g using ASP.NET Core// Identity's security stamp feature with an extremely short revalidation time span) before triggering// a challenge to redirect the user agent to the login endpoint.TempData["IgnoreAuthenticationChallenge"] = true;// For scenarios where the default challenge handler configured in the ASP.NET Core// authentication options shouldn't be used, a specific scheme can be specified here.return Challenge(new AuthenticationProperties{RedirectUri = Request.PathBase + Request.Path + QueryString.Create(Request.HasFormContentType ? Request.Form : Request.Query)});}// Retrieve the profile of the logged in user.var user = await _userManager.GetUserAsync(result.Principal) ??throw new InvalidOperationException("The user details cannot be retrieved.");// 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 authorizations = await _authorizationManager.FindAsync(subject: await _userManager.GetUserIdAsync(user),client: await _applicationManager.GetIdAsync(application),status: Statuses.Valid,type: AuthorizationTypes.Permanent,scopes: request.GetScopes()).ToListAsync();// 验证用户是否可以访问该客户端应用var userClientRel = await _context.CustomUserClientRel.Where(x => x.UserId == user.Id && x.ClientId == request.ClientId).Where(x=>x.IsEnabled == true).FirstOrDefaultAsync();if (userClientRel == null){return Forbid(authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,properties: new AuthenticationProperties(new Dictionary<string, string?>{[OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.LoginRequired,[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "该用户没有访问该应用的权限."}));}var consentType = await _applicationManager.GetConsentTypeAsync(application);switch (await _applicationManager.GetConsentTypeAsync(application)){// If the consent is external (e.g when authorizations are granted by a sysadmin),// immediately return an error if no authorization can be found in the database.case ConsentTypes.External when authorizations.Count is 0:return Forbid(authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,properties: new AuthenticationProperties(new Dictionary<string, string?>{[OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.ConsentRequired,[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] ="The logged in user is not allowed to access this client application."}));// If the consent is implicit or if an authorization was found,// return an authorization response without displaying the consent form.case ConsentTypes.Implicit:case ConsentTypes.External when authorizations.Count is not 0:case ConsentTypes.Explicit when authorizations.Count is not 0 && !request.HasPromptValue(PromptValues.Consent):// Create the claims-based identity that will be used by OpenIddict to generate tokens.//var identity = new ClaimsIdentity(// authenticationType: TokenValidationParameters.DefaultAuthenticationType,// nameType: Claims.Name,// roleType: Claims.Role);//// Add the claims that will be persisted in the tokens.//identity.SetClaim(Claims.Subject, user.Id)// .SetClaim(Claims.Email, user.Email)// .SetClaim(Claims.Name, user.UserName)// .SetClaim(Claims.Nickname, user.NickName)// //.SetClaim(Claims.PhoneNumber, user.PhoneNumber)// .SetClaims(Claims.Role, (await _userManager.GetRolesAsync(user)).ToImmutableArray());// 设置声明var identity = await this.SetClaims(user, request);// Note: in this sample, the granted scopes match the requested scope// but you may want to allow the user to uncheck specific scopes.// For that, simply restrict the list of scopes before calling SetScopes.identity.SetScopes(request.GetScopes());identity.SetResources(await _scopeManager.ListResourcesAsync(identity.GetScopes()).ToListAsync());// Automatically create a permanent authorization to avoid requiring explicit consent// for future authorization or token requests containing the same scopes.var authorization = authorizations.LastOrDefault();authorization ??= await _authorizationManager.CreateAsync(identity: identity,subject: await _userManager.GetUserIdAsync(user),client: (await _applicationManager.GetIdAsync(application))!,type: AuthorizationTypes.Permanent,scopes: identity.GetScopes());identity.SetAuthorizationId(await _authorizationManager.GetIdAsync(authorization));identity.SetDestinations(GetDestinations);return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);// At this point, no authorization was found in the database and an error must be returned// if the client application specified prompt=none in the authorization request.case ConsentTypes.Explicit when request.HasPromptValue(PromptValues.None):case ConsentTypes.Systematic when request.HasPromptValue(PromptValues.None):return Forbid(authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,properties: new AuthenticationProperties(new Dictionary<string, string?>{[OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.ConsentRequired,[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] ="Interactive user consent is required."}));// In every other case, render the consent form.default:return View(new AuthorizeViewModel{ApplicationName = await _applicationManager.GetLocalizedDisplayNameAsync(application),Scope = request.Scope});}}/// <summary>/// 同意授权/// </summary>/// <returns></returns>/// <exception cref="InvalidOperationException"></exception>[Authorize][HttpPost("~/connect/authorize")][HttpPost("~/connect/authorize/accept")]public async Task<IActionResult> Accept(){_logger.LogInformation("同意授权Authorize.Accept");// 手动创建 OpenIddict 请求var request = new OpenIddictRequest(Request.Form);// 方法 2: 或者直接从 HttpContext 获取(确保参数正确传递)var directRequest = HttpContext.GetOpenIddictServerRequest();if (directRequest != null){request = directRequest;}if (request == null){return BadRequest("无法处理授权请求");}// var request = HttpContext.GetOpenIddictServerRequest() ?? throw new InvalidOperationException("The OpenID Connect request cannot be retrieved.");// Retrieve the profile of the logged in user.var user = await _userManager.GetUserAsync(User) ??throw new InvalidOperationException("The user details cannot be retrieved.");// 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 authorizations = await _authorizationManager.FindAsync(subject: await _userManager.GetUserIdAsync(user),client: await _applicationManager.GetIdAsync(application),status: Statuses.Valid,type: AuthorizationTypes.Permanent,scopes: request.GetScopes()).ToListAsync();// Note: the same check is already made in the other action but is repeated// here to ensure a malicious user can't abuse this POST-only endpoint and// force it to return a valid response without the external authorization.if (authorizations.Count is 0 && await _applicationManager.HasConsentTypeAsync(application, ConsentTypes.External)){return Forbid(authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,properties: new AuthenticationProperties(new Dictionary<string, string?>{[OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.ConsentRequired,[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] ="The logged in user is not allowed to access this client application."}));}// Create the claims-based identity that will be used by OpenIddict to generate tokens.//var identity = new ClaimsIdentity(// authenticationType: TokenValidationParameters.DefaultAuthenticationType,// nameType: Claims.Name,// roleType: Claims.Role);//// Add the claims that will be persisted in the tokens.//identity.SetClaim(Claims.Subject, user.Id)// .SetClaim(Claims.Email, user.Email)// .SetClaim(Claims.Name, user.UserName)// .SetClaim(Claims.Nickname, user.NickName)// //.SetClaim(Claims.PhoneNumber, user.PhoneNumber)// .SetClaims(Claims.Role, (await _userManager.GetRolesAsync(user)).ToImmutableArray());// 设置声明var identity = await this.SetClaims(user, request);// Note: in this sample, the granted scopes match the requested scope// but you may want to allow the user to uncheck specific scopes.// For that, simply restrict the list of scopes before calling SetScopes.identity.SetScopes(request.GetScopes());identity.SetResources(await _scopeManager.ListResourcesAsync(identity.GetScopes()).ToListAsync());// Automatically create a permanent authorization to avoid requiring explicit consent// for future authorization or token requests containing the same scopes.var authorization = authorizations.LastOrDefault();authorization ??= await _authorizationManager.CreateAsync(identity: identity,subject: await _userManager.GetUserIdAsync(user),client: (await _applicationManager.GetIdAsync(application))!,type: AuthorizationTypes.Permanent,scopes: identity.GetScopes());identity.SetAuthorizationId(await _authorizationManager.GetIdAsync(authorization));identity.SetDestinations(GetDestinations);// Returning a SignInResult will ask OpenIddict to issue the appropriate access/identity tokens.return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);// return Authorize(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);}/// <summary>/// 拒绝授权/// </summary>/// <returns></returns>[Authorize][HttpPost("~/connect/authorize")][HttpPost("~/connect/authorize/deny")]// Notify OpenIddict that the authorization grant has been denied by the resource owner// to redirect the user agent to the client application using the appropriate response_mode.public IActionResult Deny(){_logger.LogInformation("拒绝授权Authorize.Deny");return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);}#endregion#region 获取Token地址 包括所有方式/// <summary>/// 可以指定不同的获取Token的客户端逻辑/// </summary>/// <returns></returns>/// <exception cref="InvalidOperationException"></exception>[HttpPost("~/connect/token"), Produces("application/json")]public async Task<IActionResult> Exchange(){try{_logger.LogInformation("开始处理令牌请求");var request = HttpContext.GetOpenIddictServerRequest() ?? throw new InvalidOperationException("OIDC请求不存在.");if (request == null){_logger.LogError("无法获取 OpenIddict 请求");return BadRequest(new OpenIddictResponse{Error = OpenIddictConstants.Errors.InvalidRequest,ErrorDescription = "无效的请求"});}_logger.LogInformation("授权类型: {GrantType}", request.GrantType);// 根据授权类型路由到不同的处理方法return request.GrantType switch{GrantTypes.AuthorizationCode => await HandleAuthorizationCodeGrantTypeAsync(request),GrantTypes.Password => await HandlePasswordGrantTypeAsync(request),GrantTypes.ClientCredentials => await HandleClientCredentialsGrantTypeAsync(request),GrantTypes.RefreshToken => await HandleRefreshTokenGrantTypeAsync(request),_ => HandleUnsupportedGrantType(request)};}catch (Exception ex){_logger.LogError(ex, "处理令牌请求时发生异常");return BadRequest(new OpenIddictResponse{Error = OpenIddictConstants.Errors.ServerError,ErrorDescription = "服务器内部错误"});}}/// <summary>/// 处理授权码流程/// </summary>private async Task<IActionResult> HandleAuthorizationCodeGrantTypeAsync(OpenIddictRequest request){_logger.LogInformation("处理授权码流程,客户端:{clientId}", request.ClientId);// Retrieve the claims principal stored in the authorization code/refresh token.var result = await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);// Retrieve the user profile corresponding to the authorization code/refresh token.var user = await _userManager.FindByIdAsync(result.Principal!.GetClaim(Claims.Subject)!);if (user is null){return Forbid(authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,properties: new AuthenticationProperties(new Dictionary<string, string?>{[OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant,[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The token is no longer valid."}));}// Ensure the user is still allowed to sign in.if (!await _signInManager.CanSignInAsync(user)){return Forbid(authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,properties: new AuthenticationProperties(new Dictionary<string, string?>{[OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant,[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user is no longer allowed to sign in."}));}//var identity = new ClaimsIdentity(result.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 authorization code/refresh token was issued.//identity.SetClaim(Claims.Subject, user.Id)// .SetClaim(Claims.Email, user.Email)// .SetClaim(Claims.Name, user.UserName)// .SetClaim(Claims.Nickname, user.NickName)// //.SetClaim(Claims.PhoneNumber, user.PhoneNumber)// .SetClaims(Claims.Role, (await _userManager.GetRolesAsync(user)).ToImmutableArray());var identity = await this.SetClaims(user, request);identity.SetDestinations(GetDestinations);_logger.LogInformation("授权码流程成功: {UserId}", user.Id);// Returning a SignInResult will ask OpenIddict to issue the appropriate access/identity tokens.return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);}/// <summary>/// 处理密码授权流程/// </summary>private async Task<IActionResult> HandlePasswordGrantTypeAsync(OpenIddictRequest request){_logger.LogInformation("处理密码授权流程,客户端:{clientId},用户名: {Username}", request.ClientId, request.Username);// 验证必需参数if (string.IsNullOrEmpty(request.Username) || string.IsNullOrEmpty(request.Password)){_logger.LogWarning("用户名或密码为空");return BadRequest(new OpenIddictResponse{Error = OpenIddictConstants.Errors.InvalidRequest,ErrorDescription = "用户名和密码不能为空"});}// 查找用户var user = await _userManager.FindByNameAsync(request.Username);if (user == null){_logger.LogWarning("用户不存在: {Username}", request.Username);return Unauthorized(new OpenIddictResponse{Error = OpenIddictConstants.Errors.InvalidGrant,ErrorDescription = "用户名或密码不正确"});}// 验证密码var passwordValid = await _userManager.CheckPasswordAsync(user, request.Password);if (!passwordValid){_logger.LogWarning("密码验证失败: {Username}", request.Username);return Unauthorized(new OpenIddictResponse{Error = OpenIddictConstants.Errors.InvalidGrant,ErrorDescription = "用户名或密码不正确"});}// 设置声明var identity = await this.SetClaims(user, request);// Set the list of scopes granted to the client application.identity.SetScopes(request.GetScopes());identity.SetResources(await _scopeManager.ListResourcesAsync(identity.GetScopes()).ToListAsync());identity.SetDestinations(GetDestinations);_logger.LogInformation("密码授权成功: {Username}", request.Username);return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);}/// <summary>/// 处理客户端凭据流程/// </summary>private async Task<IActionResult> HandleClientCredentialsGrantTypeAsync(OpenIddictRequest request){_logger.LogInformation("处理客户端凭据流程,客户端:{clientId}", request.ClientId);var application = await _applicationManager.FindByClientIdAsync(request.ClientId!);if (application == null){throw new InvalidOperationException("The application details cannot be found in the database.");}// Create the claims-based identity that will be used by OpenIddict to generate tokens.var identity = new ClaimsIdentity(authenticationType: TokenValidationParameters.DefaultAuthenticationType,nameType: Claims.Name,roleType: Claims.Role);// Add the claims that will be persisted in the tokens (use the client_id as the subject identifier).identity.SetClaim(Claims.Subject, await _applicationManager.GetClientIdAsync(application));identity.SetClaim(Claims.Name, await _applicationManager.GetDisplayNameAsync(application));// Note: In the original OAuth 2.0 specification, the client credentials grant// doesn't return an identity token, which is an OpenID Connect concept.//// As a non-standardized extension, OpenIddict allows returning an id_token// to convey information about the client application when the "openid" scope// is granted (i.e specified when calling principal.SetScopes()). When the "openid"// scope is not explicitly set, no identity token is returned to the client application.// Set the list of scopes granted to the client application in access_token.identity.SetScopes(request.GetScopes());identity.SetResources(await _scopeManager.ListResourcesAsync(identity.GetScopes()).ToListAsync());identity.SetDestinations(GetDestinations);_logger.LogInformation("客户端凭据授权成功: {ClientId}", request.ClientId);return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);}/// <summary>/// 处理刷新令牌流程/// </summary>private async Task<IActionResult> HandleRefreshTokenGrantTypeAsync(OpenIddictRequest request){_logger.LogInformation("处理刷新令牌流程");// Retrieve the claims principal stored in the refresh token.var result = await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);// Retrieve the user profile corresponding to the refresh token.var user = await _userManager.FindByIdAsync(result.Principal!.GetClaim(Claims.Subject)!);if (user == null){var properties = new AuthenticationProperties(new Dictionary<string, string?>{[OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant,[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The refresh token is no longer valid."});return Forbid(properties, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);}// Ensure the user is still allowed to sign in.if (!await _signInManager.CanSignInAsync(user)){var properties = new AuthenticationProperties(new Dictionary<string, string?>{[OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant,[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user is no longer allowed to sign in."});return Forbid(properties, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);}//var identity = new ClaimsIdentity(result.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, user.Id)// .SetClaim(Claims.Email, user.Email)// .SetClaim(Claims.Name, user.UserName)// .SetClaim(Claims.Nickname, user.NickName)// //.SetClaim(Claims.PhoneNumber, user.PhoneNumber)// .SetClaims(Claims.Role, (await _userManager.GetRolesAsync(user)).ToImmutableArray());// 设置声明var identity = await this.SetClaims(user, request);identity.SetDestinations(GetDestinations);_logger.LogInformation("刷新令牌成功: {UserId}", user.Id);return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);}/// <summary>/// 处理不支持的授权类型/// </summary>private IActionResult HandleUnsupportedGrantType(OpenIddictRequest request){_logger.LogWarning("不支持的授权类型: {GrantType}", request.GrantType);return BadRequest(new OpenIddictResponse{Error = OpenIddictConstants.Errors.UnsupportedGrantType,ErrorDescription = $"不支持的授权类型: {request.GrantType}"});}/// <summary>/// 设置声明/// </summary>/// <param name="user"></param>/// <param name="request"></param>/// <returns></returns>private async Task<ClaimsIdentity> SetClaims(ApplicationUser user, OpenIddictRequest request){// Create the claims-based identity that will be used by OpenIddict to generate tokens.var identity = new ClaimsIdentity(authenticationType: TokenValidationParameters.DefaultAuthenticationType,nameType: Claims.Name,roleType: Claims.Role);// 获取请求的范围var scopes = request.GetScopes();// Add the claims that will be persisted in the tokens.identity.SetClaim(Claims.Subject, user.Id).SetClaim(Claims.Name, user.UserName);// 根据范围添加声明if (scopes.Contains(Scopes.Profile)){identity.SetClaim(Claims.Nickname, user.NickName);}if (scopes.Contains(Scopes.Email)){identity.SetClaim(Claims.Email, user.Email);}// 只有明确请求 roles 范围时才包含角色if (scopes.Contains(Scopes.Roles)){identity.SetClaims(Claims.Role, (await _userManager.GetRolesAsync(user)).ToImmutableArray());}return identity;}/// <summary>/// 退出/// </summary>/// <param name="claim"></param>/// <returns></returns>private static IEnumerable<string> GetDestinations(Claim claim){// Note: by default, claims are NOT automatically included in the access and identity tokens.// To allow OpenIddict to serialize them, you must attach them a destination, that specifies// whether they should be included in access tokens, in identity tokens or in both.switch (claim.Type){case Claims.Name or Claims.PreferredUsername:yield return Destinations.AccessToken;if (claim.Subject!.HasScope(Scopes.Profile))yield return Destinations.IdentityToken;yield break;case Claims.Email:yield return Destinations.AccessToken;if (claim.Subject!.HasScope(Scopes.Email))yield return Destinations.IdentityToken;yield break;case Claims.Role:yield return Destinations.AccessToken;if (claim.Subject!.HasScope(Scopes.Roles))yield return Destinations.IdentityToken;yield break;// Never include the security stamp in the access and identity tokens, as it's a secret value.case "AspNet.Identity.SecurityStamp": yield break;default:yield return Destinations.AccessToken;yield break;}}#endregion/// <summary>/// 前端调用-退出/// </summary>/// <returns></returns>[HttpGet("~/connect/logout")]public async Task<IActionResult> Logout() {_logger.LogInformation("用户退出开始.");// 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 _signInManager.SignOutAsync();_logger.LogInformation("用户退出结束.");// 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{RedirectUri = Url.Content("~/")});}/// <summary>/// 前端调用-退出/// </summary>/// <returns></returns>[ActionName(nameof(Logout)), HttpPost("~/connect/logout"), ValidateAntiForgeryToken]public async Task<IActionResult> LogoutPost(){_logger.LogInformation("用户退出开始.");// 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 _signInManager.SignOutAsync();_logger.LogInformation("用户退出结束.");// 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{RedirectUri = Url.Content("~/")});}/// <summary>/// 获取用户信息/// </summary>/// <returns></returns>[Authorize(AuthenticationSchemes = OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)][HttpGet("~/connect/userinfo"), HttpPost("~/connect/userinfo"), Produces("application/json")]public async Task<IActionResult> Userinfo(){_logger.LogInformation("获取用户信息开始.");var user = await _userManager.FindByIdAsync(User.GetClaim(Claims.Subject)!);if (user == null){return Challenge(authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,properties: new AuthenticationProperties(new Dictionary<string, string?>{[OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidToken,[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] ="The specified access token is bound to an account that no longer exists."}));}_logger.LogInformation("获取用户信息,{userName}.", user.UserName);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] = user.Id,[Claims.Name] = user.UserName,[Claims.Nickname] = user.NickName,};if (User.HasScope(Scopes.Email)){claims[Claims.Email] = user.Email;}if (User.HasScope(Scopes.Phone)){claims[Claims.PhoneNumber] = user.PhoneNumber;}if (User.HasScope(Scopes.Roles)){claims[Claims.Role] = await _userManager.GetRolesAsync(user);}// Note: the complete list of standard claims supported by the OpenID Connect specification// can be found here: http://openid.net/specs/openid-connect-core-1_0.html#StandardClaims_logger.LogInformation("获取用户信息结束,用户信息:{claims}", claims);return Ok(claims);}}
}
新增配置appsettings.Development.json
"ConnectionStrings": {"DefaultConnection": "Server=localhost;Database=openiddict;User=root;Password=123456;SSL Mode=None;"},"IdentityServer": {"Authority": "https://xxx.com/passport"}
搭建资源API服务
新增代码入口程序
using Microsoft.IdentityModel.Tokens;
using OpenIddict.Validation.AspNetCore;var builder = WebApplication.CreateBuilder(args);
var configuration = builder.Configuration;// Add services to the container.builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();builder.Services.AddOpenIddict().AddValidation(options =>{// Note: the validation handler uses OpenID Connect discovery// to retrieve the issuer signing keys used to validate tokens.options.SetIssuer(configuration["IdentityServer:Authority"]);options.AddAudiences(configuration["IdentityServer:Audience"]);// Register the encryption credentials. This sample uses a symmetric// encryption key that is shared between the server and the API project.//// Note: in a real world application, this encryption key should be// stored in a safe place (e.g in Azure KeyVault, stored as a secret).// options.AddEncryptionKey(new SymmetricSecurityKey(Convert.FromBase64String("DRjd/GnduI3Efzen9V9BvbNUfc/VKgXltV7Kbk9sMkY=")));//options.AddEncryptionKey(new SymmetricSecurityKey(Convert.FromBase64String("DRjd/GnduI3Efzen9V9BvbNUfc/VKgXltV7Kbk9sMkY=")));// Register the System.Net.Http integration.options.UseSystemNetHttp();// Register the ASP.NET Core host.options.UseAspNetCore();});builder.Services.AddCors(options => options.AddDefaultPolicy(policy =>policy.AllowAnyHeader().AllowAnyMethod().AllowAnyOrigin()));builder.Services.AddAuthentication(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme);
builder.Services.AddAuthorization(options =>
{// 定义 ITAdminOnly 策略:需要 role=admin AND name=zhangsanoptions.AddPolicy("ZhangSanAdminOnly", policy =>{policy.RequireAuthenticatedUser(); // 必须认证policy.RequireRole("admin"); // 必须拥有 Admin 角色policy.RequireClaim("name", "zhangsan"); // 必须拥有 name=zhangsan 声明});// 可选:其他相关策略options.AddPolicy("AdminOnly", policy =>{policy.RequireAuthenticatedUser();policy.RequireRole("admin"); // 必须拥有 Admin 角色});options.AddPolicy("ITDepartment", policy =>{policy.RequireAuthenticatedUser();policy.RequireClaim("department", "IT");// 必须拥有 department=IT 声明});
});var app = builder.Build();// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{app.UseSwagger();app.UseSwaggerUI();
}app.UseCors();
app.UseHttpsRedirection();app.UseAuthentication();
app.UseAuthorization();app.MapControllers();app.Run();
新增测试控制器WeatherForecastController.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;namespace BigData.Sample.Api1.Controllers
{[Authorize][ApiController][Route("[controller]")]public class WeatherForecastController : ControllerBase{private static readonly string[] Summaries = new[]{"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"};private readonly ILogger<WeatherForecastController> _logger;public WeatherForecastController(ILogger<WeatherForecastController> logger){_logger = logger;}/// <summary>/// 需要认证后才能访问/// </summary>/// <returns></returns>[HttpGet("Get")]public IEnumerable<WeatherForecast> Get(){return Enumerable.Range(1, 5).Select(index => new WeatherForecast{Date = DateTime.Now.AddDays(index),TemperatureC = Random.Shared.Next(-20, 55),Summary = Summaries[Random.Shared.Next(Summaries.Length)]}).ToArray();}/// <summary>/// 基于policy授权的token才可以访问/// </summary>/// <returns></returns>[Authorize("ZhangSanAdminOnly")][HttpGet("GetTime")]public string GetTime(){return DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");}/// <summary>/// 基于角色的授权的token才可以访问/// </summary>/// <returns></returns>[Authorize(Roles ="admin")][HttpGet("GetTime1")]public string GetTime1(){return DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");}}
}
新增配置appsettings.Development.json
{"IdentityServer": {"Authority": "https://xxx.com/passport","Audience": "resource_server_1"}
}
Postman测试结果
-
使用password方式获取token
- 使用token访问资源api
总结:
- 一个轻量级、易用、高性能的开源替代品。它更“现代化”和“敏捷”,非常适合 ASP.NET Core 应用、API 保护和移动/SPA 应用集成。
- 完全开源免费