fix(notification-service): resolve NotificationService-005..009 — explicit TLS modes, per-credential token cache, timeout/throttle, address validation, credential redaction
This commit is contained in:
48
src/ScadaLink.NotificationService/CredentialRedactor.cs
Normal file
48
src/ScadaLink.NotificationService/CredentialRedactor.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
namespace ScadaLink.NotificationService;
|
||||
|
||||
/// <summary>
|
||||
/// NS-009: Scrubs SMTP credential secrets out of free text (typically exception
|
||||
/// messages echoed back by an SMTP server) before that text is written to a log.
|
||||
/// MailKit authentication exceptions can contain server responses that quote the
|
||||
/// supplied credentials; this prevents a password, client secret, or OAuth2 token
|
||||
/// from leaking into the operational logs.
|
||||
/// </summary>
|
||||
internal static class CredentialRedactor
|
||||
{
|
||||
private const string Mask = "***REDACTED***";
|
||||
|
||||
/// <summary>
|
||||
/// Returns <paramref name="text"/> with every secret component of the supplied
|
||||
/// colon-delimited credential string masked.
|
||||
/// </summary>
|
||||
/// <param name="text">The text to scrub (e.g. an exception message).</param>
|
||||
/// <param name="credentials">
|
||||
/// The credential string in use — Basic Auth <c>user:pass</c> or OAuth2
|
||||
/// <c>tenantId:clientId:clientSecret</c>. May be null.
|
||||
/// </param>
|
||||
public static string Scrub(string? text, string? credentials)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(credentials))
|
||||
{
|
||||
return text ?? string.Empty;
|
||||
}
|
||||
|
||||
var result = text;
|
||||
|
||||
// Mask each individual colon-delimited component (covers user, password,
|
||||
// tenant, clientId, clientSecret) and the whole packed string. Order longest
|
||||
// first so a component that is a substring of another is still fully masked.
|
||||
var parts = credentials.Split(':')
|
||||
.Where(p => p.Length >= 4)
|
||||
.Append(credentials)
|
||||
.Distinct()
|
||||
.OrderByDescending(p => p.Length);
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
result = result.Replace(part, Mask, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,24 @@ namespace ScadaLink.NotificationService;
|
||||
/// </summary>
|
||||
public interface ISmtpClientWrapper
|
||||
{
|
||||
Task ConnectAsync(string host, int port, bool useTls, CancellationToken cancellationToken = default);
|
||||
/// <summary>
|
||||
/// Connects to the SMTP server.
|
||||
/// </summary>
|
||||
/// <param name="tlsMode">
|
||||
/// NS-005: explicit three-state TLS mode (None/StartTls/Ssl) — replaces the old
|
||||
/// <c>bool useTls</c> which could not represent implicit-SSL and silently fell
|
||||
/// back to opportunistic negotiation for non-StartTLS configurations.
|
||||
/// </param>
|
||||
/// <param name="connectionTimeoutSeconds">
|
||||
/// NS-007: SMTP connection/operation timeout in seconds. A non-positive value
|
||||
/// leaves the client's default timeout in place.
|
||||
/// </param>
|
||||
Task ConnectAsync(
|
||||
string host,
|
||||
int port,
|
||||
SmtpTlsMode tlsMode,
|
||||
int connectionTimeoutSeconds,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task AuthenticateAsync(string authType, string? credentials, CancellationToken cancellationToken = default);
|
||||
Task SendAsync(string from, IEnumerable<string> bccRecipients, string subject, string body, CancellationToken cancellationToken = default);
|
||||
Task DisconnectAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -13,9 +13,33 @@ public class MailKitSmtpClientWrapper : ISmtpClientWrapper, IDisposable
|
||||
{
|
||||
private readonly SmtpClient _client = new();
|
||||
|
||||
public async Task ConnectAsync(string host, int port, bool useTls, CancellationToken cancellationToken = default)
|
||||
public async Task ConnectAsync(
|
||||
string host,
|
||||
int port,
|
||||
SmtpTlsMode tlsMode,
|
||||
int connectionTimeoutSeconds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var secureSocket = useTls ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto;
|
||||
// NS-005: map the explicit three-state TLS mode onto MailKit's socket
|
||||
// options. The old code collapsed everything to a boolean and used
|
||||
// SecureSocketOptions.Auto for the non-StartTLS case, which let MailKit
|
||||
// opportunistically negotiate TLS even when "None" was configured and
|
||||
// gave SSL-on-connect no representation at all.
|
||||
var secureSocket = tlsMode switch
|
||||
{
|
||||
SmtpTlsMode.None => SecureSocketOptions.None,
|
||||
SmtpTlsMode.StartTls => SecureSocketOptions.StartTls,
|
||||
SmtpTlsMode.Ssl => SecureSocketOptions.SslOnConnect,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(tlsMode), tlsMode, "Unknown TLS mode."),
|
||||
};
|
||||
|
||||
// NS-007: honour the configured connection timeout. SmtpClient.Timeout is
|
||||
// in milliseconds and applies to connect/auth/send operations.
|
||||
if (connectionTimeoutSeconds > 0)
|
||||
{
|
||||
_client.Timeout = connectionTimeoutSeconds * 1000;
|
||||
}
|
||||
|
||||
await _client.ConnectAsync(host, port, secureSocket, cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Text.Json;
|
||||
using MailKit;
|
||||
using MailKit.Net.Smtp;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MimeKit;
|
||||
using ScadaLink.Commons.Entities.Notifications;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
@@ -68,6 +69,30 @@ public class NotificationDeliveryService : INotificationDeliveryService
|
||||
return new NotificationResult(false, "No SMTP configuration available");
|
||||
}
|
||||
|
||||
// NS-005: validate the configured TLS mode up front — an unknown value is a
|
||||
// configuration error and must surface as a clean result, not a silent
|
||||
// fallback to opportunistic TLS negotiation.
|
||||
try
|
||||
{
|
||||
SmtpTlsModeParser.Parse(smtpConfig.TlsMode);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
_logger.LogError("Invalid SMTP TLS mode for list {List}: {Reason}", listName, ex.Message);
|
||||
return new NotificationResult(false, ex.Message);
|
||||
}
|
||||
|
||||
// NS-008: validate every email address before attempting delivery. A single
|
||||
// malformed address previously caused MailboxAddress.Parse to throw a
|
||||
// ParseException that escaped SendAsync unhandled; it must instead produce a
|
||||
// clean NotificationResult the calling script can handle.
|
||||
var addressError = ValidateAddresses(smtpConfig.FromAddress, recipients);
|
||||
if (addressError != null)
|
||||
{
|
||||
_logger.LogWarning("Notification to list {List} has invalid addresses: {Reason}", listName, addressError);
|
||||
return new NotificationResult(false, addressError);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await DeliverAsync(smtpConfig, recipients, subject, message, cancellationToken);
|
||||
@@ -75,9 +100,13 @@ public class NotificationDeliveryService : INotificationDeliveryService
|
||||
}
|
||||
catch (SmtpPermanentException ex)
|
||||
{
|
||||
// WP-12: Permanent SMTP failure — returned to script
|
||||
_logger.LogError(ex, "Permanent SMTP failure sending to list {List}", listName);
|
||||
return new NotificationResult(false, $"Permanent SMTP error: {ex.Message}");
|
||||
// WP-12: Permanent SMTP failure — returned to script.
|
||||
// NS-009: scrub credential fragments out of the server-supplied message
|
||||
// before logging or returning it.
|
||||
var detail = CredentialRedactor.Scrub(ex.Message, smtpConfig.Credentials);
|
||||
_logger.LogError(
|
||||
"Permanent SMTP failure sending to list {List}: {Detail}", listName, detail);
|
||||
return new NotificationResult(false, $"Permanent SMTP error: {detail}");
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
@@ -86,8 +115,11 @@ public class NotificationDeliveryService : INotificationDeliveryService
|
||||
}
|
||||
catch (Exception ex) when (IsTransientSmtpError(ex, cancellationToken))
|
||||
{
|
||||
// WP-12: Transient SMTP failure — hand to S&F
|
||||
_logger.LogWarning(ex, "Transient SMTP failure sending to list {List}, buffering for retry", listName);
|
||||
// WP-12: Transient SMTP failure — hand to S&F.
|
||||
// NS-009: scrub credential fragments before logging.
|
||||
_logger.LogWarning(
|
||||
"Transient SMTP failure sending to list {List} ({ExceptionType}): {Detail}; buffering for retry",
|
||||
listName, ex.GetType().Name, CredentialRedactor.Scrub(ex.Message, smtpConfig.Credentials));
|
||||
|
||||
if (_storeAndForward == null)
|
||||
{
|
||||
@@ -155,6 +187,30 @@ public class NotificationDeliveryService : INotificationDeliveryService
|
||||
return false;
|
||||
}
|
||||
|
||||
// NS-005: an unknown TLS mode is a configuration error that retrying cannot
|
||||
// fix — park the buffered message rather than throwing on every sweep.
|
||||
try
|
||||
{
|
||||
SmtpTlsModeParser.Parse(smtpConfig.TlsMode);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Buffered notification to list '{List}' cannot be delivered — {Reason}; parking.",
|
||||
payload.ListName, ex.Message);
|
||||
return false;
|
||||
}
|
||||
|
||||
// NS-008: a malformed address cannot be fixed by retrying — park it.
|
||||
var addressError = ValidateAddresses(smtpConfig.FromAddress, recipients);
|
||||
if (addressError != null)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Buffered notification to list '{List}' has invalid addresses ({Reason}); parking.",
|
||||
payload.ListName, addressError);
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await DeliverAsync(smtpConfig, recipients, payload.Subject, payload.Message, cancellationToken);
|
||||
@@ -162,7 +218,10 @@ public class NotificationDeliveryService : INotificationDeliveryService
|
||||
}
|
||||
catch (SmtpPermanentException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Buffered notification to list '{List}' failed permanently; parking.", payload.ListName);
|
||||
// NS-009: scrub credential fragments out of the message before logging.
|
||||
_logger.LogError(
|
||||
"Buffered notification to list '{List}' failed permanently ({Detail}); parking.",
|
||||
payload.ListName, CredentialRedactor.Scrub(ex.Message, smtpConfig.Credentials));
|
||||
return false;
|
||||
}
|
||||
// Transient SMTP errors propagate out of DeliverAsync — the S&F engine retries.
|
||||
@@ -171,7 +230,55 @@ public class NotificationDeliveryService : INotificationDeliveryService
|
||||
private sealed record BufferedNotification(string ListName, string Subject, string Message);
|
||||
|
||||
/// <summary>
|
||||
/// Delivers an email via SMTP. Throws on failure.
|
||||
/// NS-007: throttles concurrent SMTP deliveries to the configured
|
||||
/// <c>MaxConcurrentConnections</c>. Created lazily from the first SMTP config
|
||||
/// seen (one SMTP config is deployed per site, so the limit is stable).
|
||||
/// </summary>
|
||||
private SemaphoreSlim? _concurrencyLimiter;
|
||||
private readonly object _limiterLock = new();
|
||||
|
||||
private SemaphoreSlim GetConcurrencyLimiter(SmtpConfiguration config)
|
||||
{
|
||||
if (_concurrencyLimiter != null)
|
||||
{
|
||||
return _concurrencyLimiter;
|
||||
}
|
||||
|
||||
lock (_limiterLock)
|
||||
{
|
||||
// NS-007: a non-positive configured value would make SemaphoreSlim
|
||||
// throw; fall back to the design-doc default of 5.
|
||||
var max = config.MaxConcurrentConnections > 0 ? config.MaxConcurrentConnections : 5;
|
||||
_concurrencyLimiter ??= new SemaphoreSlim(max, max);
|
||||
return _concurrencyLimiter;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// NS-008: Validates the sender and recipient email addresses, returning a
|
||||
/// human-readable error string if any is malformed, or null if all parse.
|
||||
/// </summary>
|
||||
internal static string? ValidateAddresses(
|
||||
string fromAddress, IReadOnlyList<NotificationRecipient> recipients)
|
||||
{
|
||||
if (!MailboxAddress.TryParse(fromAddress, out _))
|
||||
{
|
||||
return $"Invalid sender (from) email address: '{fromAddress}'";
|
||||
}
|
||||
|
||||
var invalid = recipients
|
||||
.Where(r => !MailboxAddress.TryParse(r.EmailAddress, out _))
|
||||
.Select(r => r.EmailAddress)
|
||||
.ToList();
|
||||
|
||||
return invalid.Count > 0
|
||||
? $"Invalid recipient email address(es): {string.Join(", ", invalid)}"
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delivers an email via SMTP. Throws on failure (transient errors and
|
||||
/// <see cref="SmtpPermanentException"/> propagate; the caller classifies them).
|
||||
/// </summary>
|
||||
internal async Task DeliverAsync(
|
||||
SmtpConfiguration config,
|
||||
@@ -180,16 +287,23 @@ public class NotificationDeliveryService : INotificationDeliveryService
|
||||
string body,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tlsMode = SmtpTlsModeParser.Parse(config.TlsMode);
|
||||
|
||||
// NS-007: bound the number of concurrent SMTP connections per site.
|
||||
var limiter = GetConcurrencyLimiter(config);
|
||||
await limiter.WaitAsync(cancellationToken);
|
||||
|
||||
// NS-004: create exactly one client and dispose the one actually used.
|
||||
var smtp = _smtpClientFactory();
|
||||
using var disposable = smtp as IDisposable;
|
||||
|
||||
try
|
||||
{
|
||||
var useTls = config.TlsMode?.Equals("starttls", StringComparison.OrdinalIgnoreCase) == true;
|
||||
await smtp.ConnectAsync(config.Host, config.Port, useTls, cancellationToken);
|
||||
// NS-005/NS-007: explicit TLS mode and the configured connection timeout.
|
||||
await smtp.ConnectAsync(
|
||||
config.Host, config.Port, tlsMode, config.ConnectionTimeoutSeconds, cancellationToken);
|
||||
|
||||
// Resolve credentials (OAuth2 token refresh if needed)
|
||||
// Resolve credentials (OAuth2 token fetched/cached by the token service).
|
||||
var credentials = config.Credentials;
|
||||
if (config.AuthType.Equals("oauth2", StringComparison.OrdinalIgnoreCase) && _tokenService != null && credentials != null)
|
||||
{
|
||||
@@ -218,6 +332,11 @@ public class NotificationDeliveryService : INotificationDeliveryService
|
||||
}
|
||||
// Transient and SmtpPermanentException both propagate unchanged: SendAsync's
|
||||
// catch filters (SmtpPermanentException / IsTransientSmtpError) handle them.
|
||||
finally
|
||||
{
|
||||
// NS-007: always release the concurrency slot, even on failure.
|
||||
limiter.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private enum SmtpErrorClass
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -6,14 +9,18 @@ namespace ScadaLink.NotificationService;
|
||||
/// <summary>
|
||||
/// WP-11: OAuth2 Client Credentials token lifecycle — fetch, cache, refresh on expiry.
|
||||
/// Used for Microsoft 365 SMTP authentication.
|
||||
/// NS-006: tokens are cached per credential identity (tenant/client/secret), so a
|
||||
/// second SMTP configuration with different credentials never receives the first
|
||||
/// configuration's token.
|
||||
/// </summary>
|
||||
public class OAuth2TokenService
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILogger<OAuth2TokenService> _logger;
|
||||
private string? _cachedToken;
|
||||
private DateTimeOffset _tokenExpiry = DateTimeOffset.MinValue;
|
||||
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||
|
||||
// NS-006: cache keyed by a hash of the credential string. Each distinct
|
||||
// tenant/client/secret triple gets its own cached token and its own lock.
|
||||
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();
|
||||
|
||||
public OAuth2TokenService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
@@ -29,18 +36,21 @@ public class OAuth2TokenService
|
||||
/// </summary>
|
||||
public async Task<string> GetTokenAsync(string credentials, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_cachedToken != null && DateTimeOffset.UtcNow < _tokenExpiry)
|
||||
var key = CredentialKey(credentials);
|
||||
var entry = _cache.GetOrAdd(key, _ => new CacheEntry());
|
||||
|
||||
if (entry.Token != null && DateTimeOffset.UtcNow < entry.Expiry)
|
||||
{
|
||||
return _cachedToken;
|
||||
return entry.Token;
|
||||
}
|
||||
|
||||
await _lock.WaitAsync(cancellationToken);
|
||||
await entry.Lock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
// Double-check after acquiring lock
|
||||
if (_cachedToken != null && DateTimeOffset.UtcNow < _tokenExpiry)
|
||||
// Double-check after acquiring the per-credential lock.
|
||||
if (entry.Token != null && DateTimeOffset.UtcNow < entry.Expiry)
|
||||
{
|
||||
return _cachedToken;
|
||||
return entry.Token;
|
||||
}
|
||||
|
||||
var parts = credentials.Split(':', 3);
|
||||
@@ -70,18 +80,39 @@ public class OAuth2TokenService
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
_cachedToken = doc.RootElement.GetProperty("access_token").GetString()
|
||||
var token = doc.RootElement.GetProperty("access_token").GetString()
|
||||
?? throw new InvalidOperationException("No access_token in OAuth2 response");
|
||||
|
||||
var expiresIn = doc.RootElement.GetProperty("expires_in").GetInt32();
|
||||
_tokenExpiry = DateTimeOffset.UtcNow.AddSeconds(expiresIn - 60); // Refresh 60s before expiry
|
||||
entry.Token = token;
|
||||
entry.Expiry = DateTimeOffset.UtcNow.AddSeconds(expiresIn - 60); // Refresh 60s before expiry
|
||||
|
||||
_logger.LogInformation("OAuth2 token refreshed, expires in {ExpiresIn}s", expiresIn);
|
||||
return _cachedToken;
|
||||
// NS-009: the token endpoint identity is logged by tenant only — never
|
||||
// the client secret or the access token itself.
|
||||
_logger.LogInformation(
|
||||
"OAuth2 token refreshed for tenant {Tenant}, expires in {ExpiresIn}s", tenantId, expiresIn);
|
||||
return token;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.Release();
|
||||
entry.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// NS-006: a stable, non-reversible key for the credential string so the cache
|
||||
/// is partitioned by credential identity without holding the secret as a key.
|
||||
/// </summary>
|
||||
private static string CredentialKey(string credentials)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(credentials));
|
||||
return Convert.ToHexString(hash);
|
||||
}
|
||||
|
||||
private sealed class CacheEntry
|
||||
{
|
||||
public string? Token;
|
||||
public DateTimeOffset Expiry = DateTimeOffset.MinValue;
|
||||
public readonly SemaphoreSlim Lock = new(1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
49
src/ScadaLink.NotificationService/SmtpTlsMode.cs
Normal file
49
src/ScadaLink.NotificationService/SmtpTlsMode.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
namespace ScadaLink.NotificationService;
|
||||
|
||||
/// <summary>
|
||||
/// NS-005: The three TLS modes the design doc defines for SMTP connections.
|
||||
/// A single boolean cannot represent the requirement, so the configured
|
||||
/// <c>SmtpConfiguration.TlsMode</c> string is parsed into this three-state enum.
|
||||
/// </summary>
|
||||
public enum SmtpTlsMode
|
||||
{
|
||||
/// <summary>No transport security — plain SMTP. Maps to <c>SecureSocketOptions.None</c>.</summary>
|
||||
None,
|
||||
|
||||
/// <summary>Opportunistic STARTTLS upgrade (typically port 587). Maps to <c>SecureSocketOptions.StartTls</c>.</summary>
|
||||
StartTls,
|
||||
|
||||
/// <summary>Implicit TLS / SSL-on-connect (typically port 465). Maps to <c>SecureSocketOptions.SslOnConnect</c>.</summary>
|
||||
Ssl,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// NS-005: Parses the free-text <c>SmtpConfiguration.TlsMode</c> value into a
|
||||
/// <see cref="SmtpTlsMode"/>, rejecting unknown values rather than silently
|
||||
/// falling back to opportunistic negotiation.
|
||||
/// </summary>
|
||||
public static class SmtpTlsModeParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Parses a configured TLS mode string. A null or empty value defaults to
|
||||
/// <see cref="SmtpTlsMode.StartTls"/> (the design-doc default for port 587).
|
||||
/// </summary>
|
||||
/// <exception cref="ArgumentException">The value is not one of None/StartTLS/SSL.</exception>
|
||||
public static SmtpTlsMode Parse(string? tlsMode)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tlsMode))
|
||||
{
|
||||
return SmtpTlsMode.StartTls;
|
||||
}
|
||||
|
||||
return tlsMode.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"none" => SmtpTlsMode.None,
|
||||
"starttls" => SmtpTlsMode.StartTls,
|
||||
"ssl" => SmtpTlsMode.Ssl,
|
||||
_ => throw new ArgumentException(
|
||||
$"Unknown SMTP TLS mode '{tlsMode}'. Expected one of: None, StartTLS, SSL.",
|
||||
nameof(tlsMode)),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user