托管与 client 集成之间的安全通信

本文是前两篇文章的延续,演示了如何创建 自定义托管集成自定义 client 集成

.NET .NET Aspire 的主要好处之一是它能简化资源的配置和消费客户端(或集成)的使用。 本文演示如何将身份验证凭据从托管集成中的自定义资源共享到自定义 client 集成中的消耗 client。 自定义资源是允许传入或传出凭据的 MailDev 容器。 自定义 client 集成是用于发送电子邮件的 MailKit client。

先决条件

由于本文接续之前的内容,所以您应该已经创建好解决方案,作为本文的起点。 如果您尚未阅读以下文章,请先完成:

前面的文章中生成的解决方案包含以下项目:

  • MailDev托管:包含 MailDev 容器的自定义资源类型。
  • MailDevResource.AppHost:使用自定义资源的 应用主机,并将其定义为新闻稿服务的依赖项。
  • MailDevResource.NewsletterService:使用 MailDev 容器发送电子邮件的 ASP.NET Core Web API 项目。
  • MailDevResource.ServiceDefaults:包含 默认服务配置,旨在用于共享。
  • MailKit。Client:包含通过工厂公开 MailKit SmtpClient 的自定义 client 集成。

更新 MailDev 资源

若要将身份验证凭据从 MailDev 资源流式传送到 MailKit 集成,需要更新 MailDev 资源以包括用户名和密码参数。

MailDev 容器支持传入和传出简单邮件传输协议(SMTP)的基本身份验证。 要配置传入凭据,您需要设置 MAILDEV_INCOMING_USERMAILDEV_INCOMING_PASS 环境变量。 有关详细信息,请参阅 MailDev:使用情况。 将 MailDevResource.cs 文件的内容替换为以下 C# 代码,以更新 MailDev.Hosting 项目。

// For ease of discovery, resource types should be placed in
// the Aspire.Hosting.ApplicationModel namespace. If there is
// likelihood of a conflict on the resource name consider using
// an alternative namespace.
namespace Aspire.Hosting.ApplicationModel;

public sealed class MailDevResource(
    string name,
    ParameterResource? username,
    ParameterResource password)
        : ContainerResource(name), IResourceWithConnectionString
{
    // Constants used to refer to well known-endpoint names, this is specific
    // for each resource type. MailDev exposes an SMTP and HTTP endpoints.
    internal const string SmtpEndpointName = "smtp";
    internal const string HttpEndpointName = "http";

    private const string DefaultUsername = "mail-dev";

    // An EndpointReference is a core .NET Aspire type used for keeping
    // track of endpoint details in expressions. Simple literal values cannot
    // be used because endpoints are not known until containers are launched.
    private EndpointReference? _smtpReference;

    /// <summary>
    /// Gets the parameter that contains the MailDev SMTP server username.
    /// </summary>
    public ParameterResource? UsernameParameter { get; } = username;

    internal ReferenceExpression UserNameReference =>
        UsernameParameter is not null ?
        ReferenceExpression.Create($"{UsernameParameter}") :
        ReferenceExpression.Create($"{DefaultUsername}");

    /// <summary>
    /// Gets the parameter that contains the MailDev SMTP server password.
    /// </summary>
    public ParameterResource PasswordParameter { get; } = password;

    public EndpointReference SmtpEndpoint =>
        _smtpReference ??= new(this, SmtpEndpointName);

    // Required property on IResourceWithConnectionString. Represents a connection
    // string that applications can use to access the MailDev server. In this case
    // the connection string is composed of the SmtpEndpoint endpoint reference.
    public ReferenceExpression ConnectionStringExpression =>
        ReferenceExpression.Create(
            $"Endpoint=smtp://{SmtpEndpoint.Property(EndpointProperty.Host)}:{SmtpEndpoint.Property(EndpointProperty.Port)};Username={UserNameReference};Password={PasswordParameter}"
        );
}

