在 ASP.NET Core 中保留来自外部提供程序的附加声明和令牌

ASP.NET Core 应用可从外部提供程序(例如 Facebook、Google、Microsoft 和 Twitter)建立附加声明和令牌。 每个提供程序在其平台上显示有关用户的不同信息,但接收用户数据和将该数据转换为附加声明的模式是相同的。

先决条件

确定在应用中支持哪些外部身份验证提供程序。 对于每个提供程序,请注册应用并获取客户端 ID 和客户端密码。 有关详细信息,请参阅 ASP.NET Core 中的 Facebook 和 Google 身份验证。 示例应用使用 Google 身份验证提供程序

设置客户端 ID 和客户端密码

OAuth 身份验证提供程序使用客户端 ID 和客户端密码与应用建立信任关系。 向提供程序注册应用时,外部身份验证提供程序会为应用创建客户端 ID 和客户端密码值。 应用使用的每个外部提供程序都必须使用提供程序的客户端 ID 和客户端密码单独进行配置。 有关详细信息,请参阅适合的外部身份验证提供程序主题:

在身份验证提供程序的 ID 或访问令牌中发送的可选声明通常是在提供程序的联机门户中配置的。 例如,通过 Microsoft Entra ID,可在应用注册的“令牌配置”边栏选项卡中为应用的 ID 令牌分配可选声明。 有关详细信息,请参阅如何:向应用提供可选声明(Azure 文档)。 对于其他提供程序,请查阅其外部文档集。

示例应用使用 Google 提供的客户端 ID 和客户端密码配置 Google 身份验证提供程序:

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using WebGoogOauth.Data;

var builder = WebApplication.CreateBuilder(args);
var configuration = builder.Configuration;

builder.Services.AddAuthentication().AddGoogle(googleOptions =>
{
    googleOptions.ClientId = configuration["Authentication:Google:ClientId"];
    googleOptions.ClientSecret = configuration["Authentication:Google:ClientSecret"];

    googleOptions.ClaimActions.MapJsonKey("urn:google:picture", "picture", "url");
    googleOptions.ClaimActions.MapJsonKey("urn:google:locale", "locale", "string");

    googleOptions.SaveTokens = true;

    googleOptions.Events.OnCreatingTicket = ctx =>
    {
        List<AuthenticationToken> tokens = ctx.Properties.GetTokens().ToList();

        tokens.Add(new AuthenticationToken()
        {
            Name = "TicketCreated",
            Value = DateTime.UtcNow.ToString()
        });

        ctx.Properties.StoreTokens(tokens);

        return Task.CompletedTask;
    };
});

var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(connectionString));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();

builder.Services.AddDefaultIdentity<IdentityUser>(options => 
                                  options.SignIn.RequireConfirmedAccount = true)
                                 .AddEntityFrameworkStores<ApplicationDbContext>();
builder.Services.AddRazorPages();

var app = builder.Build();

// Remaining code removed for brevity.

建立身份验证范围

通过指定 Scope,指定用于从提供程序检索内容的权限列表。 下表显示了常见外部提供程序的身份验证范围。

提供程序 范围
Facebook https://www.facebook.com/dialog/oauth
Google profileemail、、 openid
Microsoft https://login.microsoftonline.com/common/oauth2/v2.0/authorize
Twitter https://api.twitter.com/oauth/authenticate

在示例应用中,当在 AuthenticationBuilder 上调用 AddGoogle 时,框架会自动添加 Google 的 profileemailopenid 范围。 如果应用需要其他范围,请将它们添加到选项中。 在下面的示例中,添加了 Google https://www.googleapis.com/auth/user.birthday.read 范围来检索用户的生日:

options.Scope.Add("https://www.googleapis.com/auth/user.birthday.read");

映射用户数据键并创建声明

在提供程序的选项中,为外部提供程序的 JSON 用户数据中的每个键或子键指定 MapJsonKeyMapJsonSubKey,以便应用 identity 在登录时读取。 有关声明类型的详细信息,请参阅 ClaimTypes

示例应用通过 Google 用户数据中的 localepicture 键创建区域设置 (urn:google:locale) 和图片 (urn:google:picture) 声明:

builder.Services.AddAuthentication().AddGoogle(googleOptions =>
{
    googleOptions.ClientId = configuration["Authentication:Google:ClientId"];
    googleOptions.ClientSecret = configuration["Authentication:Google:ClientSecret"];

    googleOptions.ClaimActions.MapJsonKey("urn:google:picture", "picture", "url");
    googleOptions.ClaimActions.MapJsonKey("urn:google:locale", "locale", "string");

    googleOptions.SaveTokens = true;

    googleOptions.Events.OnCreatingTicket = ctx =>
    {
        List<AuthenticationToken> tokens = ctx.Properties.GetTokens().ToList();

        tokens.Add(new AuthenticationToken()
        {
            Name = "TicketCreated",
            Value = DateTime.UtcNow.ToString()
        });

        ctx.Properties.StoreTokens(tokens);

        return Task.CompletedTask;
    };
});

Microsoft.AspNetCore.Identity.UI.Pages.Account.Internal.ExternalLoginModel.OnPostConfirmationAsync 中,IdentityUser (ApplicationUser) 使用 SignInAsync 登录到应用。 在登录过程中,UserManager<TUser> 可存储 Principal 中提供的用户数据的 ApplicationUser 声明。

在示例应用中,OnPostConfirmationAsync (Account/ExternalLogin.cshtml.cs) 会为登录的 ApplicationUser 建立区域设置 (urn:google:locale) 和图片 (urn:google:picture) 声明,包括 GivenName 的声明:

public async Task<IActionResult> OnPostConfirmationAsync(string returnUrl = null)
{
    returnUrl = returnUrl ?? Url.Content("~/");
    // Get the information about the user from the external login provider
    var info = await _signInManager.GetExternalLoginInfoAsync();
    if (info == null)
    {
        ErrorMessage = "Error loading external login information during confirmation.";
        return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
    }

    if (ModelState.IsValid)
    {
        var user = CreateUser();

        await _userStore.SetUserNameAsync(user, Input.Email, CancellationToken.None);
        await _emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None);

        var result = await _userManager.CreateAsync(user);
        if (result.Succeeded)
        {
            result = await _userManager.AddLoginAsync(user, info);
            if (result.Succeeded)
            {
                _logger.LogInformation("User created an account using {Name} provider.", info.LoginProvider);

                // If they exist, add claims to the user for:
                //    Given (first) name
                //    Locale
                //    Picture
                if (info.Principal.HasClaim(c => c.Type == ClaimTypes.GivenName))
                {
                    await _userManager.AddClaimAsync(user,
                        info.Principal.FindFirst(ClaimTypes.GivenName));
                }

                if (info.Principal.HasClaim(c => c.Type == "urn:google:locale"))
                {
                    await _userManager.AddClaimAsync(user,
                        info.Principal.FindFirst("urn:google:locale"));
                }

                if (info.Principal.HasClaim(c => c.Type == "urn:google:picture"))
                {
                    await _userManager.AddClaimAsync(user,
                        info.Principal.FindFirst("urn:google:picture"));
                }

                // Include the access token in the properties
                // using Microsoft.AspNetCore.Authentication;
                var props = new AuthenticationProperties();
                props.StoreTokens(info.AuthenticationTokens);
                props.IsPersistent = false;

                var userId = await _userManager.GetUserIdAsync(user);
                var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
                code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
                var callbackUrl = Url.Page(
                    "/Account/ConfirmEmail",
                    pageHandler: null,
                    values: new { area = "Identity", userId = userId, code = code },
                    protocol: Request.Scheme);

                await _emailSender.SendEmailAsync(Input.Email, "Confirm your email",
                    $"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");

                // If account confirmation is required, we need to show the link if we don't have a real email sender
                if (_userManager.Options.SignIn.RequireConfirmedAccount)
                {
                    return RedirectToPage("./RegisterConfirmation", new { Email = Input.Email });
                }

                await _signInManager.SignInAsync(user, props, info.LoginProvider);
                return LocalRedirect(returnUrl);
            }
        }
        foreach (var error in result.Errors)
        {
            ModelState.AddModelError(string.Empty, error.Description);
        }
    }

    ProviderDisplayName = info.ProviderDisplayName;
    ReturnUrl = returnUrl;
    return Page();
}

