zoukankan      html  css  js  c++  java
  • AspNetCore3.1_Secutiry源码解析_6_Authentication_OpenIdConnect

    文章目录

    oidc简介

    oidc是基于oauth2.0的上层协议。

    OAuth有点像卖电影票的,只关心用户能不能进电影院,不关心用户是谁。而oidc则像身份证,扫描就可以上飞机,一次扫描,机场不仅能知道你是否能上飞机,还可以知道你的身份信息。

    oidc兼容OAuth2.0, 可以实现跨顶级域的SSO(单点登录、登出),下个系列要学习的IdentityServer4就是对oidc协议族的一个具体实现框架。

    更多理论知识看下面的参考资料,本系列主要过下源码脉络

    博客园

    https://www.cnblogs.com/linianhui/p/openid-connect-core.html

    协议

    https://openid.net/connect/

    依赖注入

    默认架构名称是OpenIdConnect,处理器类是OpenIdConnectHandler,配置类是OpenIdConnectOptions

    public static AuthenticationBuilder AddOpenIdConnect(this AuthenticationBuilder builder)
            => builder.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, _ => { });
    
        public static AuthenticationBuilder AddOpenIdConnect(this AuthenticationBuilder builder, Action<OpenIdConnectOptions> configureOptions)
            => builder.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, configureOptions);
    
        public static AuthenticationBuilder AddOpenIdConnect(this AuthenticationBuilder builder, string authenticationScheme, Action<OpenIdConnectOptions> configureOptions)
            => builder.AddOpenIdConnect(authenticationScheme, OpenIdConnectDefaults.DisplayName, configureOptions);
    
        public static AuthenticationBuilder AddOpenIdConnect(this AuthenticationBuilder builder, string authenticationScheme, string displayName, Action<OpenIdConnectOptions> configureOptions)
        {
            builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IPostConfigureOptions<OpenIdConnectOptions>, OpenIdConnectPostConfigureOptions>());
            return builder.AddRemoteScheme<OpenIdConnectOptions, OpenIdConnectHandler>(authenticationScheme, displayName, configureOptions);
        }
    

    配置类 - OpenIdConnectOptions

    构造函数

    CallbackPath: 回调地址,即远程认证之后跳回的地址
    SignedOutCallbackPath:登出后的回调地址
    RemoteSignOutPath:远程登出地址

    scope添加openid(用户id),profile(用户基本信息),所以如果client没有这两个基本的权限是会被远程认证拒绝的。

    删除了nonce,aud等claim,添加了sub(用户id,必须有),name,profile,email等claim。MapUniqueJsonKey方法的意思是如果某claim无值,远程认证服务返回的用户json数据中中存在此key且有值,则将值插入claim中,否则什么也不做。

    然后new了防重放攻击的nonce cookie。

    public OpenIdConnectOptions()
    {
        CallbackPath = new PathString("/signin-oidc");
        SignedOutCallbackPath = new PathString("/signout-callback-oidc");
        RemoteSignOutPath = new PathString("/signout-oidc");
    
        Events = new OpenIdConnectEvents();
        Scope.Add("openid");
        Scope.Add("profile");
    
        ClaimActions.DeleteClaim("nonce");
        ClaimActions.DeleteClaim("aud");
        ClaimActions.DeleteClaim("azp");
        ClaimActions.DeleteClaim("acr");
        ClaimActions.DeleteClaim("iss");
        ClaimActions.DeleteClaim("iat");
        ClaimActions.DeleteClaim("nbf");
        ClaimActions.DeleteClaim("exp");
        ClaimActions.DeleteClaim("at_hash");
        ClaimActions.DeleteClaim("c_hash");
        ClaimActions.DeleteClaim("ipaddr");
        ClaimActions.DeleteClaim("platf");
        ClaimActions.DeleteClaim("ver");
    
        // http://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
        ClaimActions.MapUniqueJsonKey("sub", "sub");
        ClaimActions.MapUniqueJsonKey("name", "name");
        ClaimActions.MapUniqueJsonKey("given_name", "given_name");
        ClaimActions.MapUniqueJsonKey("family_name", "family_name");
        ClaimActions.MapUniqueJsonKey("profile", "profile");
        ClaimActions.MapUniqueJsonKey("email", "email");
    
        _nonceCookieBuilder = new OpenIdConnectNonceCookieBuilder(this)
        {
            Name = OpenIdConnectDefaults.CookieNoncePrefix,
            HttpOnly = true,
            SameSite = SameSiteMode.None,
            SecurePolicy = CookieSecurePolicy.SameAsRequest,
            IsEssential = true,
        };
    }
    

    配置校验 - Validate

    父类RemoteAuthenticationOptions会校验SignInSchema不允许与当前Schema相同(SignInSchema微软只提供了Cookie的实现,登录似乎除了Cookie没有别的方式可以维持登录态?)

    校验max-age不能为负数

    ClientId不能为空

    CallbackPath必须有值

    ConfigurationManager不能为null

    public override void Validate()
    {
        base.Validate();
    
        if (MaxAge.HasValue && MaxAge.Value < TimeSpan.Zero)
        {
            throw new ArgumentOutOfRangeException(nameof(MaxAge), MaxAge.Value, "The value must not be a negative TimeSpan.");
        }
    
        if (string.IsNullOrEmpty(ClientId))
        {
            throw new ArgumentException("Options.ClientId must be provided", nameof(ClientId));
        }
    
        if (!CallbackPath.HasValue)
        {
            throw new ArgumentException("Options.CallbackPath must be provided.", nameof(CallbackPath));
        }
    
        if (ConfigurationManager == null)
        {
            throw new InvalidOperationException($"Provide {nameof(Authority)}, {nameof(MetadataAddress)}, "
            + $"{nameof(Configuration)}, or {nameof(ConfigurationManager)} to {nameof(OpenIdConnectOptions)}");
        }
    }
    

    属性

    /// <summary>
    /// Gets or sets timeout value in milliseconds for back channel communications with the remote identity provider.
    /// </summary>
    /// <value>
    /// The back channel timeout.
    /// </value>
    public TimeSpan BackchannelTimeout { get; set; } = TimeSpan.FromSeconds(60);
    
    /// <summary>
    /// The HttpMessageHandler used to communicate with remote identity provider.
    /// This cannot be set at the same time as BackchannelCertificateValidator unless the value 
    /// can be downcast to a WebRequestHandler.
    /// </summary>
    public HttpMessageHandler BackchannelHttpHandler { get; set; }
    
    /// <summary>
    /// Used to communicate with the remote identity provider.
    /// </summary>
    public HttpClient Backchannel { get; set; }
    
    /// <summary>
    /// Gets or sets the type used to secure data.
    /// </summary>
    public IDataProtectionProvider DataProtectionProvider { get; set; }
    
    /// <summary>
    /// The request path within the application's base path where the user-agent will be returned.
    /// The middleware will process this request when it arrives.
    /// </summary>
    public PathString CallbackPath { get; set; }
    
    /// <summary>
    /// Gets or sets the optional path the user agent is redirected to if the user
    /// doesn't approve the authorization demand requested by the remote server.
    /// This property is not set by default. In this case, an exception is thrown
    /// if an access_denied response is returned by the remote authorization server.
    /// </summary>
    public PathString AccessDeniedPath { get; set; }
    
    /// <summary>
    /// Gets or sets the name of the parameter used to convey the original location
    /// of the user before the remote challenge was triggered up to the access denied page.
    /// This property is only used when the <see cref="AccessDeniedPath"/> is explicitly specified.
    /// </summary>
    // Note: this deliberately matches the default parameter name used by the cookie handler.
    public string ReturnUrlParameter { get; set; } = "ReturnUrl";
    
    /// <summary>
    /// Gets or sets the authentication scheme corresponding to the middleware
    /// responsible of persisting user's identity after a successful authentication.
    /// This value typically corresponds to a cookie middleware registered in the Startup class.
    /// When omitted, <see cref="AuthenticationOptions.DefaultSignInScheme"/> is used as a fallback value.
    /// </summary>
    public string SignInScheme { get; set; }
    
    /// <summary>
    /// Gets or sets the time limit for completing the authentication flow (15 minutes by default).
    /// </summary>
    public TimeSpan RemoteAuthenticationTimeout { get; set; } = TimeSpan.FromMinutes(15);
    
    public new RemoteAuthenticationEvents Events
    {
        get => (RemoteAuthenticationEvents)base.Events;
        set => base.Events = value;
    }
    
    /// <summary>
    /// Defines whether access and refresh tokens should be stored in the
    /// <see cref="AuthenticationProperties"/> after a successful authorization.
    /// This property is set to <c>false</c> by default to reduce
    /// the size of the final authentication cookie.
    /// </summary>
    public bool SaveTokens { get; set; }
    
    /// <summary>
    /// Determines the settings used to create the correlation cookie before the
    /// cookie gets added to the response.
    /// </summary>
    public CookieBuilder CorrelationCookie
    {
        get => _correlationCookieBuilder;
        set => _correlationCookieBuilder = value ?? throw new ArgumentNullException(nameof(value));
    }
    

    配置后处理逻辑 - OpenIdConnectPostConfigureOptions

    主要处理如果DataProtectionProvider,StateDataFormat等对象没有配置的话,则构造默认实现类。options.MetadataAddress += ".well-known/openid-configuration",这是配置的元数据地址,描述了oidc的所有接口地址和其他信息。

    public class OpenIdConnectPostConfigureOptions : IPostConfigureOptions<OpenIdConnectOptions>
    {
        private readonly IDataProtectionProvider _dp;
    
        public OpenIdConnectPostConfigureOptions(IDataProtectionProvider dataProtection)
        {
            _dp = dataProtection;
        }
    
        /// <summary>
        /// Invoked to post configure a TOptions instance.
        /// </summary>
        /// <param name="name">The name of the options instance being configured.</param>
        /// <param name="options">The options instance to configure.</param>
        public void PostConfigure(string name, OpenIdConnectOptions options)
        {
            options.DataProtectionProvider = options.DataProtectionProvider ?? _dp;
    
            if (string.IsNullOrEmpty(options.SignOutScheme))
            {
                options.SignOutScheme = options.SignInScheme;
            }
    
            if (options.StateDataFormat == null)
            {
                var dataProtector = options.DataProtectionProvider.CreateProtector(
                    typeof(OpenIdConnectHandler).FullName, name, "v1");
                options.StateDataFormat = new PropertiesDataFormat(dataProtector);
            }
    
            if (options.StringDataFormat == null)
            {
                var dataProtector = options.DataProtectionProvider.CreateProtector(
                    typeof(OpenIdConnectHandler).FullName,
                    typeof(string).FullName,
                    name,
                    "v1");
    
                options.StringDataFormat = new SecureDataFormat<string>(new StringSerializer(), dataProtector);
            }
    
            if (string.IsNullOrEmpty(options.TokenValidationParameters.ValidAudience) && !string.IsNullOrEmpty(options.ClientId))
            {
                options.TokenValidationParameters.ValidAudience = options.ClientId;
            }
    
            if (options.Backchannel == null)
            {
                options.Backchannel = new HttpClient(options.BackchannelHttpHandler ?? new HttpClientHandler());
                options.Backchannel.DefaultRequestHeaders.UserAgent.ParseAdd("Microsoft ASP.NET Core OpenIdConnect handler");
                options.Backchannel.Timeout = options.BackchannelTimeout;
                options.Backchannel.MaxResponseContentBufferSize = 1024 * 1024 * 10; // 10 MB
            }
    
            if (options.ConfigurationManager == null)
            {
                if (options.Configuration != null)
                {
                    options.ConfigurationManager = new StaticConfigurationManager<OpenIdConnectConfiguration>(options.Configuration);
                }
                else if (!(string.IsNullOrEmpty(options.MetadataAddress) && string.IsNullOrEmpty(options.Authority)))
                {
                    if (string.IsNullOrEmpty(options.MetadataAddress) && !string.IsNullOrEmpty(options.Authority))
                    {
                        options.MetadataAddress = options.Authority;
                        if (!options.MetadataAddress.EndsWith("/", StringComparison.Ordinal))
                        {
                            options.MetadataAddress += "/";
                        }
    
                        options.MetadataAddress += ".well-known/openid-configuration";
                    }
    
                    if (options.RequireHttpsMetadata && !options.MetadataAddress.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
                    {
                        throw new InvalidOperationException("The MetadataAddress or Authority must use HTTPS unless disabled for development by setting RequireHttpsMetadata=false.");
                    }
    
                    options.ConfigurationManager = new ConfigurationManager<OpenIdConnectConfiguration>(options.MetadataAddress, new OpenIdConnectConfigurationRetriever(),
                        new HttpDocumentRetriever(options.Backchannel) { RequireHttps = options.RequireHttpsMetadata });
                }
            }
        }
    
        private class StringSerializer : IDataSerializer<string>
        {
            public string Deserialize(byte[] data)
            {
                return Encoding.UTF8.GetString(data);
            }
    
            public byte[] Serialize(string model)
            {
                return Encoding.UTF8.GetBytes(model);
            }
        }
    

    处理器类 - OpenIdConnectHandler

    处理认证 - HandRemoteAuthenticate

    oidc登录示例图

    sequenceDiagram mysite->>sso: GET connect/authorize?callback(clientId,redirect_uri,response_type)scope,state,nonce sso->>mysite: Form.POST mysite/signin-oidc (code,id_token,scope,state)

    代码解析

    mysite向oidc的认证节点地址/connect/authorize发送请求,oidc站点根据response_mode用get或者form_post方式调用mysite的回调地址mysite/signin-oidc,HandleRemoteAuthenticateAsync就是处理oidc站点的响应的方法。

    • 判断GET/POST,从请求中提取参数,如果是get请求,id_token,access_token不允许放在query中
    • 从state参数读取信息放到properties
    • 校验correlationId,防跨站伪造攻击
    • 如果返回了id_token,校验token,将信息写入HttpContext
    • 如果返回了授权码code的处理

    代码量还是比较多,有些地方目前还不是特别理解,需求后面熟悉协议内容在回过头来看下。总体上就是对oidc站点返回信息的校验和处理。

    /// <summary>
    /// Invoked to process incoming OpenIdConnect messages.
    /// </summary>
    /// <returns>An <see cref="HandleRequestResult"/>.</returns>
    protected override async Task<HandleRequestResult> HandleRemoteAuthenticateAsync()
    {
        Logger.EnteringOpenIdAuthenticationHandlerHandleRemoteAuthenticateAsync(GetType().FullName);
    
        OpenIdConnectMessage authorizationResponse = null;
    
        if (string.Equals(Request.Method, "GET", StringComparison.OrdinalIgnoreCase))
        {
            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 (string.Equals(Request.Method, "POST", StringComparison.OrdinalIgnoreCase)
            && !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(Resources.MessageStateIsNullOrEmpty);
                }
    
                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(Resources.MessageStateIsInvalid);
            }
    
            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);
        }
    }
    

    处理远程登出 - HandleRemoteSignOutAsync

    OpenIdConectHandler跟OAuthHandler一样,继承自RemoteAuthenticationHandler,但是OpenId还实现了IAuthenticationSignOutHandler接口,因为OpenId是支持单点登录登出的,本地登出之后需要通知认证服务远程登出(注销本地站点Cookie),这样实现帐号的同步登出(注销sso站点cookie)。

    • 远程登出支持GET和Form-Post两种提交方式,客户端根据请求方式,将报文拼装好。
    • 触发远程登出事件
    • 使用SignOutScheme认证,得到身份信息 - Context.AuthenticateAsync(Options.SignOutScheme)
    • Context.Proerties中必须有iss信息,issuer就是提供认证方
    • 调用本地登出方法 - Context.SignOutAsync(Options.SignOutScheme)
    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)));
        }
    
        // assumption: if the ContentType is "application/x-www-form-urlencoded" it should be safe to read as it is small.
        else if (string.Equals(Request.Method, "POST", StringComparison.OrdinalIgnoreCase)
            && !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();
            message = new OpenIdConnectMessage(form.Select(pair => new KeyValuePair<string, string[]>(pair.Key, pair.Value)));
        }
    
        var remoteSignOutContext = new RemoteSignOutContext(Context, Scheme, Options, message);
        await Events.RemoteSignOut(remoteSignOutContext);
    
        if (remoteSignOutContext.Result != null)
        {
            if (remoteSignOutContext.Result.Handled)
            {
                Logger.RemoteSignOutHandledResponse();
                return true;
            }
            if (remoteSignOutContext.Result.Skipped)
            {
                Logger.RemoteSignOutSkipped();
                return false;
            }
            if (remoteSignOutContext.Result.Failure != null)
            {
                throw new InvalidOperationException("An error was returned from the RemoteSignOut event.", remoteSignOutContext.Result.Failure);
            }
        }
    
        if (message == null)
        {
            return false;
        }
    
        // Try to extract the session identifier from the authentication ticket persisted by the sign-in handler.
        // If the identifier cannot be found, bypass the session identifier checks: this may indicate that the
        // authentication cookie was already cleared, that the session identifier was lost because of a lossy
        // external/application cookie conversion or that the identity provider doesn't support sessions.
        var principal = (await Context.AuthenticateAsync(Options.SignOutScheme))?.Principal;
    
        var sid = principal?.FindFirst(JwtRegisteredClaimNames.Sid)?.Value;
        if (!string.IsNullOrEmpty(sid))
        {
            // Ensure a 'sid' parameter was sent by the identity provider.
            if (string.IsNullOrEmpty(message.Sid))
            {
                Logger.RemoteSignOutSessionIdMissing();
                return true;
            }
            // Ensure the 'sid' parameter corresponds to the 'sid' stored in the authentication ticket.
            if (!string.Equals(sid, message.Sid, StringComparison.Ordinal))
            {
                Logger.RemoteSignOutSessionIdInvalid();
                return true;
            }
        }
    
        var iss = principal?.FindFirst(JwtRegisteredClaimNames.Iss)?.Value;
        if (!string.IsNullOrEmpty(iss))
        {
            // Ensure a 'iss' parameter was sent by the identity provider.
            if (string.IsNullOrEmpty(message.Iss))
            {
                Logger.RemoteSignOutIssuerMissing();
                return true;
            }
            // Ensure the 'iss' parameter corresponds to the 'iss' stored in the authentication ticket.
            if (!string.Equals(iss, message.Iss, StringComparison.Ordinal))
            {
                Logger.RemoteSignOutIssuerInvalid();
                return true;
            }
        }
    
        Logger.RemoteSignOut();
    
        // We've received a remote sign-out request
        await Context.SignOutAsync(Options.SignOutScheme);
        return true;
    }
    

    处理本地登出 - Context.SignOutAsync(Options.SignOutScheme)

    方法的注释:将用户重定向到身份认证站点登出。

    • ForwardXXX是所有认证配置项的基类,可以拦截使用自己配置的Scheme。
    • 构造要发送给oidc服务的报文,包括IssuerAddress(EndSessionEndpoint:即结束会话节点地址),PostLogoutRedirectUri(登出回跳地址)等。
    • 构造RedirectUri(登录流程结束最终回到的地址):优先使用HttpContext.Properties中的RedirectUri,然后使用配置中的SignedOutRedirectUri,最后使用请求源地址。
    • 获取IdToken,放到登出请求中
    • state字段加密后(包含了redirecturi等信息),放入请求消息
    • 给oidc站点发送GET或者FormPost请求
    /// <summary>
    /// Redirect user to the identity provider for sign out
    /// </summary>
    /// <returns>A task executing the sign out procedure</returns>
    public async virtual Task SignOutAsync(AuthenticationProperties properties)
    {
        var target = ResolveTarget(Options.ForwardSignOut);
        if (target != null)
        {
            await Context.SignOutAsync(target, properties);
            return;
        }
    
        properties = properties ?? new AuthenticationProperties();
    
        Logger.EnteringOpenIdAuthenticationHandlerHandleSignOutAsync(GetType().FullName);
    
        if (_configuration == null && Options.ConfigurationManager != null)
        {
            _configuration = await Options.ConfigurationManager.GetConfigurationAsync(Context.RequestAborted);
        }
    
        var message = new OpenIdConnectMessage()
        {
            EnableTelemetryParameters = !Options.DisableTelemetry,
            IssuerAddress = _configuration?.EndSessionEndpoint ?? string.Empty,
    
            // Redirect back to SigneOutCallbackPath first before user agent is redirected to actual post logout redirect uri
            PostLogoutRedirectUri = BuildRedirectUriIfRelative(Options.SignedOutCallbackPath)
        };
    
        // Get the post redirect URI.
        if (string.IsNullOrEmpty(properties.RedirectUri))
        {
            properties.RedirectUri = BuildRedirectUriIfRelative(Options.SignedOutRedirectUri);
            if (string.IsNullOrWhiteSpace(properties.RedirectUri))
            {
                properties.RedirectUri = OriginalPathBase + OriginalPath + Request.QueryString;
            }
        }
        Logger.PostSignOutRedirect(properties.RedirectUri);
    
        // Attach the identity token to the logout request when possible.
        message.IdTokenHint = await Context.GetTokenAsync(Options.SignOutScheme, OpenIdConnectParameterNames.IdToken);
    
        var redirectContext = new RedirectContext(Context, Scheme, Options, properties)
        {
            ProtocolMessage = message
        };
    
        await Events.RedirectToIdentityProviderForSignOut(redirectContext);
        if (redirectContext.Handled)
        {
            Logger.RedirectToIdentityProviderForSignOutHandledResponse();
            return;
        }
    
        message = redirectContext.ProtocolMessage;
    
        if (!string.IsNullOrEmpty(message.State))
        {
            properties.Items[OpenIdConnectDefaults.UserstatePropertiesKey] = message.State;
        }
    
        message.State = Options.StateDataFormat.Protect(properties);
    
        if (string.IsNullOrEmpty(message.IssuerAddress))
        {
            throw new InvalidOperationException("Cannot redirect to the end session endpoint, the configuration may be missing or invalid.");
        }
    
        if (Options.AuthenticationMethod == OpenIdConnectRedirectBehavior.RedirectGet)
        {
            var redirectUri = message.CreateLogoutRequestUrl();
            if (!Uri.IsWellFormedUriString(redirectUri, UriKind.Absolute))
            {
                Logger.InvalidLogoutQueryStringRedirectUrl(redirectUri);
            }
    
            Response.Redirect(redirectUri);
        }
        else if (Options.AuthenticationMethod == OpenIdConnectRedirectBehavior.FormPost)
        {
            var content = message.BuildFormPost();
            var buffer = Encoding.UTF8.GetBytes(content);
    
            Response.ContentLength = buffer.Length;
            Response.ContentType = "text/html;charset=UTF-8";
    
            // Emit Cache-Control=no-cache to prevent client caching.
            Response.Headers[HeaderNames.CacheControl] = "no-cache, no-store";
            Response.Headers[HeaderNames.Pragma] = "no-cache";
            Response.Headers[HeaderNames.Expires] = HeaderValueEpocDate;
    
            await Response.Body.WriteAsync(buffer, 0, buffer.Length);
        }
        else
        {
            throw new NotImplementedException($"An unsupported authentication method has been configured: {Options.AuthenticationMethod}");
        }
    
        Logger.AuthenticationSchemeSignedOut(Scheme.Name);
    }
    

    oidc处理完后跳到回调地址

    oidc站点处理完登出请求之后(怎么处理的,应该是清除了oidc的cookie,或许回收了token?目前不清楚。后面看identitserver怎么实现的),回跳到callback地址,执行下面的callback方法

    callback方法很简单,就是将state字段解码,将redirect_uri拿到,然后跳过去。

    /// <summary>
    /// Response to the callback from OpenId provider after session ended.
    /// </summary>
    /// <returns>A task executing the callback procedure</returns>
    protected async virtual Task<bool> HandleSignOutCallbackAsync()
    {
        var message = new OpenIdConnectMessage(Request.Query.Select(pair => new KeyValuePair<string, string[]>(pair.Key, pair.Value)));
        AuthenticationProperties properties = null;
        if (!string.IsNullOrEmpty(message.State))
        {
            properties = Options.StateDataFormat.Unprotect(message.State);
        }
    
        var signOut = new RemoteSignOutContext(Context, Scheme, Options, message)
        {
            Properties = properties,
        };
    
        await Events.SignedOutCallbackRedirect(signOut);
        if (signOut.Result != null)
        {
            if (signOut.Result.Handled)
            {
                Logger.SignOutCallbackRedirectHandledResponse();
                return true;
            }
            if (signOut.Result.Skipped)
            {
                Logger.SignOutCallbackRedirectSkipped();
                return false;
            }
            if (signOut.Result.Failure != null)
            {
                throw new InvalidOperationException("An error was returned from the SignedOutCallbackRedirect event.", signOut.Result.Failure);
            }
        }
    
        properties = signOut.Properties;
        if (!string.IsNullOrEmpty(properties?.RedirectUri))
        {
            Response.Redirect(properties.RedirectUri);
        }
    
        return true;
    }
    

    登出时序图

    sequenceDiagram mysite->>sso: GET/FormPost mysite/connect/endsession?params... sso->>mysite: 302,移除sso站点cookie,回调到signout-callback地址 mysite->>mysite: 从state中解析redirect_uri,回跳redirect_uri

    可以看到,oidc的登出只处理了oidc认证站点的cookie,mysite本地的cookie是没有处理的,因为当前schema是OpenIdConnnect,本地Cookie是SignInSchema的事情,所以登出需要掉两次SignOut方法

    HttpContext.SignOutAsync("Cookies"); //清除本地cookie
    HttpContext.SignOutAsync("OpenIdConnect") //清除远程sso站点cookie
    

    处理质询 - HandleChallengeAsync

    • OAuth&PKCE的处理,PKCE = Proof Key for Code Exchange。主要用于NativeApp防跨站攻击的,因为NativeApp没有Cookie支持,无法使用state字段,所以需要其他的安全保障。

    https://tools.ietf.org/html/rfc7636

    • 拼装请求参数,根据配置,如果是GET,302跳转到oidc站点;如果是Form-POST,提交表单到oidc站点。
    /// <summary>
    /// Responds to a 401 Challenge. Sends an OpenIdConnect message to the 'identity authority' to obtain an identity.
    /// </summary>
    /// <returns></returns>
    protected override async Task HandleChallengeAsync(AuthenticationProperties properties)
    {
        await HandleChallengeAsyncInternal(properties);
        var location = Context.Response.Headers[HeaderNames.Location];
        if (location == StringValues.Empty)
        {
            location = "(not set)";
        }
        var cookie = Context.Response.Headers[HeaderNames.SetCookie];
        if (cookie == StringValues.Empty)
        {
            cookie = "(not set)";
        }
        Logger.HandleChallenge(location, cookie);
    }
    
    private async Task HandleChallengeAsyncInternal(AuthenticationProperties properties)
    {
        Logger.EnteringOpenIdAuthenticationHandlerHandleUnauthorizedAsync(GetType().FullName);
    
        // order for local RedirectUri
        // 1. challenge.Properties.RedirectUri
        // 2. CurrentUri if RedirectUri is not set)
        if (string.IsNullOrEmpty(properties.RedirectUri))
        {
            properties.RedirectUri = OriginalPathBase + OriginalPath + Request.QueryString;
        }
        Logger.PostAuthenticationLocalRedirect(properties.RedirectUri);
    
        if (_configuration == null && Options.ConfigurationManager != null)
        {
            _configuration = await Options.ConfigurationManager.GetConfigurationAsync(Context.RequestAborted);
        }
    
        var message = new OpenIdConnectMessage
        {
            ClientId = Options.ClientId,
            EnableTelemetryParameters = !Options.DisableTelemetry,
            IssuerAddress = _configuration?.AuthorizationEndpoint ?? string.Empty,
            RedirectUri = BuildRedirectUri(Options.CallbackPath),
            Resource = Options.Resource,
            ResponseType = Options.ResponseType,
            Prompt = properties.GetParameter<string>(OpenIdConnectParameterNames.Prompt) ?? Options.Prompt,
            Scope = string.Join(" ", properties.GetParameter<ICollection<string>>(OpenIdConnectParameterNames.Scope) ?? Options.Scope),
        };
    
        // https://tools.ietf.org/html/rfc7636
        if (Options.UsePkce && Options.ResponseType == OpenIdConnectResponseType.Code)
        {
            var bytes = new byte[32];
            CryptoRandom.GetBytes(bytes);
            var codeVerifier = Base64UrlTextEncoder.Encode(bytes);
    
            // Store this for use during the code redemption. See RunAuthorizationCodeReceivedEventAsync.
            properties.Items.Add(OAuthConstants.CodeVerifierKey, codeVerifier);
    
            using var sha256 = SHA256.Create();
            var challengeBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier));
            var codeChallenge = WebEncoders.Base64UrlEncode(challengeBytes);
    
            message.Parameters.Add(OAuthConstants.CodeChallengeKey, codeChallenge);
            message.Parameters.Add(OAuthConstants.CodeChallengeMethodKey, OAuthConstants.CodeChallengeMethodS256);
        }
    
        // Add the 'max_age' parameter to the authentication request if MaxAge is not null.
        // See http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
        var maxAge = properties.GetParameter<TimeSpan?>(OpenIdConnectParameterNames.MaxAge) ?? Options.MaxAge;
        if (maxAge.HasValue)
        {
            message.MaxAge = Convert.ToInt64(Math.Floor((maxAge.Value).TotalSeconds))
                .ToString(CultureInfo.InvariantCulture);
        }
    
        // Omitting the response_mode parameter when it already corresponds to the default
        // response_mode used for the specified response_type is recommended by the specifications.
        // See http://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#ResponseModes
        if (!string.Equals(Options.ResponseType, OpenIdConnectResponseType.Code, StringComparison.Ordinal) ||
            !string.Equals(Options.ResponseMode, OpenIdConnectResponseMode.Query, StringComparison.Ordinal))
        {
            message.ResponseMode = Options.ResponseMode;
        }
    
        if (Options.ProtocolValidator.RequireNonce)
        {
            message.Nonce = Options.ProtocolValidator.GenerateNonce();
            WriteNonceCookie(message.Nonce);
        }
    
        GenerateCorrelationId(properties);
    
        var redirectContext = new RedirectContext(Context, Scheme, Options, properties)
        {
            ProtocolMessage = message
        };
    
        await Events.RedirectToIdentityProvider(redirectContext);
        if (redirectContext.Handled)
        {
            Logger.RedirectToIdentityProviderHandledResponse();
            return;
        }
    
        message = redirectContext.ProtocolMessage;
    
        if (!string.IsNullOrEmpty(message.State))
        {
            properties.Items[OpenIdConnectDefaults.UserstatePropertiesKey] = message.State;
        }
    
        // When redeeming a 'code' for an AccessToken, this value is needed
        properties.Items.Add(OpenIdConnectDefaults.RedirectUriForCodePropertiesKey, message.RedirectUri);
    
        message.State = Options.StateDataFormat.Protect(properties);
    
        if (string.IsNullOrEmpty(message.IssuerAddress))
        {
            throw new InvalidOperationException(
                "Cannot redirect to the authorization endpoint, the configuration may be missing or invalid.");
        }
    
        if (Options.AuthenticationMethod == OpenIdConnectRedirectBehavior.RedirectGet)
        {
            var redirectUri = message.CreateAuthenticationRequestUrl();
            if (!Uri.IsWellFormedUriString(redirectUri, UriKind.Absolute))
            {
                Logger.InvalidAuthenticationRequestUrl(redirectUri);
            }
    
            Response.Redirect(redirectUri);
            return;
        }
        else if (Options.AuthenticationMethod == OpenIdConnectRedirectBehavior.FormPost)
        {
            var content = message.BuildFormPost();
            var buffer = Encoding.UTF8.GetBytes(content);
    
            Response.ContentLength = buffer.Length;
            Response.ContentType = "text/html;charset=UTF-8";
    
            // Emit Cache-Control=no-cache to prevent client caching.
            Response.Headers[HeaderNames.CacheControl] = "no-cache, no-store";
            Response.Headers[HeaderNames.Pragma] = "no-cache";
            Response.Headers[HeaderNames.Expires] = HeaderValueEpocDate;
    
            await Response.Body.WriteAsync(buffer, 0, buffer.Length);
            return;
        }
    
        throw new NotImplementedException($"An unsupported authentication method has been configured: {Options.AuthenticationMethod}");
    }
    

    OpenIdConnect的代码还是有点复杂的,很多细节无法覆盖到,后面学习了协议再回头梳理一下。

  • 相关阅读:
    zbb20190605 maven windows配置maven私服以及使用
    zbb20190528 城市经纬度json
    zbb20190528 adcode 城市编码
    zbb20190430 springboot 配置alimq
    zbb20190408 spring-boot-maven-plugin 插件的作用详解
    zbb20190131 Mybatis,mysql映射文件<!CDATA[[]]> 转义问题
    Spring Boot (29) 定时任务
    Spring Boot (28) actuator与spring-boot-admin
    Spring Boot (27) actuator服务监控与管理
    Java 8
  • 原文地址:https://www.cnblogs.com/holdengong/p/12572897.html
Copyright © 2011-2022 走看看