共用方式為


安全的通訊在主機與 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 檔案在 MailDev.Hosting 專案中更新,並用以下 C# 程式碼取代其內容:

// 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());
            }
        }
    }
}

上述設定類別現在包含 類型為 NetworkCredentialCredentials 屬性。 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 整合。