默认情况下,用户的声明存储在身份验证 cookie 中。 如果身份验证 cookie 太大,可能会导致应用失败,这是因为:

  • 浏览器检测到 cookie 标头太长。
  • 请求的总体大小太大。

如果处理用户请求需要大量用户数据:

  • 仅使用应用需要用于处理请求的用户声明数量和大小。
  • 对 Cookie 身份验证中间件的 SessionStore 使用自定义 ITicketStore,以存储请求之间的 identity 。 在服务器上保留大量 identity 信息,同时仅向客户端发送一个小的会话标识符键。

保存访问令牌

SaveTokens 定义在授权成功后,是否应在 AuthenticationProperties 中存储访问令牌和刷新令牌。 SaveTokens 默认设置为 false,以减少最终身份验证 cookie 的大小。

GoogleOptions 中,示例应用将 SaveTokens 的值设置为 true

builder.Services.AddAuthentication().AddGoogle(googleOptions =>
{
    googleOptions.ClientId = configuration["Authentication:Google:ClientId"];
    googleOptions.ClientSecret = configuration["Authentication:Google:ClientSecret"];

    googleOptions.ClaimActions.MapJsonKey("urn:google:picture", "picture", "url");
    googleOptions.ClaimActions.MapJsonKey("urn:google:locale", "locale", "string");

    googleOptions.SaveTokens = true;

    googleOptions.Events.OnCreatingTicket = ctx =>
    {
        List<AuthenticationToken> tokens = ctx.Properties.GetTokens().ToList();

        tokens.Add(new AuthenticationToken()
        {
            Name = "TicketCreated",
            Value = DateTime.UtcNow.ToString()
        });

        ctx.Properties.StoreTokens(tokens);

        return Task.CompletedTask;
    };
});

执行 OnPostConfirmationAsync 时,将来自外部提供程序的访问令牌 (ExternalLoginInfo.AuthenticationTokens) 存储在 ApplicationUserAuthenticationProperties 中。

Account/ExternalLogin.cshtml.cs 中,示例应用将访问令牌保存在 OnPostConfirmationAsync 中(针对新用户注册)和 OnGetCallbackAsync(针对之前注册的用户):

public async Task<IActionResult> OnPostConfirmationAsync(string returnUrl = null)
{
    returnUrl = returnUrl ?? Url.Content("~/");
    // Get the information about the user from the external login provider
    var info = await _signInManager.GetExternalLoginInfoAsync();
    if (info == null)
    {
        ErrorMessage = "Error loading external login information during confirmation.";
        return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
    }

    if (ModelState.IsValid)
    {
        var user = CreateUser();

        await _userStore.SetUserNameAsync(user, Input.Email, CancellationToken.None);
        await _emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None);

        var result = await _userManager.CreateAsync(user);
        if (result.Succeeded)
        {
            result = await _userManager.AddLoginAsync(user, info);
            if (result.Succeeded)
            {
                _logger.LogInformation("User created an account using {Name} provider.", info.LoginProvider);

                // If they exist, add claims to the user for:
                //    Given (first) name
                //    Locale
                //    Picture
                if (info.Principal.HasClaim(c => c.Type == ClaimTypes.GivenName))
                {
                    await _userManager.AddClaimAsync(user,
                        info.Principal.FindFirst(ClaimTypes.GivenName));
                }

                if (info.Principal.HasClaim(c => c.Type == "urn:google:locale"))
                {
                    await _userManager.AddClaimAsync(user,
                        info.Principal.FindFirst("urn:google:locale"));
                }

                if (info.Principal.HasClaim(c => c.Type == "urn:google:picture"))
                {
                    await _userManager.AddClaimAsync(user,
                        info.Principal.FindFirst("urn:google:picture"));
                }

                // Include the access token in the properties
                // using Microsoft.AspNetCore.Authentication;
                var props = new AuthenticationProperties();
                props.StoreTokens(info.AuthenticationTokens);
                props.IsPersistent = false;

                var userId = await _userManager.GetUserIdAsync(user);
                var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
                code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
                var callbackUrl = Url.Page(
                    "/Account/ConfirmEmail",
                    pageHandler: null,
                    values: new { area = "Identity", userId = userId, code = code },
                    protocol: Request.Scheme);

                await _emailSender.SendEmailAsync(Input.Email, "Confirm your email",
                    $"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");

                // If account confirmation is required, we need to show the link if we don't have a real email sender
                if (_userManager.Options.SignIn.RequireConfirmedAccount)
                {
                    return RedirectToPage("./RegisterConfirmation", new { Email = Input.Email });
                }

                await _signInManager.SignInAsync(user, props, info.LoginProvider);
                return LocalRedirect(returnUrl);
            }
        }
        foreach (var error in result.Errors)
        {
            ModelState.AddModelError(string.Empty, error.Description);
        }
    }

    ProviderDisplayName = info.ProviderDisplayName;
    ReturnUrl = returnUrl;
    return Page();
}

注意

有关将令牌传递到 Razor 服务器端 Blazor 应用的组件的信息,请参阅 ASP.NET 核心服务器端和其他 Blazor Web App 安全方案

如何添加其他自定义令牌

为了演示如何添加自定义令牌(它存储为 SaveTokens 的一部分),示例应用为 TicketCreatedAuthenticationToken.Name 创建一个带有当前 DateTimeAuthenticationToken

