自訂.NET Core Identity身份驗證和授權教學:AspNetUserLogins 自訂.NET Core Identity身份驗證和授權教學:AspNetUserLogins

Published on Wednesday, April 26, 2023

.NET Core Identity AspNetUserLogins

我們在上一篇的文章中新增了 Google 外部登入到我們專案內,但有一個很大的問題就是外部登入的會員沒有在我們的系統內進行註冊 所以判斷權限時會找不到對應的會員權限,要解決這個問題我們可以在外部登入流程中插入一段註冊本地會員的邏輯

開始寫程式之前我們先了解一下 .NET Core Identity 內建的 Class IdentityUserLogin
我們之前已經學習過IdentityUserIdentityUserClaim 現在要學習的 IdentityUserLogin 從名稱來看不是很好理解 第一眼看很像是負責紀錄會員登入次數的表,我們先看看 Github 上有沒有說明的註解

public class IdentityUserLogin<TKey> where TKey : IEquatable<TKey>
{
    /// <summary>
    /// Gets or sets the login provider for the login (e.g. facebook, google)
    /// </summary>
    public virtual string LoginProvider { get; set; } = default!;

    /// <summary>
    /// Gets or sets the unique provider identifier for this login.
    /// </summary>
    public virtual string ProviderKey { get; set; } = default!;

    /// <summary>
    /// Gets or sets the friendly name used in a UI for this login.
    /// </summary>
    public virtual string? ProviderDisplayName { get; set; }

    /// <summary>
    /// Gets or sets the primary key of the user associated with this login.
    /// </summary>
    public virtual TKey UserId { get; set; } = default!;
}

從註解中可以了解到這個 Class 是用來對應外部登入的會員與內部會員的表,可以猜測到表內會有以下類似的內容

LoginProvider = "Google"
ProviderKey = "112873593891211851878"
ProviderDisplayName = "Allen Gao"
UserId = "c905d70c-d538-4190-b30a-5a0928dbee0f"

這邊 ProviderKey 為外部系統提供的唯一編號,以 Google 為例在登入後 HttpContext 的 Claim 會新增 nameidentifier 提供一組 Google 內部的唯一會員編號 另外是 UserId 是我們內部系統的唯一編號以我們的系統為例就是使用 GUID 編號

所以根據以上信息我們需要完成兩件事情

  1. 在會員按下同意授權後 Google 會呼叫 redirect URI 之後由 GoogleHandler 進行處理,我們需要想辦法在這段邏輯中插入我們的註冊程式碼
  2. 運行我們註冊程式碼時需要將對應資料新增到 IdentityUserLogin 表
CREATE TABLE dbo.AspNetUserLogins (
    LoginProvider character varying(128) NOT NULL,
    ProviderKey character varying(128) NOT NULL,
    ProviderDisplayName text NULL,
    UserId text NOT NULL,
    CONSTRAINT PK_AspNetUserLogins PRIMARY KEY (LoginProvider, ProviderKey),
    CONSTRAINT FK_AspNetUserLogins_AspNetUsers_UserId FOREIGN KEY (UserId) REFERENCES dbo.AspNetUsers (Id) ON DELETE CASCADE
);

CREATE INDEX IX_AspNetUserLogins_UserId ON dbo.AspNetUserLogins (UserId);

並且到 CustomUserStore 實做新界面 IUserLoginStore<IdentityUser>

Task AddLoginAsync(TUser user, UserLoginInfo login, CancellationToken cancellationToken);
Task<TUser?> FindByLoginAsync(string loginProvider, string providerKey, CancellationToken cancellationToken);

最後到 AccountController 修改 ExternalSignin 方法,並新增 ExternalLoginCallback 方法

[HttpGet(template: "~/externalSignin", Name = "ExternalSignin")]
public async Task ExternalSignin()
{
    await _signInManager.SignOutAsync();
    var properties = _signInManager.ConfigureExternalAuthenticationProperties(
        GoogleDefaults.AuthenticationScheme,
        "/externalLoginCallback");
    await HttpContext.ChallengeAsync(GoogleDefaults.AuthenticationScheme, properties);        
}

