feat: add AuthService orchestrator with priority-ordered authentication
This commit is contained in:
131
src/NATS.Server/Auth/AuthService.cs
Normal file
131
src/NATS.Server/Auth/AuthService.cs
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
namespace NATS.Server.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Central authentication orchestrator that builds the appropriate authenticators
|
||||||
|
/// from NatsOptions and tries them in priority order matching the Go server:
|
||||||
|
/// NKeys > Users > Token > SimpleUserPassword.
|
||||||
|
/// Reference: golang/nats-server/server/auth.go — checkClientAuth, configureAuthentication.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AuthService
|
||||||
|
{
|
||||||
|
private readonly List<IAuthenticator> _authenticators;
|
||||||
|
private readonly string? _noAuthUser;
|
||||||
|
private readonly Dictionary<string, User>? _usersMap;
|
||||||
|
|
||||||
|
public bool IsAuthRequired { get; }
|
||||||
|
public bool NonceRequired { get; }
|
||||||
|
|
||||||
|
private AuthService(List<IAuthenticator> authenticators, bool authRequired, bool nonceRequired,
|
||||||
|
string? noAuthUser, Dictionary<string, User>? usersMap)
|
||||||
|
{
|
||||||
|
_authenticators = authenticators;
|
||||||
|
IsAuthRequired = authRequired;
|
||||||
|
NonceRequired = nonceRequired;
|
||||||
|
_noAuthUser = noAuthUser;
|
||||||
|
_usersMap = usersMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AuthService Build(NatsOptions options)
|
||||||
|
{
|
||||||
|
var authenticators = new List<IAuthenticator>();
|
||||||
|
var authRequired = false;
|
||||||
|
var nonceRequired = false;
|
||||||
|
Dictionary<string, User>? usersMap = null;
|
||||||
|
|
||||||
|
// Priority order (matching Go): NKeys > Users > Token > SimpleUserPassword
|
||||||
|
|
||||||
|
if (options.NKeys is { Count: > 0 })
|
||||||
|
{
|
||||||
|
authenticators.Add(new NKeyAuthenticator(options.NKeys));
|
||||||
|
authRequired = true;
|
||||||
|
nonceRequired = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.Users is { Count: > 0 })
|
||||||
|
{
|
||||||
|
authenticators.Add(new UserPasswordAuthenticator(options.Users));
|
||||||
|
authRequired = true;
|
||||||
|
usersMap = new Dictionary<string, User>(StringComparer.Ordinal);
|
||||||
|
foreach (var u in options.Users)
|
||||||
|
usersMap[u.Username] = u;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(options.Authorization))
|
||||||
|
{
|
||||||
|
authenticators.Add(new TokenAuthenticator(options.Authorization));
|
||||||
|
authRequired = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(options.Username) && !string.IsNullOrEmpty(options.Password))
|
||||||
|
{
|
||||||
|
authenticators.Add(new SimpleUserPasswordAuthenticator(options.Username, options.Password));
|
||||||
|
authRequired = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AuthService(authenticators, authRequired, nonceRequired, options.NoAuthUser, usersMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AuthResult? Authenticate(ClientAuthContext context)
|
||||||
|
{
|
||||||
|
if (!IsAuthRequired)
|
||||||
|
return new AuthResult { Identity = string.Empty };
|
||||||
|
|
||||||
|
foreach (var authenticator in _authenticators)
|
||||||
|
{
|
||||||
|
var result = authenticator.Authenticate(context);
|
||||||
|
if (result != null)
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_noAuthUser != null && IsNoCredentials(context))
|
||||||
|
return ResolveNoAuthUser();
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsNoCredentials(ClientAuthContext context)
|
||||||
|
{
|
||||||
|
var opts = context.Opts;
|
||||||
|
return string.IsNullOrEmpty(opts.Username)
|
||||||
|
&& string.IsNullOrEmpty(opts.Password)
|
||||||
|
&& string.IsNullOrEmpty(opts.Token)
|
||||||
|
&& string.IsNullOrEmpty(opts.Nkey)
|
||||||
|
&& string.IsNullOrEmpty(opts.Sig);
|
||||||
|
}
|
||||||
|
|
||||||
|
private AuthResult? ResolveNoAuthUser()
|
||||||
|
{
|
||||||
|
if (_noAuthUser == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (_usersMap != null && _usersMap.TryGetValue(_noAuthUser, out var user))
|
||||||
|
{
|
||||||
|
return new AuthResult
|
||||||
|
{
|
||||||
|
Identity = user.Username,
|
||||||
|
AccountName = user.Account,
|
||||||
|
Permissions = user.Permissions,
|
||||||
|
Expiry = user.ConnectionDeadline,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AuthResult { Identity = _noAuthUser };
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] GenerateNonce()
|
||||||
|
{
|
||||||
|
Span<byte> raw = stackalloc byte[11];
|
||||||
|
RandomNumberGenerator.Fill(raw);
|
||||||
|
return raw.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public string EncodeNonce(byte[] nonce)
|
||||||
|
{
|
||||||
|
return Convert.ToBase64String(nonce)
|
||||||
|
.TrimEnd('=')
|
||||||
|
.Replace('+', '-')
|
||||||
|
.Replace('/', '_');
|
||||||
|
}
|
||||||
|
}
|
||||||
172
tests/NATS.Server.Tests/AuthServiceTests.cs
Normal file
172
tests/NATS.Server.Tests/AuthServiceTests.cs
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
using NATS.Server.Auth;
|
||||||
|
using NATS.Server.Protocol;
|
||||||
|
|
||||||
|
namespace NATS.Server.Tests;
|
||||||
|
|
||||||
|
public class AuthServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void IsAuthRequired_false_when_no_auth_configured()
|
||||||
|
{
|
||||||
|
var service = AuthService.Build(new NatsOptions());
|
||||||
|
service.IsAuthRequired.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsAuthRequired_true_when_token_configured()
|
||||||
|
{
|
||||||
|
var service = AuthService.Build(new NatsOptions { Authorization = "mytoken" });
|
||||||
|
service.IsAuthRequired.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsAuthRequired_true_when_username_configured()
|
||||||
|
{
|
||||||
|
var service = AuthService.Build(new NatsOptions { Username = "admin", Password = "pass" });
|
||||||
|
service.IsAuthRequired.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsAuthRequired_true_when_users_configured()
|
||||||
|
{
|
||||||
|
var opts = new NatsOptions
|
||||||
|
{
|
||||||
|
Users = [new User { Username = "alice", Password = "secret" }],
|
||||||
|
};
|
||||||
|
var service = AuthService.Build(opts);
|
||||||
|
service.IsAuthRequired.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsAuthRequired_true_when_nkeys_configured()
|
||||||
|
{
|
||||||
|
var opts = new NatsOptions
|
||||||
|
{
|
||||||
|
NKeys = [new NKeyUser { Nkey = "UABC" }],
|
||||||
|
};
|
||||||
|
var service = AuthService.Build(opts);
|
||||||
|
service.IsAuthRequired.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Authenticate_succeeds_when_no_auth_required()
|
||||||
|
{
|
||||||
|
var service = AuthService.Build(new NatsOptions());
|
||||||
|
var ctx = new ClientAuthContext
|
||||||
|
{
|
||||||
|
Opts = new ClientOptions { Token = "anything" },
|
||||||
|
Nonce = [],
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = service.Authenticate(ctx);
|
||||||
|
result.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Authenticate_token_success()
|
||||||
|
{
|
||||||
|
var service = AuthService.Build(new NatsOptions { Authorization = "mytoken" });
|
||||||
|
var ctx = new ClientAuthContext
|
||||||
|
{
|
||||||
|
Opts = new ClientOptions { Token = "mytoken" },
|
||||||
|
Nonce = [],
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = service.Authenticate(ctx);
|
||||||
|
result.ShouldNotBeNull();
|
||||||
|
result.Identity.ShouldBe("token");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Authenticate_token_failure()
|
||||||
|
{
|
||||||
|
var service = AuthService.Build(new NatsOptions { Authorization = "mytoken" });
|
||||||
|
var ctx = new ClientAuthContext
|
||||||
|
{
|
||||||
|
Opts = new ClientOptions { Token = "wrong" },
|
||||||
|
Nonce = [],
|
||||||
|
};
|
||||||
|
|
||||||
|
service.Authenticate(ctx).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Authenticate_simple_user_password_success()
|
||||||
|
{
|
||||||
|
var service = AuthService.Build(new NatsOptions { Username = "admin", Password = "pass" });
|
||||||
|
var ctx = new ClientAuthContext
|
||||||
|
{
|
||||||
|
Opts = new ClientOptions { Username = "admin", Password = "pass" },
|
||||||
|
Nonce = [],
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = service.Authenticate(ctx);
|
||||||
|
result.ShouldNotBeNull();
|
||||||
|
result.Identity.ShouldBe("admin");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Authenticate_multi_user_success()
|
||||||
|
{
|
||||||
|
var opts = new NatsOptions
|
||||||
|
{
|
||||||
|
Users = [
|
||||||
|
new User { Username = "alice", Password = "secret1" },
|
||||||
|
new User { Username = "bob", Password = "secret2" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
var service = AuthService.Build(opts);
|
||||||
|
var ctx = new ClientAuthContext
|
||||||
|
{
|
||||||
|
Opts = new ClientOptions { Username = "bob", Password = "secret2" },
|
||||||
|
Nonce = [],
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = service.Authenticate(ctx);
|
||||||
|
result.ShouldNotBeNull();
|
||||||
|
result.Identity.ShouldBe("bob");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NoAuthUser_fallback_when_no_creds()
|
||||||
|
{
|
||||||
|
var opts = new NatsOptions
|
||||||
|
{
|
||||||
|
Users = [
|
||||||
|
new User { Username = "default", Password = "unused" },
|
||||||
|
],
|
||||||
|
NoAuthUser = "default",
|
||||||
|
};
|
||||||
|
var service = AuthService.Build(opts);
|
||||||
|
var ctx = new ClientAuthContext
|
||||||
|
{
|
||||||
|
Opts = new ClientOptions(),
|
||||||
|
Nonce = [],
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = service.Authenticate(ctx);
|
||||||
|
result.ShouldNotBeNull();
|
||||||
|
result.Identity.ShouldBe("default");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NKeys_tried_before_users()
|
||||||
|
{
|
||||||
|
var opts = new NatsOptions
|
||||||
|
{
|
||||||
|
NKeys = [new NKeyUser { Nkey = "UABC" }],
|
||||||
|
Users = [new User { Username = "alice", Password = "secret" }],
|
||||||
|
};
|
||||||
|
var service = AuthService.Build(opts);
|
||||||
|
|
||||||
|
var ctx = new ClientAuthContext
|
||||||
|
{
|
||||||
|
Opts = new ClientOptions { Username = "alice", Password = "secret" },
|
||||||
|
Nonce = [],
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = service.Authenticate(ctx);
|
||||||
|
result.ShouldNotBeNull();
|
||||||
|
result.Identity.ShouldBe("alice");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user