OpenIddict学习实践小结
Openiddict开源的身份认证和授权库,可用它见OAuth 2.0/OpenID Connect功能集成到应用程序中。
另一个流行的库是IdentityServer4,但其已另起新项目变为收费的了,旧IdentityServer4项目不再维护,Openiddict是一个很好的替代库。
本次学习只是简单的了解下如何将Openiddict应用到在asp.net core应用程序中,目标是搭建一个独立的身份认证服务器,为各种不同类型的客户端提供身份认证服务。本文主要记录次此学习实践的一些细节总结。
本次实践完整的源码地址:( https://github.com/izanhzh/amos-learn/tree/main/OpeniddictTest )
注:源码仓库未公开
服务端
创建一个asp.net core web应用项目:
OpeniddictTest.Server
,将启动地址设置为:https://localhost:5001
主要引入的一些
nuget
包包名 主要作用 OpenIddict.AspNetCore 注册OpenIddict server中间件,将应用程序变成一个身份认证服务器 OpenIddict.EntityFrameworkCore 将OpenIddict相关的一些实体注册添加到数据库 OpenIddict.Quartz 参照官网例子引入的,本次实践没有深入理解,大概知道是用于自动清除过期token等 Microsoft.AspNetCore.Identity.UI、Microsoft.AspNetCore.Identity.EntityFrameworkCore 方便快速搭建用户账户登录等功能 配置
Startup.cs
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using OpenIddict.Validation.AspNetCore; using OpeniddictTest.Server.Data; using Quartz; using static OpenIddict.Abstractions.OpenIddictConstants; namespace OpeniddictTest.Server { public class Startup { public Startup(IConfiguration configuration) { Configuration = configuration; } public IConfiguration Configuration { get; } // This method gets called by the runtime. Use this method to add services to the container. public void ConfigureServices(IServiceCollection services) { services.AddControllersWithViews(); services.AddDbContext<ApplicationDbContext>(options => { // Configure Entity Framework Core to use Microsoft SQL Server. options.UseSqlServer(Configuration.GetConnectionString("Default")); // Register the entity sets needed by OpenIddict. // Note: use the generic overload if you need to replace the default OpenIddict entities. options.UseOpenIddict(); }); // Register the Identity services. // AddIdentity时,包含了AddAuthentication(xxx).AddCookie(),定义了使用IdentityConstants.ApplicationScheme验证方案 https://github.com/dotnet/aspnetcore/blob/main/src/Identity/Core/src/IdentityServiceCollectionExtensions.cs services.AddIdentity<ApplicationUser, IdentityRole<long>>() .AddEntityFrameworkStores<ApplicationDbContext>() .AddDefaultTokenProviders() .AddDefaultUI();//DefaultUI要配合_LoginPartial.cshtml及endpoints.MapRazorPages()使用 services.AddQuartz(options => { options.UseMicrosoftDependencyInjectionJobFactory(); options.UseSimpleTypeLoader(); options.UseInMemoryStore(); }); services.AddQuartzHostedService(options => options.WaitForJobsToComplete = true); services.AddOpenIddict() // Register the OpenIddict core components. .AddCore(options => { // Configure OpenIddict to use the Entity Framework Core stores and models. // Note: call ReplaceDefaultEntities() to replace the default entities. options.UseEntityFrameworkCore().UseDbContext<ApplicationDbContext>(); // Enable Quartz.NET integration. options.UseQuartz(); }) // Register the OpenIddict server components. // AddServer会对认证方案OpenIddictServerAspNetCoreDefaults.AuthenticationScheme进行Handle .AddServer(options => { // Enable the token endpoint. options.SetAuthorizationEndpointUris("/connect/authorize"); options.SetLogoutEndpointUris("connect/logout"); options.SetTokenEndpointUris("connect/token"); options.SetUserinfoEndpointUris("connect/userinfo"); // Enable the client credentials flow. options.AllowClientCredentialsFlow(); options.AllowAuthorizationCodeFlow(); options.RegisterScopes(Scopes.Email); options.RegisterScopes(Scopes.Profile); options.RegisterScopes(Scopes.Roles); // Register the signing and encryption credentials. options.AddDevelopmentEncryptionCertificate() .AddDevelopmentSigningCertificate(); // Register the ASP.NET Core host and configure the ASP.NET Core options. options.UseAspNetCore() .EnableAuthorizationEndpointPassthrough() .EnableTokenEndpointPassthrough() .EnableStatusCodePagesIntegration() .EnableLogoutEndpointPassthrough() .EnableUserinfoEndpointPassthrough();//启动这个才会进入到自定义的connect/userinfo中 }) // Register the OpenIddict validation components. // AddValidation会对认证方案OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme进行Handle .AddValidation(options => { // Import the configuration from the local OpenIddict server instance. options.UseLocalServer(); // Register the ASP.NET Core host. options.UseAspNetCore(); }); // Register the worker responsible of seeding the database with the sample clients. // Note: in a real world application, this step should be part of a setup script. services.AddHostedService<Worker>(); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } else { 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(); } app.UseHttpsRedirection(); app.UseStaticFiles(); app.UseRouting(); app.UseAuthentication(); app.Use(async (ctx, next) => { if (ctx.User.Identity?.IsAuthenticated != true) { var result = await ctx.AuthenticateAsync(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme); if (result.Succeeded && result.Principal != null) { ctx.User = result.Principal; } } await next(); }); app.UseAuthorization(); app.UseEndpoints(endpoints => { endpoints.MapControllers(); endpoints.MapControllerRoute( name: "default", pattern: "{controller=Home}/{action=Index}/{id?}"); endpoints.MapRazorPages(); }); } } }
ApplicationDbContext
需要继承IdentityDbContext
,并重写OnModelCreating
,添加modelBuilder.UseOpenIddict()
进行注册OpenIddict需要的实体public class ApplicationDbContext : IdentityDbContext<ApplicationUser, IdentityRole<long>, long> { public ApplicationDbContext(DbContextOptions options) : base(options) { } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { base.OnConfiguring(optionsBuilder); } protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.UseOpenIddict(); } }
在
services.AddOpenIddict().AddServer(options =>xxx)
中,指定了几个终结点,这些终结点是需要在项目中进行实现的(详见后文描述),当OpenIddict
进行身份认证的时候会去访问这些终结点options.SetAuthorizationEndpointUris("/connect/authorize"); options.SetLogoutEndpointUris("connect/logout"); options.SetTokenEndpointUris("connect/token"); options.SetUserinfoEndpointUris("connect/userinfo");
Worker
用于初始化数据,参照官网资料创建一个OpenIddictApplication
,也就是配置需要接入的客户端,实际项目中可以用其他方式进行创建配置可以端,增加配置管理页面等using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using OpenIddict.Abstractions; using OpeniddictTest.Server.Data; using System; using System.Threading; using System.Threading.Tasks; using static OpenIddict.Abstractions.OpenIddictConstants; namespace OpeniddictTest.Server { public class Worker : IHostedService { private readonly IServiceProvider _serviceProvider; 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(); var manager = scope.ServiceProvider.GetRequiredService<IOpenIddictApplicationManager>(); var webClient1AppDescriptor = new OpenIddictApplicationDescriptor { ClientId = "WebMvc1", ClientSecret = "3C68DE8C-7195-4E1B-835E-6DDE77319419", ConsentType = ConsentTypes.Systematic, DisplayName = "Web测试客户端1", //只有配置了的Uri才允许回调,否则认证回调时会提示redirect_uri无效 RedirectUris = { new Uri("https://localhost:5003/callback/login"),//客户端的回调地址 new Uri("https://oauth.pstmn.io/v1/callback")//支持postman进行测试回调获取token }, PostLogoutRedirectUris = { new Uri("https://localhost:5003/callback/logout") }, Permissions = { //只有配置了的Endpoints,客户端才允许访问 Permissions.Endpoints.Authorization, Permissions.Endpoints.Token, Permissions.Endpoints.Logout, Permissions.Scopes.Email, Permissions.Scopes.Profile, Permissions.Scopes.Roles, Permissions.GrantTypes.ClientCredentials, Permissions.GrantTypes.AuthorizationCode, Permissions.ResponseTypes.Code, }, Type = ClientTypes.Confidential }; var webClient1App = await manager.FindByClientIdAsync(webClient1AppDescriptor.ClientId); if (webClient1App is null) { await manager.CreateAsync(webClient1AppDescriptor); } else { await manager.UpdateAsync(webClient1App, webClient1AppDescriptor); } } public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; } }
现实中一般会存在这样的一种情况,我们系统最初不需要与外部系统做数据对接,因此也就不需要对外部系统提供身份认证服务,使用
asp.net core identity
就可以为我们的系统功能进行身份认证。但是后来出现了与外部系统数据对接的需求,某个外部系统想要调用我们的系统获取一些数据,或者外部系统想要用我们系统的用户信息登录他们的系统(类似于现在很多网站支持用微信登录),且外部系统不是本公司的,那么一般会采用
OAuth2.0
方案,我们不需要提供系统的账户密码给外部系统就可以完成身份认证。此时可以引入
OpenIddict
来进行OAuth2.0
实现,那么就会存在这么一个问题,系统同时存在两种身份认证方案,一个是默认的identity
身份认证方案,一个是OpenIddict
的身份认证方案。假设某个Controller
我们的系统可以调用,我们用AuthorizeAttribute
特性标记了该Controller
,AuthorizeAttribute
默认不设定身份认证方案 (AuthenticationSchemes
) 参数时,默认是按identity
身份认证方案进行处理的,当外部系统调用时,因为是通过OpenIddict
进行身份认证的,因此会用OpenIddict
的身份认证方案去获取登录凭证,而不同认证方案获取到的凭证是不能共用的,也就说此时外部系统获取到的登录凭证,还不能直接调用Controller
, 除非我们对Controller
再指定AuthenticationSchemes
参数。针对这种情况,我们可以在
Configure
中,添加下面这段代码,系统首先会判断当前请求有没有identity
登录凭证,如果没有,则会再判断有没有OpenIddict
的登录凭证,如果有OpenIddict
的登录凭证,则将OpenIddict
登录凭证的用户信息赋给identity
登录凭证,这样我们的Controller
就不需要做任何处理,既可以让本系统的功能用identity
进行身份认证调用,也可以让外部系统用OpenIddict
进行身份认证调用。app.Use(async (ctx, next) => { if (ctx.User.Identity?.IsAuthenticated != true) { var result = await ctx.AuthenticateAsync(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme); if (result.Succeeded && result.Principal != null) { ctx.User = result.Principal; } } await next(); });
添加一个
AuthorizationController
实现之前配置定义的登录、注销等终结点using Microsoft.AspNetCore; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Primitives; using Microsoft.IdentityModel.Tokens; using OpenIddict.Abstractions; using OpenIddict.Server.AspNetCore; using OpeniddictTest.Server.Data; using OpeniddictTest.Server.Helpers; using OpeniddictTest.Server.Models.Authorization; using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using System.Security.Claims; using System.Threading.Tasks; using static OpenIddict.Abstractions.OpenIddictConstants; namespace OpeniddictTest.Server.Controllers { public class AuthorizationController : Controller { private readonly IOpenIddictApplicationManager _applicationManager; private readonly IOpenIddictAuthorizationManager _authorizationManager; private readonly IOpenIddictScopeManager _scopeManager; private readonly SignInManager<ApplicationUser> _signInManager; private readonly UserManager<ApplicationUser> _userManager; public AuthorizationController( IOpenIddictApplicationManager applicationManager, IOpenIddictAuthorizationManager authorizationManager, IOpenIddictScopeManager scopeManager, SignInManager<ApplicationUser> signInManager, UserManager<ApplicationUser> userManager) { _applicationManager = applicationManager; _authorizationManager = authorizationManager; _scopeManager = scopeManager; _signInManager = signInManager; _userManager = userManager; } [HttpGet("~/connect/authorize")] [HttpPost("~/connect/authorize")] [IgnoreAntiforgeryToken] 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 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 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 == null || !result.Succeeded || request.HasPrompt(Prompts.Login) || (request.MaxAge != null && result.Properties?.IssuedUtc != null && DateTimeOffset.UtcNow - result.Properties.IssuedUtc > TimeSpan.FromSeconds(request.MaxAge.Value))) { // If the client application requested promptless authentication, // return an error indicating that the user is not logged in. if (request.HasPrompt(Prompts.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 -> authorization redirects, the prompt=login flag // is removed from the authorization request payload before redirecting the user. var prompt = string.Join(" ", request.GetPrompts().Remove(Prompts.Login)); var parameters = Request.HasFormContentType ? Request.Form.Where(parameter => parameter.Key != Parameters.Prompt).ToList() : Request.Query.Where(parameter => parameter.Key != Parameters.Prompt).ToList(); parameters.Add(KeyValuePair.Create(Parameters.Prompt, new StringValues(prompt))); // 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(parameters) }); } // 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(); 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.Any(): 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.Any(): case ConsentTypes.Explicit when authorizations.Any() && !request.HasPrompt(Prompts.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, await _userManager.GetUserIdAsync(user)) .SetClaim(Claims.Email, await _userManager.GetEmailAsync(user)) .SetClaim(Claims.Name, await _userManager.GetUserNameAsync(user)) .SetClaims(Claims.Role, (await _userManager.GetRolesAsync(user)).ToImmutableArray()); // 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.HasPrompt(Prompts.None): case ConsentTypes.Systematic when request.HasPrompt(Prompts.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 }); } } [Authorize, FormValueRequired("submit.Accept")] [HttpPost("~/connect/authorize"), ValidateAntiForgeryToken] public async Task<IActionResult> Accept() { 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.Any() && 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, await _userManager.GetUserIdAsync(user)) .SetClaim(Claims.Email, await _userManager.GetEmailAsync(user)) .SetClaim(Claims.Name, await _userManager.GetUserNameAsync(user)) .SetClaims(Claims.Role, (await _userManager.GetRolesAsync(user)).ToImmutableArray()); // 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); } [Authorize, FormValueRequired("submit.Deny")] [HttpPost("~/connect/authorize"), ValidateAntiForgeryToken] // 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() => Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); [HttpGet("~/connect/logout")] public IActionResult Logout() => View(); [ActionName(nameof(Logout)), HttpPost("~/connect/logout"), ValidateAntiForgeryToken] public async Task<IActionResult> LogoutPost() { // 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(); // 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 = "/" }); } [HttpPost("~/connect/token"), IgnoreAntiforgeryToken, Produces("application/json")] 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); // 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, await _userManager.GetUserIdAsync(user)) .SetClaim(Claims.Email, await _userManager.GetEmailAsync(user)) .SetClaim(Claims.Name, await _userManager.GetUserNameAsync(user)) .SetClaims(Claims.Role, (await _userManager.GetRolesAsync(user)).ToImmutableArray()); identity.SetDestinations(GetDestinations); // Returning a SignInResult will ask OpenIddict to issue the appropriate access/identity tokens. return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } else if (request.IsClientCredentialsGrantType()) { 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); return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } throw new InvalidOperationException("The specified grant type is not supported."); } 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: 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; } } } }
在
Authorize
方法中,先判断了请求是否是OpenIddict
身份认证的请求,如果是,则会用默认的身份认证方案(Identity
)校验是否有登录,如果没有,会返回一个ChallengeResult
将请求重定向用默认的身份认证方案(Identity
)进行登录,登录完成后回调回来,重新执行一次Authorize
方法,此时就有了默认的身份认证方案(Identity
)的登录凭证了,根据这个登录凭证信息生成ClaimsPrincipal
,对OpenIddict
身份认证方案进行SignIn操作,记录OpenIddict
身份认证方案的登录凭证。增加授权确认页面,路径:
Views/Authorization/Authorize.cshtml
,用于让用户点击确认要授权@using Microsoft.Extensions.Primitives @using OpeniddictTest.Server.Models.Authorization @model AuthorizeViewModel <div class="jumbotron"> <h1>Authorization</h1> <p class="lead text-left">Do you want to grant <strong>@Model.ApplicationName</strong> access to your data? (scopes requested: @Model.Scope)</p> <form asp-controller="Authorization" asp-action="Authorize" method="post"> @* Flow the request parameters so they can be received by the Accept/Reject actions: *@ @foreach (var parameter in Context.Request.HasFormContentType ? (IEnumerable<KeyValuePair<string, StringValues>>)Context.Request.Form : Context.Request.Query) { <input type="hidden" name="@parameter.Key" value="@parameter.Value" /> } <input class="btn btn-lg btn-success" name="submit.Accept" type="submit" value="Yes" /> <input class="btn btn-lg btn-danger" name="submit.Deny" type="submit" value="No" /> </form> </div>
增加注销确认页面,路径:
Views/Authorization/Logout.cshtml
,用于让用户点击确认要注销@using Microsoft.Extensions.Primitives <div class="jumbotron"> <h1>Log out</h1> <p class="lead text-left">Are you sure you want to sign out?</p> <form asp-controller="Authorization" asp-action="Logout" method="post"> @* Flow the request parameters so they can be received by the LogoutPost action: *@ @foreach (var parameter in Context.Request.HasFormContentType ? (IEnumerable<KeyValuePair<string, StringValues>>)Context.Request.Form : Context.Request.Query) { <input type="hidden" name="@parameter.Key" value="@parameter.Value" /> } <input class="btn btn-lg btn-success" name="Confirm" type="submit" value="Yes" /> </form> </div>
客户端(asp.net core mvc)
创建一个asp.net core web应用项目:
OpeniddictTest.Web.Mvc
,将启动地址设置为:https://localhost:5003
主要引入的一些
nuget
包包名 主要作用 OpenIddict.AspNetCore 注册OpenIddict client中间件,帮助请求OpenIddict身份认证服务完成身份认证 配置
Startup.cs
using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.HttpsPolicy; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using OpenIddict.Client; using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using static OpenIddict.Abstractions.OpenIddictConstants; namespace OpeniddictTest.Web.Mvc { public class Startup { public Startup(IConfiguration configuration) { Configuration = configuration; } public IConfiguration Configuration { get; } // This method gets called by the runtime. Use this method to add services to the container. public void ConfigureServices(IServiceCollection services) { services.AddControllersWithViews(); services.AddAuthentication(options => { options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; }) .AddCookie(options => { options.LoginPath = "/login"; options.LogoutPath = "/logout"; options.ExpireTimeSpan = TimeSpan.FromMinutes(50); options.SlidingExpiration = false; }); services.AddOpenIddict() // Register the OpenIddict client components. .AddClient(options => { // Note: this sample uses the code flow, but you can enable the other flows if necessary. options.AllowAuthorizationCodeFlow(); // Register the signing and encryption credentials used to protect // sensitive data like the state tokens produced by OpenIddict. options.AddDevelopmentEncryptionCertificate() .AddDevelopmentSigningCertificate(); // Register the ASP.NET Core host and configure the ASP.NET Core-specific options. options.UseAspNetCore() .EnableStatusCodePagesIntegration() .EnableRedirectionEndpointPassthrough() .EnablePostLogoutRedirectionEndpointPassthrough(); // Register the System.Net.Http integration and use the identity of the current // assembly as a more specific user agent, which can be useful when dealing with // providers that use the user agent as a way to throttle requests (e.g Reddit). options.UseSystemNetHttp() .SetProductInformation(typeof(Startup).Assembly); options.DisableTokenStorage(); // Add a client registration matching the client application definition in the server project. options.AddRegistration(new OpenIddictClientRegistration { Issuer = new Uri("https://localhost:5001/", UriKind.Absolute), ClientId = "WebMvc1", ClientSecret = "3C68DE8C-7195-4E1B-835E-6DDE77319419", Scopes = { Scopes.Email, Scopes.Roles, Scopes.Profile }, // Note: to mitigate mix-up attacks, it's recommended to use a unique redirection endpoint // URI per provider, unless all the registered providers support returning a special "iss" // parameter containing their URL as part of authorization responses. For more information, // see https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#section-4.4. RedirectUri = new Uri("callback/login", UriKind.Relative), PostLogoutRedirectUri = new Uri("callback/logout", UriKind.Relative) }); }); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } else { 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(); } app.UseHttpsRedirection(); app.UseStaticFiles(); app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.UseEndpoints(endpoints => { endpoints.MapControllers(); endpoints.MapControllerRoute( name: "default", pattern: "{controller=Home}/{action=Index}/{id?}"); }); } } }
先注册了
Cookie
身份认证中间件,从Cookie中获取用户登录信息,并指定登录和注销的终结点,这两个终结点是要进行实现的(详见后文描述)options.LoginPath = "/login"; options.LogoutPath = "/logout";
然后注册了
OpenIddict
客户端生成认证中间件,指定了身份认证服务器登录后回调和注销后回调的终结点,这两个终结点是要进行实现的(详见后文描述)options.AddRegistration(new OpenIddictClientRegistration { Issuer = new Uri("https://localhost:5001/", UriKind.Absolute), ClientId = "WebMvc1", ClientSecret = "3C68DE8C-7195-4E1B-835E-6DDE77319419", Scopes = { Scopes.Email, Scopes.Roles, Scopes.Profile }, // Note: to mitigate mix-up attacks, it's recommended to use a unique redirection endpoint // URI per provider, unless all the registered providers support returning a special "iss" // parameter containing their URL as part of authorization responses. For more information, // see https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#section-4.4. RedirectUri = new Uri("callback/login", UriKind.Relative), PostLogoutRedirectUri = new Uri("callback/logout", UriKind.Relative) });
添加一个
AuthenticationController
实现之前配置定义的相关终结点using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Mvc; using Microsoft.IdentityModel.Tokens; using OpenIddict.Abstractions; using OpenIddict.Client.AspNetCore; using System; using System.Collections.Generic; using System.Linq; using System.Security.Claims; using System.Threading.Tasks; using static OpenIddict.Abstractions.OpenIddictConstants; namespace OpeniddictTest.Web.Mvc.Controllers { public class AuthenticationController : Controller { [HttpGet("~/login")] public ActionResult LogIn(string returnUrl) { var properties = new AuthenticationProperties { // Only allow local return URLs to prevent open redirect attacks. RedirectUri = Url.IsLocalUrl(returnUrl) ? returnUrl : "/Home/Index" }; // Ask the OpenIddict client middleware to redirect the user agent to the identity provider. return Challenge(properties, OpenIddictClientAspNetCoreDefaults.AuthenticationScheme); } [HttpPost("~/logout"), ValidateAntiForgeryToken] public async Task<ActionResult> LogOut(string returnUrl) { // Retrieve the identity stored in the local authentication cookie. If it's not available, // this indicate that the user is already logged out locally (or has not logged in yet). // // 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.Succeeded) { // Only allow local return URLs to prevent open redirect attacks. return Redirect(Url.IsLocalUrl(returnUrl) ? returnUrl : "/"); } // Remove the local authentication cookie before triggering a redirection to the remote server. // // For scenarios where the default sign-out handler configured in the ASP.NET Core // authentication options shouldn't be used, a specific scheme can be specified here. await HttpContext.SignOutAsync(); var properties = new AuthenticationProperties(new Dictionary<string, string> { // While not required, the specification encourages sending an id_token_hint // parameter containing an identity token returned by the server for this user. [OpenIddictClientAspNetCoreConstants.Properties.IdentityTokenHint] = result.Properties.GetTokenValue(OpenIddictClientAspNetCoreConstants.Tokens.BackchannelIdentityToken) }) { // Only allow local return URLs to prevent open redirect attacks. RedirectUri = Url.IsLocalUrl(returnUrl) ? returnUrl : "/" }; // Ask the OpenIddict client middleware to redirect the user agent to the identity provider. return SignOut(properties, OpenIddictClientAspNetCoreDefaults.AuthenticationScheme); } // Note: this controller uses the same callback action for all providers // but for users who prefer using a different action per provider, // the following action can be split into separate actions. [HttpGet("~/callback/login"), HttpPost("~/callback/login"), IgnoreAntiforgeryToken] public async Task<ActionResult> LogInCallback() { // Retrieve the authorization data validated by OpenIddict as part of the callback handling. var result = await HttpContext.AuthenticateAsync(OpenIddictClientAspNetCoreDefaults.AuthenticationScheme); // Multiple strategies exist to handle OAuth 2.0/OpenID Connect callbacks, each with their pros and cons: // // * Directly using the tokens to perform the necessary action(s) on behalf of the user, which is suitable // for applications that don't need a long-term access to the user's resources or don't want to store // access/refresh tokens in a database or in an authentication cookie (which has security implications). // It is also suitable for applications that don't need to authenticate users but only need to perform // action(s) on their behalf by making API calls using the access token returned by the remote server. // // * Storing the external claims/tokens in a database (and optionally keeping the essential claims in an // authentication cookie so that cookie size limits are not hit). For the applications that use ASP.NET // Core Identity, the UserManager.SetAuthenticationTokenAsync() API can be used to store external tokens. // // Note: in this case, it's recommended to use column encryption to protect the tokens in the database. // // * Storing the external claims/tokens in an authentication cookie, which doesn't require having // a user database but may be affected by the cookie size limits enforced by most browser vendors // (e.g Safari for macOS and Safari for iOS/iPadOS enforce a per-domain 4KB limit for all cookies). // // Note: this is the approach used here, but the external claims are first filtered to only persist // a few claims like the user identifier. The same approach is used to store the access/refresh tokens. // Important: if the remote server doesn't support OpenID Connect and doesn't expose a userinfo endpoint, // result.Principal.Identity will represent an unauthenticated identity and won't contain any claim. // // Such identities cannot be used as-is to build an authentication cookie in ASP.NET Core (as the // antiforgery stack requires at least a name claim to bind CSRF cookies to the user's identity) but // the access/refresh tokens can be retrieved using result.Properties.GetTokens() to make API calls. if (!(result.Principal is ClaimsPrincipal) || !result.Principal.Identity.IsAuthenticated) { throw new InvalidOperationException("The external authorization data cannot be used for authentication."); } // Build an identity based on the external claims and that will be used to create the authentication cookie. var identity = new ClaimsIdentity( authenticationType: TokenValidationParameters.DefaultAuthenticationType, nameType: Claims.Name, roleType: Claims.Role); // By default, OpenIddict will automatically try to map the email/name and name identifier claims from // their standard OpenID Connect or provider-specific equivalent, if available. If needed, additional // claims can be resolved from the external identity and copied to the final authentication cookie. identity.SetClaim(Claims.Email, result.Principal.GetClaim(Claims.Email)) .SetClaim(Claims.Name, result.Principal.GetClaim(Claims.Name)) .SetClaim(Claims.Role, result.Principal.GetClaim(Claims.Role)) .SetClaim(ClaimTypes.NameIdentifier, result.Principal.GetClaim(ClaimTypes.NameIdentifier)); // Preserve the registration identifier to be able to resolve it later. identity.SetClaim(Claims.Private.RegistrationId, result.Principal.GetClaim(Claims.Private.RegistrationId)); // Build the authentication properties based on the properties that were added when the challenge was triggered. var properties = new AuthenticationProperties(result.Properties.Items) { RedirectUri = result.Properties.RedirectUri ?? "/" }; // If needed, the tokens returned by the authorization server can be stored in the authentication cookie. // // To make cookies less heavy, tokens that are not used are filtered out before creating the cookie. properties.StoreTokens(result.Properties.GetTokens().Where(token => token.Name switch { // Preserve the access, identity and refresh tokens returned in the token response, if available. OpenIddictClientAspNetCoreConstants.Tokens.BackchannelAccessToken => true, OpenIddictClientAspNetCoreConstants.Tokens.BackchannelIdentityToken => true, OpenIddictClientAspNetCoreConstants.Tokens.RefreshToken => true, // Ignore the other tokens. _ => false })); // Ask the default sign-in handler to return a new cookie and redirect the // user agent to the return URL stored in the authentication properties. // // For scenarios where the default sign-in handler configured in the ASP.NET Core // authentication options shouldn't be used, a specific scheme can be specified here. //return SignIn(new ClaimsPrincipal(identity), properties, CookieAuthenticationDefaults.AuthenticationScheme); //return SignIn(new ClaimsPrincipal(identity), properties, CookieAuthenticationDefaults.AuthenticationScheme); await HttpContext.SignInAsync(new ClaimsPrincipal(identity), properties); return Redirect(result!.Properties!.RedirectUri); } // Note: this controller uses the same callback action for all providers // but for users who prefer using a different action per provider, // the following action can be split into separate actions. [HttpGet("~/callback/logout"), HttpPost("~/callback/logout"), IgnoreAntiforgeryToken] public async Task<ActionResult> LogOutCallback() { // Retrieve the data stored by OpenIddict in the state token created when the logout was triggered. var result = await HttpContext.AuthenticateAsync(OpenIddictClientAspNetCoreDefaults.AuthenticationScheme); // In this sample, the local authentication cookie is always removed before the user agent is redirected // to the authorization server. Applications that prefer delaying the removal of the local cookie can // remove the corresponding code from the logout action and remove the authentication cookie in this action. return Redirect(result!.Properties!.RedirectUri); } } }
LogIn
方法只做了一件事,返回一个ChallengeResult
, 将请求重定向到用OpenIddictClientAspNetCoreDefaults.AuthenticationScheme
身份认证方案进行登录, 此时会通过OpenIddict
客户端中间件,向配置的身份认证服务器请求登录。用户在身份认证服务器提供的登录页面操作完成登录后,回调回来,调用
LogInCallback
方法,此方法会根据回调回来的登录凭证信息,生成ClaimsPrincipal
,对Cookie
身份认证方案进行SignIn操作,记录Cookie
身份认证方案的登录凭证。
整体流程就是用户访问客户端需要授权的接口, 经过
Cookie
身份验证中间件,从Cookie
中获取登录凭证,如果没有,重定向到/login
登录终结点,登录终结点再重定向到OpenIddict
身份认证服务器中进行登录,登录完成后,OpenIddict
身份认证服务器回调/callback/login
终结点,在/callback/login
中将登录凭证保存到Cookie
中。注销的流程类似。