身份验证和授权
身份验证是从用户处获取标识凭据(例如名称和密码)并根据授权验证这些凭据的过程。 如果凭据有效,则提交凭据的实体被视为经过身份验证的标识。 一旦建立了标识,授权过程将确定该标识是否能够访问给定的资源。
有许多方法可以将身份验证和授权集成到与 ASP.NET Web 应用程序通信的 .NET MAUI 应用中,包括使用 ASP.NET Core 标识,Microsoft、Google、Facebook 或 Twitter 等外部身份验证提供程序,以及身份验证中间件。 eShop 多平台应用通过使用 IdentityServer 的容器化标识微服务执行身份验证和授权。 应用从 IdentityServer 请求安全令牌来对用户进行身份验证或访问资源。 要使 IdentityServer 代表用户颁发令牌,用户必须登录到 IdentityServer。 但是,IdentityServer 不提供用于身份验证的用户界面或数据库。 因此,在 eShop 参考应用程序中,ASP.NET Core 标识用于此目的。
身份验证
当应用程序需要知道当前用户的标识时,便需要进行身份验证。 ASP.NET Core 识别用户的主要机制是 ASP.NET Core 标识成员资格系统,它将用户信息存储在开发人员配置的数据存储中。 通常,此数据存储为 EntityFramework 存储,但自定义存储或第三方包可用于将标识信息存储在 Azure 存储、DocumentDB 或其他位置。
对于使用本地用户数据存储并通过 cookie 保留请求间的标识信息(这在 ASP.NET Web 应用程序中很常见)的身份验证方案,ASP.NET Core 标识是一个合适的解决方案。 然而,cookie 并不总是保留和传输数据的自然方式。 例如,公开从应用访问的 RESTful 终结点的 ASP.NET Core Web 应用程序通常需要使用持有者令牌身份验证,因为在这种情况下无法使用 cookie。 但是,可以轻松检索持有者令牌并将其包含在从应用发出的 Web 请求的授权标头中。
使用 IdentityServer 颁发持有者令牌
IdentityServer 是适用于 ASP.NET Core 的开源 OpenID Connect 和 OAuth 2.0 框架,可用于许多身份验证和授权方案,包括为本地 ASP.NET Core 标识用户颁发安全令牌。
注意
OpenID Connect 和 OAuth 2.0 非常相似,只是职责不同。
OpenID Connect 是基于 OAuth 2.0 协议构建的身份验证层。 OAuth 2 是一种允许应用程序从安全令牌服务请求访问令牌并使用它们与 API 通信的协议。 这种委托降低了客户端应用程序和 API 的复杂性,因为可以集中身份验证和授权。
OpenID Connect 和 OAuth 2.0 结合了身份验证和 API 访问这两个基本安全问题,而 IdentityServer 是这些协议的实现。
在使用客户端到微服务直接通信的应用程序中,例如 eShop 参考应用程序,可以使用充当安全令牌服务 (STS) 的专用身份验证微服务来对用户进行身份验证,如下图所示。 有关客户端到微服务直接通信的详细信息,请参阅微服务。
eShop 多平台应用与标识微服务通信,该微服务使用 IdentityServer 执行身份验证和 API 访问控制。 因此,此多平台应用从 IdentityServer 请求令牌,用于对用户进行身份验证或访问资源:
- 使用 IdentityServer 对用户进行身份验证是通过多平台应用请求一个标识令牌来实现的,该令牌代表身份验证过程的结果。 它至少包含用户的标识符,以及有关如何以及何时对用户进行身份验证的信息。 它还可能包括其他标识数据。
- 使用 IdentityServer 访问资源是通过多平台应用请求一个访问令牌来实现的,该令牌允许访问一个 API 资源。 客户端请求访问令牌并将其转发到 API。 访问令牌包含有关客户端和用户的信息(如果有)。 然后,API 使用该信息来授权访问其数据。
注意
客户端必须先向 IdentityServer 注册,然后才能成功请求令牌。 有关添加客户端的详细信息,请参阅定义客户端。
将 IdentityServer 添加到 Web 应用程序
为了让 ASP.NET Core Web 应用程序使用 IdentityServer,必须将其添加到 Web 应用程序的 Visual Studio 解决方案中。 有关详细信息,请参阅 IdentityServer 文档中的设置和概述。
一旦 IdentityServer 包含在 Web 应用程序的 Visual Studio 解决方案中,就必须将其添加到其 HTTP 请求处理管道中,以便处理 OpenID Connect 和 OAuth 2.0 终结点的请求。 这是在 Identity.API
项目的 Program.cs 中配置的,如下面的代码示例所示:
...
app.UseIdentityServer();
Web 应用程序的 HTTP 请求处理管道中的顺序很重要。 因此,必须在实现登录屏幕的 UI 框架之前将 IdentityServer 添加到管道中。
配置 IdentityServer
IdentityServer 是通过调用 AddIdentityServer
方法在 Identity.API
项目的 Program.cs 中配置的,如 eShop 参考应用程序中的以下代码示例所示:
builder.Services.AddIdentityServer(options =>
{
options.Authentication.CookieLifetime = TimeSpan.FromHours(2);
options.Events.RaiseErrorEvents = true;
options.Events.RaiseInformationEvents = true;
options.Events.RaiseFailureEvents = true;
options.Events.RaiseSuccessEvents = true;
// TODO: Remove this line in production.
options.KeyManagement.Enabled = false;
})
.AddInMemoryIdentityResources(Config.GetResources())
.AddInMemoryApiScopes(Config.GetApiScopes())
.AddInMemoryApiResources(Config.GetApis())
.AddInMemoryClients(Config.GetClients(builder.Configuration))
.AddAspNetIdentity<ApplicationUser>()
// TODO: Not recommended for production - you need to store your key material somewhere secure
.AddDeveloperSigningCredential();
调用 services.AddIdentityServer
方法后,会调用额外的 Fluent API 来配置以下内容:
- 用于签名的凭据。
- 用户可能会请求访问的 API 和标识资源。
- 将连接到请求令牌的客户端。
- ASP.NET Core 标识。
提示
动态加载 IdentityServer 配置。 IdentityServer 的 API 允许从内存中的配置对象列表配置 IdentityServer。 在 eShop 参考应用程序中,这些内存中的集合被硬编码到应用程序中。 但是,在生产场景中,可以从配置文件或数据库动态加载它们。
有关将 IdentityServer 配置为使用 ASP.NET Core 标识的信息,请参阅 IdentityServer 文档中的使用 ASP.NET Core 标识。
配置 API 资源
配置 API 资源时,AddInMemoryApiResources
方法需要一个 IEnumerable<ApiResource>
集合。 以下代码示例演示了在 eShop 参考应用程序中提供此集合的 GetApis
方法:
public static IEnumerable<ApiResource> GetApis()
{
return new List<ApiResource>
{
new ApiScope("orders", "Orders Service"),
new ApiScope("basket", "Basket Service"),
new ApiScope("webhooks", "Webhooks registration Service"),
};
}
此方法指定 IdentityServer 应保护订单和购物篮 API。 因此,调用这些 API 时将需要 IdentityServer 托管的访问令牌。 有关 ApiResource
类型的详细信息,请参阅 IdentityServer 文档中的 API 资源。
配置标识资源
配置标识资源时,AddInMemoryIdentityResources
方法需要一个 IEnumerable<IdentityResource>
集合。 标识资源是用户 ID、名称或电子邮件地址等数据。 每个标识资源都有唯一的名称,可以为其分配任意声明类型,该类型将包含在用户的标识令牌中。 以下代码示例演示了在 eShop 参考应用程序中提供此集合的 GetResources
方法:
public static IEnumerable<IdentityResource> GetResources()
{
return new List<IdentityResource>
{
new IdentityResources.OpenId(),
new IdentityResources.Profile()
};
}
OpenID Connect 规范指定了一些标准标识资源。 最低要求是支持为用户发出唯一 ID。 这是通过公开 IdentityResources.OpenId
标识资源来实现的。
注意
IdentityResources 类支持 OpenID Connect 规范中定义的所有范围(openid、电子邮件、配置文件、电话和地址)。
IdentityServer 还支持定义自定义标识资源。 有关详细信息,请参阅 IdentityServer 文档中的定义自定义标识资源。 有关 IdentityResource 类型的详细信息,请参阅 IdentityServer 文档中的标识资源。
配置客户端
客户端是可以从 IdentityServer 请求令牌的应用程序。 通常,必须为每个客户端至少定义以下设置:
- 唯一的客户端 ID。
- 允许与令牌服务的交互(称为授权类型)。
- 标识和访问令牌发送到的位置(称为重定向 URI)。
- 允许客户端访问的资源列表(称为范围)。
配置客户端时,AddInMemoryClients
方法需要一个 IEnumerable<Client>
集合。 以下代码示例显示了 eShop 多平台应用在 GetClients
方法中的配置,该方法在 eShop 参考应用程序中提供了此集合:
public static IEnumerable<Client> GetClients(Dictionary<string,string> clientsUrl)
{
return new List<Client>
{
// Omitted for brevity
new Client
{
ClientId = "maui",
ClientName = "eShop MAUI OpenId Client",
AllowedGrantTypes = GrantTypes.Code,
//Used to retrieve the access token on the back channel.
ClientSecrets =
{
new Secret("secret".Sha256())
},
RedirectUris = { configuration["MauiCallback"] },
RequireConsent = false,
RequirePkce = true,
PostLogoutRedirectUris = { $"{configuration["MauiCallback"]}/Account/Redirecting" },
AllowedScopes = new List<string>
{
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile,
IdentityServerConstants.StandardScopes.OfflineAccess,
"orders",
"basket",
"mobileshoppingagg",
"webhooks"
},
//Allow requesting refresh tokens for long lived API access
AllowOfflineAccess = true,
AllowAccessTokensViaBrowser = true,
AlwaysIncludeUserClaimsInIdToken = true,
AccessTokenLifetime = 60 * 60 * 2, // 2 hours
IdentityTokenLifetime = 60 * 60 * 2 // 2 hours
}
};
}
此配置指定以下属性的数据:
属性 | 说明 |
---|---|
ClientId |
客户端的唯一 ID。 |
ClientName |
客户端显示名称,用于日志记录和同意屏幕。 |
AllowedGrantTypes |
指定客户端希望如何与 IdentityServer 交互。 有关详细信息,请参阅配置身份验证流。 |
ClientSecrets |
指定从令牌终结点请求令牌时使用的客户端机密凭据。 |
RedirectUris |
指定要向其返回令牌或授权代码的允许 URI。 |
RequireConsent |
指定是否需要同意屏幕。 |
RequirePkce |
指定使用授权代码的客户端是否必须发送证明密钥。 |
PostLogoutRedirectUris |
指定注销后要重定向到的允许 URI。 |
AllowedCorsOrigins |
指定客户端的源,以便 IdentityServer 可以允许来自该源的跨域调用。 |
AllowedScopes |
指定客户端有权访问的资源。 默认情况下,客户端无权访问任何资源。 |
AllowOfflineAccess |
指定客户端是否可以请求刷新令牌。 |
AllowAccessTokensViaBrowser |
指定客户端是否可从浏览器窗口接收访问令牌。 |
AlwaysIncludeUserClaimsInIdToken |
指定用户声明将始终添加到 ID 令牌中。 默认情况下,必须使用 userinfo 终结点检索这些内容。 |
AccessTokenLifetime |
指定访问令牌的生存期(以秒为单位)。 |
IdentityTokenLifetime |
指定标识令牌的生存期(以秒为单位)。 |
配置身份验证流
可以通过在 Client.AllowedGrantTypes
属性中指定授权类型来配置客户端和 IdentityServer 之间的身份验证流。 OpenID Connect 和 OAuth 2.0 规范定义了多个身份验证流,包括:
身份验证流程 | 说明 |
---|---|
隐式 | 此流针对基于浏览器的应用程序进行了优化,应用于仅用户身份验证或身份验证和访问令牌请求。 所有令牌都通过浏览器传输,因此不允许使用刷新令牌等高级功能。 |
授权代码 | 此流提供了在反向通道(与浏览器前向通道相对)上检索令牌的能力,同时还支持客户端身份验证。 |
混合 | 此流是隐式和授权代码授予类型的组合。 标识令牌通过浏览器通道传输,并包含签名协议响应和其他项目,如授权代码。 成功验证响应后,应使用反向通道检索访问和刷新令牌。 |
提示
请考虑使用混合身份验证流。 混合身份验证流可缓解应用于浏览器通道的许多攻击,并且是希望检索访问令牌(可能还有刷新令牌)的本机应用程序的推荐流。
有关身份验证流的详细信息,请参阅 IdentityServer 文档中的授权类型。
执行身份验证
要使 IdentityServer 代表用户颁发令牌,用户必须登录到 IdentityServer。 但是,IdentityServer 不提供用于身份验证的用户界面或数据库。 因此,在 eShop 参考应用程序中,ASP.NET Core 标识用于此目的。
eShop 多平台应用使用混合身份验证流向 IdentityServer 进行身份验证,如下图所示。
向 <base endpoint>:5105/connect/authorize
发出了登录请求。 成功进行身份验证后,IdentityServer 返回包含授权代码和标识令牌的身份验证响应。 授权代码发送到 <base endpoint>:5105/connect/token
,后者用访问、标识和刷新令牌进行响应。
eShop 多平台应用通过向 <base endpoint>:5105/connect/endsession
发送带有附加参数的请求来注销 IdentityServer。 退出登录后,Identity Server 通过将注销后重定向 URI 发送回多平台应用来做出响应。 下图阐释了该过程。
在 eShop 多平台应用中,与 IdentityServer 的通信由 IdentityService
类执行,该类实现 IIdentityService
接口。 此接口指定实现类必须提供 SignInAsync
、SignOutAsync
、GetUserInfoAsync
和 GetAuthTokenAsync
方法。
登录
当用户点击 LoginView
上的 LOGIN
按钮时,将会执行 LoginViewModel
类中的 SignInCommand
,进而执行 SignInAsync
方法。 下面的代码示例演示此方法:
[RelayCommand]
private async Task SignInAsync()
{
await IsBusyFor(
async () =>
{
var loginSuccess = await _appEnvironmentService.IdentityService.SignInAsync();
if (loginSuccess)
{
await NavigationService.NavigateToAsync("//Main/Catalog");
}
});
}
此方法调用 IdentityService
类中的 SignInAsync
方法,如以下代码示例所示:
public async Task<bool> SignInAsync()
{
var response = await GetClient().LoginAsync(new LoginRequest()).ConfigureAwait(false);
if (response.IsError)
{
return false;
}
await _settingsService
.SetUserTokenAsync(
new UserToken
{
AccessToken = response.AccessToken,
IdToken = response.IdentityToken,
RefreshToken = response.RefreshToken,
ExpiresAt = response.AccessTokenExpiration
})
.ConfigureAwait(false);
return !response.IsError;
}
IdentityService
使用与 IdentityModel.OidcClient
NuGet 包一起提供的 OidcClient
。 此客户端在应用程序中向用户显示身份验证 Web 视图,并捕获身份验证结果。 客户端使用所需的参数连接到 IdentityServer 的授权终结点的 URI。 授权终结点位于作为用户设置公开的基本终结点的端口 5105 上的 /connect/authorize
。 有关用户设置的详细信息,请参阅配置管理。
注意
通过对 OAuth 实施代码交换证明密钥 (PKCE) 扩展来减少 eShop 多平台应用的攻击面。 PKCE 保护授权代码在被截获时不被使用。 这是通过客户端生成一个机密验证程序来实现的,该验证程序的哈希在授权请求中传递,并且在兑换授权代码时以未经过哈希处理的方式显示。 有关 PKCE 的详细信息,请参阅 Internet 工程任务组网站上的OAuth 公共客户端的代码交换证明密钥。
如果令牌终结点收到有效的身份验证信息、授权代码和 PKCE 机密验证程序,它将使用访问令牌、标识令牌和刷新令牌进行响应。 访问令牌(允许访问 API 资源)和标识令牌作为应用程序设置进行存储,并执行页面导航。 因此,eShop 多平台应用中的整体效果是这样的:假设用户能够成功通过 IdentityServer 进行身份验证,他们将导航到 //Main/Catalog
路由,这是一个 TabbedPage
,将 CatalogView
显示为其选定的选项卡。
有关页面导航的信息,请参阅导航。 有关 WebView 导航如何导致执行视图模型方法的信息,请参阅使用行为调用导航。 有关应用程序设置的信息,请参阅配置管理。
注意
当应用配置为使用 SettingsView
中的模拟服务时,eShop 还允许模拟登录。 在此模式下,应用不会与 IdentityServer 通信,而是允许用户使用任何凭据登录。
注销
当用户点击 ProfileView
中的 LOG OUT
按钮时,将会执行 ProfileViewModel
类中的 LogoutCommand
,进而执行 LogoutAsync
方法。 此方法执行向 LoginView
页的页面导航,传递一个设置为 true
的 Logout
查询参数。
该参数在 ApplyQueryAttributes
方法中求值。 如果 Logout
参数以 true
值显示,则执行 LoginViewModel
类的 PerformLogoutAsync
方法,如以下代码示例所示:
private async Task PerformLogoutAsync()
{
await _appEnvironmentService.IdentityService.SignOutAsync();
_settingsService.UseFakeLocation = false;
UserName.Value = string.Empty;
Password.Value = string.Empty;
}
此方法调用 IdentityService
类中的 SignOutAsync
方法,该方法调用 OidcClient
以结束用户的会话并清除任何保存的用户令牌。 有关应用程序设置的详细信息,请参阅配置管理。 下面的代码示例说明 SignOutAsync
方法:
public async Task<bool> SignOutAsync()
{
var response = await GetClient().LogoutAsync(new LogoutRequest()).ConfigureAwait(false);
if (response.IsError)
{
return false;
}
await _settingsService.SetUserTokenAsync(default);
return !response.IsError;
}
此方法使用 OidcClient
通过所需的参数调用 IdentityServer 的结束会话终结点的 URI。 结束会话终结点位于作为用户设置公开的基本终结点的端口 5105 上的 /connect/endsession
。 用户成功注销后,LoginView
向用户显示,并且任何已保存的用户信息将被清除。
有关页面导航的信息,请参阅导航。 有关 WebView
导航如何导致执行视图模型方法的信息,请参阅使用行为调用导航。 有关应用程序设置的信息,请参阅配置管理。
注意
当应用配置为使用 SettingsView
中的模拟服务时,eShop 还允许模拟注销。 在此模式下,应用不与 IdentityServer 通信,而是从应用程序设置中清除所有存储的令牌。
授权
经过身份验证后,ASP.NET Core Web API 通常需要授权访问,这允许服务向某些经过身份验证的用户提供 API,但并非全部用户。
可以通过将 Authorize 属性应用于控制器或操作来限制对 ASP.NET Core 路由的访问,从而将对控制器或操作的访问限制为经过身份验证的用户,如以下代码示例所示:
[Authorize]
public sealed class BasketController : Controller
{
// Omitted for brevity
}
如果未经授权的用户尝试访问标有 Authorize 属性的控制器或操作,API 框架将返回 401 (unauthorized)
HTTP 状态代码。
注意
可以在 Authorize 属性上指定参数以将 API 限制到特定用户。 有关详细信息,请参阅 ASP.NET Core 文档:授权。
IdentityServer 可集成到授权工作流中,以便访问令牌提供控制授权。 下图显示了此方法。
eShop 多平台应用与标识微服务通信,并在身份验证过程中请求访问令牌。 然后,访问令牌作为访问请求的一部分转发到订购和购物篮微服务公开的 API。 访问令牌包含有关客户端和用户的信息。 然后,API 使用该信息来授权访问其数据。 有关如何配置 IdentityServer 以保护 API 的信息,请参阅配置 API 资源。
配置 IdentityServer 以执行授权
若要使用 IdentityServer 执行授权,必须将其授权中间件添加到 Web 应用程序的 HTTP 请求管道中。 中间件添加到 AddApplicationServices
扩展方法中,该方法从 Program
类中的 AddDefaultAuthentication
方法调用,并在 eShop 参考应用程序的以下代码示例中进行了演示:
public static IServiceCollection AddDefaultAuthentication(this IHostApplicationBuilder builder)
{
var services = builder.Services;
var configuration = builder.Configuration;
var identitySection = configuration.GetSection("Identity");
if (!identitySection.Exists())
{
// No identity section, so no authentication
return services;
}
// prevent from mapping "sub" claim to nameidentifier.
JsonWebTokenHandler.DefaultInboundClaimTypeMap.Remove("sub");
services.AddAuthentication().AddJwtBearer(options =>
{
var identityUrl = identitySection.GetRequiredValue("Url");
var audience = identitySection.GetRequiredValue("Audience");
options.Authority = identityUrl;
options.RequireHttpsMetadata = false;
options.Audience = audience;
options.TokenValidationParameters.ValidIssuers = [identityUrl];
options.TokenValidationParameters.ValidateAudience = false;
});
services.AddAuthorization();
return services;
}
此方法可确保只能使用有效的访问令牌访问 API。 中间件会验证传入令牌,以确保它从受信任的颁发者发送,并验证令牌是否可用于接收它的 API。 因此,浏览订购或购物篮控制器将返回 401 (unauthorized)
HTTP 状态代码,指示需要访问令牌。
向 API 发出访问请求
在向订购和购物篮微服务发出请求时,身份验证过程中从 IdentityServer 获取的访问令牌必须包含在请求中,如下代码示例所示:
public async Task CreateOrderAsync(Models.Orders.Order newOrder)
{
var authToken = await _identityService.GetAuthTokenAsync().ConfigureAwait(false);
if (string.IsNullOrEmpty(authToken))
{
return;
}
var uri = $"{UriHelper.CombineUri(_settingsService.GatewayOrdersEndpointBase, ApiUrlBase)}?api-version=1.0";
var success = await _requestProvider.PostAsync(uri, newOrder, authToken, "x-requestid").ConfigureAwait(false);
}
访问令牌使用 IIdentityService
实现进行存储,且可使用 GetAuthTokenAsync
方法进行检索。
同样,在向受 IdentityServer 保护的 API 发送数据时也必须包含访问令牌,如以下代码示例所示:
public async Task ClearBasketAsync()
{
var authToken = await _identityService.GetAuthTokenAsync().ConfigureAwait(false);
if (string.IsNullOrEmpty(authToken))
{
return;
}
await GetBasketClient().DeleteBasketAsync(new DeleteBasketRequest(), CreateAuthenticationHeaders(authToken))
.ConfigureAwait(false);
}
访问令牌从 IIdentityService
检索并包含在对 BasketService
类中的 ClearBasketAsync
方法的调用中。
eShop 多平台应用中的 RequestProvider
类使用 HttpClient
类向 eShop 参考应用程序公开的 RESTful API 发出请求。 向需要授权的订购和购物篮 API 发出请求时,请求必须包含有效的访问令牌。 这是通过向 HttpClient 实例的标头添加访问令牌来实现的,如以下代码示例所示:
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
HttpClient
类的 DefaultRequestHeaders
属性公开随每个请求发送的标头,访问令牌被添加到以字符串 Bearer
为前缀的 Authorization
标头中。 当请求发送到 RESTful API 时,会提取并验证 Authorization
标头的值,以确保它是从受信任的颁发者发送的,并用于确定用户是否有权调用接收它的 API。
有关 eShop 多平台应用如何发出 Web 请求的详细信息,请参阅访问远程数据。
总结
有许多方法可以将身份验证和授权集成到与 ASP.NET Web 应用程序通信的 .NET MAUI 应用中。 eShop 多平台应用通过使用 IdentityServer 的容器化标识微服务执行身份验证和授权。 IdentityServer 是一个适用于 ASP.NET Core 的开源 OpenID Connect 和 OAuth 2.0 框架,它与 ASP.NET Core 标识集成以执行持有者令牌身份验证。
此多平台应用从 IdentityServer 请求安全令牌来对用户进行身份验证或访问资源。 访问资源时,访问令牌必须包含在对需要授权的 API 的请求中。 IdentityServer 的中间件将验证传入的访问令牌,以确保这些令牌是从受信任的颁发者发送的,并且可有效用于接收它们的 API。