builder.Services.AddAuthentication().AddGoogle(googleOptions =>
{
    googleOptions.ClientId = configuration["Authentication:Google:ClientId"];
    googleOptions.ClientSecret = configuration["Authentication:Google:ClientSecret"];

    googleOptions.ClaimActions.MapJsonKey("urn:google:picture", "picture", "url");
    googleOptions.ClaimActions.MapJsonKey("urn:google:locale", "locale", "string");

    googleOptions.SaveTokens = true;

    googleOptions.Events.OnCreatingTicket = ctx =>
    {
        List<AuthenticationToken> tokens = ctx.Properties.GetTokens().ToList();

        tokens.Add(new AuthenticationToken()
        {
            Name = "TicketCreated",
            Value = DateTime.UtcNow.ToString()
        });

        ctx.Properties.StoreTokens(tokens);

        return Task.CompletedTask;
    };
});

创建和添加声明

框架提供用于创建声明和向集合添加声明的常见操作和扩展方法。 有关详细信息,请参阅 ClaimActionCollectionMapExtensionsClaimActionCollectionUniqueExtensions

用户可通过从 ClaimAction 进行派生和实现抽象 Run 方法来定义自定义操作。

有关详细信息,请参阅 Microsoft.AspNetCore.Authentication.OAuth.Claims

添加和更新用户声明

声明在首次注册时(而不是登录时)从外部提供程序复制到用户数据库。 如果用户注册使用应用后在应用中启用了其他声明,请对用户调用 SignInManager.RefreshSignInAsync 来强制生成新的身份验证 cookie。

在使用测试用户帐户的开发环境中,删除再重新创建用户帐户。 对于生产系统,添加到应用的新声明可回填到用户帐户中。 在 Areas/Pages/Identity/Account/Manage搭建 ExternalLogin 页面的基架到应用后,请将以下代码添加到 ExternalLogin.cshtml.cs 文件中的 ExternalLoginModel

添加已添加的声明的字典。 使用字典键保存声明类型,并使用值来保存默认值。 将以下行添加到类的顶部。 以下示例假定为用户的 Google 图片添加了一个声明,其中一个通用头像图像为默认值:

private readonly IReadOnlyDictionary<string, string> _claimsToSync =
     new Dictionary<string, string>()
     {
             { "urn:google:picture", "https://localhost:5001/headshot.png" },
     };

OnGetCallbackAsync 方法的默认代码替换为以下代码。 该代码会循环访问声明字典。 已为每位用户添加(回填)或更新声明。 添加或更新声明时,使用 SignInManager<TUser> 刷新用户登录,同时保留现有的身份验证属性 (AuthenticationProperties)。

private readonly IReadOnlyDictionary<string, string> _claimsToSync =
     new Dictionary<string, string>()
     {
             { "urn:google:picture", "https://localhost:5001/headshot.png" },
     };

public async Task<IActionResult> OnGetCallbackAsync(string returnUrl = null, string remoteError = null)
{
    returnUrl = returnUrl ?? Url.Content("~/");
    if (remoteError != null)
    {
        ErrorMessage = $"Error from external provider: {remoteError}";
        return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
    }
    var info = await _signInManager.GetExternalLoginInfoAsync();
    if (info == null)
    {
        ErrorMessage = "Error loading external login information.";
        return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
    }

    // Sign in the user with this external login provider if the user already has a login.
    var result = await _signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false, bypassTwoFactor: true);
    if (result.Succeeded)
    {
        _logger.LogInformation("{Name} logged in with {LoginProvider} provider.", info.Principal.Identity.Name, info.LoginProvider);
        if (_claimsToSync.Count > 0)
        {
            var user = await _userManager.FindByLoginAsync(info.LoginProvider,
                info.ProviderKey);
            var userClaims = await _userManager.GetClaimsAsync(user);
            bool refreshSignIn = false;

            foreach (var addedClaim in _claimsToSync)
            {
                var userClaim = userClaims
                    .FirstOrDefault(c => c.Type == addedClaim.Key);

                if (info.Principal.HasClaim(c => c.Type == addedClaim.Key))
                {
                    var externalClaim = info.Principal.FindFirst(addedClaim.Key);

                    if (userClaim == null)
                    {
                        await _userManager.AddClaimAsync(user,
                            new Claim(addedClaim.Key, externalClaim.Value));
                        refreshSignIn = true;
                    }
                    else if (userClaim.Value != externalClaim.Value)
                    {
                        await _userManager
                            .ReplaceClaimAsync(user, userClaim, externalClaim);
                        refreshSignIn = true;
                    }
                }
                else if (userClaim == null)
                {
                    // Fill with a default value
                    await _userManager.AddClaimAsync(user, new Claim(addedClaim.Key,
                        addedClaim.Value));
                    refreshSignIn = true;
                }
            }

            if (refreshSignIn)
            {
                await _signInManager.RefreshSignInAsync(user);
            }
        }

        return LocalRedirect(returnUrl);
    }
    if (result.IsLockedOut)
    {
        return RedirectToPage("./Lockout");
    }
    else
    {
        // If the user does not have an account, then ask the user to create an account.
        ReturnUrl = returnUrl;
        ProviderDisplayName = info.ProviderDisplayName;
        if (info.Principal.HasClaim(c => c.Type == ClaimTypes.Email))
        {
            Input = new InputModel
            {
                Email = info.Principal.FindFirstValue(ClaimTypes.Email)
            };
        }
        return Page();
    }
}

如果在用户登录时更改声明,但不需要回填步骤,那么将采用类似的方法。 若要更新用户的声明,请对该用户调用以下代码:

删除声明操作和声明

ClaimActionCollection.Remove(String) 从集合中删除给定 ClaimType 的所有声明操作。 ClaimActionCollectionMapExtensions.DeleteClaim(ClaimActionCollection, String) 从 identity 中删除给定 ClaimType 的声明。 DeleteClaim 主要用于 OpenID Connect (OIDC) 来删除协议生成的声明。

示例应用输出

运行示例应用并选择“MyClaims”链接:

User Claims

http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier
    9b342344f-7aab-43c2-1ac1-ba75912ca999
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name
    someone@gmail.com
AspNet.Identity.SecurityStamp
    7D4312MOWRYYBFI1KXRPHGOSTBVWSFDE
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname
    Judy
urn:google:locale
    en
urn:google:picture
    https://lh4.googleusercontent.com/-XXXXXX/XXXXXX/XXXXXX/XXXXXX/photo.jpg

Authentication Properties

.Token.access_token
    yc23.AlvoZqz56...1lxltXV7D-ZWP9
.Token.token_type
    Bearer
.Token.expires_at
    2019-04-11T22:14:51.0000000+00:00
.Token.TicketCreated
    4/11/2019 9:14:52 PM
.TokenNames
    access_token;token_type;expires_at;TicketCreated
.persistent
.issued
    Thu, 11 Apr 2019 20:51:06 GMT
.expires
    Thu, 25 Apr 2019 20:51:06 GMT

使用代理或负载均衡器转发请求信息

如果应用部署在代理服务器或负载均衡器后面,则可能会将某些原始请求信息转发到请求标头中的应用。 此信息通常包括安全请求方案 (https)、主机和客户端 IP 地址。 应用不会自动读取这些请求标头以发现和使用原始请求信息。

