.NET Core Identity AspNetUserLogins

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

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

/// <summary>
/// Represents a login and its associated provider for a user.
/// </summary>
/// <typeparam name="TKey">The type of the primary key of the user associated with this login.</typeparam>
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 表

首先建立 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>,當使用第三方登入時需要先用 FindByLoginAsync 檢查這個第三方會員是否在本地建立資料過, 沒有的話才使用 AddLoginAsync 建立新的本地會員

IUserClaimStore 一樣,我們也需要實做 IUserLoginStore 介面這樣才能知道要怎麼存取 aspnetuserlogins 資料表

public async Task AddLoginAsync(IdentityUser user, UserLoginInfo login, CancellationToken cancellationToken)
{
    if (user == null) throw new ArgumentNullException(nameof(user));
    if (login == null) throw new ArgumentNullException(nameof(login));

    var connString = _configuration.GetSection("ConnectionStrings").GetValue<string>("DefaultConnection");
    await using var conn = new NpgsqlConnection(connString);
    await conn.OpenAsync(cancellationToken);

    // 使用小寫表名及欄位名
    var command = @"
    INSERT INTO dbo.aspnetuserlogins (loginprovider, providerkey, providerdisplayname, userid)
    VALUES (@LoginProvider, @ProviderKey, @ProviderDisplayName, @UserId)
    ON CONFLICT (loginprovider, providerkey) DO NOTHING;
";

    var parameters = new
    {
        LoginProvider = login.LoginProvider,
        ProviderKey = login.ProviderKey,
        ProviderDisplayName = login.ProviderDisplayName,
        UserId = Guid.Parse(user.Id)
    };

    await conn.ExecuteAsync(command, parameters);
}

    public async Task<IdentityUser?> FindByLoginAsync(string loginProvider, string providerKey, CancellationToken cancellationToken)
{
    var connString = _configuration.GetSection("ConnectionStrings").GetValue<string>("DefaultConnection");
    await using var conn = new NpgsqlConnection(connString);
    await conn.OpenAsync();

    await using (var sqlConnection = conn)
    {
        var command = $"SELECT UserId " +
                      $"FROM dbo.AspNetUserLogins " +
                      "WHERE LoginProvider = @LoginProvider AND ProviderKey = @ProviderKey;";

        var userId = await sqlConnection.QuerySingleOrDefaultAsync<string?>(command, new {
            LoginProvider = loginProvider,
            ProviderKey = providerKey
        });

        if (userId == null) {
            return null;
        }

        command = "SELECT * FROM dbo.AspNetUsers WHERE Id = @Id;";

        return await sqlConnection.QuerySingleAsync<IdentityUser>(command, new { Id = userId });
    }
}

最後到 AccountController 新增 LoginGoogleGoogleResponse 方法,LoginGoogle 方法跟上一篇寫的 ExternalSignin 方法意思一樣, 只是這次改用 ControllerBase.Challenge 底層的邏輯都是一致的,最後都會轉跳到 Google 登入。

另外這裡設定了 redirectUrl 讓我們在 https://localhost:7011/signin-google 執行完成後轉跳到另一個 API(GoogleResponse)來設定本地的會員資料。

[HttpGet("LoginGoogle")]
public IActionResult LoginGoogle(string returnUrl = "/")
{
    var redirectUrl = Url.Action("GoogleResponse", "Account", new { returnUrl });
    var properties = _signInManager.ConfigureExternalAuthenticationProperties("Google", redirectUrl);
    return Challenge(properties, "Google");
}

這個階段 .Net 將會把會員的資料設定到 IdentityConstants.ExternalScheme 這個 cookie 裡面, 這時 SignInManager.GetExternalLoginInfoAsync 可以透過 HttpContext 來讀取 IdentityConstants.ExternalScheme 的內容, 也就是說 info 變數目前就是儲存著第三方登入的結果。

目前確認第三方登入已經登入成功了,並且 IdentityConstants.ExternalScheme 這個外部登入的 cookie 也有資料,不過我們的網站真正需要的是 IdentityConstants.ApplicationScheme 這個 cookie,所以目前只完成了一半的流程,接下來就要使用 SignInManager.ExternalLoginSignInAsync 方法 進行本地登入,看過原始碼後會發現這個方法其實就跟 SignInManager.PasswordSignInAsync 非常相似,差別就在於一個需要密碼一個不用而已, 這是因為外部登入已經在 Google 平台驗證過密碼了,所以我們網站只需要直接登入綁定的會員即可,下面的 LoginProvider 就是 Google, ProviderKey 就是會員的編號可以理解成帳號。

最後如果在資料庫找不到會員的話就幫忙新增一個新的本地會員並直接登入。

[HttpGet("GoogleResponse")]
public async Task<IActionResult> GoogleResponse(string returnUrl = "/")
{
    var info = await _signInManager.GetExternalLoginInfoAsync();
    if (info == null)
    {
        return RedirectToAction("LoginGoogle"); // 重新嘗試
    }

    // 嘗試登入現有帳號
    var signInResult = await _signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, false);

    if (!signInResult.Succeeded)
    {
        // 如果帳號不存在,就建立一個新使用者
        var email = info.Principal.FindFirstValue(System.Security.Claims.ClaimTypes.Email);
        var user = new IdentityUser { UserName = email, Email = email };

        var result = await _userManager.CreateAsync(user);
        if (result.Succeeded)
        {
            await _userManager.AddLoginAsync(user, info);
            await _signInManager.SignInAsync(user, isPersistent: false);
        }
        else
        {
            return BadRequest(result.Errors);
        }
    }

    return LocalRedirect(returnUrl);
}

最後是 Program.cs 的完整設定,需要確認 IdentityConstants.ExternalScheme 有對應的儲存方式,這裡選擇的是用 cookie 來保存, 如果少了這一行代表外部的登入結果找不到地方保存,後續的登入流程無法繼續。

.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;
});

完成後 Get LoginGoogle 測試整體流程,成功後能在 aspnetusersaspnetuserlogins 看到新的會員與第三方的綁定,最後在 Get ListHttpContextClaim 確認 HttpContext 有設定成功。

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


Summary

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