这些更新添加 UsernameParameterPasswordParameter 属性。 这些属性用于存储 MailDev 用户名和密码的参数。 ConnectionStringExpression 属性更新为在连接字符串中包含用户名和密码参数。 接下来,使用以下 C# 代码更新 MailDev.Hosting 项目中的 MailDevResourceBuilderExtensions.cs 文件:

using Aspire.Hosting.ApplicationModel;

// Put extensions in the Aspire.Hosting namespace to ease discovery as referencing
// the .NET Aspire hosting package automatically adds this namespace.
namespace Aspire.Hosting;

public static class MailDevResourceBuilderExtensions
{
    private const string UserEnvVarName = "MAILDEV_INCOMING_USER";
    private const string PasswordEnvVarName = "MAILDEV_INCOMING_PASS";

    /// <summary>
    /// Adds the <see cref="MailDevResource"/> to the given
    /// <paramref name="builder"/> instance. Uses the "2.1.0" tag.
    /// </summary>
    /// <param name="builder">The <see cref="IDistributedApplicationBuilder"/>.</param>
    /// <param name="name">The name of the resource.</param>
    /// <param name="httpPort">The HTTP port.</param>
    /// <param name="smtpPort">The SMTP port.</param>
    /// <returns>
    /// An <see cref="IResourceBuilder{MailDevResource}"/> instance that
    /// represents the added MailDev resource.
    /// </returns>
    public static IResourceBuilder<MailDevResource> AddMailDev(
        this IDistributedApplicationBuilder builder,
        string name,
        int? httpPort = null,
        int? smtpPort = null,
        IResourceBuilder<ParameterResource>? userName = null,
        IResourceBuilder<ParameterResource>? password = null)
    {
        var passwordParameter = password?.Resource ??
            ParameterResourceBuilderExtensions.CreateDefaultPasswordParameter(
                builder, $"{name}-password");

        // The AddResource method is a core API within .NET Aspire and is
        // used by resource developers to wrap a custom resource in an
        // IResourceBuilder<T> instance. Extension methods to customize
        // the resource (if any exist) target the builder interface.
        var resource = new MailDevResource(
            name, userName?.Resource, passwordParameter);

        return builder.AddResource(resource)
                      .WithImage(MailDevContainerImageTags.Image)
                      .WithImageRegistry(MailDevContainerImageTags.Registry)
                      .WithImageTag(MailDevContainerImageTags.Tag)
                      .WithHttpEndpoint(
                          targetPort: 1080,
                          port: httpPort,
                          name: MailDevResource.HttpEndpointName)
                      .WithEndpoint(
                          targetPort: 1025,
                          port: smtpPort,
                          name: MailDevResource.SmtpEndpointName)
                      .WithEnvironment(context =>
                      {
                          context.EnvironmentVariables[UserEnvVarName] = resource.UserNameReference;
                          context.EnvironmentVariables[PasswordEnvVarName] = resource.PasswordParameter;
                      });
    }
}

// This class just contains constant strings that can be updated periodically
// when new versions of the underlying container are released.
internal static class MailDevContainerImageTags
{
    internal const string Registry = "docker.io";

    internal const string Image = "maildev/maildev";

    internal const string Tag = "2.1.0";
}

前面的代码更新 AddMailDev 扩展方法以包含 userNamepassword 参数。 更新 WithEnvironment 方法以包含 UserEnvVarNamePasswordEnvVarName 环境变量。 这些环境变量用于设置 MailDev 用户名和密码。

更新应用主机

将资源更新为包含用户名和密码参数后,需要更新应用主机以包括这些参数。 使用以下 C# 代码更新 MailDevResource.AppHost 项目中的 Program.cs 文件:

var builder = DistributedApplication.CreateBuilder(args);

var mailDevUsername = builder.AddParameter("maildev-username");
var mailDevPassword = builder.AddParameter("maildev-password");

var maildev = builder.AddMailDev(
    name: "maildev",
    userName: mailDevUsername,
    password: mailDevPassword);