方案用于通过外部提供程序影响身份验证流的链接生成。 丢失安全方案 (https) 会导致应用生成不正确且不安全的重定向 URL。

使用转发标头中间件以使应用可以使用原始请求信息来进行请求处理。

有关详细信息,请参阅配置 ASP.NET Core 以使用代理服务器和负载均衡器

查看或下载示例代码如何下载

ASP.NET Core 应用可从外部提供程序(例如 Facebook、Google、Microsoft 和 Twitter)建立附加声明和令牌。 每个提供程序在其平台上显示有关用户的不同信息,但接收用户数据和将该数据转换为附加声明的模式是相同的。

查看或下载示例代码如何下载

先决条件

确定在应用中支持哪些外部身份验证提供程序。 对于每个提供程序,请注册应用并获取客户端 ID 和客户端密码。 有关详细信息,请参阅 ASP.NET Core 中的 Facebook 和 Google 身份验证。 示例应用使用 Google 身份验证提供程序

设置客户端 ID 和客户端密码

OAuth 身份验证提供程序使用客户端 ID 和客户端密码与应用建立信任关系。 向提供程序注册应用时,外部身份验证提供程序会为应用创建客户端 ID 和客户端密码值。 应用使用的每个外部提供程序都必须使用提供程序的客户端 ID 和客户端密码单独进行配置。 有关详细信息,请参阅适合你的方案的外部身份验证提供程序主题:

在身份验证提供程序的 ID 或访问令牌中发送的可选声明通常是在提供程序的联机门户中配置的。 例如,通过 Microsoft Entra ID,可让你在应用注册的“令牌配置”边栏选项卡中为应用的 ID 令牌分配可选声明。 有关详细信息,请参阅如何:向应用提供可选声明(Azure 文档)。 对于其他提供程序,请查阅其外部文档集。

示例应用使用 Google 提供的客户端 ID 和客户端密码配置 Google 身份验证提供程序:

services.AddAuthentication().AddGoogle(options =>
{
    // Provide the Google Client ID
    options.ClientId = "XXXXXXXXXXXXXXX.apps.googleusercontent.com";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientId" "{Client ID}"

    // Provide the Google Client Secret
    options.ClientSecret = "{Client Secret}";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientSecret" "{Client Secret}"

    options.ClaimActions.MapJsonKey("urn:google:picture", "picture", "url");
    options.ClaimActions.MapJsonKey("urn:google:locale", "locale", "string");
    options.SaveTokens = true;

    options.Events.OnCreatingTicket = ctx =>
    {
        List<AuthenticationToken> tokens = ctx.Properties.GetTokens().ToList(); 

        tokens.Add(new AuthenticationToken()
        {
            Name = "TicketCreated", 
            Value = DateTime.UtcNow.ToString()
        });

        ctx.Properties.StoreTokens(tokens);

        return Task.CompletedTask;
    };
});

建立身份验证范围

通过指定 Scope,指定用于从提供程序检索内容的权限列表。 下表显示了常见外部提供程序的身份验证范围。

提供程序 范围
Facebook https://www.facebook.com/dialog/oauth
Google profileemail、、 openid
Microsoft https://login.microsoftonline.com/common/oauth2/v2.0/authorize
Twitter https://api.twitter.com/oauth/authenticate

在示例应用中,当在 AuthenticationBuilder 上调用 AddGoogle 时,框架会自动添加 Google 的 profileemailopenid 范围。 如果应用需要其他范围,请将它们添加到选项中。 在下面的示例中,添加了 Google https://www.googleapis.com/auth/user.birthday.read 范围来检索用户的生日:

options.Scope.Add("https://www.googleapis.com/auth/user.birthday.read");

映射用户数据键并创建声明

在提供程序的选项中,为外部提供程序的 JSON 用户数据中的每个键/子项指定 MapJsonKeyMapJsonSubKey,以便在登录时读取应用 identity。 有关声明类型的详细信息,请参阅 ClaimTypes

示例应用通过 Google 用户数据中的 localepicture 键创建区域设置 (urn:google:locale) 和图片 (urn:google:picture) 声明:

services.AddAuthentication().AddGoogle(options =>
{
    // Provide the Google Client ID
    options.ClientId = "XXXXXXXXXXXXXXX.apps.googleusercontent.com";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientId" "{Client ID}"

    // Provide the Google Client Secret
    options.ClientSecret = "{Client Secret}";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientSecret" "{Client Secret}"

    options.ClaimActions.MapJsonKey("urn:google:picture", "picture", "url");
    options.ClaimActions.MapJsonKey("urn:google:locale", "locale", "string");
    options.SaveTokens = true;

    options.Events.OnCreatingTicket = ctx =>
    {
        List<AuthenticationToken> tokens = ctx.Properties.GetTokens().ToList(); 

        tokens.Add(new AuthenticationToken()
        {
            Name = "TicketCreated", 
            Value = DateTime.UtcNow.ToString()
        });

        ctx.Properties.StoreTokens(tokens);

        return Task.CompletedTask;
    };
});

Microsoft.AspNetCore.Identity.UI.Pages.Account.Internal.ExternalLoginModel.OnPostConfirmationAsync 中,IdentityUser (ApplicationUser) 使用 SignInAsync 登录到应用。 在登录过程中,UserManager<TUser> 可存储 Principal 中提供的用户数据的 ApplicationUser 声明。

在示例应用中,OnPostConfirmationAsync (Account/ExternalLogin.cshtml.cs) 会为登录的 ApplicationUser 建立区域设置 (urn:google:locale) 和图片 (urn:google:picture) 声明,包括 GivenName 的声明:

public async Task<IActionResult> OnPostConfirmationAsync(string returnUrl = null)
{
    returnUrl = returnUrl ?? Url.Content("~/");
    // Get the information about the user from the external login provider
    var info = await _signInManager.GetExternalLoginInfoAsync();

    if (info == null)
    {
        ErrorMessage = 
            "Error loading external login information during confirmation.";

        return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
    }

    if (ModelState.IsValid)
    {
        var user = new IdentityUser
        {
            UserName = Input.Email, 
            Email = Input.Email 
        };

        var result = await _userManager.CreateAsync(user);

        if (result.Succeeded)
        {
            result = await _userManager.AddLoginAsync(user, info);

            if (result.Succeeded)
            {
                // If they exist, add claims to the user for:
                //    Given (first) name
                //    Locale
                //    Picture
                if (info.Principal.HasClaim(c => c.Type == ClaimTypes.GivenName))
                {
                    await _userManager.AddClaimAsync(user, 
                        info.Principal.FindFirst(ClaimTypes.GivenName));
                }

                if (info.Principal.HasClaim(c => c.Type == "urn:google:locale"))
                {
                    await _userManager.AddClaimAsync(user, 
                        info.Principal.FindFirst("urn:google:locale"));
                }

                if (info.Principal.HasClaim(c => c.Type == "urn:google:picture"))
                {
                    await _userManager.AddClaimAsync(user, 
                        info.Principal.FindFirst("urn:google:picture"));
                }

                // Include the access token in the properties
                var props = new AuthenticationProperties();
                props.StoreTokens(info.AuthenticationTokens);
                props.IsPersistent = true;

                await _signInManager.SignInAsync(user, props);

                _logger.LogInformation(
                    "User created an account using {Name} provider.", 
                    info.LoginProvider);

                return LocalRedirect(returnUrl);
            }
        }

        foreach (var error in result.Errors)
        {
            ModelState.AddModelError(string.Empty, error.Description);
        }
    }

    LoginProvider = info.LoginProvider;
    ReturnUrl = returnUrl;
    return Page();
}

