AspNetCore3.1_Secutiry源碼解析_5_Authentication_OAuth


文章目錄

OAuth簡介

現在隨便一個網站,不用注冊,只用微信掃一掃,然后就可以自動登錄,然后第三方網站右上角還出現了你的微信頭像和昵稱,怎么做到的?

sequenceDiagram 用戶->>x站點: 請求微信登錄 x站點->>微信: 請求 oauth token 微信->>用戶: x站點請求基本資料權限,是否同意? 用戶->>微信: 同意 微信->>x站點: token x站點->>微信: 請求user基本資料(token) 微信->微信: 校驗token 微信->>x站點: user基本資料

大概就這么個意思,OAuth可以讓第三方獲取有限的授權去獲取資源。

入門的看博客

https://www.cnblogs.com/linianhui/p/oauth2-authorization.html

英文好有基礎的直接看協議

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

依賴注入

配置類:OAuthOptions
處理器類: OAuthHandler

public static class OAuthExtensions
{
    public static AuthenticationBuilder AddOAuth(this AuthenticationBuilder builder, string authenticationScheme, Action<OAuthOptions> configureOptions)
        => builder.AddOAuth<OAuthOptions, OAuthHandler<OAuthOptions>>(authenticationScheme, configureOptions);

    public static AuthenticationBuilder AddOAuth(this AuthenticationBuilder builder, string authenticationScheme, string displayName, Action<OAuthOptions> configureOptions)
        => builder.AddOAuth<OAuthOptions, OAuthHandler<OAuthOptions>>(authenticationScheme, displayName, configureOptions);

    public static AuthenticationBuilder AddOAuth<TOptions, THandler>(this AuthenticationBuilder builder, string authenticationScheme, Action<TOptions> configureOptions)
        where TOptions : OAuthOptions, new()
        where THandler : OAuthHandler<TOptions>
        => builder.AddOAuth<TOptions, THandler>(authenticationScheme, OAuthDefaults.DisplayName, configureOptions);

    public static AuthenticationBuilder AddOAuth<TOptions, THandler>(this AuthenticationBuilder builder, string authenticationScheme, string displayName, Action<TOptions> configureOptions)
        where TOptions : OAuthOptions, new()
        where THandler : OAuthHandler<TOptions>
    {
        builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IPostConfigureOptions<TOptions>, OAuthPostConfigureOptions<TOptions, THandler>>());
        return builder.AddRemoteScheme<TOptions, THandler>(authenticationScheme, displayName, configureOptions);
    }
}

OAuthOptions - 配置類

classDiagram class OAuthOptions{ ClientId ClientSecret AuthorizationEndpoint TokenEndPoint UserInformationEndPoint Scope Events ClaimActions StateDataFormat } class RemoteAuthenticationOptions{ BackchannelTimeout BackchannelHttpHandler Backchannel DataProtectionProvider CallbackPath AccessDeniedPath ReturnUrlParameter SignInScheme RemoteAuthenticationTimeout SaveTokens } class AuthenticationSchemeOptions{ } OAuthOptions-->RemoteAuthenticationOptions RemoteAuthenticationOptions-->AuthenticationSchemeOptions

下面是校驗邏輯,這些配置是必需的。

public override void Validate()
{
    base.Validate();

    if (string.IsNullOrEmpty(ClientId))
    {
        throw new ArgumentException(string.Format(CultureInfo.CurrentCulture, Resources.Exception_OptionMustBeProvided, nameof(ClientId)), nameof(ClientId));
    }

    if (string.IsNullOrEmpty(ClientSecret))
    {
        throw new ArgumentException(string.Format(CultureInfo.CurrentCulture, Resources.Exception_OptionMustBeProvided, nameof(ClientSecret)), nameof(ClientSecret));
    }

    if (string.IsNullOrEmpty(AuthorizationEndpoint))
    {
        throw new ArgumentException(string.Format(CultureInfo.CurrentCulture, Resources.Exception_OptionMustBeProvided, nameof(AuthorizationEndpoint)), nameof(AuthorizationEndpoint));
    }

    if (string.IsNullOrEmpty(TokenEndpoint))
    {
        throw new ArgumentException(string.Format(CultureInfo.CurrentCulture, Resources.Exception_OptionMustBeProvided, nameof(TokenEndpoint)), nameof(TokenEndpoint));
    }

    if (!CallbackPath.HasValue)
    {
        throw new ArgumentException(string.Format(CultureInfo.CurrentCulture, Resources.Exception_OptionMustBeProvided, nameof(CallbackPath)), nameof(CallbackPath));
    }
}

OAuthPostConfigureOptions - 配置處理

  1. DataProtectionProvider沒有配置的話則使用默認實現
  2. Backchannel沒有配置的話則處理構造默認配置
  3. StateDataFormat沒有配置的話則使用PropertiesDataFormat