builder.AddProject<Projects.MailDevResource_NewsletterService>("newsletterservice")
       .WithReference(maildev);

builder.Build().Run();

前面的代码为 MailDev 用户名和密码添加了两个参数。 它将这些参数分配给 MAILDEV_INCOMING_USERMAILDEV_INCOMING_PASS 环境变量。 AddMailDev 方法对 WithEnvironment 进行两次链式调用,其中包含这些环境变量。 有关参数的详细信息,请参阅 外部参数

接下来,为这些参数配置机密。 右键单击 MailDevResource.AppHost 项目并选择 Manage User Secrets。 将以下 JSON 添加到 机密文件:json

{
  "Parameters:maildev-username": "@admin",
  "Parameters:maildev-password": "t3st1ng"
}

警告

这些凭据仅用于演示目的,MailDev 用于本地开发。 这些凭据是虚构的,不应在生产环境中使用。

更新 MailKit 集成

建议 client 集成应预期连接字符串中包含各种键/值对,并将这些对解析为适当的属性。 使用以下 C# 代码更新 MailKit.Client 项目中的 MailKitClientSettings.cs 文件:

using System.Data.Common;
using System.Net;

namespace MailKit.Client;

/// <summary>
/// Provides the client configuration settings for connecting MailKit to an SMTP server.
/// </summary>
public sealed class MailKitClientSettings
{
    internal const string DefaultConfigSectionName = "MailKit:Client";

    /// <summary>
    /// Gets or sets the SMTP server <see cref="Uri"/>.
    /// </summary>
    /// <value>
    /// The default value is <see langword="null"/>.
    /// </value>
    public Uri? Endpoint { get; set; }

    /// <summary>
    /// Gets or sets the network credentials that are optionally configurable for SMTP
    /// server's that require authentication.
    /// </summary>
    /// <value>
    /// The default value is <see langword="null"/>.
    /// </value>
    public NetworkCredential? Credentials { get; set; }

    /// <summary>
    /// Gets or sets a boolean value that indicates whether the database health check is disabled or not.
    /// </summary>
    /// <value>
    /// The default value is <see langword="false"/>.
    /// </value>
    public bool DisableHealthChecks { get; set; }

    /// <summary>
    /// Gets or sets a boolean value that indicates whether the OpenTelemetry tracing is disabled or not.
    /// </summary>
    /// <value>
    /// The default value is <see langword="false"/>.
    /// </value>
    public bool DisableTracing { get; set; }

    /// <summary>
    /// Gets or sets a boolean value that indicates whether the OpenTelemetry metrics are disabled or not.
    /// </summary>
    /// <value>
    /// The default value is <see langword="false"/>.
    /// </value>
    public bool DisableMetrics { get; set; }

    internal void ParseConnectionString(string? connectionString)
    {
        if (string.IsNullOrWhiteSpace(connectionString))
        {
            throw new InvalidOperationException($"""
                    ConnectionString is missing.
                    It should be provided in 'ConnectionStrings:<connectionName>'
                    or '{DefaultConfigSectionName}:Endpoint' key.'
                    configuration section.
                    """);
        }

        if (Uri.TryCreate(connectionString, UriKind.Absolute, out var uri))
        {
            Endpoint = uri;
        }
        else
        {
            var builder = new DbConnectionStringBuilder
            {
                ConnectionString = connectionString
            };
            
            if (builder.TryGetValue("Endpoint", out var endpoint) is false)
            {
                throw new InvalidOperationException($"""
                        The 'ConnectionStrings:<connectionName>' (or 'Endpoint' key in
                        '{DefaultConfigSectionName}') is missing.
                        """);
            }

            if (Uri.TryCreate(endpoint.ToString(), UriKind.Absolute, out uri) is false)
            {
                throw new InvalidOperationException($"""
                        The 'ConnectionStrings:<connectionName>' (or 'Endpoint' key in
                        '{DefaultConfigSectionName}') isn't a valid URI.
                        """);
            }

            Endpoint = uri;
            
            if (builder.TryGetValue("Username", out var username) &&
                builder.TryGetValue("Password", out var password))
            {
                Credentials = new(
                    username.ToString(), password.ToString());
            }
        }
    }
}