默认情况下,用户的声明存储在身份验证 cookie 中。 如果身份验证 cookie 太大,可能会导致应用失败,这是因为:

  • 浏览器检测到 cookie 标头太长。
  • 请求的总体大小太大。

如果处理用户请求需要大量用户数据:

  • 仅使用应用需要用于处理请求的用户声明数量和大小。
  • 对 Cookie 身份验证中间件的 SessionStore 使用自定义 ITicketStore,以存储请求之间的 identity 。 在服务器上保留大量 identity 信息,同时仅向客户端发送一个小的会话标识符键。

保存访问令牌

SaveTokens 定义在授权成功后,是否应在 AuthenticationProperties 中存储访问令牌和刷新令牌。 SaveTokens 默认设置为 false,以减少最终身份验证 cookie 的大小。

GoogleOptions 中,示例应用将 SaveTokens 的值设置为 true

services.AddAuthentication().AddGoogle(options =>
{
    // Provide the Google Client ID
    options.ClientId = "XXXXXXXXXXXXXXX.apps.googleusercontent.com";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientId" "{Client ID}"

    // Provide the Google Client Secret
    options.ClientSecret = "{Client Secret}";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientSecret" "{Client Secret}"

    options.ClaimActions.MapJsonKey("urn:google:picture", "picture", "url");
    options.ClaimActions.MapJsonKey("urn:google:locale", "locale", "string");
    options.SaveTokens = true;

    options.Events.OnCreatingTicket = ctx =>
    {
        List<AuthenticationToken> tokens = ctx.Properties.GetTokens().ToList(); 

        tokens.Add(new AuthenticationToken()
        {
            Name = "TicketCreated", 
            Value = DateTime.UtcNow.ToString()
        });

        ctx.Properties.StoreTokens(tokens);

        return Task.CompletedTask;
    };
});

执行 OnPostConfirmationAsync 时,将来自外部提供程序的访问令牌 (ExternalLoginInfo.AuthenticationTokens) 存储在 ApplicationUserAuthenticationProperties 中。

Account/ExternalLogin.cshtml.cs 中,示例应用将访问令牌保存在 OnPostConfirmationAsync 中(针对新用户注册)和 OnGetCallbackAsync(针对之前注册的用户):

public async Task<IActionResult> OnPostConfirmationAsync(string returnUrl = null)
{
    returnUrl = returnUrl ?? Url.Content("~/");
    // Get the information about the user from the external login provider
    var info = await _signInManager.GetExternalLoginInfoAsync();

    if (info == null)
    {
        ErrorMessage = 
            "Error loading external login information during confirmation.";

        return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
    }

    if (ModelState.IsValid)
    {
        var user = new IdentityUser
        {
            UserName = Input.Email, 
            Email = Input.Email 
        };

        var result = await _userManager.CreateAsync(user);

        if (result.Succeeded)
        {
            result = await _userManager.AddLoginAsync(user, info);

            if (result.Succeeded)
            {
                // If they exist, add claims to the user for:
                //    Given (first) name
                //    Locale
                //    Picture
                if (info.Principal.HasClaim(c => c.Type == ClaimTypes.GivenName))
                {
                    await _userManager.AddClaimAsync(user, 
                        info.Principal.FindFirst(ClaimTypes.GivenName));
                }

                if (info.Principal.HasClaim(c => c.Type == "urn:google:locale"))
                {
                    await _userManager.AddClaimAsync(user, 
                        info.Principal.FindFirst("urn:google:locale"));
                }

                if (info.Principal.HasClaim(c => c.Type == "urn:google:picture"))
                {
                    await _userManager.AddClaimAsync(user, 
                        info.Principal.FindFirst("urn:google:picture"));
                }

                // Include the access token in the properties
                var props = new AuthenticationProperties();
                props.StoreTokens(info.AuthenticationTokens);
                props.IsPersistent = true;

                await _signInManager.SignInAsync(user, props);

                _logger.LogInformation(
                    "User created an account using {Name} provider.", 
                    info.LoginProvider);

                return LocalRedirect(returnUrl);
            }
        }

        foreach (var error in result.Errors)
        {
            ModelState.AddModelError(string.Empty, error.Description);
        }
    }

    LoginProvider = info.LoginProvider;
    ReturnUrl = returnUrl;
    return Page();
}

注意

有关将令牌传递到 Razor 服务器端 Blazor 应用的组件的信息,请参阅 ASP.NET 核心服务器端和其他 Blazor Web App 安全方案

如何添加其他自定义令牌

为了演示如何添加自定义令牌(它存储为 SaveTokens 的一部分),示例应用为 TicketCreatedAuthenticationToken.Name 创建一个带有当前 DateTimeAuthenticationToken

services.AddAuthentication().AddGoogle(options =>
{
    // Provide the Google Client ID
    options.ClientId = "XXXXXXXXXXXXXXX.apps.googleusercontent.com";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientId" "{Client ID}"

    // Provide the Google Client Secret
    options.ClientSecret = "{Client Secret}";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientSecret" "{Client Secret}"

    options.ClaimActions.MapJsonKey("urn:google:picture", "picture", "url");
    options.ClaimActions.MapJsonKey("urn:google:locale", "locale", "string");
    options.SaveTokens = true;

    options.Events.OnCreatingTicket = ctx =>
    {
        List<AuthenticationToken> tokens = ctx.Properties.GetTokens().ToList(); 

        tokens.Add(new AuthenticationToken()
        {
            Name = "TicketCreated", 
            Value = DateTime.UtcNow.ToString()
        });

        ctx.Properties.StoreTokens(tokens);

        return Task.CompletedTask;
    };
});

创建和添加声明

框架提供用于创建声明和向集合添加声明的常见操作和扩展方法。 有关详细信息,请参阅 ClaimActionCollectionMapExtensionsClaimActionCollectionUniqueExtensions

用户可通过从 ClaimAction 进行派生和实现抽象 Run 方法来定义自定义操作。

有关详细信息,请参阅 Microsoft.AspNetCore.Authentication.OAuth.Claims

添加和更新用户声明

声明在首次注册时(而不是登录时)从外部提供程序复制到用户数据库。 如果用户注册使用应用后在应用中启用了其他声明,请对用户调用 SignInManager.RefreshSignInAsync 来强制生成新的身份验证 cookie。

