feat: add authenticators, Account, and ClientPermissions (Tasks 3-7, 9)
- Account: per-account SubList and client tracking - IAuthenticator interface, AuthResult, ClientAuthContext - TokenAuthenticator: constant-time token comparison - UserPasswordAuthenticator: multi-user with bcrypt/plain support - SimpleUserPasswordAuthenticator: single user/pass config - NKeyAuthenticator: Ed25519 nonce signature verification - ClientPermissions: SubList-based publish/subscribe authorization
This commit is contained in:
121
src/NATS.Server/Auth/ClientPermissions.cs
Normal file
121
src/NATS.Server/Auth/ClientPermissions.cs
Normal file
@@ -0,0 +1,121 @@
|
||||
using System.Collections.Concurrent;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Auth;
|
||||
|
||||
public sealed class ClientPermissions : IDisposable
|
||||
{
|
||||
private readonly PermissionSet? _publish;
|
||||
private readonly PermissionSet? _subscribe;
|
||||
private readonly ConcurrentDictionary<string, bool> _pubCache = new(StringComparer.Ordinal);
|
||||
|
||||
private ClientPermissions(PermissionSet? publish, PermissionSet? subscribe)
|
||||
{
|
||||
_publish = publish;
|
||||
_subscribe = subscribe;
|
||||
}
|
||||
|
||||
public static ClientPermissions? Build(Permissions? permissions)
|
||||
{
|
||||
if (permissions == null)
|
||||
return null;
|
||||
|
||||
var pub = PermissionSet.Build(permissions.Publish);
|
||||
var sub = PermissionSet.Build(permissions.Subscribe);
|
||||
|
||||
if (pub == null && sub == null)
|
||||
return null;
|
||||
|
||||
return new ClientPermissions(pub, sub);
|
||||
}
|
||||
|
||||
public bool IsPublishAllowed(string subject)
|
||||
{
|
||||
if (_publish == null)
|
||||
return true;
|
||||
|
||||
return _pubCache.GetOrAdd(subject, s => _publish.IsAllowed(s));
|
||||
}
|
||||
|
||||
public bool IsSubscribeAllowed(string subject, string? queue = null)
|
||||
{
|
||||
if (_subscribe == null)
|
||||
return true;
|
||||
|
||||
return _subscribe.IsAllowed(subject);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_publish?.Dispose();
|
||||
_subscribe?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PermissionSet : IDisposable
|
||||
{
|
||||
private readonly SubList? _allow;
|
||||
private readonly SubList? _deny;
|
||||
|
||||
private PermissionSet(SubList? allow, SubList? deny)
|
||||
{
|
||||
_allow = allow;
|
||||
_deny = deny;
|
||||
}
|
||||
|
||||
public static PermissionSet? Build(SubjectPermission? permission)
|
||||
{
|
||||
if (permission == null)
|
||||
return null;
|
||||
|
||||
bool hasAllow = permission.Allow is { Count: > 0 };
|
||||
bool hasDeny = permission.Deny is { Count: > 0 };
|
||||
|
||||
if (!hasAllow && !hasDeny)
|
||||
return null;
|
||||
|
||||
SubList? allow = null;
|
||||
SubList? deny = null;
|
||||
|
||||
if (hasAllow)
|
||||
{
|
||||
allow = new SubList();
|
||||
foreach (var subject in permission.Allow!)
|
||||
allow.Insert(new Subscription { Subject = subject, Sid = "_perm_" });
|
||||
}
|
||||
|
||||
if (hasDeny)
|
||||
{
|
||||
deny = new SubList();
|
||||
foreach (var subject in permission.Deny!)
|
||||
deny.Insert(new Subscription { Subject = subject, Sid = "_perm_" });
|
||||
}
|
||||
|
||||
return new PermissionSet(allow, deny);
|
||||
}
|
||||
|
||||
public bool IsAllowed(string subject)
|
||||
{
|
||||
bool allowed = true;
|
||||
|
||||
if (_allow != null)
|
||||
{
|
||||
var result = _allow.Match(subject);
|
||||
allowed = result.PlainSubs.Length > 0 || result.QueueSubs.Length > 0;
|
||||
}
|
||||
|
||||
if (allowed && _deny != null)
|
||||
{
|
||||
var result = _deny.Match(subject);
|
||||
allowed = result.PlainSubs.Length == 0 && result.QueueSubs.Length == 0;
|
||||
}
|
||||
|
||||
return allowed;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_allow?.Dispose();
|
||||
_deny?.Dispose();
|
||||
}
|
||||
}
|
||||
66
src/NATS.Server/Auth/NKeyAuthenticator.cs
Normal file
66
src/NATS.Server/Auth/NKeyAuthenticator.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using NATS.NKeys;
|
||||
|
||||
namespace NATS.Server.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates clients using NKey (Ed25519) public-key signature verification.
|
||||
/// The server sends a random nonce in the INFO message. The client signs the nonce
|
||||
/// with their private key and sends the public key + base64-encoded signature in CONNECT.
|
||||
/// The server verifies the signature against the registered NKey users.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reference: golang/nats-server/server/auth.go — checkNKeyAuth
|
||||
/// </remarks>
|
||||
public sealed class NKeyAuthenticator(IEnumerable<NKeyUser> nkeyUsers) : IAuthenticator
|
||||
{
|
||||
private readonly Dictionary<string, NKeyUser> _nkeys = nkeyUsers.ToDictionary(
|
||||
u => u.Nkey,
|
||||
u => u,
|
||||
StringComparer.Ordinal);
|
||||
|
||||
public AuthResult? Authenticate(ClientAuthContext context)
|
||||
{
|
||||
var clientNkey = context.Opts.Nkey;
|
||||
if (string.IsNullOrEmpty(clientNkey))
|
||||
return null;
|
||||
|
||||
if (!_nkeys.TryGetValue(clientNkey, out var nkeyUser))
|
||||
return null;
|
||||
|
||||
var clientSig = context.Opts.Sig;
|
||||
if (string.IsNullOrEmpty(clientSig))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
// Decode base64 signature (handle both standard and URL-safe base64)
|
||||
byte[] sigBytes;
|
||||
try
|
||||
{
|
||||
sigBytes = Convert.FromBase64String(clientSig);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
// Try URL-safe base64 by converting to standard base64
|
||||
var padded = clientSig.Replace('-', '+').Replace('_', '/');
|
||||
padded = padded.PadRight(padded.Length + (4 - padded.Length % 4) % 4, '=');
|
||||
sigBytes = Convert.FromBase64String(padded);
|
||||
}
|
||||
|
||||
var kp = KeyPair.FromPublicKey(clientNkey);
|
||||
if (!kp.Verify(context.Nonce, sigBytes))
|
||||
return null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AuthResult
|
||||
{
|
||||
Identity = clientNkey,
|
||||
AccountName = nkeyUser.Account,
|
||||
Permissions = nkeyUser.Permissions,
|
||||
};
|
||||
}
|
||||
}
|
||||
61
src/NATS.Server/Auth/SimpleUserPasswordAuthenticator.cs
Normal file
61
src/NATS.Server/Auth/SimpleUserPasswordAuthenticator.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace NATS.Server.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates a single username/password pair configured on the server.
|
||||
/// Supports plain-text and bcrypt-hashed passwords.
|
||||
/// Uses constant-time comparison for both username and password to prevent timing attacks.
|
||||
/// Reference: golang/nats-server/server/auth.go checkClientAuth for single user.
|
||||
/// </summary>
|
||||
public sealed class SimpleUserPasswordAuthenticator : IAuthenticator
|
||||
{
|
||||
private readonly byte[] _expectedUsername;
|
||||
private readonly string _serverPassword;
|
||||
|
||||
public SimpleUserPasswordAuthenticator(string username, string password)
|
||||
{
|
||||
_expectedUsername = Encoding.UTF8.GetBytes(username);
|
||||
_serverPassword = password;
|
||||
}
|
||||
|
||||
public AuthResult? Authenticate(ClientAuthContext context)
|
||||
{
|
||||
var clientUsername = context.Opts.Username;
|
||||
if (string.IsNullOrEmpty(clientUsername))
|
||||
return null;
|
||||
|
||||
var clientUsernameBytes = Encoding.UTF8.GetBytes(clientUsername);
|
||||
if (!CryptographicOperations.FixedTimeEquals(clientUsernameBytes, _expectedUsername))
|
||||
return null;
|
||||
|
||||
var clientPassword = context.Opts.Password ?? string.Empty;
|
||||
|
||||
if (!ComparePasswords(_serverPassword, clientPassword))
|
||||
return null;
|
||||
|
||||
return new AuthResult { Identity = clientUsername };
|
||||
}
|
||||
|
||||
private static bool ComparePasswords(string serverPassword, string clientPassword)
|
||||
{
|
||||
// Bcrypt hashes start with "$2" (e.g., $2a$, $2b$, $2y$)
|
||||
if (serverPassword.StartsWith("$2"))
|
||||
{
|
||||
try
|
||||
{
|
||||
return BCrypt.Net.BCrypt.Verify(clientPassword, serverPassword);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Plain-text: constant-time comparison to prevent timing attacks
|
||||
var serverBytes = Encoding.UTF8.GetBytes(serverPassword);
|
||||
var clientBytes = Encoding.UTF8.GetBytes(clientPassword);
|
||||
return CryptographicOperations.FixedTimeEquals(serverBytes, clientBytes);
|
||||
}
|
||||
}
|
||||
66
src/NATS.Server/Auth/UserPasswordAuthenticator.cs
Normal file
66
src/NATS.Server/Auth/UserPasswordAuthenticator.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace NATS.Server.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates clients by looking up username in a dictionary and comparing
|
||||
/// the password using bcrypt (for $2-prefixed hashes) or constant-time comparison
|
||||
/// (for plain text passwords).
|
||||
/// Reference: golang/nats-server/server/auth.go checkClientPassword.
|
||||
/// </summary>
|
||||
public sealed class UserPasswordAuthenticator : IAuthenticator
|
||||
{
|
||||
private readonly Dictionary<string, User> _users;
|
||||
|
||||
public UserPasswordAuthenticator(IEnumerable<User> users)
|
||||
{
|
||||
_users = new Dictionary<string, User>(StringComparer.Ordinal);
|
||||
foreach (var user in users)
|
||||
_users[user.Username] = user;
|
||||
}
|
||||
|
||||
public AuthResult? Authenticate(ClientAuthContext context)
|
||||
{
|
||||
var username = context.Opts.Username;
|
||||
if (string.IsNullOrEmpty(username))
|
||||
return null;
|
||||
|
||||
if (!_users.TryGetValue(username, out var user))
|
||||
return null;
|
||||
|
||||
var clientPassword = context.Opts.Password ?? string.Empty;
|
||||
|
||||
if (!ComparePasswords(user.Password, clientPassword))
|
||||
return null;
|
||||
|
||||
return new AuthResult
|
||||
{
|
||||
Identity = user.Username,
|
||||
AccountName = user.Account,
|
||||
Permissions = user.Permissions,
|
||||
Expiry = user.ConnectionDeadline,
|
||||
};
|
||||
}
|
||||
|
||||
private static bool ComparePasswords(string serverPassword, string clientPassword)
|
||||
{
|
||||
if (IsBcrypt(serverPassword))
|
||||
{
|
||||
try
|
||||
{
|
||||
return BCrypt.Net.BCrypt.Verify(clientPassword, serverPassword);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
var serverBytes = Encoding.UTF8.GetBytes(serverPassword);
|
||||
var clientBytes = Encoding.UTF8.GetBytes(clientPassword);
|
||||
return CryptographicOperations.FixedTimeEquals(serverBytes, clientBytes);
|
||||
}
|
||||
|
||||
private static bool IsBcrypt(string password) => password.StartsWith("$2");
|
||||
}
|
||||
Reference in New Issue
Block a user