65.9K
CodeProject 正在变化。 阅读更多。
Home

Azure AD 安全的 Blazor WebAssembly 无服务器 Cosmos DB

starIconstarIconstarIconstarIconstarIcon

5.00/5 (1投票)

2020 年 5 月 26 日

CPOL

15分钟阅读

viewsIcon

4936

如何在不将凭据存储在客户端也不托管自己的身份验证服务的情况下安全连接?解决方案是使用 Azure Active Directory 进行身份验证,并与无服务器 Azure Function 安全通信。

一个 Blazor 🔥 WebAssembly 🕸 应用 可以作为一组静态网站资源部署,并托管在任何发布静态文件的服务器上。作为一个浏览器应用程序,它能够调用 HTTP API 端点。作为一个单页应用程序(SPA),它可以动态地从数据中渲染新页面。因为它支持 .NET Standard,所以可以加载 .NET Standard 目标 NuGet 包,包括 Cosmos DB 🌍 SDK。这使得可以直接连接到 Azure Cosmos DB SQL API 后端。有一个主要的挑战:如何在不将凭据存储在客户端也不托管自己的身份验证服务的情况下安全连接?

解决方案是使用 Azure Active Directory 进行身份验证,并与无服务器 Azure Function 安全通信。该函数应用使用安全存储的主凭据连接到 Cosmos DB,并生成一个临时的令牌,该令牌授予用户最多五小时的有限访问权限。然后,客户端应用使用提供的令牌直接连接到 Cosmos DB。

此解决方案的存储库位于

 JeremyLikness/AzureBlazorCosmosWasm

该应用程序需要一些初始配置,本文将对此进行解释。这篇文章探讨了我如何构建解决方案,以及如何从存储库中构建和配置你自己的应用程序。

Blazor WebAssembly 和身份验证

第一步是在 Blazor WebAssembly 应用中设置身份验证。我不是 Azure AD 专家,因此找到关于如何将 Blazor WebAssembly 与 Azure AD 结合使用的专用文档非常有帮助。

特别感谢 Javier Calvarro Nelson 花时间指导我正确配置解决方案。

我将总结高层步骤

  1. 在 Azure Active Directory 应用程序注册中注册你的应用程序。这使你的应用程序能够针对 Azure AD 进行用户身份验证,并使用用户的身份和凭据代表用户发出请求。
  2. 请务必记下应用程序注册的两个重要组件:注册所属的 *租户*(或 *目录*)(这就像 Azure AD 身份验证服务的邮政编码),以及唯一标识你的应用程序的 *客户端* ID(就像邮寄地址)。
  3. 使用内置的 Azure AD 模板生成 Blazor WebAssembly 应用程序
    dotnet new blazorwasm -au SingleOrg --client-id "{CLIENT ID}" --tenant-id "{TENANT ID}"

这将脚手架一个提供与 Azure AD 完全集成的应用程序。前面引用的文档解释了身份验证模板生成了哪些代码和组件以及它们的工作原理。要使用此博客文章的示例项目,请先创建一个应用程序注册。设置完成后,更新 AzureBlazorWasm 项目 wwwroot 文件夹下的 appsettings.json 文件,以使用你的租户和客户端。它看起来会像这样

"AzureAd": {
    "Authority": "https://login.microsoftonline.com/{directory id}",
    "ClientId": "{clientid}",
    "ValidateAuthority": true
  }

此信息存储为客户端应用程序的一部分,并且是“明文”的,这意味着用户可以轻松访问和读取它。幸运的是,这里没有秘密。配置仅提供用于请求身份验证的终结点,使用你组织的租户,以及你 Blazor 应用的唯一标识符(客户端 ID)。登录过程会将你重定向到 Azure AD,在那里会提示你使用为你的租户配置的任何过程登录。这可能包括双因素身份验证。如果身份验证成功,则会将一个已签名的令牌发送回 Blazor WebAssembly 客户端。

Azure AD Login Process

现在你拥有了一个安全的 Blazor 客户端。你可以使用任何 Razor 组件上的 [Authorize] 属性来阻止未经授权的用户访问。我将在本文后面展示这一点。

使用 Azure AD 保护 Azure Functions

下一步是创建一个安全的 Azure Functions <⚡> 连接。函数应用将与 Cosmos DB 🌍 通信以检索令牌,因此你不希望任何人都可以访问它。你可以从现有的 CosmosAuthentication 应用开始,并将其发布到 Azure。如果你不熟悉此过程,此处有相关文档:使用 Visual Studio 开发 Azure Functions:发布到 Azure。函数应用程序尚未得到保护。但是,代码会检查已通过身份验证的用户,否则将返回 401 Unauthorized HTTP 状态码。

遵循本文档中的步骤来保护你的函数应用:使用 Azure AD 保护 Azure Functions 应用:快速设置。我使用了快速选项并创建了一个新的应用程序注册。我选择了“允许匿名请求(无操作)”选项,因为代码本身会验证连接用户的身份,并且该 API 不打算手动访问。快速设置将自动为函数应用创建一个应用程序注册。

还需要执行一些额外的配置。在 Azure 门户中,导航到“Azure Active Directory”,然后是“应用程序注册”,最后打开你*函数应用*(不是你的 Blazor WebAssembly 客户端)的注册。打开“身份验证”并确保“ID 令牌”的“隐式授权”已设置。

Implicit grant

接下来,我们需要公开 API 以供其他应用程序使用。打开“公开 API”选项卡。有两个步骤。首先,如果它尚不存在,你需要为 user_impersonation 添加一个*范围*,它本质上是适用于该 API 的*权限*。点击“添加范围”并将其命名为 user_impersonation。如果你的访问权限允许,请选择“管理员和用户”进行同意。在函数应用可以使用任何凭据之前,用户必须通过提供同意来明确选择加入。某些组织要求管理员提供同意。你可以随时撤销同意,从而有效地禁用对函数应用的访问。确保范围已启用。

User Impersonation Scope

范围只是客户端应用程序请求的权限。在这种情况下,客户端应用程序是 Blazor WebAssembly 应用程序。为了使其能够与函数应用程序一起工作,你需要对其进行授权。在同一页面的底部有一个“授权的客户端应用程序”部分。点击“添加客户端应用程序”并粘贴为你的 Blazor WebAssembly 应用程序注册生成的客户端 ID。在“授权的范围”下,确保 user_impersonation 已选中。

注意 完整的范围是应用程序注册的 URL(通常与你的函数应用程序相同)和范围名称。稍后你需要用到它。Blazor 客户端假定函数应用程序注册的名称与函数应用程序的名称相同。

我的函数应用程序名为 cosmosauthenticationfunc,因此我的完整范围是

https://cosmosauthenticationfunc.azurewebsites.net/user_impersonation

现在是时候停顿一下了。我们做了什么?

  • 我们创建了一个应用程序注册,以使用 Azure AD 保护 Blazor WebAssembly 应用程序。
  • 我们生成了必要的代码,以便从 Blazor WebAssembly 应用程序使用 Azure AD 进行身份验证。
  • 我们创建了一个应用程序注册,以使用 Azure AD 保护 Azure Functions 应用程序。
  • 我们启用了对令牌的支持。
  • 我们确保 user_impersonation 范围(权限)可用。
  • 我们授权了我们的 Blazor WebAssembly 应用程序使用 user_impersonation 范围安全地访问 Azure Functions 应用程序。

我们还没有完成!即使函数应用程序已配置为授予 Blazor WebAssembly 应用程序权限,Blazor 应用程序也必须显式请求权限。这是完成闭环的最后一步。在 Azure Active Directory 应用程序注册中,导航到你的 Blazor WebAssembly 注册。点击“API 权限”,然后点击“添加权限”。选择“我的 API”并选择 Azure Functions 应用程序注册。点击“委托的权限”并确保 user_impersonation 已选中。点击“添加权限”以应用和保存。

Request Permissions

现在函数应用程序已被锁定!但工作还没有完成。如果你尝试从 Blazor WebAssembly 应用程序访问 Azure Function,即使你已登录,你也会收到 401 Unauthorized HTTP 状态码。这是因为请求不包含任何身份验证信息。必须配置 HttpClient 以将 Azure AD 令牌放入请求头中。

配置 Blazor WebAssembly 使用 Azure AD 令牌

在 Blazor WebAssembly 应用程序中,HttpClient 是使用依赖项注入注入的。默认客户端配置为与客户端相同的基本 URL,这样你就可以使用部分路径调用本地服务器,如下所示

var entity = await _client.GetJsonAsync<MyEntity>("/api/entity/get");

这在 Program.cs 中配置

builder.Services.AddTransient(sp => new HttpClient()
   { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });

Azure Functions 终结点位于不同的基本 URL,并且要求请求头包含身份验证信息。为了配置客户端,我添加了 Microsoft.Extensions.Http NuGet 包,它提供了创建 HttpFactory 的帮助方法。这允许配置可以按名称请求的客户端特定实例。稍后将详细介绍,首先我们需要设置授权。

该应用程序有一个内置的 AuthorizationMessageHandler,它会自动配置请求头以包含本地请求的授权令牌。我们需要一个自定义处理程序,允许将令牌发送到远程函数地址。在 Data 文件夹中,我创建了这个

public class CosmosAuthorizationMessageHandler : AuthorizationMessageHandler
{
    public CosmosAuthorizationMessageHandler(
        IConfiguration config,
        IAccessTokenProvider provider,
        NavigationManager navigation) : base(provider, navigation)
    {
        var section = config.GetSection(nameof(TokenClient));
        var endpoint = section.GetValue<string>(nameof(TokenClient.Endpoint));
        ConfigureHandler(new[] { endpoint });
    }
}

自定义处理程序的主要目的是授权将 Azure AD 令牌存储在请求头中,当客户端访问函数终结点时。我们将从一个名为 TokenClient 的类访问函数终结点。为了配置终结点,我在 appsettings.json 中创建了一个部分,它在 wwwroot 下部署。这使得可以在 CI/CD 过程中更新终结点值。配置文件中的部分如下所示

"TokenClient": {
    "Endpoint": "https://{functionapp}.azurewebsites.net/"
}

用你的终结点替换文件中的设置。处理程序使用此配置来获取终结点。现在消息处理程序已完成,我们可以在 Program.cs 中设置 HttpClient 工厂。首先,我创建了一个本地帮助方法来从配置中检索函数终结点。虽然消息处理程序已将其配置为授权的终结点,但我们需要它来配置 HttpClient 实例的基本地址并请求范围。

static string functionEndpoint(WebAssemblyHostBuilder builder) =>
    builder.Configuration
        .GetSection(nameof(TokenClient))
        .GetValue<string>(nameof(CosmosAuthorizationMessageHandler.Endpoint));

接下来,我更新了模板生成的身份验证配置,以明确请求 user_impersonation 范围并注册了我的自定义消息处理程序。

builder.Services.AddMsalAuthentication(options =>
{
    options.ProviderOptions
    .DefaultAccessTokenScopes.Add($"{functionEndpoint(builder)}user_impersonation");
    builder.Configuration.Bind("AzureAd", options.ProviderOptions.Authentication);
});
builder.Services.AddTransient<CosmosAuthorizationMessageHandler>();

最后,我注册了自定义 HttpClient 以使用自定义消息处理程序,并默认使用函数终结点。的基址。

// configure the client to talk to the Azure Functions endpoint.
builder.Services.AddHttpClient(nameof(TokenClient),
    client =>
    {
        client.BaseAddress = new Uri(functionEndpoint(builder));
    }).AddHttpMessageHandler<CosmosAuthorizationMessageHandler>();

// register the client to retrieve Cosmos DB tokens.
builder.Services.AddTransient<TokenClient>();

第一步使用扩展程序注册一个名为“TokenClient”的“命名”HttpClient。第二个注册我们将用于检索令牌的客户端。现在我们可以查看 TokenClient 实现,了解它是如何使用的。

public class TokenClient
{
    private readonly HttpClient _client;

    public TokenClient(IHttpClientFactory factory)
    {
        _client = factory.CreateClient(nameof(TokenClient));
    }

    public async Task<CosmosToken> GetTokenAsync()
    {
        return await _client.GetJsonAsync<CosmosToken>($"api/RequestToken");
    }
}

客户端非常简单。它使用注入的工厂来获取已使用消息处理程序配置的命名 HttpClient,该处理程序会将 Azure AD 令牌应用于请求。请注意,它不使用完全限定的域名,因为该域名已配置为基 URL。该服务公开 GetTokenAsync() 方法来请求令牌。当我构建应用程序时,我开始时返回了一个空令牌。令牌定义在项目之间共享,并包含 Cosmos DB 终结点和将从 Cosmos DB 检索的临时令牌(密钥)。

public class CosmosToken
{
    public string Endpoint { get; set; }
    public string Key { get; set; }
}

在此步骤之后,我能够成功地从 Azure Functions 应用程序检索到一个空令牌。接下来,Azure Functions 应用程序需要检索已通过身份验证的用户的信息,并使用该信息生成 Cosmos DB 令牌。

无服务器 Cosmos DB 令牌生成

Azure AD 令牌是一个签名和编码的载荷,包含一组受信任的声明。这些声明包含用户唯一身份信息。你也可以配置自定义声明。例如,要访问用户的电子邮件,你需要添加一个电子邮件声明。当提示用户为你的应用程序提供同意时,它将包含电子邮件作为同意的项目之一。

这是我对从 Blazor WebAssembly 应用程序传递到 Azure Functions 终结点的令牌的粗略概念化。

Token Claims

我以电子邮件为例,但我在此示例中不使用它。

在 Azure Functions 端,我们现在可以这样做

[FunctionName("RequestToken")]
public async Task<IActionResult> Run(
    [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = null)] 
        HttpRequest req,
    ILogger log,
    ClaimsPrincipal principal)
{
    if (principal == null)
    {
        log.LogWarning("No principal.");
        return new UnauthorizedResult();
    }

    if (principal.Identity == null)
    {
        log.LogWarning("No identity.");
        return new UnauthorizedResult();
    }

    if (!principal.Identity.IsAuthenticated)
    {
        log.LogWarning("Request was not authenticated.");
        return new UnauthorizedResult();
    }

    var id = principal.FindFirst(ClaimTypes.NameIdentifier).Value;
    log.LogInformation("Authenticated user {user}.", id);

    return new OkResult();
}

请注意,我在参数中包含 ClaimsPrincipal,如果存在,Azure Functions 会自动传入与请求关联的 principal。日志消息是我用来确认令牌身份验证正在工作的方式。它还没有用,因为我们还没有令牌,但我们至少可以获得用户的唯一 NameIdentifier 声明。为了获取令牌,我创建了一个 CosmosClientWrapper,它被注入到函数类中。如果你不熟悉如何在 Azure Functions 中设置依赖项注入,请阅读:在 Azure Functions 中使用依赖项注入

客户端包装器使用依赖项注入来获取配置。它期望一个名为“CosmosConnection”的配置字符串。请确保将其设置在配置的 ConnectionStrings 部分,无论是在你的本地 JSON 设置中还是在实际应用程序的应用程序设置中。此连接字符串使用主密钥,因此它可以操纵权限。

还注入了一个记录器来说明正在发生的情况。

private readonly string CosmosConnection = nameof(CosmosConnection);

public CosmosClientWrapper(
    IConfiguration config,
    ILogger<CosmosClientWrapper> logger)
{
    _logger = logger;
    _client = new CosmosClient(config.GetConnectionString(CosmosConnection));
}

Cosmos DB 用户和权限

Cosmos DB 仅支持 Azure AD 用于“控制平面”或在门户或使用命令行界面管理 Cosmos DB 帐户。而“数据平面”(这是我们感兴趣的)需要 主密钥或资源令牌。这就是为什么我们使用 Azure Function 来“代理”事务并为用户安全地检索令牌。你可以阅读之前链接的文章中有关 Cosmos DB 权限的更多信息,但这里有一个简要的总结

  • 创建一个作用域限定到数据库的命名用户。
  • 应用一个命名权限,该权限指定用户是否可以更新或仅读取数据,他们可以访问哪个容器,以及可选的用于多租户解决方案的分区键。
  • 请求一个令牌,以在命名的权限集下进行操作。

令牌默认持续一小时。它可以配置为在十秒到五小时之间到期。每次请求一组权限的令牌时,都会获得一个带有新到期日期的新令牌。

从概念上讲,这就是我们将要做的

Cosmos DB Resource Token

首先,我们需要检查用户是否存在。如果不存在,我们将创建用户。用户的名称与 NameIdentifier 声明相同,因此对每个用户都是唯一的。虽然这不是本示例的一部分,但你可以在其 Azure AD 访问权限被撤销时编写代码来删除用户。

private async Task<User> CreateOrReadUserAsync(
    Database database, string id)
{
    _logger.LogInformation("User request for {user}.", id);
    var user = database.GetUser(id);
    UserResponse userResult = null;
    try
    {
        userResult = await user.ReadAsync();
    }
    catch (CosmosException cex)
    {
        if (cex.StatusCode != System.Net.HttpStatusCode.NotFound)
        {
            throw;
        }
    }
    if (userResult?.Resource == null)
    {
        _logger.LogInformation("User {user} not found.", id);
        var newUser = await database.CreateUserAsync(id);
        user = newUser.User;
        _logger.LogInformation("User {user} created.", id);
    }
    else
    {
        _logger.LogInformation("User {user} exists.", id);
    }
    return user;
}

以下代码为用户创建了一组唯一的权限。我们授予对包含博客文章的容器的读取访问权限。与其担心权限是否已存在,不如使用 *upsert* 功能来添加或更新它们。然后我们请求并返回令牌。

public async Task<CosmosToken> GetTokenForId(string id)
{
    var database = _client.GetDatabase(BlogContext.MyBlogs);
    var cosmosUser = await CreateOrReadUserAsync(database, id);
    var permissionId = $"Permission-{id}-blogs";
    var container = database.GetContainer(nameof(BlogContext));
    var permissions = new PermissionProperties(
        id: permissionId,
        permissionMode: PermissionMode.Read,
        container: container);
    await cosmosUser.UpsertPermissionAsync(permissions);
    _logger.LogInformation("Permissions upsert for {user} successful.", id);
    var token = await cosmosUser.GetPermission(permissionId).ReadAsync();
    return new CosmosToken
    {
        Endpoint = _client.Endpoint.ToString(),
        Key = token.Resource.Token
    };
}