在使用测试用户帐户的开发环境中,只需删除再重新创建用户帐户即可。 对于生产系统,添加到应用的新声明可回填到用户帐户中。 在 Areas/Pages/Identity/Account/Manage搭建 ExternalLogin 页面的基架到应用后,请将以下代码添加到 ExternalLogin.cshtml.cs 文件中的 ExternalLoginModel

添加已添加的声明的字典。 使用字典键保存声明类型,并使用值来保存默认值。 将以下行添加到类的顶部。 以下示例假定为用户的 Google 图片添加了一个声明,其中一个通用头像图像为默认值:

private readonly IReadOnlyDictionary<string, string> _claimsToSync = 
    new Dictionary<string, string>()
    {
        { "urn:google:picture", "https://localhost:5001/headshot.png" },
    };

OnGetCallbackAsync 方法的默认代码替换为以下代码。 该代码会循环访问声明字典。 已为每位用户添加(回填)或更新声明。 添加或更新声明时,使用 SignInManager<TUser> 刷新用户登录,同时保留现有的身份验证属性 (AuthenticationProperties)。

public async Task<IActionResult> OnGetCallbackAsync(
    string returnUrl = null, string remoteError = null)
{
    returnUrl = returnUrl ?? Url.Content("~/");

    if (remoteError != null)
    {
        ErrorMessage = $"Error from external provider: {remoteError}";

        return RedirectToPage("./Login", new {ReturnUrl = returnUrl });
    }

    var info = await _signInManager.GetExternalLoginInfoAsync();

    if (info == null)
    {
        ErrorMessage = "Error loading external login information.";
        return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
    }

    // Sign in the user with this external login provider if the user already has a 
    // login.
    var result = await _signInManager.ExternalLoginSignInAsync(info.LoginProvider, 
        info.ProviderKey, isPersistent: false, bypassTwoFactor : true);

    if (result.Succeeded)
    {
        _logger.LogInformation("{Name} logged in with {LoginProvider} provider.", 
            info.Principal.Identity.Name, info.LoginProvider);

        if (_claimsToSync.Count > 0)
        {
            var user = await _userManager.FindByLoginAsync(info.LoginProvider, 
                info.ProviderKey);
            var userClaims = await _userManager.GetClaimsAsync(user);
            bool refreshSignIn = false;

            foreach (var addedClaim in _claimsToSync)
            {
                var userClaim = userClaims
                    .FirstOrDefault(c => c.Type == addedClaim.Key);

                if (info.Principal.HasClaim(c => c.Type == addedClaim.Key))
                {
                    var externalClaim = info.Principal.FindFirst(addedClaim.Key);

                    if (userClaim == null)
                    {
                        await _userManager.AddClaimAsync(user, 
                            new Claim(addedClaim.Key, externalClaim.Value));
                        refreshSignIn = true;
                    }
                    else if (userClaim.Value != externalClaim.Value)
                    {
                        await _userManager
                            .ReplaceClaimAsync(user, userClaim, externalClaim);
                        refreshSignIn = true;
                    }
                }
                else if (userClaim == null)
                {
                    // Fill with a default value
                    await _userManager.AddClaimAsync(user, new Claim(addedClaim.Key, 
                        addedClaim.Value));
                    refreshSignIn = true;
                }
            }

            if (refreshSignIn)
            {
                await _signInManager.RefreshSignInAsync(user);
            }
        }

        return LocalRedirect(returnUrl);
    }

    if (result.IsLockedOut)
    {
        return RedirectToPage("./Lockout");
    }
    else
    {
        // If the user does not have an account, then ask the user to create an 
        // account.
        ReturnUrl = returnUrl;
        ProviderDisplayName = info.ProviderDisplayName;

        if (info.Principal.HasClaim(c => c.Type == ClaimTypes.Email))
        {
            Input = new InputModel
            {
                Email = info.Principal.FindFirstValue(ClaimTypes.Email)
            };
        }

        return Page();
    }
}

如果在用户登录时更改声明,但不需要回填步骤,那么将采用类似的方法。 若要更新用户的声明,请对该用户调用以下代码:

删除声明操作和声明

ClaimActionCollection.Remove(String) 从集合中删除给定 ClaimType 的所有声明操作。 ClaimActionCollectionMapExtensions.DeleteClaim(ClaimActionCollection, String) 从 identity 中删除给定 ClaimType 的声明。 DeleteClaim 主要用于 OpenID Connect (OIDC) 来删除协议生成的声明。

示例应用输出

User Claims

http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier
    9b342344f-7aab-43c2-1ac1-ba75912ca999
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name
    someone@gmail.com
AspNet.Identity.SecurityStamp
    7D4312MOWRYYBFI1KXRPHGOSTBVWSFDE
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname
    Judy
urn:google:locale
    en
urn:google:picture
    https://lh4.googleusercontent.com/-XXXXXX/XXXXXX/XXXXXX/XXXXXX/photo.jpg

Authentication Properties

.Token.access_token
    yc23.AlvoZqz56...1lxltXV7D-ZWP9
.Token.token_type
    Bearer
.Token.expires_at
    2019-04-11T22:14:51.0000000+00:00
.Token.TicketCreated
    4/11/2019 9:14:52 PM
.TokenNames
    access_token;token_type;expires_at;TicketCreated
.persistent
.issued
    Thu, 11 Apr 2019 20:51:06 GMT
.expires
    Thu, 25 Apr 2019 20:51:06 GMT

使用代理或负载均衡器转发请求信息

如果应用部署在代理服务器或负载均衡器后面,则可能会将某些原始请求信息转发到请求标头中的应用。 此信息通常包括安全请求方案 (https)、主机和客户端 IP 地址。 应用不会自动读取这些请求标头以发现和使用原始请求信息。

方案用于通过外部提供程序影响身份验证流的链接生成。 丢失安全方案 (https) 会导致应用生成不正确且不安全的重定向 URL。

使用转发标头中间件以使应用可以使用原始请求信息来进行请求处理。

有关详细信息,请参阅配置 ASP.NET Core 以使用代理服务器和负载均衡器

ASP.NET Core 应用可从外部提供程序(例如 Facebook、Google、Microsoft 和 Twitter)建立附加声明和令牌。 每个提供程序在其平台上显示有关用户的不同信息,但接收用户数据和将该数据转换为附加声明的模式是相同的。

查看或下载示例代码如何下载

先决条件

确定在应用中支持哪些外部身份验证提供程序。 对于每个提供程序,请注册应用并获取客户端 ID 和客户端密码。 有关详细信息,请参阅 ASP.NET Core 中的 Facebook 和 Google 身份验证。 示例应用使用 Google 身份验证提供程序

设置客户端 ID 和客户端密码

OAuth 身份验证提供程序使用客户端 ID 和客户端密码与应用建立信任关系。 向提供程序注册应用时,外部身份验证提供程序会为应用创建客户端 ID 和客户端密码值。 应用使用的每个外部提供程序都必须使用提供程序的客户端 ID 和客户端密码单独进行配置。 有关详细信息,请参阅适合你的方案的外部身份验证提供程序主题:

