在上一章中,咱们了解到,Cookie认证是一种本地认证方式,一般认证与受权都在同一个服务中,也可使用Cookie共享的方式分开部署,但局限性较大,而现在随着微服务的流行,更加偏向于将之前的单体应用拆分为多个服务并独立部署,而此时,就须要一个统一的认证中心,以及一种远程认证方式,本文就来介绍一下现在最为流行的远程认证方式:OAuth 和 OpenID Connect。javascript
目录css
OAuth 2.0
在介绍OAuth以前,咱们先简单介绍一下OpenID。OpenID 是一个以用户为中心的数字身份识别框架,它具备开放、分散性。OpenID 的建立基于这样一个概念:咱们能够经过 URI (又叫URL或网站地址)来认证一个网站的惟一身份,同理,咱们也能够经过这种方式来做为用户的身份认证。html
OpenID的认证很是简单,当你访问须要认证的A网站时,A网站要求你输入你的OpenID用户名,而后会跳转你的OpenID服务网站,输入用户名密码验证经过后,再跳回A网站,而些时已经显示认证成功。除了一处注册,处处通行外,OpenID还可使全部支持OpenID的网站共享用户资源,而用户能够控制哪些信息能够被共享,例如姓名、地址、电话号码等。java
而OAuth是一个关于受权(authorization)的开放网络标准,在全世界获得普遍应用,在官网对其是这样定义的:git
An open protocol to allow secure API authorization in a simple and standard method from desktop and web applications.github
OAuth关注的是第三方应用访问其受保护资源的能力,而OpenID关注的是第三方应用获取用户身份的能力。web
现在大多网站都已再也不支持OpenID,最为流行的是OAuth 2.0 (在本文中提到OAuth也均指2.0版本),而OpenID的最新版是OpenID Connect,是OpenID的第三代技术,下文会来介绍。shell
关于OAuth的介绍,网上很是之多,本文就再也不过多叙述,而是主要讲解如何在 ASP.NET Core 中使用 OAuth 认证。若是你对 OAuth 并不了解,那么建议先去网上查看一下这方面的资料,再来阅读本文。而在本文中提到的OAuth认证指的是 ASP.NET Core 中的一种认证方式,而OAuth自己只是一种受权协议,但愿不要混淆。json
在 OAuth 协议中包含如下四种受权模式:浏览器
-
受权码模式(authorization code)
-
简化模式(implicit)
-
密码模式(resource owner password credentials)
-
客户端模式(client credentials)
在以上四种模式中,只有第一种Code模式须要服务端参与,其它的只在客户端就可完成,所以,在 ASP.NET Core 的 OAuth 认证中,也就只有Code模式。
用例
先来看一下具体的用法:
对于项目的建立能够参考上一章,而后添OAuth的Nuget包引用:
dotnet add package Microsoft.AspNetCore.Authentication.OAuth --version 2.0.0
而后在ConfigureServices
配置服务:
public void ConfigureServices(IServiceCollection services) { services.AddAuthentication(options => { options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme; options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; options.DefaultChallengeScheme = OAuthDefaults.DisplayName; }) .AddCookie() .AddOAuth(OAuthDefaults.DisplayName, options => { options.ClientId = "oauth.code"; options.ClientSecret = "secret"; options.AuthorizationEndpoint = "https://oidc.faasx.com/connect/authorize"; options.TokenEndpoint = "https://oidc.faasx.com/connect/token"; options.CallbackPath = "/signin-oauth"; options.Scope.Add("openid"); options.Scope.Add("profile"); options.Scope.Add("email"); options.SaveTokens = true; // 事件执行顺序 : // 1.建立Ticket以前触发 options.Events.OnCreatingTicket = context => Task.CompletedTask; // 2.建立Ticket失败时触发 options.Events.OnRemoteFailure = context => Task.CompletedTask; // 3.Ticket接收完成以后触发 options.Events.OnTicketReceived = context => Task.CompletedTask; // 4.Challenge时触发,默认跳转到OAuth服务器 // options.Events.OnRedirectToAuthorizationEndpoint = context => context.Response.Redirect(context.RedirectUri); }); }
上面前六个参数是都必填的(在IdentityServer中Scope必须包含openid),不然会报错,SaveTokens
属性用来设置是否将OAuth服务器返回的Token信息保存到AuthenticationProperties
中。
https://oidc.faasx.com 是我使用 IdentityServer4 搭建的一个OIDC服务,源码地址在 IdentityServerSample ,而本文中并不会涉及到IdentityServer的相关知识,后续有机会再来单独介绍一下它。
最后,注册中间件:
public void Configure(IApplicationBuilder app) { app.UseAuthentication(); // 受权,与上一章Cookie认证中的实现同样 app.UseAuthorize(); // 个人信息 app.Map("/profile", builder => builder.Run(async context => { await context.Response.WriteHtmlAsync(async res => { await res.WriteAsync($"<h1>你好,当前登陆用户: {HttpResponseExtensions.HtmlEncode(context.User.Identity.Name)}</h1>"); await res.WriteAsync("<a class=\"btn btn-default\" href=\"/Account/Logout\">退出</a>"); await res.WriteAsync($"<h2>AuthenticationType:{context.User.Identity.AuthenticationType}</h2>"); await res.WriteAsync("<h2>Claims:</h2>"); await res.WriteTableHeader(new string[] { "Claim Type", "Value" }, context.User.Claims.Select(c => new string[] { c.Type, c.Value })); // 在第一章中介绍过HandleAuthenticateOnceAsync方法,在此调用并不会有多余的性能损耗。 var result = await context.AuthenticateAsync(); await res.WriteAsync("<h2>Tokens:</h2>"); await res.WriteTableHeader(new string[] { "Token Type", "Value" }, result.Properties.GetTokens().Select(token => new string[] { token.Name, token.Value })); }); })); // 退出 app.Map("/Account/Logout", builder => builder.Run(async context => { await context.SignOutAsync(); context.Response.Redirect("/"); })); // 首页 app.Run(async context => { await context.Response.WriteHtmlAsync(async res => { await res.WriteAsync($"<h2>Hello OAuth Authentication</h2>"); await res.WriteAsync("<a class=\"btn btn-default\" href=\"/profile\">个人信息</a>"); }); }); }
在 上一章 中有介绍到远程认证并不具有SignIn/SignOut
的功能,而在这里的context.SignOutAsync()
方法是由 CookieHandler 来执行的,由于咱们指定了options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme
,而DefaultSignOutScheme
默认会使用DefaultSignInScheme
中指定的值。
而后运行,访问:http://localhost:5001,点击 “个人信息” 按钮,将会跳转到OAuth服务器,登陆成功后则显示受权页面:
点击容许,跳回咱们的网站,并已登陆成功,显示以下:
如图,Cliams中是空的,只有Token的相关信息,包含:access_token, token_type, expires_at 三个值。后续,可使用access_token
来访问OAuth服务方提供的受保护资源。这也就解释了为何说OAuth只是受权,由于咱们获得的只有一个自己没法识别的access_token
,而没有关于用户身份的任何信息,这也正是OAuth的本意。而国内却大多使用OAuth来作认证,以致于大多人都认为OAuth指的是认证,而非受权。虽然OAuth后来补充了 RFC7662 - OAuth2 Token Introspection 协议,让咱们能够获取到用户的身份,可是并不建议使用,而是使用下面要介绍的OpenID Connect来作身份的认证。
下面再来简单介绍一下其运行流程。
源码分析
AddOAuth
与上一章中介绍的AddCookie实现逻辑相似,而OAuthOptions
中的参数,都是OAuth中的标准参数,不用多说。主要来介绍一下OAuthHandler
,其用来完成获取Code,再使用Code获取AccessToken的整个流程:
public class OAuthHandler<TOptions> : RemoteAuthenticationHandler<TOptions> where TOptions : OAuthOptions, new() { ... protected override async Task<HandleRequestResult> HandleRemoteAuthenticateAsync() { // 获取从OAuth服务器返回的state var state = query["state"]; var properties = Options.StateDataFormat.Unprotect(state); // 获取从OAuth服务器返回的code var code = query["code"]; var tokens = await ExchangeCodeAsync(code, BuildRedirectUri(Options.CallbackPath)); // ClaimsIssuer参数继承自父类:protected virtual string ClaimsIssuer => Options.ClaimsIssuer ?? Scheme.Name; var identity = new ClaimsIdentity(ClaimsIssuer); if (Options.SaveTokens) { // 保存Token信息到properties中,包括access_token refresh_token token_type expires_at properties.StoreTokens(authTokens); } var ticket = await CreateTicketAsync(identity, properties, tokens); return HandleRequestResult.Success(ticket); } // 使用上面获取到的受权码,拼装请求参数,而后调用TokenEndpoint,获取到Token。 protected virtual async Task<OAuthTokenResponse> ExchangeCodeAsync(string code, string redirectUri) { } // 调用BuildChallengeUrl方法拼装请求参数,而后跳转。 protected override async Task HandleChallengeAsync(AuthenticationProperties properties) { } }
补充一点,对于远程认证Handler,都只有请求路径(一般是认证服务器回调)与咱们指定的CallbackPath
一致时,才会执行,这一点在 第一章 中也有介绍过。
以上代码简单展现了OAuth受权码模式的基本实现,完整的代码在:OAuthHandler,总的来讲,OAuth认证仍是比较简单的,我在这里再简单叙述一下。
受权码模式的整个流程以下:
+----------+
| Resource | | Owner | | | +----------+ ^ | (B) +----|-----+ Client Identifier +---------------+ | -+----(A)-- & Redirection URI ---->| | | User- | | Authorization | | Agent -+----(B)-- User authenticates --->| Server | | | | | | -+----(C)-- Authorization Code ---<| | +-|----|---+ +---------------+ | | ^ v (A) (C) | | | | | | ^ v | | +---------+ | | | |>---(D)-- Authorization Code ---------' | | Client | & Redirection URI | | | | | |<---(E)----- Access Token -------------------' +---------+ (w/ Optional Refresh Token)
第一次未登陆时访问,将会跳转到认证服务器并带上returnUrl参数,其附带 client_id, scope, response_type, redirect_uri, state 五个参数,请求报文以下(为方便展现,都会使用URLDecode解码):
GET /connect/authorize?client_id=oauth.code&scope=openid profile email&response_type=code&redirect_uri=http://localhost:5001/signin-oauth&state=CfDJ8B4XRZETkRhMt3mT9VduB8K32v-jJapr_X1RhEIiixwkk7L8krUsn32tBnyn3D0NX8PjPPpGtiAEG6O0bWI9ke42XhA0hrk-nI5nM86Fj9BDVQMoUwFJlrmT3QWBV7qTHWwPVWIXsK6lZR00owdKOqAL7g-9LjVv150V3NeBHD1P_Jp9xiK1sN_WywIbEUSwE_ut_c6w4V5nilEe6MqU-4JUoz5BTiqXDGG5kTd36ivGal4ihisn07csWFdodvC61A HTTP/1.1 Referer: http://localhost:5001/
其ReturnUrl的拼装代码以下:
protected virtual string BuildChallengeUrl(AuthenticationProperties properties, string redirectUri) { var scope = FormatScope(); var state = Options.StateDataFormat.Protect(properties); var parameters = new Dictionary<string, string> { { "client_id", Options.ClientId }, { "scope", scope }, { "response_type", "code" }, { "redirect_uri", redirectUri }, { "state", state }, }; return QueryHelpers.AddQueryString(Options.AuthorizationEndpoint, parameters); }
在OAuth服务登陆成功后,返回以下:
HTTP/1.1 302 Found Location: http://localhost:5001/signin-oauth?code=011e45b0f509969ac85aa69ab199636ddb33c13a06c711672b1be99509a5e205&scope=openid profile email&state=CfDJ8B4XRZETkRhMt3mT9VduB8K32v-jJapr_X1RhEIiixwkk7L8krUsn32tBnyn3D0NX8PjPPpGtiAEG6O0bWI9ke42XhA0hrk-nI5nM86Fj9BDVQMoUwFJlrmT3QWBV7qTHWwPVWIXsK6lZR00owdKOqAL7g-9LjVv150V3NeBHD1P_Jp9xiK1sN_WywIbEUSwE_ut_c6w4V5nilEe6MqU-4JUoz5BTiqXDGG5kTd36ivGal4ihisn07csWFdodvC61A&session_state=AwD762ldo1cgW58P0qKbK20amkXDIsIm_GMm8oTas0Q.05716f0d6ff235cbc38da1c1d0508fa5
其 state
是咱们在上一步中保存的 properties
,code
则是最重要的参数,用来获取 access_token,由服务端来执行:
protected virtual async Task<OAuthTokenResponse> ExchangeCodeAsync(string code, string redirectUri) { var tokenRequestParameters = new Dictionary<string, string>() { { "client_id", Options.ClientId }, { "redirect_uri", redirectUri }, { "client_secret", Options.ClientSecret }, { "code", code }, { "grant_type", "authorization_code" }, }; var requestContent = new FormUrlEncodedContent(tokenRequestParameters); var requestMessage = new HttpRequestMessage(HttpMethod.Post, Options.TokenEndpoint); requestMessage.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); requestMessage.Content = requestContent; var response = await Backchannel.SendAsync(requestMessage, Context.RequestAborted); var payload = JObject.Parse(await response.Content.ReadAsStringAsync()); // payload: // { // "id_token": "xxx", // "access_token": "xxx", // "expires_in": 3600, // "token_type": "Bearer" // } return OAuthTokenResponse.Success(payload); }
最后调用Cookie认证的Sign方法写入Cookie,并跳转到个人信息页面:
HTTP/1.1 302 Found Location: http://localhost:5001/profile Set-Cookie: .AspNetCore.Correlation.OAuth.In32Oho-aTNH4EOvCWNZYSs--sYgA3eRfoJC9tZkgBY=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/signin-oauth; samesite=lax Set-Cookie: .AspNetCore.Cookies=CfDJ8B4XRZETkRhMt3mT9VduB8JypeVkaj-mkVB8iXvf2tG2d8Xfs5CoX2wugjdlUkBv2DY73FAvPwPJOo81GpKqRJjjYzgwemGkB98ZTN7dbKI9rT__Xwi-xsPZ-8gPCBtoeSnn1RfagM2kcprjk4djhBTrrJK1AVh3qufyu195Nju4Fqrmv91NKfelpztX0qaeVWS4y5cgbpKJwfeqJ3AfSSGkwnMFRIbKcX-TgJHKIDxQsP8OhAxm572GGv02X5WvpYZZF7Tc90zvNyH5HEzwv1nJ2yNuRUIgmSx6425M5RM684fq1fvaVIN29sORJCEj69gmw3xt7wpY3BsMYDyRXH1XSoa_n7WEjvnT6lfy1zYqSLDM0MAMOlkBzAXRQ5Vjr8IVhTPkGOEsxT0jqHeJrVOzYD5PcuveP-oey3-n7OJyrKOtByu5qUfzI2Gs_isUBiQSWcSfyQo9sLuB1Jj5dHtakCXOYUt8Hu2ysxUKugQiRSyh5WmxUm_RBuV8_QFzpD-3Ke5Brd1Kjl95Yo6iik66rfoHm1rcUOjKFGFvBl0be4uYz6-vhgauvhb-sa-gq-6uGLzNk_Qm9l-vf1nJk7h_qQ8OgwhjgNScrhMMt7r8DET3pX-_gYg7Dl56KBGCpVX6nrBFgjjrdpN6kvDh6v26zrEpgYW6IHqVkje8HMgZbWe2PdNflrA9DmV6XtOxncoLc3EusWmpkUk-FCXG3lIzd4VlC8iim7fbCJHd5Z6Cn6b9cRGTTf4juvUcOWvTbHXi6HkT2f1Ym9eFZ-7BBghRWwc2fERPgxPZEcwgkSdEgoHPq_eZtnshgHVTSM1e_FUyiZxh8miVJbRRhzWgdR5xNW--lOD6ShdNH6-22dKKOZbPdxTkxraZl9SXslTdR1ILoD4Z23Jyi-rRZ42uPzCIrX4PnJIzm9HjFvjGQJedL8mm2tDaIuYQ2_LBvyz8Wms9e0T_VXJCaf53IE2rqAKahwxAV7kRDudEPNIp4y7pJ7djdhEahtdVUwiIh4Pz9y1p74zA42HeI2lUn6pTTetH_npKn_dqu1puge_lXTncSH2yNqFZZTCQsNO6INjQjDMLRTkLGptQrjrMPz17MpFnb4lA1eVAo0R9EdlDIOAep2f2PuPzc-fVub5olnb8NjGUWy9J4rW6C-HBMj3sAlpZz9eHCYAOkRElKxeEgpyS1yOLA3469neukGKYFUySsTfDdEvU3JqsViFO3v_8EwCe80eiePQv1l4SF7AWwIqQhZuXf-n4_TXnoKFlZz9QqesA_npYg1LgnrhHhUXEAhHvyehEKLRXFUDMQqIls6b_WxWQ8d-9FBthl-WlZqMippZ1TJzGKLntsSXximbSnkGMuQfKNdESgIdUfvD1Dx8zbPVs_2U87slOiCAwbrXPi9oVIFj8OuTBFKaf6NY8hh2aQ9ywDlekGCujnnh6EzZoPHuCVCNQ1bqKzAxnvAZ6Eg; path=/; samesite=lax; httponly
OpenID Connect
OpenID Connect是OpenID的升级版,简称OIDC,是2014年初发布的开放标准,定义了一种基于OAuth2的可互操做的方式来来提供用户身份认证。在OIDC中,应用程序没必要再为每一个客户端构建不一样的协议,而是能够将一个协议提供给多个客户端,它还使用了JOSN签名和加密规范,用来在传递携带签名和加密的信息,并使用简单的REST/JSON消息流来实现,和以前任何一种身份认证协议相比,开发者均可以轻松的集成。
上文中介绍到OAuth2是一个受权协议,它没法提供完善的身份认证功能。而OIDC使用OAuth2的受权服务器来为第三方客户端提供用户的身份认证,并把对应的身份认证信息传递给客户端,能够适用于各类类型的客户端(好比服务端应用,移动APP,JS应用),并彻底兼容OAuth2,也就是说你搭建了一个OIDC的服务后,也能够看成一个OAuth2的服务来用(上面的OAuth服务器其实就是使用的OIDC服务器),应用场景如图:
OIDC在OAuth的基础上扩展了一些新的概念,避免了OAuth中的不少误区:
ID Tokens
OpenID Connect Id Token是一个签名的JSON Web Token(JWT:RFC7519),它包含一组关于用户身份的声明(claim),如:用户的标识(sub)、颁发令牌的提供程序的标识符(iss)、建立此标识的Client标识(aud),还包含token的有效期以及其余相关的上下文信息。
因为ID Token使用的是JWT签名,客户端能够直接解析出Token中的内容而无需依赖外部服务,所以咱们可使用ID Token来作身份认证,而不须要使用access_token。不过,OIDC为了保持于OAuth的兼容,会同时提供Id token和access_token。
UserInfo Endpoint
OIDC还提供了一个包含当前用户信息的标准的受保护的资源。UserInfo Endpoint不是身份认证的一部分,而是提供附加的标识信息,它提供了一组标准化的属性:好比profile、email、phone和address。OIDC中定义了一个特殊的openid
scope,而且是必须的,它包含对Id token和UserInfo Endpoint的访问权限。
服务发现和客户端注册
OIDC定义了一个发现协议,客户端能够自动的获取有关如何与身份认证提供者进行交互的信息,还定义了一个客户端注册协议,容许客户端引入新的身份提供程序(identity providers)。经过这两种机制和一个通用的身份API,OIDC能够在互联网规模上良好的运行,而不须要任何一方事先知道对方的存在。
Hybrid Flow
混合流模式是受权码模式与隐式模式的组合,在一次请求中能够同时获取Code和ID Token,ResponseType能够是:code id_token
, code id_token token
和 code token
。
简单的介绍了一下OIDC的基本概念(网上对于OIDC的介绍不少,本文就很少叙述),下面就来介绍一下 ASP.NET Core 中的OIDC认证。
示例
首先添OpenIdConnect
的Nuget包引用:
dotnet add package Microsoft.AspNetCore.Authentication.OpenIdConnect --version 2.0.0
OIDC的注册其实要比OAuth还简单些:
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(o =>
{
o.ClientId = "oidc.hybrid"; o.ClientSecret = "secret"; // 若不设置Authority,就必须指定MetadataAddress o.Authority = "https://oidc.faasx.com/"; // 默认为Authority+".well-known/openid-configuration" //o.MetadataAddress = "https://oidc.faasx.com/.well-known/openid-configuration"; o.RequireHttpsMetadata = false; // 使用混合流 o.ResponseType = OpenIdConnectResponseType.CodeIdToken; // 是否将Tokens保存到AuthenticationProperties中 o.SaveTokens = true; // 是否从UserInfoEndpoint获取Claims o.GetClaimsFromUserInfoEndpoint = true; // 在本示例中,使用的是IdentityServer,而它的ClaimType使用的是JwtClaimTypes。 o.TokenValidationParameters.NameClaimType = "name"; //JwtClaimTypes.Name; // 如下参数均有对应的默认值,一般无需设置。 //o.CallbackPath = new PathString("/signin-oidc"); //o.SignedOutCallbackPath = new PathString("/signout-callback-oidc"); //o.RemoteSignOutPath = new PathString("/signout-oidc"); //o.Scope.Add("openid"); //o.Scope.Add("profile"); //o.ResponseMode = OpenIdConnectResponseMode.FormPost; /***********************************相关事件***********************************/ // 未受权时,重定向到OIDC服务器时触发 //o.Events.OnRedirectToIdentityProvider = context => Task.CompletedTask; // 获取到受权码时触发 //o.Events.OnAuthorizationCodeReceived = context => Task.CompletedTask; // 接收到OIDC服务器返回的认证信息(包含Code, ID Token等)时触发 //o.Events.OnMessageReceived = context => Task.CompletedTask; // 接收到TokenEndpoint返回的信息时触发 //o.Events.OnTokenResponseReceived = context => Task.CompletedTask; // 验证Token时触发 //o.Events.OnTokenValidated = context => Task.CompletedTask; // 接收到UserInfoEndpoint返回的信息时触发 //o.Events.OnUserInformationReceived = context => Task.CompletedTask; // 出现异常时触发 //o.Events.OnAuthenticationFailed = context => Task.CompletedTask; // 退出时,重定向到OIDC服务器时触发 //o.Events.OnRedirectToIdentityProviderForSignOut = context => Task.CompletedTask; // OIDC服务器退出后,服务端回调时触发 //o.Events.OnRemoteSignOut = context => Task.CompletedTask; // OIDC服务器退出后,客户端重定向时触发 //o.Events.OnSignedOutCallbackRedirect = context => Task.CompletedTask; });
如上,ClientId
,ClientSecret
与在OAuth中的做用同样,而在这里不须要再分别指定各类Endpoint,是经过指定一个MetadataAddress
地址来自动发现,可访问 https://oidc.faasx.com/.well-known/openid-configuration来了解一下MetadataAddress中包含的信息。
而后,在上文OAuth认证示例中的Configure
方法基础上添加signout
和signout-remote
:
// 本地退出 app.Map("/signout", builder => builder.Run(async context => { await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); await context.Response.WriteHtmlAsync(async res => { await res.WriteAsync($"<h1>Signed out {HttpResponseExtensions.HtmlEncode(context.User.Identity.Name)}</h1>"); await res.WriteAsync("<a class=\"btn btn-default\" href=\"/\">Home</a>"); }); })); // 远程退出 app.Map("/signout-remote", builder => builder.Run(async context => { await context.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme, new AuthenticationProperties() { RedirectUri = "/signout" }); }));
如上,远程退出使用的是OpenIdConnectDefaults.AuthenticationScheme
,而本地退出则使用的CookieAuthenticationDefaults.AuthenticationScheme
。
运行流程
登陆
来探索一下OIDC的运行流程,首先运行,点击一下个人信息:
请求: GET http://localhost:5002/profile HTTP/1.1 响应: HTTP/1.1 302 Found Location: https://oidc.faasx.com/connect/authorize?client_id=oidc.hybrid&redirect_uri=http://localhost:5002/signin-oidc&response_type=code id_token&scope=openid profile&response_mode=form_post&nonce=636428853279287956.N2IxZmFlZDgtNDNmZC00OTRmLTljMWItNTVjMmQwOTVjNTQ3MWUyMDcxNTctZDg4Yy00MDRiLThmNmQtYjE1YTdjOGE4MThm&state=CfDJ8B4XRZETkRhMt3mT9VduB8LSACJO9seruKlM3kYPxaRyWcUSt0BvPMd6RUGAiay8qraTWLdMh9B3ClRJDE-BtMRYTmzGJSHegueIW-fyq2G9TpUtSQCd23BxAYrdB4SeGQte2IXaQ82cKMz-aSHQ7TTzhPO_fgDtIVlwDJBtwgKQzEkEyyLsfH2DHxwr_Ojn3M-uRHId2bi9RF2gR_1hqoTdYlv-CZodFKuUGSMCqJO4cZLsuuAb-PrSnamz7h7MOpPixIOgQq5gd25sxF8avpSTsoT5HbU2fCiqX7g3rbCLzMG-rTnDftN8uZRiqc-JcyGkLPGIoj-FLNoW_yfZbGk&x-client-SKU=ID_NET&x-client-ver=2.1.4.0 Set-Cookie: .AspNetCore.OpenIdConnect.Nonce.CfDJ8B4XRZETkRhMt3mT9VduB8IHI9Q_BeT6uPrlI72UUqej78UfqAdiczZsPOxF3Gy7bSm7Swh9Jh0_haVi-UnQxUTGq-9xQLcaMX-PuXMjf6-6sLqc15NUgoLQ1w3KWqjkt7-3NlYW4qka6LqDPRtWJxT7vICtPhjx8ecNWtW_ijqBg_W8osmZLvFGS3PzPipP1UF14AkaIp48dFV1qNqt67Yta8ebXH7SHGkUhfcA5R-O0B8t-Q7sWL4NTN8AdKQF02HMqs_duf-4FLf2p7Rbpsc=N; expires=Fri, 06 Oct 2017 11:30:27 GMT; path=/signin-oidc; httponly Set-Cookie: .AspNetCore.Correlation.OpenIdConnect.BqW1filmAVCStL92ghXFQZBoLQjG5-Gl_m60zxCW9BA=N; expires=Fri, 06 Oct 2017 11:30:27 GMT; path=/signin-oidc; httponly
因为咱们尚未登陆,会执行context.ChallengeAsync()
方法,而咱们在上面指定了DefaultChallengeScheme
为:OpenIdConnectDefaults.AuthenticationScheme,所以会进入到OpenIdConnectHandler中来。
如上,重定向到了OIDC服务器,并写入了.AspNetCore.OpenIdConnect.Nonce.xxx
和.AspNetCore.Correlation.OpenIdConnect.xxx
两个Cookie,前者是OIDC的标准,会包含在ID Token中,用来减缓重放攻击,后者由Options.CorrelationCookie.Name + Scheme.Name + "." + correlationId
组成, 用于防止CSRF。
而重定向地址中包含以下几个参数:
-
client_id 客户端标识,对应于
OpenIdConnectOptions.ClientId
。 -
redirect_uri 回调地址,对应于
OpenIdConnectOptions.CallbackPath
。 -
response_type 受权类型,对应于
OpenIdConnectOptions.ResponseType
。 -
scope 权限范围,对应于
OpenIdConnectOptions.Scope
。 -
response_mode 响应模式,对应于
OpenIdConnectOptions.ResponseMode
,表示OIDC服务器来跳转到咱们的应用时传递参数的方式。 -
nonce OIDC服务器会在
identity token
中包含此参数,在认证时与Cookie中的.AspNetCore.OpenIdConnect.Nonce.xxx
对比验证。 -
state 用于保存状态,会原封不动地返回,在 ASP.NET Core 中,用
AuthenticationProperties
对象来表示。 -
x-client-SKU/x-client-ver IdentityServer附加信息。
在OIDC服务器登陆成功后,connect/authorize
输出的是一个自动提交的表单:
<form method='post' action='http://localhost:5002/signin-oidc'> <input type='hidden' name='code' value='eb53860906da276a1bb5318c5d539db085c6ca1fd3467da9d918aa7524f20f63' /> <input type='hidden' name='id_token' value='eyJhbGciOiJSUzI1NiIsImtpZCI6IjdlYzk5MjVlMmUzMTA2NmY2ZmU2ODgzMDRhZjU1ZmM0IiwidHlwIjoiSldUIn0.eyJuYmYiOjE1MDcyODg1MjcsImV4cCI6MTUwNzI4ODgyNywiaXNzIjoiaHR0cDovL29pZGMuZmFhc3guY29tIiwiYXVkIjoib2lkYy5oeWJyaWQiLCJub25jZSI6IjYzNjQyODg1MzI3OTI4Nzk1Ni5OMkl4Wm1GbFpEZ3RORE5tWkMwME9UUm1MVGxqTVdJdE5UVmpNbVF3T1RWak5UUTNNV1V5TURjeE5UY3RaRGc0WXkwME1EUmlMVGhtTm1RdFlqRTFZVGRqT0dFNE1UaG0iLCJpYXQiOjE1MDcyODg1MjcsImNfaGFzaCI6ImJNT1FNVGdDV3VMX25EbHo5MDU4M3ciLCJzaWQiOiJjZTVjODg2ZmFhZDFiNTc4MDVkNzExYjkzZTliYWQ0ZiIsInN1YiI6IjAwMSIsImF1dGhfdGltZSI6MTUwNzI4ODM5NSwiaWRwIjoibG9jYWwiLCJhbXIiOlsicHdkIl19.BBn0SigvOW9USk-Mi1WP_lJPWI9I06gsuomhqp69Ip5y3kqFyiBCVanzULsR4fBa0tOOBOtcPJzAfivzLqsMRwW1QfRamfVIlXuuzcRsR8WP1pFxvFPekwKi-D6-RLmmQzUT-_78WvboiAu_dZtwe0cm4ZLDCJH6LLCPs2xXTHYuNI7YoyAgeGKDAhWle0VrsbdlrcubPPgQFfFXPdLDInnLr8eEMpUZ7nru0FJgxm3Ah4hGXPKMud8jhLUMXDcSaKseL8tDgxIowmhpXOknU-y9x5FlrZUFOReDxaBZe7DG5V0xsPrdhMxMkZQbHHz8cJoaYrqcwHClm8rScEPxVA' /> <input type='hidden' name='scope' value='openid profile' /> <input type='hidden' name='state' value='CfDJ8B4XRZETkRhMt3mT9VduB8LSACJO9seruKlM3kYPxaRyWcUSt0BvPMd6RUGAiay8qraTWLdMh9B3ClRJDE-BtMRYTmzGJSHegueIW-fyq2G9TpUtSQCd23BxAYrdB4SeGQte2IXaQ82cKMz-aSHQ7TTzhPO_fgDtIVlwDJBtwgKQzEkEyyLsfH2DHxwr_Ojn3M-uRHId2bi9RF2gR_1hqoTdYlv-CZodFKuUGSMCqJO4cZLsuuAb-PrSnamz7h7MOpPixIOgQq5gd25sxF8avpSTsoT5HbU2fCiqX7g3rbCLzMG-rTnDftN8uZRiqc-JcyGkLPGIoj-FLNoW_yfZbGk' /> <input type='hidden' name='session_state' value='g0m_0W2scFKHxVQP_8jdNt48r2XV8wDlF__-ST9aYtk.28e1d34baad6d122a92c667329084600' /> </form> <script>(function(){document.forms[0].submit();})();</script>
如上,能够看到,表单中包含有id_token
,由于咱们使用的是code id_token
类型,而后便进入到了咱们应用程序的OIDC认证逻辑中:
首先经过IdToken
,能够来解析出AuthenticationProperties
,ClaimsPrincipal
等信息,而后使用Code
,调用TokenEndpoint
,获取access_token
等信息:
{
"id_token": "....", "access_token": "...", "expires_in": 3600, "token_type": "Bearer" }
由于咱们将SaveTokens
设置为true
,则会将token信息保存到AuthenticationProperties
中来:
public class OpenIdConnectHandler : RemoteAuthenticationHandler<OpenIdConnectOptions>, IAuthenticationSignOutHandler { protected override async Task<HandleRequestResult> HandleRemoteAuthenticateAsync() { ... if (Options.SaveTokens) { SaveTokens(properties, tokenEndpointResponse ?? authorizationResponse); } ... } private void SaveTokens(AuthenticationProperties properties, OpenIdConnectMessage message) { var tokens = new List<AuthenticationToken>(); if (!string.IsNullOrEmpty(message.AccessToken)) { tokens.Add(new AuthenticationToken { Name = OpenIdConnectParameterNames.AccessToken, Value = message.AccessToken }); } if (!string.IsNullOrEmpty(message.IdToken)) { tokens.Add(new AuthenticationToken { Name = OpenIdConnectParameterNames.IdToken, Value = message.IdToken }); } if (!string.IsNullOrEmpty(message.RefreshToken)) { tokens.Add(new AuthenticationToken { Name = OpenIdConnectParameterNames.RefreshToken, Value = message.RefreshToken }); } if (!string.IsNullOrEmpty(message.TokenType)) { tokens.Add(new AuthenticationToken { Name = OpenIdConnectParameterNames.TokenType, Value = message.TokenType }); } if (!string.IsNullOrEmpty(message.ExpiresIn)) { if (int.TryParse(message.ExpiresIn, NumberStyles.Integer, CultureInfo.InvariantCulture, out int value)) { var expiresAt = Clock.UtcNow + TimeSpan.FromSeconds(value); tokens.Add(new AuthenticationToken { Name = "expires_at", Value = expiresAt.ToString("o", CultureInfo.InvariantCulture) }); } } properties.StoreTokens(tokens); } }
当咱们作身份验证时,可能会须要更详细的用户Claims,能够将GetClaimsFromUserInfoEndpoint
设置为True,使用UserInfoEndpoint返回的信息来从新建立ClaimsPrincipal
对象:
protected override async Task<HandleRequestResult> HandleRemoteAuthenticateAsync() { ... if (Options.GetClaimsFromUserInfoEndpoint) { return await GetUserInformationAsync(tokenEndpointResponse ?? authorizationResponse, jwt, user, properties); // UserInfoEndpoin返回的信息以下: // { // "name": "Alice Smith", // "given_name": "Alice", // "family_name": "Smith", // "website": "http://alice.com", // "sub": "001" // } } else { var identity = (ClaimsIdentity)user.Identity; foreach (var action in Options.ClaimActions) { action.Run(null, identity, ClaimsIssuer); } } return HandleRequestResult.Success(new AuthenticationTicket(user, properties, Scheme.Name)); }
最后调用CookieHandler的SignInAsync
方法,将AuthenticationTicket
写入到Cookie中,响应以下:
HTTP/1.1 302 Found Location: http://localhost:5002/profile Set-Cookie: .AspNetCore.Correlation.OpenIdConnect.02q09RpgJBAi3rGZwy0WiyHUWGgLuDQIbVRUJuEXYBw=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/signin-oidc; samesite=lax Set-Cookie: .AspNetCore.OpenIdConnect.Nonce.xxx=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/signin-oidc; samesite=lax Set-Cookie: .AspNetCore.Cookies=chunks-2; path=/; samesite=lax; httponly Set-Cookie: .AspNetCore.CookiesC1=xxx; path=/; samesite=lax; httponly Set-Cookie: .AspNetCore.CookiesC2=xxx; path=/; samesite=lax; httponly
浏览器中显示以下:
接下来可使用Token中的access_token
访问OIDC中的受保护资源(与OAuth用法同样),也可用使用Claims
进行受权。能够看出,OIDC的认证流程比OAuth更加便捷和严谨,感兴趣的能够查看 OpenIdConnectHandler 的源码来更深一步的了解。
退出
退出分为两种,一种是本地的退出,并不会使OIDC服务器退出,只须要简单的调用CookieHandler的SignOutAsync
便可,无需多说。
而远程退出则会同时退出本地应用和OIDC服务器,大体逻辑是先跳转到OIDC服务器,退出后,OIDC服务器会回调本地应用,完成本地的退出,该回调地址是经过OpenIdConnectOptions.RemoteSignOutPath
指定的。
当咱们点击退出时,首先执行OpenIdConnectHandler的SignOutAsync
方法,Http报文以下:
请求报文: GET http://localhost:5002/signout-remote HTTP/1.1 Cookie: .AspNetCore.Cookies=xxx 响应报文: HTTP/1.1 302 Found Location: http://oidc.faasx.com/connect/endsession?post_logout_redirect_uri=http://localhost:5002/signout-callback-oidc&id_token_hint=eyJhbGciOiJSUzI1NiIsImtpZCI6IjdlYzk5MjVlMmUzMTA2NmY2ZmU2ODgzMDRhZjU1ZmM0IiwidHlwIjoiSldUIn0.eyJuYmYiOjE1MDc0MjgzMTAsImV4cCI6MTUwNzQyODYxMCwiaXNzIjoiaHR0cDovL29pZGMuZmFhc3guY29tIiwiYXVkIjoib2lkYy5oeWJyaWQiLCJub25jZSI6IjYzNjQzMDI1MTExOTUyNjYxNS5NVFU0WXpNMFl6RXRZemcwWmkwME5UWTFMVGcyTW1ZdE5XUmtZbUZtTUdabU5UWXlaalF6TkRReE9HWXROalEwWXkwMFpHTmtMVGhqTlRFdE1XWTROV0V3TW1NM09UZG0iLCJpYXQiOjE1MDc0MjgzMTAsImF0X2hhc2giOiJQSHRvd1JrUHhYLWNsclBzRnYxMkpnIiwic2lkIjoiMzFmMWJmMTA1MTU4MmZjMGE3ZTZhZjFjY2I0Y2RlMzUiLCJzdWIiOiIwMDEiLCJhdXRoX3RpbWUiOjE1MDc0MjgzMTAsImlkcCI6ImxvY2FsIiwiYW1yIjpbInB3ZCJdfQ.hIxkDhsx_WE6IxM68O7uqkqdQquXXnOtxhlnrYiBJuU7Ex_aApVXoKUdHS8HMx1nLswntr6SRsrygyMJnGMdzP5JutGsmfO_i1WYGqk3BlTD7ry0wfBd_U9OaVFcJhcVZq4q5u3SA47Wxqex9vifiHrTBQFT_l6JqpevRLn-y91IxTl9rnXKfrHowhPsHJjdLzda3Lyj0wWWtb2N_ng19mRChmDd4RXucP9mBHdQDyLZtvIJ5iIzV4pqtL7VCylFzV4RBLbCzUeuRnHI3E_MqTaGWvVyFbUAKpr55TmVoc6lAOS4ie4CPzilR52KLWZ4l9eQh-WeIOA3NBgzHlJigg&state=CfDJ8B4XRZETkRhMt3mT9VduB8IbG5c9um6S_maiHLsMKlIFRVtKMyLuXfqgB6e1OhWWVVJDYbgedt6hmyi1ny2aMbKW-SgHTru73YezUAZpre2ELXM3trlnX3YW_FTkcGE_RUyaR3hQ3eEYFmMdgdZdf0M&x-client-SKU=ID_NET&x-client-ver=2.1.4.0
而后浏览器跳转到OIDC服务器,显示以下:
其HTML中还嵌套一个IFrame:
<iframe width="0" height="0" class="signout" src="http://oidc.faasx.com/connect/endsession/callback?endSessionId=CfDJ8ADnXgOvSAFKqD4LwO6fek3_sYV1rgqtFD-CM4iSTjIo5wVq7lP0euy9tskf5BZ5hJHGweIMBQcnOcc4UR35xe94aaywrULsbUfA8n_qNkVvtJbU0-EKMG2cadbhc6AHm06yyr8WpPEhZUvcVwlBFWNYnU9X6KErwyTE3oEe0yx-mOxWacIwWUbblQRjElil6PXICoR-0J6I4GfkPFRyHyja4EJz4IK_Ik-vr1Lw_CoAbpSQij2eIj54HfeE41TBJceoMNGJq8hJO_ybL-CKma1hNJ_sQ3Jc9h5uS8Y5oEig"></iframe>
该IFrame中的内容以下:
<!DOCTYPE html> <html> <style>iframe{display:none;width:0;height:0;}</style> <body> <iframe src='http://localhost:5002/signout-oidc?sid=854b9825a8cf571f7995e1ebafde8d37&iss=http%3A%2F%2Foidc.faasx.com'> </iframe> </body></html>
在用户毫无感受的状况下,调用咱们的应用服务器中配置的回调地址OpenIdConnectOptions.RemoteSignOutPath
,清除本地的Cookie,实现同步退出:
protected virtual async Task<bool> HandleRemoteSignOutAsync() { OpenIdConnectMessage message = null; if (string.Equals(Request.Method, "GET", StringComparison.OrdinalIgnoreCase)) { message = new OpenIdConnectMessage(Request.Query.Select(pair => new KeyValuePair<string, string[]>(pair.Key, pair.Value))); } ... var remoteSignOutContext = new RemoteSignOutContext(Context, Scheme, Options, message); await Events.RemoteSignOut(remoteSignOutContext); ... await Context.SignOutAsync(Options.SignOutScheme); return true; }
而上图中的here
连接则跳转到OpenIdConnectOptions.SignedOutCallbackPath
,执行HandleSignOutCallbackAsync
方法,咱们能够经过注册事件的方式来附加一些业务逻辑:
protected async virtual Task<bool> HandleSignOutCallbackAsync() { var message = new OpenIdConnectMessage(Request.Query.Select(pair => new KeyValuePair<string, string[]>(pair.Key, pair.Value))); ... var signOut = new RemoteSignOutContext(Context, Scheme, Options, message) { Properties = properties }; await Events.SignedOutCallbackRedirect(signOut); ... if (!string.IsNullOrEmpty(properties?.RedirectUri)) { Response.Redirect(properties.RedirectUri); } return true; }
总结
本文简单介绍了OAuth和OpenID Connect的基本概念以及它们在 ASP.NET Core 中做为认证客户端的实现,若是咱们只须要 "访问第三方资源" 的受权,使用OAuth认证便可。而在咱们须要对本身的多个应用进行统一的身份验证时,应该使用OpenID Connect来实现,OpenID Connect不只包含身份验证,还包含OAuth的受权协议,是更加推荐的作法。在下一章中来介绍一下另外一种本地认证方式:JWTBearer,也是在现代Web应用中比较流行的认证方式。