public void PostConfigure(string name, TOptions options)
{
    options.DataProtectionProvider = options.DataProtectionProvider ?? _dp;
    if (options.Backchannel == null)
    {
        options.Backchannel = new HttpClient(options.BackchannelHttpHandler ?? new HttpClientHandler());
        options.Backchannel.DefaultRequestHeaders.UserAgent.ParseAdd("Microsoft ASP.NET Core OAuth handler");
        options.Backchannel.Timeout = options.BackchannelTimeout;
        options.Backchannel.MaxResponseContentBufferSize = 1024 * 1024 * 10; // 10 MB
    }

    if (options.StateDataFormat == null)
    {
        var dataProtector = options.DataProtectionProvider.CreateProtector(
            typeof(THandler).FullName, name, "v1");
        options.StateDataFormat = new PropertiesDataFormat(dataProtector);
    }
}

這個StateDataFormat就是處理state字段的加密解密的,state在認證過程中用於防止跨站偽造攻擊和存放一些狀態信息,我們看一下協議的定義

 state
         RECOMMENDED.  An opaque value used by the client to maintain
         state between the request and callback.  The authorization
         server includes this value when redirecting the user-agent back
         to the client.  The parameter SHOULD be used for preventing
         cross-site request forgery as described in Section 10.12.

比如,認證之后的回跳地址就是存放在這里。所以如果希望從state字段中解密得到信息的話,就需要使用到PropertiesDataFormat。PropertiesDataFormat沒有任何代碼,繼承自SecureDataFormat。 為什么這里介紹這么多呢,因為實際項目中用到過這個。

public class SecureDataFormat<TData> : ISecureDataFormat<TData>
{
    private readonly IDataSerializer<TData> _serializer;
    private readonly IDataProtector _protector;

    public SecureDataFormat(IDataSerializer<TData> serializer, IDataProtector protector)
    {
        _serializer = serializer;
        _protector = protector;
    }

    public string Protect(TData data)
    {
        return Protect(data, purpose: null);
    }

    public string Protect(TData data, string purpose)
    {
        var userData = _serializer.Serialize(data);

        var protector = _protector;
        if (!string.IsNullOrEmpty(purpose))
        {
            protector = protector.CreateProtector(purpose);
        }

        var protectedData = protector.Protect(userData);
        return Base64UrlTextEncoder.Encode(protectedData);
    }

    public TData Unprotect(string protectedText)
    {
        return Unprotect(protectedText, purpose: null);
    }

    public TData Unprotect(string protectedText, string purpose)
    {
        try
        {
            if (protectedText == null)
            {
                return default(TData);
            }

            var protectedData = Base64UrlTextEncoder.Decode(protectedText);
            if (protectedData == null)
            {
                return default(TData);
            }

            var protector = _protector;
            if (!string.IsNullOrEmpty(purpose))
            {
                protector = protector.CreateProtector(purpose);
            }

            var userData = protector.Unprotect(protectedData);
            if (userData == null)
            {
                return default(TData);
            }

            return _serializer.Deserialize(userData);
        }
        catch
        {
            // TODO trace exception, but do not leak other information
            return default(TData);
        }
    }
}

AddRemoteSchema和AddShema的差別就是做了下面的處理,確認始終有不是遠程schema的SignInSchema

private class EnsureSignInScheme<TOptions> : IPostConfigureOptions<TOptions> where TOptions : RemoteAuthenticationOptions
{
    private readonly AuthenticationOptions _authOptions;

    public EnsureSignInScheme(IOptions<AuthenticationOptions> authOptions)
    {
        _authOptions = authOptions.Value;
    }

    public void PostConfigure(string name, TOptions options)
    {
        options.SignInScheme = options.SignInScheme ?? _authOptions.DefaultSignInScheme ?? _authOptions.DefaultScheme;
    }
}