前面的设置类现在包括 NetworkCredential类型的 Credentials 属性。 ParseConnectionString 方法更新为分析连接字符串中的 UsernamePassword 键。 如果存在 UsernamePassword 键,则会创建 NetworkCredential 并将其分配给 Credentials 属性。

在设置类经过更新以了解和填写凭据后,更新工厂类,使其在凭据配置后能有条件地使用这些凭据。 使用以下 C# 代码更新 MailKit.Client 项目中的 MailKitClientFactory.cs 文件:

using System.Net;
using MailKit.Net.Smtp;

namespace MailKit.Client;

/// <summary>
/// A factory for creating <see cref="ISmtpClient"/> instances
/// given a <paramref name="smtpUri"/> (and optional <paramref name="credentials"/>).
/// </summary>
/// <param name="settings">
/// The <see cref="MailKitClientSettings"/> settings for the SMTP server
/// </param>
public sealed class MailKitClientFactory(MailKitClientSettings settings) : IDisposable
{
    private readonly SemaphoreSlim _semaphore = new(1, 1);

    private SmtpClient? _client;

    /// <summary>
    /// Gets an <see cref="ISmtpClient"/> instance in the connected state
    /// (and that's been authenticated if configured).
    /// </summary>
    /// <param name="cancellationToken">Used to abort client creation and connection.</param>
    /// <returns>A connected (and authenticated) <see cref="ISmtpClient"/> instance.</returns>
    /// <remarks>
    /// Since both the connection and authentication are considered expensive operations,
    /// the <see cref="ISmtpClient"/> returned is intended to be used for the duration of a request
    /// (registered as 'Scoped') and is automatically disposed of.
    /// </remarks>
    public async Task<ISmtpClient> GetSmtpClientAsync(
        CancellationToken cancellationToken = default)
    {
        await _semaphore.WaitAsync(cancellationToken);

        try
        {
            if (_client is null)
            {
                _client = new SmtpClient();

                await _client.ConnectAsync(settings.Endpoint, cancellationToken)
                             .ConfigureAwait(false);

                if (settings.Credentials is not null)
                {
                    await _client.AuthenticateAsync(settings.Credentials, cancellationToken)
                                 .ConfigureAwait(false);
                }
            }
        }
        finally
        {
            _semaphore.Release();
        }       

        return _client;
    }

    public void Dispose()
    {
        _client?.Dispose();
        _semaphore.Dispose();
    }
}

当工厂确定已配置凭据时,它会在连接后使用 SMTP server 进行身份验证,然后再返回 SmtpClient

运行示例

现在,你已更新资源、相应的集成项目和应用主机,接下来即可运行示例应用。 若要从 IDE 运行示例,请选择 F5 或使用解决方案根目录中的 dotnet run 来启动应用程序—应会看到 .NET.NET Aspire 仪表板。 导航到 maildev 容器资源并查看详细信息。 在 环境变量 部分下,资源详细信息中应会显示用户名和密码参数:

.NET Aspire 仪表板:MailDev 容器资源详细信息。

同样,应在 newsletterservice 资源详细信息的 环境变量 部分下看到连接字符串:

.NET.NET Aspire 仪表板:新闻稿服务资源详细信息。

验证一切是否按预期工作。

总结

本文演示了如何将身份验证凭据从自定义资源流向自定义 client 集成。 自定义资源是允许传入或传出凭据的 MailDev 容器。 自定义 client 集成是一个 MailKit client,用于发送电子邮件。 通过更新资源以包括 usernamepassword 参数,以及更新集成以分析和使用这些参数,身份验证会将凭据从托管集成流到 client 集成。