函数应用程序假定你已经创建了一个名为“myblogs”的数据库。名为“BlogContext”的容器由 Entity Framework Core 创建。我将在接下来的内容中说明如何设置。

设置数据库

此时,你拥有访问 Cosmos DB 所需的一切,可以使用资源令牌。在此示例中,我使用 Entity Framework Core,以与我之前的博客文章保持一致,该文章使用 ASP.NET Core 托管的解决方案。该博客文章解释了使用 EF Core 的理由以及数据模型是如何设置的。

要首次创建数据库并填充一些数据,请更新 BlogData 项目中的 SeedData 类,其中包含你想要开始的博客和文章,并从控制台应用程序调用它。你需要修改示例以传递一个 DbContextOptionsBuilder,该生成器使用主密钥配置 Cosmos DB 连接字符串。安装 EF Core Cosmos Provider,然后通过添加显式 NuGet 包引用将引用的 Cosmos DB SDK 升级到 3.9.1 或更高版本。

完成客户端

现在所有基础部分都已就位。为了从 Blazor WebAssembly 应用程序检索数据,我创建了一个特殊的客户端。它旨在返回一个 DbContext,但如果希望直接使用 SDK 而不是通过 EF Core,你可以轻松地将其修改为返回一个 CosmosClient 实例。

public class BlogClient
{
    private CosmosToken _credentials;

    private readonly TokenClient _tokenClient;

    public BlogClient(TokenClient tokenClient)
    {
        _tokenClient = tokenClient;
    }

    public async Task<BlogContext> GetDbContextAsync()
    {
        if (_credentials == null)
        {
            _credentials = await _tokenClient.GetTokenAsync();
        }

        BlogContext context = null;

        CosmosToken getCredentials() => _credentials;

        var options = new DbContextOptionsBuilder<BlogContext>()
            .UseCosmos(getCredentials().Endpoint,
                getCredentials().Key,
                Context.MyBlogs,
            opt =>
                opt.ConnectionMode(Microsoft.Azure.Cosmos.ConnectionMode.Gateway));

        try
        {
            context = new BlogContext(options.Options);
        }
        catch
        {
            _credentials = await _tokenClient.GetTokenAsync();
            context = new BlogContext(options.Options);
        }

        return context;
    }
}

客户端创建一个上下文实例来访问数据库并持有对资源令牌的引用。如果构造函数抛出异常,它会假定是由于令牌过期,然后获取一个新的令牌重试第二次。如果第二次失败,则表示是另一个问题,错误将传播。

Index.razor 页面有一些有趣的元素需要指出。它使用

@attribute [Authorize]

该属性要求对组件进行授权。在组件渲染之前,用户将被强制登录。路由设置为 /cosmos,因此默认路由可以渲染不需要身份验证的页面。当它请求上下文时,它会捕获消息处理程序在令牌不可用、被撤销或过期时抛出的 AccessTokenNotAvailableException。异常类提供了一个方便的 Redirect() 方法,该方法将用户重定向到重新登录。

当我运行应用程序并访问我的 Cosmos DB 实例时,我看到博客和文章的渲染如下

Application Screenshot

这是生成的 Azure Functions 日志的外观

Azure Functions logs

浏览器的网络检查器显示了握手的以下序列

Network

请求显示两次是因为第一次是 CORS 请求,紧随其后的是实际的 API 调用。请求顺序如下

  1. 请求令牌
  2. 使用 Cosmos DB 进行身份验证并检索连接详细信息
  3. 获取有关容器的详细信息
  4. 设置查询容器
  5. 检索有关分区键的信息(未为此配置)
  6. 检索文档

文档请求返回的载荷看起来像这样

JSON Document

它由 EF Core 自动解析和物化。

结论

这就是故事的结局。我很乐意指向一个可工作的示例,但是由于这是一个基于 Azure AD 的解决方案,因此需要一个可以登录的已配置实例。我分享此解决方案的目标是展示 Blazor 的灵活性、直接访问 Cosmos DB 的强大功能以及在不存储任何秘密或凭据的情况下正确保护客户端应用程序所需的步骤。

一如既往,我非常感谢你的想法、反馈、问题和评论,所以请向下滚动加入或开始对话!

此致,

Jeremy Likness

© . All rights reserved.