OAuthHandler

  • 解密state
  • 校驗CorrelationId,防跨站偽造攻擊
  • 如果error不為空說明失敗返回錯誤
  • 拿到授權碼code,換取token
  • 如果SaveTokens設置為true,將access_token,refresh_token,token_type存放到properties中
  • 創建憑據,返回成功
  protected override async Task<HandleRequestResult> HandleRemoteAuthenticateAsync()
        {
            var query = Request.Query;

            var state = query["state"];
            var properties = Options.StateDataFormat.Unprotect(state);

            if (properties == null)
            {
                return HandleRequestResult.Fail("The oauth state was missing or invalid.");
            }

            // OAuth2 10.12 CSRF
            if (!ValidateCorrelationId(properties))
            {
                return HandleRequestResult.Fail("Correlation failed.", properties);
            }

            var error = query["error"];
            if (!StringValues.IsNullOrEmpty(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 (StringValues.Equals(error, "access_denied"))
                {
                    return await HandleAccessDeniedErrorAsync(properties);
                }

                var failureMessage = new StringBuilder();
                failureMessage.Append(error);
                var errorDescription = query["error_description"];
                if (!StringValues.IsNullOrEmpty(errorDescription))
                {
                    failureMessage.Append(";Description=").Append(errorDescription);
                }
                var errorUri = query["error_uri"];
                if (!StringValues.IsNullOrEmpty(errorUri))
                {
                    failureMessage.Append(";Uri=").Append(errorUri);
                }

                return HandleRequestResult.Fail(failureMessage.ToString(), properties);
            }

            var code = query["code"];

            if (StringValues.IsNullOrEmpty(code))
            {
                return HandleRequestResult.Fail("Code was not found.", properties);
            }

            var tokens = await ExchangeCodeAsync(code, BuildRedirectUri(Options.CallbackPath));

            if (tokens.Error != null)
            {
                return HandleRequestResult.Fail(tokens.Error, properties);
            }

            if (string.IsNullOrEmpty(tokens.AccessToken))
            {
                return HandleRequestResult.Fail("Failed to retrieve access token.", properties);
            }

            var identity = new ClaimsIdentity(ClaimsIssuer);

            if (Options.SaveTokens)
            {
                var authTokens = new List<AuthenticationToken>();

                authTokens.Add(new AuthenticationToken { Name = "access_token", Value = tokens.AccessToken });
                if (!string.IsNullOrEmpty(tokens.RefreshToken))
                {
                    authTokens.Add(new AuthenticationToken { Name = "refresh_token", Value = tokens.RefreshToken });
                }

                if (!string.IsNullOrEmpty(tokens.TokenType))
                {
                    authTokens.Add(new AuthenticationToken { Name = "token_type", Value = tokens.TokenType });
                }

                if (!string.IsNullOrEmpty(tokens.ExpiresIn))
                {
                    int value;
                    if (int.TryParse(tokens.ExpiresIn, NumberStyles.Integer, CultureInfo.InvariantCulture, out value))
                    {
                        // https://www.w3.org/TR/xmlschema-2/#dateTime
                        // https://msdn.microsoft.com/en-us/library/az4se3k1(v=vs.110).aspx
                        var expiresAt = Clock.UtcNow + TimeSpan.FromSeconds(value);
                        authTokens.Add(new AuthenticationToken
                        {
                            Name = "expires_at",
                            Value = expiresAt.ToString("o", CultureInfo.InvariantCulture)
                        });
                    }
                }

                properties.StoreTokens(authTokens);
            }

            var ticket = await CreateTicketAsync(identity, properties, tokens);
            if (ticket != null)
            {
                return HandleRequestResult.Success(ticket);
            }
            else
            {
                return HandleRequestResult.Fail("Failed to retrieve user information from remote server.", properties);
            }
        }

實戰

最近做一個第三方對接的項目,我們有多個站點、自己的IdentityServer認證中心,這個聯合項目要求將我們的系統以iframe的形式嵌套在他們的菜單里面。整個對接流程大致如下。

sequenceDiagram 第三方->>第三方: 登錄 第三方->>本公司系統: 點擊菜單請求地址 本公司系統->>第三方: 跳轉OAuth靜默授權地址(1) 第三方->>本公司系統: 帶授權碼跳轉回調地址(2) 本公司系統->>第三方: 使用code換token(3) 本公司系統->>第三方: 使用token讀取個人資料(4) 本公司系統->>本公司系統: 用戶名密碼模式與本公司認證中心靜默授權(5) 本公司系統->>本公司系統: 上下文注入需要的Claims,使用CookieSchema登錄維持登錄態(6) 本公司系統->>本公司系統: 回跳到開始授權時的地址(7)

利用微軟框架,可以比較快速實現

  1. 定義XXOptions,繼承自OAuthOptions

    • ClientId:必填,客戶端id
    • ClientSecret:必填,客戶端秘鑰
    • AuthorizationEndpoint:必填,授權地址,對應步驟(1)
    • TokenEndpoint:必填,中間件會帶着授權碼code跳轉到此地址換取token,對應步驟(2,3)
    • UserInformationEndpoint:選填,用戶信息接口地址,框架沒有使用此屬性,需要自己實現,對應步驟(4)
    • CallbackPath:必填,授權流程結束之后回跳地址,對應步驟(7)
    • 訂閱事件:Events.OnCreatingTicket += async (OAuthCreatingTicketContext context) =>
      {
      //用戶憑據簽發時觸發,將用戶信息同步到本公司,使用ClientCredential模式與
      //本公司IdentityServer認證中心通訊實現靜默授權
      //然后將本公司相關會話信息填充到憑據中
      };
    • SignInSchema:認證完后登入架構名(建議Cookies)
    • 如果有特有的配置,也在此處定義
  2. 定義XXOAuthHandler,繼承自OAuthHandler

    • 重寫ExchangeCodeAsync,此方法負責使用code換取token,父類實現使用的是form-post,如果任何地方與實際情況不匹配,可以進行重寫
    • 重寫HandleChallengeAsync方法,此方法負責構建質詢地址,即步驟(1)的靜默授權地址+回調地址
    • 重寫CreateTicketAsync方法,此方法負責構建用戶憑證,包括所有需要未來維持在Cookie中的信息。可以在此處請求UserInformationEndpoint請求用戶資料,然后填充到憑證中。
    • 重寫HandleRemoteAuthenticateAsync:此方法為主干邏輯方法,如果與實際有差異可以進行重寫,否則使用父類實現即可。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM