.Net 5.0 通过IdentityServer4实现单点登录之客户端通过授权码换取IdToken和AcessToken并访问Api部分源码解析
接着上文.Net 5.0 通过IdentityServer4实现单点登录之id4部分源码解析,id4服务端通过配置参数和客户端传递得参数生成了一个自动提交表单的html页面,并携带以下参数code、scope、state、session_state,调用了客户端/signin-oidc方法,那么接下去继续解析剩余流程.
首先id4服务端触发了/signin-oidc 方法,请求会先被认证中间件拦截源码如下:
var handlers = context.RequestServices.GetRequiredService<IAuthenticationHandlerProvider>(); foreach (var scheme in await Schemes.GetRequestHandlerSchemesAsync()) { //这里oidc流程是远程调用的一种,所以会执行OpenIdConnectHandler实例的HandleRequestAsync方法 var handler = await handlers.GetHandlerAsync(context, scheme.Name) as IAuthenticationRequestHandler; if (handler != null && await handler.HandleRequestAsync()) { return; } }
HandlerRequestAsync方法逻辑如下:
public override async Task<bool> HandleRequestAsync() { //判断请求的路径是否是远程登出方法 /signout-oidc if (Options.RemoteSignOutPath.HasValue && Options.RemoteSignOutPath == Request.Path) { //执行远程登出 return await HandleRemoteSignOutAsync(); } //判断请求的路径是否是远程登出回调 /signout-callback-oidc else if (Options.SignedOutCallbackPath.HasValue && Options.SignedOutCallbackPath == Request.Path) { //执行远程登出回调 return await HandleSignOutCallbackAsync(); } //判断请求的路径是否是远程登录 /signin-oidc else if (Options.CallbackPath == Request.Path) { AuthenticationTicket? ticket = null; Exception? exception = null; AuthenticationProperties? properties = null; try { var authResult = await HandleRemoteAuthenticateAsync(); if (authResult == null) { exception = new InvalidOperationException("Invalid return state, unable to redirect."); } else if (authResult.Handled) { return true; } else if (authResult.Skipped || authResult.None) { return false; } else if (!authResult.Succeeded) { exception = authResult.Failure ?? new InvalidOperationException("Invalid return state, unable to redirect."); properties = authResult.Properties; } ticket = authResult?.Ticket; } catch (Exception ex) { exception = ex; } if (exception != null) { var errorContext = new RemoteFailureContext(Context, Scheme, Options, exception) { Properties = properties }; await Events.RemoteFailure(errorContext); if (errorContext.Result != null) { if (errorContext.Result.Handled) { return true; } else if (errorContext.Result.Skipped) { return false; } else if (errorContext.Result.Failure != null) { throw new Exception("An error was returned from the RemoteFailure event.", errorContext.Result.Failure); } } if (errorContext.Failure != null) { throw new Exception("An error was encountered while handling the remote login.", errorContext.Failure); } } var ticketContext = new TicketReceivedContext(Context, Scheme, Options, ticket) { ReturnUri = ticket.Properties.RedirectUri }; ticket.Properties.RedirectUri = null; // Mark which provider produced this identity so we can cross-check later in HandleAuthenticateAsync //ticketContext.Properties.Items[AuthSchemeKey] = Scheme.Name; await Events.TicketReceived(ticketContext); if (ticketContext.Result != null) { if (ticketContext.Result.Handled) { return true; } else if (ticketContext.Result.Skipped) { return false; } } await Context.SignInAsync(SignInScheme, ticketContext.Principal!, ticketContext.Properties); // Default redirect path is the base path if (string.IsNullOrEmpty(ticketContext.ReturnUri)) { ticketContext.ReturnUri = "/"; } Response.Redirect(ticketContext.ReturnUri); return true; } else { return false; } }
这里为了稍微改动了源码,少贴一些源码图,这里很明显进入最后一个else if,进入远程认证流程,源码如下:
protected override async Task<HandleRequestResult> HandleRemoteAuthenticateAsync() { OpenIdConnectMessage authorizationResponse = null; if (HttpMethods.IsGet(Request.Method)) { authorizationResponse = new OpenIdConnectMessage(Request.Query.Select(pair => new KeyValuePair<string, string[]>(pair.Key, pair.Value))); // response_mode=query (explicit or not) and a response_type containing id_token // or token are not considered as a safe combination and MUST be rejected. // See http://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#Security if (!string.IsNullOrEmpty(authorizationResponse.IdToken) || !string.IsNullOrEmpty(authorizationResponse.AccessToken)) { if (Options.SkipUnrecognizedRequests) { // Not for us? return HandleRequestResult.SkipHandler(); } return HandleRequestResult.Fail("An OpenID Connect response cannot contain an " + "identity token or an access token when using response_mode=query"); } } // assumption: if the ContentType is "application/x-www-form-urlencoded" it should be safe to read as it is small. else if (HttpMethods.IsPost(Request.Method) && !string.IsNullOrEmpty(Request.ContentType) // May have media/type; charset=utf-8, allow partial match. && Request.ContentType.StartsWith("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase) && Request.Body.CanRead) { var form = await Request.ReadFormAsync(); authorizationResponse = new OpenIdConnectMessage(form.Select(pair => new KeyValuePair<string, string[]>(pair.Key, pair.Value))); } if (authorizationResponse == null) { if (Options.SkipUnrecognizedRequests) { // Not for us? return HandleRequestResult.SkipHandler(); } return HandleRequestResult.Fail("No message."); } AuthenticationProperties properties = null; try { properties = ReadPropertiesAndClearState(authorizationResponse); var messageReceivedContext = await RunMessageReceivedEventAsync(authorizationResponse, properties); if (messageReceivedContext.Result != null) { return messageReceivedContext.Result; } authorizationResponse = messageReceivedContext.ProtocolMessage; properties = messageReceivedContext.Properties; if (properties == null || properties.Items.Count == 0) { // Fail if state is missing, it's required for the correlation id. if (string.IsNullOrEmpty(authorizationResponse.State)) { // This wasn't a valid OIDC message, it may not have been intended for us. Logger.NullOrEmptyAuthorizationResponseState(); if (Options.SkipUnrecognizedRequests) { return HandleRequestResult.SkipHandler(); } return HandleRequestResult.Fail("OpenIdConnectAuthenticationHandler: message.State is null or empty."); } properties = ReadPropertiesAndClearState(authorizationResponse); } if (properties == null) { Logger.UnableToReadAuthorizationResponseState(); if (Options.SkipUnrecognizedRequests) { // Not for us? return HandleRequestResult.SkipHandler(); } // if state exists and we failed to 'unprotect' this is not a message we should process. return HandleRequestResult.Fail("Unable to unprotect the message.State."); } if (!ValidateCorrelationId(properties)) { return HandleRequestResult.Fail("Correlation failed.", properties); } // if any of the error fields are set, throw error null if (!string.IsNullOrEmpty(authorizationResponse.Error)) { // Note: access_denied errors are special protocol errors indicating the user didn't // approve the authorization demand requested by the remote authorization server. // Since it's a frequent scenario (that is not caused by incorrect configuration), // denied errors are handled differently using HandleAccessDeniedErrorAsync(). // Visit https://tools.ietf.org/html/rfc6749#section-4.1.2.1 for more information. if (string.Equals(authorizationResponse.Error, "access_denied", StringComparison.Ordinal)) { var result = await HandleAccessDeniedErrorAsync(properties); if (!result.None) { return result; } } return HandleRequestResult.Fail(CreateOpenIdConnectProtocolException(authorizationResponse, response: null), properties); } if (_configuration == null && Options.ConfigurationManager != null) { Logger.UpdatingConfiguration(); _configuration = await Options.ConfigurationManager.GetConfigurationAsync(Context.RequestAborted); } PopulateSessionProperties(authorizationResponse, properties); ClaimsPrincipal user = null; JwtSecurityToken jwt = null; string nonce = null; var validationParameters = Options.TokenValidationParameters.Clone(); // Hybrid or Implicit flow if (!string.IsNullOrEmpty(authorizationResponse.IdToken)) { Logger.ReceivedIdToken(); user = ValidateToken(authorizationResponse.IdToken, properties, validationParameters, out jwt); nonce = jwt.Payload.Nonce; if (!string.IsNullOrEmpty(nonce)) { nonce = ReadNonceCookie(nonce); } var tokenValidatedContext = await RunTokenValidatedEventAsync(authorizationResponse, null, user, properties, jwt, nonce); if (tokenValidatedContext.Result != null) { return tokenValidatedContext.Result; } authorizationResponse = tokenValidatedContext.ProtocolMessage; user = tokenValidatedContext.Principal; properties = tokenValidatedContext.Properties; jwt = tokenValidatedContext.SecurityToken; nonce = tokenValidatedContext.Nonce; } Options.ProtocolValidator.ValidateAuthenticationResponse(new OpenIdConnectProtocolValidationContext() { ClientId = Options.ClientId, ProtocolMessage = authorizationResponse, ValidatedIdToken = jwt, Nonce = nonce }); OpenIdConnectMessage tokenEndpointResponse = null; // Authorization Code or Hybrid flow if (!string.IsNullOrEmpty(authorizationResponse.Code)) { var authorizationCodeReceivedContext = await RunAuthorizationCodeReceivedEventAsync(authorizationResponse, user, properties, jwt); if (authorizationCodeReceivedContext.Result != null) { return authorizationCodeReceivedContext.Result; } authorizationResponse = authorizationCodeReceivedContext.ProtocolMessage; user = authorizationCodeReceivedContext.Principal; properties = authorizationCodeReceivedContext.Properties; var tokenEndpointRequest = authorizationCodeReceivedContext.TokenEndpointRequest; // If the developer redeemed the code themselves... tokenEndpointResponse = authorizationCodeReceivedContext.TokenEndpointResponse; jwt = authorizationCodeReceivedContext.JwtSecurityToken; if (!authorizationCodeReceivedContext.HandledCodeRedemption) { tokenEndpointResponse = await RedeemAuthorizationCodeAsync(tokenEndpointRequest); } var tokenResponseReceivedContext = await RunTokenResponseReceivedEventAsync(authorizationResponse, tokenEndpointResponse, user, properties); if (tokenResponseReceivedContext.Result != null) { return tokenResponseReceivedContext.Result; } authorizationResponse = tokenResponseReceivedContext.ProtocolMessage; tokenEndpointResponse = tokenResponseReceivedContext.TokenEndpointResponse; user = tokenResponseReceivedContext.Principal; properties = tokenResponseReceivedContext.Properties; // no need to validate signature when token is received using "code flow" as per spec // [http://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation]. validationParameters.RequireSignedTokens = false; // At least a cursory validation is required on the new IdToken, even if we've already validated the one from the authorization response. // And we'll want to validate the new JWT in ValidateTokenResponse. var tokenEndpointUser = ValidateToken(tokenEndpointResponse.IdToken, properties, validationParameters, out var tokenEndpointJwt); // Avoid reading & deleting the nonce cookie, running the event, etc, if it was already done as part of the authorization response validation. if (user == null) { nonce = tokenEndpointJwt.Payload.Nonce; if (!string.IsNullOrEmpty(nonce)) { nonce = ReadNonceCookie(nonce); } var tokenValidatedContext = await RunTokenValidatedEventAsync(authorizationResponse, tokenEndpointResponse, tokenEndpointUser, properties, tokenEndpointJwt, nonce); if (tokenValidatedContext.Result != null) { return tokenValidatedContext.Result; } authorizationResponse = tokenValidatedContext.ProtocolMessage; tokenEndpointResponse = tokenValidatedContext.TokenEndpointResponse; user = tokenValidatedContext.Principal; properties = tokenValidatedContext.Properties; jwt = tokenValidatedContext.SecurityToken; nonce = tokenValidatedContext.Nonce; } else { if (!string.Equals(jwt.Subject, tokenEndpointJwt.Subject, StringComparison.Ordinal)) { throw new SecurityTokenException("The sub claim does not match in the id_token's from the authorization and token endpoints."); } jwt = tokenEndpointJwt; } // Validate the token response if it wasn't provided manually if (!authorizationCodeReceivedContext.HandledCodeRedemption) { Options.ProtocolValidator.ValidateTokenResponse(new OpenIdConnectProtocolValidationContext() { ClientId = Options.ClientId, ProtocolMessage = tokenEndpointResponse, ValidatedIdToken = jwt, Nonce = nonce }); } } if (Options.SaveTokens) { SaveTokens(properties, tokenEndpointResponse ?? authorizationResponse); } if (Options.GetClaimsFromUserInfoEndpoint) { return await GetUserInformationAsync(tokenEndpointResponse ?? authorizationResponse, jwt, user, properties); } else { using (var payload = JsonDocument.Parse("{}")) { var identity = (ClaimsIdentity)user.Identity; foreach (var action in Options.ClaimActions) { action.Run(payload.RootElement, identity, ClaimsIssuer); } } } return HandleRequestResult.Success(new AuthenticationTicket(user, properties, Scheme.Name)); } catch (Exception exception) { Logger.ExceptionProcessingMessage(exception); // Refresh the configuration for exceptions that may be caused by key rollovers. The user can also request a refresh in the event. if (Options.RefreshOnIssuerKeyNotFound && exception is SecurityTokenSignatureKeyNotFoundException) { if (Options.ConfigurationManager != null) { Logger.ConfigurationManagerRequestRefreshCalled(); Options.ConfigurationManager.RequestRefresh(); } } var authenticationFailedContext = await RunAuthenticationFailedEventAsync(authorizationResponse, exception); if (authenticationFailedContext.Result != null) { return authenticationFailedContext.Result; } return HandleRequestResult.Fail(exception, properties); } }
下面分析下这里到底干了什么
1、从前文Id4生成的html的form表单中提取表单参数code、scope、state、session_state值,写入OpenIdConnectMessage实例值
2、解密客户端自身传递的state值,解析出AuthenticationProperties值
3、处理了CorrelationId相关
4、去id4服务端同步一下配置
5、带着下图的参数去id4服务的connect/token节点获取idtoken和acess_token
接着分析id4服务的connect/token节点的执行流程
1、校验客户端的合法性
2、校验授权码的有效性,并根据code去IPersistedGrantStore查找真正的授权码实例 是否过期等等
3、校验codeChallengeMethod和codeChallenge值是否有效(PKCE模式)
private bool ValidateCodeVerifierAgainstCodeChallenge(string codeVerifier, string codeChallenge, string codeChallengeMethod) { if (codeChallengeMethod == OidcConstants.CodeChallengeMethods.Plain) { return TimeConstantComparer.IsEqual(codeVerifier.Sha256(), codeChallenge); } var codeVerifierBytes = Encoding.ASCII.GetBytes(codeVerifier); var hashedBytes = codeVerifierBytes.Sha256(); var transformedCodeVerifier = Base64Url.Encode(hashedBytes); return TimeConstantComparer.IsEqual(transformedCodeVerifier.Sha256(), codeChallenge); }
这里结合源码分析下pkce的实现流程,大致如下
在客户端生成随机16位code_verifier,sha256加密生成code_challenge传给id4服务换取授权码.code_challenge会在id4做存储,这时客户端会在cookie中写入一个加密的state只有客户端能解,state里面存了code_verifier,接着客户端去id4拿令牌前,会解密state,拿到code_verifier,传给id4,在通过code_challenge_method加密和之前存储的进行比较.
demo中code应为是mvc客户端,属于机密客户端,所以code是post传输,相对安全.
4、判断当前用户是否处于活跃状态
5、最后生成idtoken和access_token 这里应为简单且冗长,不分析了.
6、返回如下信息到客户端