示例应用使用 Google 提供的客户端 ID 和客户端密码配置 Google 身份验证提供程序:

services.AddAuthentication().AddGoogle(options =>
{
    // Provide the Google Client ID
    options.ClientId = "XXXXXXXXXXXXXXX.apps.googleusercontent.com";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientId" "{Client ID}"

    // Provide the Google Client Secret
    options.ClientSecret = "{Client Secret}";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientSecret" "{Client Secret}"

    options.ClaimActions.MapJsonKey("urn:google:picture", "picture", "url");
    options.ClaimActions.MapJsonKey("urn:google:locale", "locale", "string");
    options.SaveTokens = true;

    options.Events.OnCreatingTicket = ctx =>
    {
        List<AuthenticationToken> tokens = ctx.Properties.GetTokens().ToList(); 

        tokens.Add(new AuthenticationToken()
        {
            Name = "TicketCreated", 
            Value = DateTime.UtcNow.ToString()
        });

        ctx.Properties.StoreTokens(tokens);

        return Task.CompletedTask;
    };
});

建立身份验证范围

通过指定 Scope,指定用于从提供程序检索内容的权限列表。 下表显示了常见外部提供程序的身份验证范围。

提供程序 范围
Facebook https://www.facebook.com/dialog/oauth
Google https://www.googleapis.com/auth/userinfo.profile
Microsoft https://login.microsoftonline.com/common/oauth2/v2.0/authorize
Twitter https://api.twitter.com/oauth/authenticate

在示例应用中,当在 AuthenticationBuilder 上调用 AddGoogle 时,框架会自动添加 Google 的 userinfo.profile 范围。 如果应用需要其他范围,请将它们添加到选项中。 在下面的示例中,为了检索用户的生日,添加了 Google https://www.googleapis.com/auth/user.birthday.read 范围:

options.Scope.Add("https://www.googleapis.com/auth/user.birthday.read");

映射用户数据键并创建声明

在提供程序的选项中,为外部提供程序的 JSON 用户数据中的每个键/子项指定 MapJsonKeyMapJsonSubKey,以便在登录时读取应用 identity。 有关声明类型的详细信息,请参阅 ClaimTypes

示例应用通过 Google 用户数据中的 localepicture 键创建区域设置 (urn:google:locale) 和图片 (urn:google:picture) 声明:

services.AddAuthentication().AddGoogle(options =>
{
    // Provide the Google Client ID
    options.ClientId = "XXXXXXXXXXXXXXX.apps.googleusercontent.com";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientId" "{Client ID}"

    // Provide the Google Client Secret
    options.ClientSecret = "{Client Secret}";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientSecret" "{Client Secret}"

    options.ClaimActions.MapJsonKey("urn:google:picture", "picture", "url");
    options.ClaimActions.MapJsonKey("urn:google:locale", "locale", "string");
    options.SaveTokens = true;

    options.Events.OnCreatingTicket = ctx =>
    {
        List<AuthenticationToken> tokens = ctx.Properties.GetTokens().ToList(); 

        tokens.Add(new AuthenticationToken()
        {
            Name = "TicketCreated", 
            Value = DateTime.UtcNow.ToString()
        });

        ctx.Properties.StoreTokens(tokens);

        return Task.CompletedTask;
    };
});

Microsoft.AspNetCore.Identity.UI.Pages.Account.Internal.ExternalLoginModel.OnPostConfirmationAsync 中,IdentityUser (ApplicationUser) 使用 SignInAsync 登录到应用。 在登录过程中,UserManager<TUser> 可存储 Principal 中提供的用户数据的 ApplicationUser 声明。

在示例应用中,OnPostConfirmationAsync (Account/ExternalLogin.cshtml.cs) 会为登录的 ApplicationUser 建立区域设置 (urn:google:locale) 和图片 (urn:google:picture) 声明,包括 GivenName 的声明:

public async Task<IActionResult> OnPostConfirmationAsync(string returnUrl = null)
{
    returnUrl = returnUrl ?? Url.Content("~/");
    // Get the information about the user from the external login provider
    var info = await _signInManager.GetExternalLoginInfoAsync();

    if (info == null)
    {
        ErrorMessage = 
            "Error loading external login information during confirmation.";

        return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
    }

    if (ModelState.IsValid)
    {
        var user = new IdentityUser
        {
            UserName = Input.Email, 
            Email = Input.Email 
        };

        var result = await _userManager.CreateAsync(user);

        if (result.Succeeded)
        {
            result = await _userManager.AddLoginAsync(user, info);

            if (result.Succeeded)
            {
                // If they exist, add claims to the user for:
                //    Given (first) name
                //    Locale
                //    Picture
                if (info.Principal.HasClaim(c => c.Type == ClaimTypes.GivenName))
                {
                    await _userManager.AddClaimAsync(user, 
                        info.Principal.FindFirst(ClaimTypes.GivenName));
                }

                if (info.Principal.HasClaim(c => c.Type == "urn:google:locale"))
                {
                    await _userManager.AddClaimAsync(user, 
                        info.Principal.FindFirst("urn:google:locale"));
                }

                if (info.Principal.HasClaim(c => c.Type == "urn:google:picture"))
                {
                    await _userManager.AddClaimAsync(user, 
                        info.Principal.FindFirst("urn:google:picture"));
                }

                // Include the access token in the properties
                var props = new AuthenticationProperties();
                props.StoreTokens(info.AuthenticationTokens);
                props.IsPersistent = true;

                await _signInManager.SignInAsync(user, props);

                _logger.LogInformation(
                    "User created an account using {Name} provider.", 
                    info.LoginProvider);

                return LocalRedirect(returnUrl);
            }
        }

        foreach (var error in result.Errors)
        {
            ModelState.AddModelError(string.Empty, error.Description);
        }
    }

    LoginProvider = info.LoginProvider;
    ReturnUrl = returnUrl;
    return Page();
}

默认情况下,用户的声明存储在身份验证 cookie 中。 如果身份验证 cookie 太大,可能会导致应用失败,这是因为:

  • 浏览器检测到 cookie 标头太长。
  • 请求的总体大小太大。

如果处理用户请求需要大量用户数据:

  • 仅使用应用需要用于处理请求的用户声明数量和大小。
  • 对 Cookie 身份验证中间件的 SessionStore 使用自定义 ITicketStore,以存储请求之间的 identity 。 在服务器上保留大量 identity 信息,同时仅向客户端发送一个小的会话标识符键。

保存访问令牌

SaveTokens 定义在授权成功后,是否应在 AuthenticationProperties 中存储访问令牌和刷新令牌。 SaveTokens 默认设置为 false,以减少最终身份验证 cookie 的大小。

GoogleOptions 中,示例应用将 SaveTokens 的值设置为 true

