1. 程式人生 > >ASP.NET Core 認證與授權[3]:OAuth & OpenID Connect認證

ASP.NET Core 認證與授權[3]:OAuth & OpenID Connect認證

原文: ASP.NET Core 認證與授權[3]:OAuth & OpenID Connect認證

上一章中,我們瞭解到,Cookie認證是一種本地認證方式,通常認證與授權都在同一個服務中,也可以使用Cookie共享的方式分開部署,但侷限性較大,而如今隨著微服務的流行,更加偏向於將以前的單體應用拆分為多個服務並獨立部署,而此時,就需要一個統一的認證中心,以及一種遠端認證方式,本文就來介紹一下如今最為流行的遠端認證方式:OAuth 和 OpenID Connect。

目錄

  1. OAuth 2.0
  2. OpenID Connect

OAuth 2.0

在介紹OAuth之前,我們先簡單介紹一下OpenID。OpenID 是一個以使用者為中心的數字身份識別框架,它具有開放、分散性。OpenID 的建立基於這樣一個概念:我們可以通過 URI (又叫URL或網站地址)來認證一個網站的唯一身份,同理,我們也可以通過這種方式來作為使用者的身份認證。

OpenID的認證非常簡單,當你訪問需要認證的A網站時,A網站要求你輸入你的OpenID使用者名稱,然後會跳轉你的OpenID服務網站,輸入使用者名稱密碼驗證通過後,再跳回A網站,而些時已經顯示認證成功。除了一處註冊,到處通行外,OpenID還可以使所有支援OpenID的網站共享使用者資源,而使用者可以控制哪些資訊可以被共享,例如姓名、地址、電話號碼等。

而OAuth是一個關於授權(authorization)的開放網路標準,在全世界得到廣泛應用,在官網對其是這樣定義的:

An open protocol to allow secure API authorization in a simple and standard method from desktop and web applications.

OAuth關注的是第三方應用訪問其受保護資源的能力,而OpenID關注的是第三方應用獲取使用者身份的能力。

如今大多網站都已不再支援OpenID,最為流行的是OAuth 2.0 (在本文中提到OAuth也均指2.0版本),而OpenID的最新版是OpenID Connect,是OpenID的第三代技術,下文會來介紹。

關於OAuth的介紹,網上非常之多,本文就不再過多敘述,而是主要講解如何在 ASP.NET Core 中使用 OAuth 認證。如果你對 OAuth 並不瞭解,那麼建議先去網上檢視一下這方面的資料,再來閱讀本文。而在本文中提到的OAuth認證指的是 ASP.NET Core 中的一種認證方式,而OAuth本身只是一種授權協議,希望不要混淆。

在 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伺服器,登入成功後則顯示授權頁面:

oauth_consent

點選允許,跳回我們的網站,並已登入成功,顯示如下:

oauth_profile

如圖,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 是我們在上一步中儲存的 propertiescode則是最重要的引數,用來獲取 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-protocols

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中定義了一個特殊的openidscope,並且是必須的,它包含對Id tokenUserInfo Endpoint的訪問許可權。

服務發現和客戶端註冊

OIDC定義了一個發現協議,客戶端可以自動的獲取有關如何與身份認證提供者進行互動的資訊,還定義了一個客戶端註冊協議,允許客戶端引入新的身份提供程式(identity providers)。通過這兩種機制和一個通用的身份API,OIDC可以在網際網路規模上良好的執行,而不需要任何一方事先知道對方的存在。

Hybrid Flow

混合流模式是授權碼模式與隱式模式的組合,在一次請求中可以同時獲取Code和ID Token,ResponseType可以是:code id_token, code id_token tokencode 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;

});

如上,ClientIdClientSecret與在OAuth中的作用一樣,而在這裡不需要再分別指定各種Endpoint,是通過指定一個MetadataAddress地址來自動發現,可訪問 https://oidc.faasx.com/.well-known/openid-configuration 來了解一下MetadataAddress中包含的資訊。

然後,在上文OAuth認證示例中的Configure方法基礎上新增signoutsignout-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,可以來解析出AuthenticationPropertiesClaimsPrincipal等資訊,然後使用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

瀏覽器中顯示如下:

oidc-claims

接下來可以使用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伺服器,顯示如下:

oidc_logout

其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應用中比較流行的認證方式。

附本文示例程式碼:https://github.com/RainingNight/AspNetCoreSample/tree/master/src/Functional/Authentication/OIDCSample/Startup.cs