feat: implement TLS cert-to-user mapping via X500 DN matching
This commit is contained in:
@@ -34,6 +34,13 @@ public sealed class AuthService
|
||||
var nonceRequired = false;
|
||||
Dictionary<string, User>? usersMap = null;
|
||||
|
||||
// TLS certificate mapping (highest priority when enabled)
|
||||
if (options.TlsMap && options.TlsVerify && options.Users is { Count: > 0 })
|
||||
{
|
||||
authenticators.Add(new TlsMapAuthenticator(options.Users));
|
||||
authRequired = true;
|
||||
}
|
||||
|
||||
// Priority order (matching Go): NKeys > Users > Token > SimpleUserPassword
|
||||
|
||||
if (options.NKeys is { Count: > 0 })
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Auth;
|
||||
@@ -11,4 +12,5 @@ public sealed class ClientAuthContext
|
||||
{
|
||||
public required ClientOptions Opts { get; init; }
|
||||
public required byte[] Nonce { get; init; }
|
||||
public X509Certificate2? ClientCertificate { get; init; }
|
||||
}
|
||||
|
||||
67
src/NATS.Server/Auth/TlsMapAuthenticator.cs
Normal file
67
src/NATS.Server/Auth/TlsMapAuthenticator.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace NATS.Server.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates clients by mapping TLS certificate subject DN to configured users.
|
||||
/// Corresponds to Go server/auth.go checkClientTLSCertSubject.
|
||||
/// </summary>
|
||||
public sealed class TlsMapAuthenticator : IAuthenticator
|
||||
{
|
||||
private readonly Dictionary<string, User> _usersByDn;
|
||||
private readonly Dictionary<string, User> _usersByCn;
|
||||
|
||||
public TlsMapAuthenticator(IReadOnlyList<User> users)
|
||||
{
|
||||
_usersByDn = new Dictionary<string, User>(StringComparer.OrdinalIgnoreCase);
|
||||
_usersByCn = new Dictionary<string, User>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var user in users)
|
||||
{
|
||||
_usersByDn[user.Username] = user;
|
||||
_usersByCn[user.Username] = user;
|
||||
}
|
||||
}
|
||||
|
||||
public AuthResult? Authenticate(ClientAuthContext context)
|
||||
{
|
||||
var cert = context.ClientCertificate;
|
||||
if (cert == null)
|
||||
return null;
|
||||
|
||||
var dn = cert.SubjectName;
|
||||
var dnString = dn.Name; // RFC 2253 format
|
||||
|
||||
// Try exact DN match first
|
||||
if (_usersByDn.TryGetValue(dnString, out var user))
|
||||
return BuildResult(user);
|
||||
|
||||
// Try CN extraction
|
||||
var cn = ExtractCn(dn);
|
||||
if (cn != null && _usersByCn.TryGetValue(cn, out user))
|
||||
return BuildResult(user);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ExtractCn(X500DistinguishedName dn)
|
||||
{
|
||||
var dnString = dn.Name;
|
||||
foreach (var rdn in dnString.Split(',', StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (rdn.StartsWith("CN=", StringComparison.OrdinalIgnoreCase))
|
||||
return rdn[3..];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static AuthResult BuildResult(User user)
|
||||
{
|
||||
return new AuthResult
|
||||
{
|
||||
Identity = user.Username,
|
||||
AccountName = user.Account,
|
||||
Permissions = user.Permissions,
|
||||
Expiry = user.ConnectionDeadline,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -367,6 +367,7 @@ public sealed class NatsClient : IDisposable
|
||||
{
|
||||
Opts = ClientOpts,
|
||||
Nonce = _nonce ?? [],
|
||||
ClientCertificate = TlsState?.PeerCert,
|
||||
};
|
||||
|
||||
var result = _authService.Authenticate(context);
|
||||
|
||||
134
tests/NATS.Server.Tests/TlsMapAuthenticatorTests.cs
Normal file
134
tests/NATS.Server.Tests/TlsMapAuthenticatorTests.cs
Normal file
@@ -0,0 +1,134 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class TlsMapAuthenticatorTests
|
||||
{
|
||||
private static X509Certificate2 CreateSelfSignedCert(string cn)
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var req = new CertificateRequest($"CN={cn}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
return req.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddYears(1));
|
||||
}
|
||||
|
||||
private static X509Certificate2 CreateCertWithDn(string dn)
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var req = new CertificateRequest(dn, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
return req.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddYears(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Matches_user_by_cn()
|
||||
{
|
||||
var users = new List<User>
|
||||
{
|
||||
new() { Username = "alice", Password = "" },
|
||||
};
|
||||
var auth = new TlsMapAuthenticator(users);
|
||||
var cert = CreateSelfSignedCert("alice");
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new Protocol.ClientOptions(),
|
||||
Nonce = [],
|
||||
ClientCertificate = cert,
|
||||
};
|
||||
|
||||
var result = auth.Authenticate(ctx);
|
||||
result.ShouldNotBeNull();
|
||||
result.Identity.ShouldBe("alice");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Returns_null_when_no_cert()
|
||||
{
|
||||
var users = new List<User>
|
||||
{
|
||||
new() { Username = "alice", Password = "" },
|
||||
};
|
||||
var auth = new TlsMapAuthenticator(users);
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new Protocol.ClientOptions(),
|
||||
Nonce = [],
|
||||
ClientCertificate = null,
|
||||
};
|
||||
|
||||
var result = auth.Authenticate(ctx);
|
||||
result.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Returns_null_when_cn_doesnt_match()
|
||||
{
|
||||
var users = new List<User>
|
||||
{
|
||||
new() { Username = "alice", Password = "" },
|
||||
};
|
||||
var auth = new TlsMapAuthenticator(users);
|
||||
var cert = CreateSelfSignedCert("bob");
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new Protocol.ClientOptions(),
|
||||
Nonce = [],
|
||||
ClientCertificate = cert,
|
||||
};
|
||||
|
||||
var result = auth.Authenticate(ctx);
|
||||
result.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Matches_by_full_dn_string()
|
||||
{
|
||||
var users = new List<User>
|
||||
{
|
||||
new() { Username = "CN=alice, O=TestOrg", Password = "" },
|
||||
};
|
||||
var auth = new TlsMapAuthenticator(users);
|
||||
var cert = CreateCertWithDn("CN=alice, O=TestOrg");
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new Protocol.ClientOptions(),
|
||||
Nonce = [],
|
||||
ClientCertificate = cert,
|
||||
};
|
||||
|
||||
var result = auth.Authenticate(ctx);
|
||||
result.ShouldNotBeNull();
|
||||
result.Identity.ShouldBe("CN=alice, O=TestOrg");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Returns_permissions_from_matched_user()
|
||||
{
|
||||
var perms = new Permissions
|
||||
{
|
||||
Publish = new SubjectPermission { Allow = ["foo.>"] },
|
||||
};
|
||||
var users = new List<User>
|
||||
{
|
||||
new() { Username = "alice", Password = "", Permissions = perms },
|
||||
};
|
||||
var auth = new TlsMapAuthenticator(users);
|
||||
var cert = CreateSelfSignedCert("alice");
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new Protocol.ClientOptions(),
|
||||
Nonce = [],
|
||||
ClientCertificate = cert,
|
||||
};
|
||||
|
||||
var result = auth.Authenticate(ctx);
|
||||
result.ShouldNotBeNull();
|
||||
result.Permissions.ShouldNotBeNull();
|
||||
result.Permissions.Publish!.Allow!.ShouldContain("foo.>");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user