diff --git a/libs/host/Configuration/Options.cs b/libs/host/Configuration/Options.cs index 942617a476..b3b9d82aaa 100644 --- a/libs/host/Configuration/Options.cs +++ b/libs/host/Configuration/Options.cs @@ -11,8 +11,8 @@ using System.Security.Cryptography.X509Certificates; using CommandLine; using Garnet.server; -using Garnet.server.Auth; using Garnet.server.Auth.Aad; +using Garnet.server.Auth.Settings; using Garnet.server.TLS; using Microsoft.Extensions.Logging; using Tsavorite.core; @@ -167,6 +167,9 @@ internal sealed class Options [Option("aad-authorized-app-ids", Required = false, Separator = ',', HelpText = "The authorized client app Ids for AAD authentication. Should be a comma separated string.")] public string AuthorizedAadApplicationIds { get; set; } + [Option("aad-validate-acl-username", Required = false, Separator = ',', HelpText = "Only valid for AclWithAAD mode. Validates username - expected to be OID of client app or a valid group's object id of which the client is part of.")] + public bool? AadValidateUsername { get; set; } + [OptionValidation] [Option("aof", Required = false, HelpText = "Enable write ahead logging (append-only file).")] public bool? EnableAOF { get; set; } @@ -623,7 +626,10 @@ private IAuthenticationSettings GetAuthenticationSettings(ILogger logger = null) case GarnetAuthenticationMode.Aad: return new AadAuthenticationSettings(AuthorizedAadApplicationIds?.Split(','), AadAudiences?.Split(','), AadIssuers?.Split(','), IssuerSigningTokenProvider.Create(AadAuthority, logger)); case GarnetAuthenticationMode.ACL: - return new AclAuthenticationSettings(AclFile, Password); + return new AclAuthenticationPasswordSettings(AclFile, Password); + case GarnetAuthenticationMode.AclWithAad: + var aadAuthSettings = new AadAuthenticationSettings(AuthorizedAadApplicationIds?.Split(','), AadAudiences?.Split(','), AadIssuers?.Split(','), IssuerSigningTokenProvider.Create(AadAuthority, logger), AadValidateUsername.GetValueOrDefault()); + return new AclAuthenticationAadSettings(AclFile, Password, aadAuthSettings); default: logger?.LogError("Unsupported authentication mode: {mode}", AuthenticationMode); throw new Exception($"Authentication mode {AuthenticationMode} is not supported."); diff --git a/libs/host/defaults.conf b/libs/host/defaults.conf index 0005aaaab3..ac8288c5d4 100644 --- a/libs/host/defaults.conf +++ b/libs/host/defaults.conf @@ -111,6 +111,9 @@ /* The authorized client app Ids for AAD authentication. Should be a comma separated string. */ "AuthorizedAadApplicationIds" : null, + /* Whether to validate username as ObjectId or a valid Group objectId if present in claims - meant to be used with ACL setup. */ + "AadValidateUsername": false, + /* Enable write ahead logging (append-only file). */ "EnableAOF" : false, diff --git a/libs/server/Auth/Aad/IssuerSigningTokenProvider.cs b/libs/server/Auth/Aad/IssuerSigningTokenProvider.cs index 944865c90e..261d550e98 100644 --- a/libs/server/Auth/Aad/IssuerSigningTokenProvider.cs +++ b/libs/server/Auth/Aad/IssuerSigningTokenProvider.cs @@ -31,15 +31,19 @@ public class IssuerSigningTokenProvider : IDisposable private readonly ILogger _logger; - private IssuerSigningTokenProvider(string authority, IReadOnlyCollection signingTokens, ILogger logger) + protected IssuerSigningTokenProvider(string authority, IReadOnlyCollection signingTokens, bool refreshTokens = true, ILogger logger = null) { _authority = authority; - _refreshTimer = new Timer(RefreshSigningTokens, null, TimeSpan.Zero, TimeSpan.FromDays(1)); + if (refreshTokens) + { + _refreshTimer = new Timer(RefreshSigningTokens, null, TimeSpan.Zero, TimeSpan.FromDays(1)); + } _signingTokens = signingTokens; _logger = logger; } + private void RefreshSigningTokens(object _) { try @@ -103,7 +107,7 @@ public static IssuerSigningTokenProvider Create(string authority, ILogger logger } var signingTokens = RetrieveSigningTokens(authority); - return new IssuerSigningTokenProvider(authority, signingTokens, logger); + return new IssuerSigningTokenProvider(authority, signingTokens, refreshTokens: true, logger); } } } \ No newline at end of file diff --git a/libs/server/Auth/AuthenticationSettings.cs b/libs/server/Auth/AuthenticationSettings.cs deleted file mode 100644 index 237005d881..0000000000 --- a/libs/server/Auth/AuthenticationSettings.cs +++ /dev/null @@ -1,236 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -using System; -using System.Collections.Generic; -using Garnet.server.Auth.Aad; - -namespace Garnet.server.Auth -{ - /// - /// Authentication mode - /// - public enum GarnetAuthenticationMode - { - /// - /// No auth - Garnet accepts any and all connections - /// - NoAuth, - - /// - /// Password - Garnet accepts connections with correct connection string - /// - Password, - - /// - /// AAD - Garnet accepts connection with correct AAD principal - /// In AAD mode, token may expire. Clients are expected to periodically refresh token with Garnet by running AUTH command. - /// - Aad, - - /// - /// ACL - Garnet validates new connections and commands against configured ACL users and access rules. - /// - ACL - } - - /// - /// Authentication settings - /// - public interface IAuthenticationSettings : IDisposable - { - /// - /// Create an authenticator using the current settings. - /// - /// The main store the authenticator will be associated with. - IGarnetAuthenticator CreateAuthenticator(StoreWrapper storeWrapper); - } - - /// - /// No auth settings - /// - public class NoAuthSettings : IAuthenticationSettings - { - /// - /// Creates a no auth authenticator - /// - /// The main store the authenticator will be associated with. - public IGarnetAuthenticator CreateAuthenticator(StoreWrapper storeWrapper) - { - return new GarnetNoAuthAuthenticator(); - } - - /// - /// Dispose - /// - public void Dispose() - { - // No-op - } - } - - /// - /// Password auth settings - /// - public class PasswordAuthenticationSettings : IAuthenticationSettings - { - private readonly byte[] _pwd; - - /// - /// Constructor - /// - /// The password - public PasswordAuthenticationSettings(string pwd) - { - if (string.IsNullOrEmpty(pwd)) - { - throw new Exception("Password cannot be null."); - } - _pwd = System.Text.Encoding.ASCII.GetBytes(pwd); - } - - /// - /// Creates a password auth authenticator - /// - /// The main store the authenticator will be associated with. - public IGarnetAuthenticator CreateAuthenticator(StoreWrapper storeWrapper) - { - return new GarnetPasswordAuthenticator(_pwd); - } - - /// - /// Dispose - /// - public void Dispose() - { - // No op - } - } - - /// - /// AAD auth settings - /// - public class AadAuthenticationSettings : IAuthenticationSettings - { - private readonly IReadOnlyCollection _authorizedAppIds; - private readonly IReadOnlyCollection _audiences; - private readonly IReadOnlyCollection _issuers; - private IssuerSigningTokenProvider _signingTokenProvider; - private bool _disposed; - - /// - /// Constructor - /// - /// Allowed app Ids - /// Allowed audiences - /// Allowed issuers - /// Signing token provider - public AadAuthenticationSettings(string[] authorizedAppIds, string[] audiences, string[] issuers, IssuerSigningTokenProvider signingTokenProvier) - { - if (authorizedAppIds == null || authorizedAppIds.Length == 0) - { - throw new Exception("Authorized app Ids cannot be empty."); - } - - if (audiences == null || audiences.Length == 0) - { - throw new Exception("Audiences cannot be empty."); - } - - if (issuers == null || issuers.Length == 0) - { - throw new Exception("Issuers cannot be empty."); - } - - if (signingTokenProvier == null) - { - throw new Exception("Signing token provider cannot be null."); - } - - _authorizedAppIds = new HashSet(authorizedAppIds, StringComparer.OrdinalIgnoreCase); - _audiences = new HashSet(audiences, StringComparer.OrdinalIgnoreCase); - _issuers = new HashSet(issuers, StringComparer.OrdinalIgnoreCase); - _signingTokenProvider = signingTokenProvier; - } - - /// - /// Creates an AAD auth authenticator - /// - /// The main store the authenticator will be associated with. - public IGarnetAuthenticator CreateAuthenticator(StoreWrapper storeWrapper) - { - return new GarnetAadAuthenticator(_authorizedAppIds, _audiences, _issuers, _signingTokenProvider, storeWrapper.logger); - } - - /// - /// Dispose impl - /// - /// Flag to run disposal logic - protected virtual void Dispose(bool disposing) - { - if (!_disposed) - { - if (disposing) - { - _signingTokenProvider?.Dispose(); - _signingTokenProvider = null; - } - - _disposed = true; - } - } - - /// - /// Dispose - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - } - - /// - /// ACL authentication settings - /// - public class AclAuthenticationSettings : IAuthenticationSettings - { - /// - /// Location of a the ACL configuration file to load users from - /// - public readonly string AclConfigurationFile; - - /// - /// Default user password, in case aclConfiguration file is undefined or does not specify default password - /// - public readonly string DefaultPassword; - - /// - /// Creates and initializes new ACL authentication settings - /// - /// Location of the ACL configuration file - /// Optional default password, if not defined through aclConfigurationFile - public AclAuthenticationSettings(string aclConfigurationFile, string defaultPassword = "") - { - AclConfigurationFile = aclConfigurationFile; - DefaultPassword = defaultPassword; - } - - /// - /// Creates an ACL authenticator - /// - /// The main store the authenticator will be associated with. - public IGarnetAuthenticator CreateAuthenticator(StoreWrapper storeWrapper) - { - return new GarnetACLAuthenticator(storeWrapper.accessControlList, storeWrapper.logger); - } - - /// - /// Dispose - /// - public void Dispose() - { - // No-op - } - } -} \ No newline at end of file diff --git a/libs/server/Auth/GarnetACLAuthenticator.cs b/libs/server/Auth/GarnetACLAuthenticator.cs index 70089f75a1..32636d6184 100644 --- a/libs/server/Auth/GarnetACLAuthenticator.cs +++ b/libs/server/Auth/GarnetACLAuthenticator.cs @@ -8,22 +8,22 @@ namespace Garnet.server.Auth { - class GarnetACLAuthenticator : IGarnetAuthenticator + abstract class GarnetACLAuthenticator : IGarnetAuthenticator { /// /// The Access Control List to authenticate users against /// - readonly AccessControlList _acl; + protected readonly AccessControlList _acl; /// /// Logger to use to output log messages to /// - readonly ILogger _logger; + protected readonly ILogger _logger; /// /// If authenticated, contains a reference to the authenticated user. Otherwise null. /// - User _user = null; + protected User _user = null; /// /// Initializes a new ACLAuthenticator instance. @@ -65,14 +65,11 @@ public bool Authenticate(ReadOnlySpan password, ReadOnlySpan usernam // Check if user exists and set default user if username is unspecified string uname = Encoding.ASCII.GetString(username); User user = string.IsNullOrEmpty(uname) ? _acl.GetDefaultUser() : _acl.GetUser(uname); - - // Try to authenticate user - ACLPassword passwordHash = ACLPassword.ACLPasswordFromString(Encoding.ASCII.GetString(password)); - if (user.IsEnabled && user.ValidatePassword(passwordHash)) + if (user == null) { - _user = user; - successful = true; + return false; } + successful = AuthenticateInternal(user, username, password); } catch (Exception ex) { @@ -83,6 +80,8 @@ public bool Authenticate(ReadOnlySpan password, ReadOnlySpan usernam return successful; } + protected abstract bool AuthenticateInternal(User user, ReadOnlySpan username, ReadOnlySpan password); + /// /// Returns the currently authorized user. /// diff --git a/libs/server/Auth/GarnetAadAuthenticator.cs b/libs/server/Auth/GarnetAadAuthenticator.cs index 6746bf39b8..0e518bbb9f 100644 --- a/libs/server/Auth/GarnetAadAuthenticator.cs +++ b/libs/server/Auth/GarnetAadAuthenticator.cs @@ -21,6 +21,8 @@ class GarnetAadAuthenticator : IGarnetAuthenticator private const string _appIdAcrClaim = "appidacr"; private const string _scopeClaim = "http://schemas.microsoft.com/identity/claims/scope"; private const string _appIdClaim = "appid"; + private const string _oidClaim = "http://schemas.microsoft.com/identity/claims/objectidentifier"; + private const string _groupsClaim = "groups"; public bool IsAuthenticated => IsAuthorized(); @@ -35,6 +37,7 @@ class GarnetAadAuthenticator : IGarnetAuthenticator private readonly IReadOnlyCollection _audiences; private readonly IReadOnlyCollection _issuers; private readonly IssuerSigningTokenProvider _signingTokenProvider; + private readonly bool _validateUsername; private readonly ILogger _logger; @@ -43,12 +46,14 @@ public GarnetAadAuthenticator( IReadOnlyCollection audiences, IReadOnlyCollection issuers, IssuerSigningTokenProvider signingTokenProvider, + bool validateUsername, ILogger logger) { _authorizedAppIds = authorizedAppIds; _signingTokenProvider = signingTokenProvider; _audiences = audiences; _issuers = issuers; + _validateUsername = validateUsername; _logger = logger; } @@ -64,13 +69,12 @@ public bool Authenticate(ReadOnlySpan password, ReadOnlySpan usernam IssuerSigningKeys = _signingTokenProvider.SigningTokens }; parameters.EnableAadSigningKeyIssuerValidation(); - var identity = _tokenHandler.ValidateToken(Encoding.UTF8.GetString(password), parameters, out var token); _validFrom = token.ValidFrom; _validateTo = token.ValidTo; - _authorized = IsIdentityAuthorized(identity); + _authorized = IsIdentityAuthorized(identity, username); _logger?.LogInformation($"Authentication successful. Token valid from {_validFrom} to {_validateTo}"); return IsAuthorized(); @@ -85,20 +89,48 @@ public bool Authenticate(ReadOnlySpan password, ReadOnlySpan usernam } } - private bool IsIdentityAuthorized(ClaimsPrincipal identity) + private bool IsIdentityAuthorized(ClaimsPrincipal identity, ReadOnlySpan userName) { var claims = identity.Claims .GroupBy(claim => claim.Type) .ToDictionary(group => group.Key, group => string.Join(',', group.Select(c => c.Value)), StringComparer.OrdinalIgnoreCase); - return IsApplicationPrincipal(claims) && IsApplicationAuthorized(claims); + bool isValid = IsApplicationPrincipal(claims) && IsApplicationAuthorized(claims); + return !_validateUsername ? isValid : _validateUsername && IsUserNameAuthorized(claims, userName); } - private bool IsApplicationAuthorized(IDictionary claims) { return claims.TryGetValue(_appIdClaim, out var appId) && _authorizedAppIds.Contains(appId); } + /// + /// Validates the username for OID or Group claim. A given token issued to client object maybe part of a + /// AAD Group or an ObjectID incase of Application. We validate for OID first and then all groups. + /// + /// token claims mapping + /// input username + private bool IsUserNameAuthorized(IDictionary claims, ReadOnlySpan userName) + { + var userNameStr = Encoding.UTF8.GetString(userName); + if (claims.TryGetValue(_oidClaim, out var oid) && oid.Equals(userNameStr, StringComparison.InvariantCultureIgnoreCase)) + { + return true; + } + if (claims.TryGetValue(_groupsClaim, out var groups)) + { + var splitGroups = groups.Split(","); + foreach (var group in splitGroups) + { + if (group.Equals(userNameStr, StringComparison.InvariantCultureIgnoreCase)) + { + return true; + } + } + } + return false; + } + + private bool IsAuthorized() { var now = DateTime.UtcNow; diff --git a/libs/server/Auth/GarnetAclWithAadAuthenticator.cs b/libs/server/Auth/GarnetAclWithAadAuthenticator.cs new file mode 100644 index 0000000000..0e26f0d7b8 --- /dev/null +++ b/libs/server/Auth/GarnetAclWithAadAuthenticator.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Garnet.server.ACL; +using Microsoft.Extensions.Logging; + +namespace Garnet.server.Auth +{ + class GarnetAclWithAadAuthenticator : GarnetACLAuthenticator + { + /// + /// Authenticator to validate username and password. + /// + private readonly IGarnetAuthenticator _garnetAuthenticator; + public GarnetAclWithAadAuthenticator(AccessControlList accessControlList, IGarnetAuthenticator garnetAuthenticator, ILogger logger) : base(accessControlList, logger) + { + _garnetAuthenticator = garnetAuthenticator; + } + + /// + /// Authenticate the given user/password combination. + /// + /// User details to use for authentication. + /// Password to authenticate with. + /// Username to authenticate with. If empty, will authenticate default user. + /// true if authentication was successful + protected override bool AuthenticateInternal(User user, ReadOnlySpan username, ReadOnlySpan password) + { + if (user.IsEnabled && password.Length > 0 && _garnetAuthenticator.Authenticate(password, username)) + { + _user = user; + return true; + } + return false; + } + } +} \ No newline at end of file diff --git a/libs/server/Auth/GarnetAclWithPasswordAuthenticator.cs b/libs/server/Auth/GarnetAclWithPasswordAuthenticator.cs new file mode 100644 index 0000000000..d29fc61497 --- /dev/null +++ b/libs/server/Auth/GarnetAclWithPasswordAuthenticator.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Garnet.server.ACL; +using Microsoft.Extensions.Logging; + +namespace Garnet.server.Auth +{ + class GarnetAclWithPasswordAuthenticator : GarnetACLAuthenticator + { + + public GarnetAclWithPasswordAuthenticator(AccessControlList accessControlList, ILogger logger) : base(accessControlList, logger) + { + } + + /// + /// Authenticate the given user/password combination. + /// + /// User details to use for authentication. + /// Password to authenticate with. + /// Username to authenticate with. If empty, will authenticate default user. + /// true if authentication was successful + protected override bool AuthenticateInternal(User user, ReadOnlySpan username, ReadOnlySpan password) + { + // Try to authenticate user + ACLPassword passwordHash = ACLPassword.ACLPasswordFromString(Encoding.ASCII.GetString(password)); + if (user.IsEnabled && user.ValidatePassword(passwordHash)) + { + _user = user; + return true; + } + return false; + } + } +} \ No newline at end of file diff --git a/libs/server/Auth/Settings/AadAuthenticationSettings.cs b/libs/server/Auth/Settings/AadAuthenticationSettings.cs new file mode 100644 index 0000000000..4e65d4688a --- /dev/null +++ b/libs/server/Auth/Settings/AadAuthenticationSettings.cs @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; +using System.Collections.Generic; +using Garnet.server.Auth.Aad; + +namespace Garnet.server.Auth.Settings +{ + /// + /// AAD auth settings + /// + public class AadAuthenticationSettings : IAuthenticationSettings + { + private readonly IReadOnlyCollection _authorizedAppIds; + private readonly IReadOnlyCollection _audiences; + private readonly IReadOnlyCollection _issuers; + private IssuerSigningTokenProvider _signingTokenProvider; + private bool _validateUsername; + private bool _disposed; + + /// + /// Constructor + /// + /// Allowed app Ids + /// Allowed audiences + /// Allowed issuers + /// Signing token provider + /// whether to validate username or not. + public AadAuthenticationSettings(string[] authorizedAppIds, string[] audiences, string[] issuers, IssuerSigningTokenProvider signingTokenProvider, bool validateUsername = false) + { + if (authorizedAppIds == null || authorizedAppIds.Length == 0) + { + throw new Exception("Authorized app Ids cannot be empty."); + } + + if (audiences == null || audiences.Length == 0) + { + throw new Exception("Audiences cannot be empty."); + } + + if (issuers == null || issuers.Length == 0) + { + throw new Exception("Issuers cannot be empty."); + } + + if (signingTokenProvider == null) + { + throw new Exception("Signing token provider cannot be null."); + } + + _authorizedAppIds = new HashSet(authorizedAppIds, StringComparer.OrdinalIgnoreCase); + _audiences = new HashSet(audiences, StringComparer.OrdinalIgnoreCase); + _issuers = new HashSet(issuers, StringComparer.OrdinalIgnoreCase); + _signingTokenProvider = signingTokenProvider; + _validateUsername = validateUsername; + } + + /// + /// Creates an AAD auth authenticator + /// + /// The main store the authenticator will be associated with. + public IGarnetAuthenticator CreateAuthenticator(StoreWrapper storeWrapper) + { + return new GarnetAadAuthenticator(_authorizedAppIds, _audiences, _issuers, _signingTokenProvider, _validateUsername, storeWrapper.logger); + } + + /// + /// Dispose impl + /// + /// Flag to run disposal logic + protected virtual void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + _signingTokenProvider?.Dispose(); + _signingTokenProvider = null; + } + + _disposed = true; + } + } + + /// + /// Dispose + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + } +} \ No newline at end of file diff --git a/libs/server/Auth/Settings/AclAuthenticationAadSettings.cs b/libs/server/Auth/Settings/AclAuthenticationAadSettings.cs new file mode 100644 index 0000000000..f48ef9c14a --- /dev/null +++ b/libs/server/Auth/Settings/AclAuthenticationAadSettings.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +namespace Garnet.server.Auth.Settings +{ + /// + /// ACL authentication with AAD settings + /// + public class AclAuthenticationAadSettings : AclAuthenticationSettings + { + + AadAuthenticationSettings _aadAuthenticationSettings; + + /// + /// Creates and initializes new ACL authentication settings + /// + /// Location of the ACL configuration file + /// Optional default password, if not defined through aclConfigurationFile + /// AAD settings used for authentication + public AclAuthenticationAadSettings(string aclConfigurationFile, string defaultPassword = "", AadAuthenticationSettings aadAuthenticationSettings = null) : base(aclConfigurationFile, defaultPassword) + { + _aadAuthenticationSettings = aadAuthenticationSettings; + } + + /// + /// Creates an ACL authenticator + /// + /// The main store the authenticator will be associated with. + + protected override IGarnetAuthenticator CreateAuthenticatorInternal(StoreWrapper storeWrapper) + { + return new GarnetAclWithAadAuthenticator(storeWrapper.accessControlList, _aadAuthenticationSettings.CreateAuthenticator(storeWrapper), storeWrapper.logger); + } + } +} \ No newline at end of file diff --git a/libs/server/Auth/Settings/AclAuthenticationPasswordSettings.cs b/libs/server/Auth/Settings/AclAuthenticationPasswordSettings.cs new file mode 100644 index 0000000000..cfd4e770c0 --- /dev/null +++ b/libs/server/Auth/Settings/AclAuthenticationPasswordSettings.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +namespace Garnet.server.Auth.Settings +{ + /// + /// ACL authentication with AAD settings. + /// + public class AclAuthenticationPasswordSettings : AclAuthenticationSettings + { + + /// + /// Creates and initializes new ACL authentication settings + /// + /// Location of the ACL configuration file + /// Optional default password, if not defined through aclConfigurationFile + public AclAuthenticationPasswordSettings(string aclConfigurationFile, string defaultPassword = "") : base(aclConfigurationFile, defaultPassword) + { + } + + /// + /// Creates an ACL authenticator + /// + /// The main store the authenticator will be associated with. + + protected override IGarnetAuthenticator CreateAuthenticatorInternal(StoreWrapper storeWrapper) + { + return new GarnetAclWithPasswordAuthenticator(storeWrapper.accessControlList, storeWrapper.logger); + } + } +} \ No newline at end of file diff --git a/libs/server/Auth/Settings/AclAuthenticationSettings.cs b/libs/server/Auth/Settings/AclAuthenticationSettings.cs new file mode 100644 index 0000000000..52d5a870fb --- /dev/null +++ b/libs/server/Auth/Settings/AclAuthenticationSettings.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +namespace Garnet.server.Auth.Settings +{ + /// + /// ACL authentication settings + /// + public abstract class AclAuthenticationSettings : IAuthenticationSettings + { + /// + /// Location of a the ACL configuration file to load users from + /// + public readonly string AclConfigurationFile; + + /// + /// Default user password, in case aclConfiguration file is undefined or does not specify default password + /// + public readonly string DefaultPassword; + + /// + /// Creates and initializes new ACL authentication settings + /// + /// Location of the ACL configuration file + /// Optional default password, if not defined through aclConfigurationFile + public AclAuthenticationSettings(string aclConfigurationFile, string defaultPassword = "") + { + AclConfigurationFile = aclConfigurationFile; + DefaultPassword = defaultPassword; + } + + /// + /// Creates an ACL authenticator + /// + /// The main store the authenticator will be associated with. + public IGarnetAuthenticator CreateAuthenticator(StoreWrapper storeWrapper) + { + return CreateAuthenticatorInternal(storeWrapper); + } + + /// + /// Creates the internal implementation specific ACL authenticator. + /// + /// The main store the authenticator will be associated with. + /// IGarnetAuthenticator instance + protected abstract IGarnetAuthenticator CreateAuthenticatorInternal(StoreWrapper storeWrapper); + + + /// + /// Dispose + /// + public void Dispose() + { + // No-op + } + } +} \ No newline at end of file diff --git a/libs/server/Auth/Settings/AuthenticationSettings.cs b/libs/server/Auth/Settings/AuthenticationSettings.cs new file mode 100644 index 0000000000..a7565d928e --- /dev/null +++ b/libs/server/Auth/Settings/AuthenticationSettings.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; +using System.Collections.Generic; +using Garnet.server.Auth.Aad; + +namespace Garnet.server.Auth.Settings +{ + /// + /// Authentication mode + /// + public enum GarnetAuthenticationMode + { + /// + /// No auth - Garnet accepts any and all connections + /// + NoAuth, + + /// + /// Password - Garnet accepts connections with correct connection string + /// + Password, + + /// + /// AAD - Garnet accepts connection with correct AAD principal + /// In AAD mode, token may expire. Clients are expected to periodically refresh token with Garnet by running AUTH command. + /// + Aad, + + /// + /// ACL - Garnet validates new connections and commands against configured ACL users and access rules. + /// + ACL, + /// + /// ACL mode using Aad token instead of password. Here username is expected to be ObjectId or a valid Group's Object Id and token will be validated for claims. + /// + AclWithAad + } + + /// + /// Authentication settings + /// + public interface IAuthenticationSettings : IDisposable + { + /// + /// Create an authenticator using the current settings. + /// + /// The main store the authenticator will be associated with. + IGarnetAuthenticator CreateAuthenticator(StoreWrapper storeWrapper); + } +} \ No newline at end of file diff --git a/libs/server/Auth/Settings/NoAuthSettings.cs b/libs/server/Auth/Settings/NoAuthSettings.cs new file mode 100644 index 0000000000..2cc7a237e9 --- /dev/null +++ b/libs/server/Auth/Settings/NoAuthSettings.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +namespace Garnet.server.Auth.Settings +{ + /// + /// No auth settings + /// + public class NoAuthSettings : IAuthenticationSettings + { + /// + /// Creates a no auth authenticator + /// + /// The main store the authenticator will be associated with. + public IGarnetAuthenticator CreateAuthenticator(StoreWrapper storeWrapper) + { + return new GarnetNoAuthAuthenticator(); + } + + /// + /// Dispose + /// + public void Dispose() + { + // No-op + } + } +} \ No newline at end of file diff --git a/libs/server/Auth/Settings/PasswordAuthenticationSettings.cs b/libs/server/Auth/Settings/PasswordAuthenticationSettings.cs new file mode 100644 index 0000000000..0b2eda1cb2 --- /dev/null +++ b/libs/server/Auth/Settings/PasswordAuthenticationSettings.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; + +namespace Garnet.server.Auth.Settings +{ + /// + /// Password auth settings + /// + public class PasswordAuthenticationSettings : IAuthenticationSettings + { + private readonly byte[] _pwd; + + /// + /// Constructor + /// + /// The password + public PasswordAuthenticationSettings(string pwd) + { + if (string.IsNullOrEmpty(pwd)) + { + throw new Exception("Password cannot be null."); + } + _pwd = System.Text.Encoding.ASCII.GetBytes(pwd); + } + + /// + /// Creates a password auth authenticator + /// + /// The main store the authenticator will be associated with. + public IGarnetAuthenticator CreateAuthenticator(StoreWrapper storeWrapper) + { + return new GarnetPasswordAuthenticator(_pwd); + } + + /// + /// Dispose + /// + public void Dispose() + { + // No op + } + } +} \ No newline at end of file diff --git a/libs/server/Resp/ACLCommands.cs b/libs/server/Resp/ACLCommands.cs index 474d90068e..94661b4ac7 100644 --- a/libs/server/Resp/ACLCommands.cs +++ b/libs/server/Resp/ACLCommands.cs @@ -7,6 +7,7 @@ using Garnet.common; using Garnet.server.ACL; using Garnet.server.Auth; +using Garnet.server.Auth.Settings; using Microsoft.Extensions.Logging; namespace Garnet.server @@ -27,7 +28,7 @@ private bool ProcessACLCommands(ReadOnlySpan bufSpan, int count) { // Only proceed if current authenticator can be used with ACL commands. // Currently only GarnetACLAuthenticator is supported. - if (!_authenticator.HasACLSupport || (_authenticator.GetType() != typeof(GarnetACLAuthenticator))) + if (!_authenticator.HasACLSupport || (_authenticator.GetType().BaseType != typeof(GarnetACLAuthenticator))) { if (!DrainCommands(bufSpan, count)) return false; @@ -214,7 +215,7 @@ private bool ProcessACLCommands(ReadOnlySpan bufSpan, int count) // NOTE: This is temporary as long as ACL operations are only supported when using the ACL authenticator Debug.Assert(this.storeWrapper.serverOptions.AuthSettings != null); - Debug.Assert(this.storeWrapper.serverOptions.AuthSettings.GetType() == typeof(AclAuthenticationSettings)); + Debug.Assert(this.storeWrapper.serverOptions.AuthSettings.GetType().BaseType == typeof(AclAuthenticationSettings)); AclAuthenticationSettings aclAuthenticationSettings = (AclAuthenticationSettings)this.storeWrapper.serverOptions.AuthSettings; // Try to reload the configured ACL configuration file diff --git a/libs/server/Servers/GarnetServerOptions.cs b/libs/server/Servers/GarnetServerOptions.cs index 8e740136fc..1330deab4b 100644 --- a/libs/server/Servers/GarnetServerOptions.cs +++ b/libs/server/Servers/GarnetServerOptions.cs @@ -3,7 +3,7 @@ using System; using System.IO; -using Garnet.server.Auth; +using Garnet.server.Auth.Settings; using Garnet.server.TLS; using Microsoft.Extensions.Logging; using Tsavorite.core; diff --git a/libs/server/StoreWrapper.cs b/libs/server/StoreWrapper.cs index 6bf134f360..5e26353eae 100644 --- a/libs/server/StoreWrapper.cs +++ b/libs/server/StoreWrapper.cs @@ -9,7 +9,7 @@ using System.Threading.Tasks; using Garnet.common; using Garnet.server.ACL; -using Garnet.server.Auth; +using Garnet.server.Auth.Settings; using Microsoft.Extensions.Logging; using Tsavorite.core; @@ -135,7 +135,7 @@ public StoreWrapper( // If ACL authentication is enabled, initiate access control list // NOTE: This is a temporary workflow. ACL should always be initiated and authenticator // should become a parameter of AccessControlList. - if ((this.serverOptions.AuthSettings != null) && (this.serverOptions.AuthSettings.GetType() == typeof(AclAuthenticationSettings))) + if ((this.serverOptions.AuthSettings != null) && (this.serverOptions.AuthSettings.GetType().BaseType == typeof(AclAuthenticationSettings))) { // Create a new access control list and register it with the authentication settings AclAuthenticationSettings aclAuthenticationSettings = (AclAuthenticationSettings)this.serverOptions.AuthSettings; diff --git a/test/Garnet.test.cluster/ClusterAadAuthTests.cs b/test/Garnet.test.cluster/ClusterAadAuthTests.cs new file mode 100644 index 0000000000..81dabdc035 --- /dev/null +++ b/test/Garnet.test.cluster/ClusterAadAuthTests.cs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using Garnet.server.Auth.Settings; +using Microsoft.IdentityModel.Tokens; +using NUnit.Framework; + +namespace Garnet.test.cluster +{ + + [TestFixture] + [NonParallelizable] + class ClusterAadAuthTests + { + ClusterTestContext context; + + readonly HashSet monitorTests = []; + + private const string issuer = "https://sts.windows.net/975f013f-7f24-47e8-a7d3-abc4752bf346/"; + + [SetUp] + public void Setup() + { + context = new ClusterTestContext(); + context.Setup(monitorTests); + } + + [TearDown] + public void TearDown() + { + context.TearDown(); + } + + [Test, Order(1)] + [Category("CLUSTER-AUTH"), Timeout(60000)] + public void ValidateClusterAuthWithObjectId() + { + var nodes = 2; + var audience = Guid.NewGuid().ToString(); + JwtTokenGenerator tokenGenerator = new JwtTokenGenerator(issuer, audience); + + var appId = Guid.NewGuid().ToString(); + var objId = Guid.NewGuid().ToString(); + var tokenClaims = new List + { + new Claim("appidacr","1"), + new Claim("appid", appId), + new Claim("http://schemas.microsoft.com/identity/claims/objectidentifier",objId), + }; + var authSettings = new AadAuthenticationSettings([appId], [audience], [issuer], new MockIssuerSigningTokenProvider(new List { tokenGenerator.SecurityKey }, context.logger), true); + + var token = tokenGenerator.CreateToken(tokenClaims, DateTime.Now.AddMinutes(10)); + ValidateConnectionsWithToken(objId, token, nodes, authSettings); + } + + [Test, Order(2)] + [Category("CLUSTER-AUTH"), Timeout(60000)] + public void ValidateClusterAuthWithGroupOid() + { + var nodes = 2; + var audience = Guid.NewGuid().ToString(); + JwtTokenGenerator tokenGenerator = new JwtTokenGenerator(issuer, audience); + + var appId = Guid.NewGuid().ToString(); + var objId = Guid.NewGuid().ToString(); + var groupIds = new List { Guid.NewGuid().ToString(), Guid.NewGuid().ToString() }; + var tokenClaims = new List + { + new Claim("appidacr","1"), + new Claim("appid", appId), + new Claim("http://schemas.microsoft.com/identity/claims/objectidentifier", objId), + new Claim("groups", string.Join(',', groupIds)), + }; + var authSettings = new AadAuthenticationSettings([appId], [audience], [issuer], new MockIssuerSigningTokenProvider(new List { tokenGenerator.SecurityKey }, context.logger), true); + var token = tokenGenerator.CreateToken(tokenClaims, DateTime.Now.AddMinutes(10)); + ValidateConnectionsWithToken(groupIds.First(), token, nodes, authSettings); + } + + private void ValidateConnectionsWithToken(string aclUsername, string token, int nodeCount, AadAuthenticationSettings authenticationSettings) + { + var userCredential = new ServerCredential { user = aclUsername, IsAdmin = true, IsClearText = true }; + var clientCredentials = new ServerCredential { user = aclUsername, password = token }; + context.GenerateCredentials([userCredential]); + context.CreateInstances(nodeCount, useAcl: true, clusterCreds: clientCredentials, authenticationSettings: authenticationSettings); + + + context.CreateConnection(useTLS: false, clientCreds: clientCredentials); + + for (int i = 0; i < nodeCount; i++) + { + context.clusterTestUtils.Authenticate(i, clientCredentials.user, clientCredentials.password, context.logger); + context.clusterTestUtils.Meet(i, (i + 1) % nodeCount, context.logger); + var ex = Assert.Throws(() => context.clusterTestUtils.Authenticate(i, "randomUserId", clientCredentials.password, context.logger)); + Assert.AreEqual("WRONGPASS Invalid username/password combination", ex.Message); + } + + } + } +} \ No newline at end of file diff --git a/test/Garnet.test.cluster/ClusterTestContext.cs b/test/Garnet.test.cluster/ClusterTestContext.cs index a351ff53ef..c6477fde38 100644 --- a/test/Garnet.test.cluster/ClusterTestContext.cs +++ b/test/Garnet.test.cluster/ClusterTestContext.cs @@ -9,6 +9,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; +using Garnet.server.Auth.Settings; using Microsoft.Extensions.Logging; using NUnit.Framework; using StackExchange.Redis; @@ -102,7 +103,8 @@ public void CreateInstances( bool useTLS = false, bool useAcl = false, X509CertificateCollection certificates = null, - ServerCredential clusterCreds = new ServerCredential()) + ServerCredential clusterCreds = new ServerCredential(), + AadAuthenticationSettings authenticationSettings = null) { endpoints = TestUtils.GetEndPoints(shards, 7000); nodes = TestUtils.CreateGarnetCluster( @@ -131,7 +133,8 @@ public void CreateInstances( aclFile: credManager.aclFilePath, authUsername: clusterCreds.user, authPassword: clusterCreds.password, - certificates: certificates); + certificates: certificates, + authenticationSettings: authenticationSettings); foreach (var node in nodes) node.Start(); diff --git a/test/Garnet.test.cluster/JwtTokenHelpers.cs b/test/Garnet.test.cluster/JwtTokenHelpers.cs new file mode 100644 index 0000000000..10532a57bd --- /dev/null +++ b/test/Garnet.test.cluster/JwtTokenHelpers.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; +using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Security.Claims; +using System.Text; +using System.Threading.Tasks; +using Garnet.server.Auth.Aad; +using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.Tokens; + +namespace Garnet.test.cluster +{ + internal class MockIssuerSigningTokenProvider : IssuerSigningTokenProvider + { + internal MockIssuerSigningTokenProvider(IReadOnlyCollection signingTokens, ILogger logger = null) : base(string.Empty, signingTokens, false, logger) + { } + + } + + internal class JwtTokenGenerator + { + private readonly string Issuer; + + private readonly string Audience; + + // Our random signing key - used to sign and validate the tokens + public SecurityKey SecurityKey { get; } + + // the signing credentials used by the token handler to sign tokens + public SigningCredentials SigningCredentials { get; } + + // the token handler we'll use to actually issue tokens + public readonly JwtSecurityTokenHandler JwtSecurityTokenHandler = new(); + + internal JwtTokenGenerator(string issuer, string audience) + { + Issuer = issuer; + Audience = audience; + SecurityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("Test secret key for authentication and signing the token to be generated")); + SigningCredentials = new SigningCredentials(SecurityKey, SecurityAlgorithms.HmacSha256); + } + + internal string CreateToken(List claims, DateTime expiryTime) + { + return JwtSecurityTokenHandler.WriteToken(new JwtSecurityToken(Issuer, Audience, claims, expires: expiryTime, signingCredentials: SigningCredentials)); + } + + } +} \ No newline at end of file diff --git a/test/Garnet.test/TestUtils.cs b/test/Garnet.test/TestUtils.cs index ae3ccf0469..3b49fe2fe2 100644 --- a/test/Garnet.test/TestUtils.cs +++ b/test/Garnet.test/TestUtils.cs @@ -15,7 +15,7 @@ using Garnet.client; using Garnet.common; using Garnet.server; -using Garnet.server.Auth; +using Garnet.server.Auth.Settings; using Garnet.server.TLS; using Microsoft.Extensions.Logging; using NUnit.Framework; @@ -190,7 +190,7 @@ public static GarnetServer CreateGarnetServer( IAuthenticationSettings authenticationSettings = null; if (useAcl) { - authenticationSettings = new AclAuthenticationSettings(aclFile, defaultPassword); + authenticationSettings = new AclAuthenticationPasswordSettings(aclFile, defaultPassword); } else if (defaultPassword != null) { @@ -315,7 +315,8 @@ public static GarnetServer[] CreateGarnetCluster( bool useAcl = false, // NOTE: Temporary until ACL is enforced as default string aclFile = null, X509CertificateCollection certificates = null, - ILoggerFactory loggerFactory = null) + ILoggerFactory loggerFactory = null, + AadAuthenticationSettings authenticationSettings = null) { if (UseAzureStorage) IgnoreIfNotRunningAzureTests(); @@ -353,7 +354,8 @@ public static GarnetServer[] CreateGarnetCluster( useAcl: useAcl, aclFile: aclFile, certificates: certificates, - logger: loggerFactory?.CreateLogger("GarnetServer")); + logger: loggerFactory?.CreateLogger("GarnetServer"), + aadAuthenticationSettings: authenticationSettings); Assert.IsNotNull(opts); int iter = 0; @@ -397,6 +399,7 @@ public static GarnetServerOptions GetGarnetServerOptions( bool useAcl = false, // NOTE: Temporary until ACL is enforced as default string aclFile = null, X509CertificateCollection certificates = null, + AadAuthenticationSettings aadAuthenticationSettings = null, ILogger logger = null) { if (UseAzureStorage) @@ -412,9 +415,13 @@ public static GarnetServerOptions GetGarnetServerOptions( if (!UseAzureStorage) _CheckpointDir = new DirectoryInfo(string.IsNullOrEmpty(_CheckpointDir) ? "." : _CheckpointDir).FullName; IAuthenticationSettings authenticationSettings = null; - if (useAcl) + if (useAcl && aadAuthenticationSettings != null) + { + authenticationSettings = new AclAuthenticationAadSettings(aclFile, authPassword, aadAuthenticationSettings); + } + else if (useAcl) { - authenticationSettings = new AclAuthenticationSettings(aclFile, authPassword); + authenticationSettings = new AclAuthenticationPasswordSettings(aclFile, authPassword); } else if (authPassword != null) {