services.AddAuthentication().AddGoogle(options =>
{
    // Provide the Google Client ID
    options.ClientId = "XXXXXXXXXXXXXXX.apps.googleusercontent.com";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientId" "{Client ID}"

    // Provide the Google Client Secret
    options.ClientSecret = "{Client Secret}";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientSecret" "{Client Secret}"

    options.ClaimActions.MapJsonKey("urn:google:picture", "picture", "url");
    options.ClaimActions.MapJsonKey("urn:google:locale", "locale", "string");
    options.SaveTokens = true;

    options.Events.OnCreatingTicket = ctx =>
    {
        List<AuthenticationToken> tokens = ctx.Properties.GetTokens().ToList(); 

        tokens.Add(new AuthenticationToken()
        {
            Name = "TicketCreated", 
            Value = DateTime.UtcNow.ToString()
        });

        ctx.Properties.StoreTokens(tokens);

        return Task.CompletedTask;
    };
});

执行 OnPostConfirmationAsync 时,将来自外部提供程序的访问令牌 (ExternalLoginInfo.AuthenticationTokens) 存储在 ApplicationUserAuthenticationProperties 中。

Account/ExternalLogin.cshtml.cs 中,示例应用将访问令牌保存在 OnPostConfirmationAsync 中(针对新用户注册)和 OnGetCallbackAsync(针对之前注册的用户):

public async Task<IActionResult> OnPostConfirmationAsync(string returnUrl = null)
{
    returnUrl = returnUrl ?? Url.Content("~/");
    // Get the information about the user from the external login provider
    var info = await _signInManager.GetExternalLoginInfoAsync();

    if (info == null)
    {
        ErrorMessage = 
            "Error loading external login information during confirmation.";

        return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
    }

    if (ModelState.IsValid)
    {
        var user = new IdentityUser
        {
            UserName = Input.Email, 
            Email = Input.Email 
        };

        var result = await _userManager.CreateAsync(user);

        if (result.Succeeded)
        {
            result = await _userManager.AddLoginAsync(user, info);

            if (result.Succeeded)
            {
                // If they exist, add claims to the user for:
                //    Given (first) name
                //    Locale
                //    Picture
                if (info.Principal.HasClaim(c => c.Type == ClaimTypes.GivenName))
                {
                    await _userManager.AddClaimAsync(user, 
                        info.Principal.FindFirst(ClaimTypes.GivenName));
                }

                if (info.Principal.HasClaim(c => c.Type == "urn:google:locale"))
                {
                    await _userManager.AddClaimAsync(user, 
                        info.Principal.FindFirst("urn:google:locale"));
                }

                if (info.Principal.HasClaim(c => c.Type == "urn:google:picture"))
                {
                    await _userManager.AddClaimAsync(user, 
                        info.Principal.FindFirst("urn:google:picture"));
                }

                // Include the access token in the properties
                var props = new AuthenticationProperties();
                props.StoreTokens(info.AuthenticationTokens);
                props.IsPersistent = true;

                await _signInManager.SignInAsync(user, props);

                _logger.LogInformation(
                    "User created an account using {Name} provider.", 
                    info.LoginProvider);

                return LocalRedirect(returnUrl);
            }
        }

        foreach (var error in result.Errors)
        {
            ModelState.AddModelError(string.Empty, error.Description);
        }
    }

    LoginProvider = info.LoginProvider;
    ReturnUrl = returnUrl;
    return Page();
}

如何添加其他自定义令牌

为了演示如何添加自定义令牌(它存储为 SaveTokens 的一部分),示例应用为 TicketCreatedAuthenticationToken.Name 创建一个带有当前 DateTimeAuthenticationToken

services.AddAuthentication().AddGoogle(options =>
{
    // Provide the Google Client ID
    options.ClientId = "XXXXXXXXXXXXXXX.apps.googleusercontent.com";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientId" "{Client ID}"

    // Provide the Google Client Secret
    options.ClientSecret = "{Client Secret}";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientSecret" "{Client Secret}"

    options.ClaimActions.MapJsonKey("urn:google:picture", "picture", "url");
    options.ClaimActions.MapJsonKey("urn:google:locale", "locale", "string");
    options.SaveTokens = true;

    options.Events.OnCreatingTicket = ctx =>
    {
        List<AuthenticationToken> tokens = ctx.Properties.GetTokens().ToList(); 

        tokens.Add(new AuthenticationToken()
        {
            Name = "TicketCreated", 
            Value = DateTime.UtcNow.ToString()
        });

        ctx.Properties.StoreTokens(tokens);

        return Task.CompletedTask;
    };
});

创建和添加声明

框架提供用于创建声明和向集合添加声明的常见操作和扩展方法。 有关详细信息,请参阅 ClaimActionCollectionMapExtensionsClaimActionCollectionUniqueExtensions

用户可通过从 ClaimAction 进行派生和实现抽象 Run 方法来定义自定义操作。

有关详细信息,请参阅 Microsoft.AspNetCore.Authentication.OAuth.Claims

删除声明操作和声明

ClaimActionCollection.Remove(String) 从集合中删除给定 ClaimType 的所有声明操作。 ClaimActionCollectionMapExtensions.DeleteClaim(ClaimActionCollection, String) 从 identity 中删除给定 ClaimType 的声明。 DeleteClaim 主要用于 OpenID Connect (OIDC) 来删除协议生成的声明。

示例应用输出

User Claims

http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier
    9b342344f-7aab-43c2-1ac1-ba75912ca999
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name
    someone@gmail.com
AspNet.Identity.SecurityStamp
    7D4312MOWRYYBFI1KXRPHGOSTBVWSFDE
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname
    Judy
urn:google:locale
    en
urn:google:picture
    https://lh4.googleusercontent.com/-XXXXXX/XXXXXX/XXXXXX/XXXXXX/photo.jpg

Authentication Properties

.Token.access_token
    yc23.AlvoZqz56...1lxltXV7D-ZWP9
.Token.token_type
    Bearer
.Token.expires_at
    2019-04-11T22:14:51.0000000+00:00
.Token.TicketCreated
    4/11/2019 9:14:52 PM
.TokenNames
    access_token;token_type;expires_at;TicketCreated
.persistent
.issued
    Thu, 11 Apr 2019 20:51:06 GMT
.expires
    Thu, 25 Apr 2019 20:51:06 GMT

使用代理或负载均衡器转发请求信息

如果应用部署在代理服务器或负载均衡器后面,则可能会将某些原始请求信息转发到请求标头中的应用。 此信息通常包括安全请求方案 (https)、主机和客户端 IP 地址。 应用不会自动读取这些请求标头以发现和使用原始请求信息。

方案用于通过外部提供程序影响身份验证流的链接生成。 丢失安全方案 (https) 会导致应用生成不正确且不安全的重定向 URL。

使用转发标头中间件以使应用可以使用原始请求信息来进行请求处理。

有关详细信息,请参阅配置 ASP.NET Core 以使用代理服务器和负载均衡器

其他资源