Condividi tramite


Comunicazione sicura tra l'hosting e le integrazioni di client

Questo articolo è una continuazione di due articoli precedenti che illustrano la creazione di integrazioni di hosting personalizzate e integrazioni di client personalizzate.

Uno dei principali vantaggi di .NET.NET Aspire è il modo in cui semplifica la configurabilità delle risorse e dei client consumatori (o delle integrazioni). Questo articolo dimostra come condividere le credenziali di autenticazione da una risorsa personalizzata in un'integrazione di hosting con il servizio consumatore client all'interno di un'integrazione personalizzata client. La risorsa personalizzata è un contenitore denominato MailDev che consente l'uso di credenziali in ingresso o in uscita. L'integrazione client personalizzata è un client MailKit che invia messaggi di posta elettronica.

Prerequisiti

Poiché questo articolo continua dal contenuto precedente, è necessario aver già creato la soluzione risultante come punto di partenza per questo articolo. Se non è già stato fatto, completare gli articoli seguenti:

La soluzione risultante da questi articoli precedenti contiene i progetti seguenti:

  • MailDev. Hosting: contiene il tipo di risorsa personalizzato per il contenitore MailDev.
  • MailDevResource.AppHost: l'host dell'app che usa la risorsa personalizzata e lo definisce come dipendenza per un servizio Newsletter.
  • MailDevResource.NewsletterService: un progetto API Web ASP.NET Core che invia messaggi di posta elettronica usando il contenitore MailDev.
  • MailDevResource.ServiceDefaults: contiene le configurazioni predefinite del servizio destinate alla condivisione.
  • MailKit.Client: contiene l'integrazione di client personalizzata che espone il SmtpClient MailKit tramite una factory.

Aggiornare la risorsa MailDev

Per eseguire il flusso delle credenziali di autenticazione dalla risorsa MailDev all'integrazione di MailKit, è necessario aggiornare la risorsa MailDev per includere i parametri nome utente e password.

Il contenitore MailDev supporta l'autenticazione di base per il protocollo SMTP (Simple Transfer Protocol) in ingresso e in uscita. Per configurare le credenziali per l'ingresso, è necessario impostare le variabili di ambiente MAILDEV_INCOMING_USER e MAILDEV_INCOMING_PASS. Per altre informazioni, vedere MailDev: Utilizzo. Aggiornare il file MailDevResource.cs nel progetto MailDev.Hosting sostituendone il contenuto con il codice C# seguente:

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

Questi aggiornamenti aggiungono le proprietà UsernameParameter e PasswordParameter. Queste proprietà vengono usate per archiviare i parametri per l'MailDev nome utente e password. La proprietà ConnectionStringExpression viene aggiornata per includere i parametri nome utente e password nella stringa di connessione. Aggiornare quindi il file MailDevResourceBuilderExtensions.cs nel progetto MailDev.Hosting con il codice C# seguente:

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";
}

Il codice precedente aggiorna il metodo di estensione AddMailDev per includere i parametri userName e password. Il metodo WithEnvironment viene aggiornato per includere le variabili di ambiente UserEnvVarName e PasswordEnvVarName. Queste variabili di ambiente vengono usate per impostare il nome utente e la password MailDev.

Aggiornare l'host dell'app

Ora che la risorsa viene aggiornata per includere i parametri nome utente e password, è necessario aggiornare l'host dell'app per includere questi parametri. Aggiornare il file Program.cs nel progetto MailDevResource.AppHost con il codice C# seguente:

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

Il codice precedente aggiunge due parametri per il nome utente e la password MailDev. Assegna questi parametri alle variabili di ambiente MAILDEV_INCOMING_USER e MAILDEV_INCOMING_PASS. Il metodo AddMailDev ha due chiamate concatenate a WithEnvironment, che includono queste variabili di ambiente. Per altre informazioni sui parametri, vedere Parametri esterni.

Configurare quindi i segreti per questi parametri. Fare clic con il pulsante destro del mouse sul progetto di MailDevResource.AppHost e scegliere Manage User Secrets. Aggiungere i seguenti JSON ai segreti di nel filejson.

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

Avvertimento

Queste credenziali sono solo a scopo dimostrativo e MailDev è destinato allo sviluppo locale. Queste credenziali sono fittizie e non devono essere usate in un ambiente di produzione.

Aggiornare l'integrazione di MailKit

È bene che le integrazioni client si aspettino che le stringhe di connessione contengano varie coppie chiave/valore e analizzino queste coppie nelle proprietà appropriate. Aggiornare il file MailKitClientSettings.cs nel progetto MailKit.Client con il codice C# seguente:

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

La classe delle impostazioni precedente include ora una proprietà Credentials di tipo NetworkCredential. Il metodo ParseConnectionString viene aggiornato per analizzare le chiavi Username e Password dalla stringa di connessione. Se sono presenti le chiavi Username e Password, viene creato un NetworkCredential e assegnato alla proprietà Credentials.

Con la classe delle impostazioni aggiornata per comprendere e popolare le credenziali, aggiornare la factory in modo da usare in modo condizionale le credenziali, se configurate. Aggiornare il file MailKitClientFactory.cs nel progetto MailKit.Client con il codice C# seguente:

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

Quando la factory determina che le credenziali sono state configurate, esegue l'autenticazione con il server SMTP dopo la connessione prima di restituire il SmtpClient.

Esegui l'esempio

Dopo aver aggiornato la risorsa, i progetti di integrazione corrispondenti e l'host dell'app, si è pronti per eseguire l'app di esempio. Per eseguire l'esempio dall'IDE, selezionare F5 oppure utilizzare dotnet run dalla directory radice della soluzione per avviare l'applicazione. Dovrebbe essere visualizzato il dashboard .NET.NET Aspire. Vai alla risorsa del container maildev e visualizza i dettagli. I parametri nome utente e password dovrebbero essere visibili nei dettagli della risorsa, nella sezione Variabili di Ambiente:

.NET Aspire Dashboard: dettagli delle risorse del contenitore MailDev.

Analogamente, nei dettagli della risorsa newsletterservice, nella sezione Variabili di ambiente, dovrebbe vedere la stringa di connessione:

.NET.NET Aspire Dashboard: dettagli delle risorse del servizio Newsletter.

Verificare che tutto funzioni come previsto.

Sommario

Questo articolo ha illustrato come eseguire il flusso delle credenziali di autenticazione da una risorsa personalizzata a un'integrazione di client personalizzata. La risorsa personalizzata è un contenitore MailDev che consente di gestire le credenziali in entrata o in uscita. L'integrazione client personalizzata è un client MailKit che invia messaggi di posta elettronica. Aggiornando la risorsa per includere i parametri username e password e aggiornando l'integrazione per effettuare il parsing e usare questi parametri, le credenziali fluiscono dall'integrazione dell'hosting all'integrazione client.