[HttpGet(template: "~/externalLoginCallback", Name = "ExternalLoginCallback")]
public async Task<IActionResult> ExternalLoginCallback(string returnUrl = null)
{
    var info = await _signInManager.GetExternalLoginInfoAsync();
    var userEmail = info.Principal.FindFirst(ClaimTypes.Email)?.Value.Normalize();

    var result = await _signInManager.ExternalLoginSignInAsync(
        info.LoginProvider,
        info.ProviderKey,
        isPersistent: true,
        bypassTwoFactor: true
    );

    if (result.Succeeded)
        return Redirect("/swagger");
    
    var user = await _userManager.FindByNameAsync(userEmail);
    if (user == null)
    {
        var identityUser = new IdentityUser(userEmail);
        await CreateUser(identityUser.UserName);
        await AddClaim(identityUser.UserName);
        user = await _userManager.FindByNameAsync(userEmail);
    }

    var userLogin = await _userManager.FindByLoginAsync(info.LoginProvider, info.ProviderKey);
    if (userLogin == null)
    {
        await _userManager.AddLoginAsync(user, new UserLoginInfo(
            info.LoginProvider,
            info.ProviderKey,
            info.ProviderDisplayName
        ));
    }
    
    await _signInManager.SignOutAsync();
    await _signInManager.SignInAsync(user, isPersistent: true);

    return Redirect("/swagger");
}

調整 Program.cs 驗證方法

.AddAuthentication(o =>
{
    o.DefaultScheme = IdentityConstants.ApplicationScheme;
})
.AddCookie(IdentityConstants.ExternalScheme, o =>
{
    o.ExpireTimeSpan = TimeSpan.FromMinutes(10);
})
.AddGoogle(GoogleDefaults.AuthenticationScheme, o =>
{
    o.ClientId = configuration["Authentication:Google:ClientId"];
    o.ClientSecret = configuration["Authentication:Google:ClientSecret"];
    o.SignInScheme = IdentityConstants.ExternalScheme;
});

這裡有設定 DefaultScheme 所以當我們執行一般本地登入時會在瀏覽器寫入一個新的 Cookie 叫做 .AspNetCore.Identity.Application 另外在執行 Google 登入時因為有額外設定 SignInScheme 所以會將外部登入的狀態保存到另一個 Cookie 叫做 .AspNetCore.Identity.External

如果沒有設定 SignInScheme 那麼登入的 Cookie 還是會使用預設的 Scheme .AspNetCore.Identity.Application 這邊沒有設定好的話呼叫 SignInManager 內部提供的方法會造成問題,例如 GetExternalLoginInfoAsync 方法裡固定會去檢查 .AspNetCore.Identity.External

回到 AccountController 的 ExternalSignin方法執行完成後會轉跳到新增加的 ExternalLoginCallback 方法
這邊可以使用 GetExternalLoginInfoAsync 方法會取得遠端登入 User 的基本資料
如果第一次登入 ExternalLoginSignInAsync 因為會找不到本地會員所以結果會是失敗,失敗的話會使用Email去註冊一個新的本地會員 第二次後 ExternalLoginSignInAsync 這個方法會透過輸入的 LoginProvider(Google) 與 ProviderKey(112873593891211851878) 這兩個參數可以對應到本地的 IdentityUser 並且直接登入

有了這樣的架構如果之後增加 Facebook 或者其他登入時,因為會有另一組 LoginProvider(Facebook) 與 ProviderKey(292908172340981234878) 不需要在新增本地 User 所以只要直接寫入到 AspNetUserLogins,這樣就可以使用不同的 LoginProvider 登入到同一個本地 User


Summary

今天了解了 AspNetUserLogins 的運作原理,有了這個表後就可以同時使用許多種的外部登入方式而且只會對應到同一個本地使用者上, 並不會用 Google 登入就建立一個或用 Facebook 登入就建立一個,或者整個網站只支援一個外部登入方式

今天的進度 Github