Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ACL with Microsoft Entra token support #378

Merged
merged 14 commits into from
May 21, 2024
Merged
5 changes: 5 additions & 0 deletions libs/host/Configuration/Options.cs
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,11 @@ private IAuthenticationSettings GetAuthenticationSettings(ILogger logger = null)
return new AadAuthenticationSettings(AuthorizedAadApplicationIds?.Split(','), AadAudiences?.Split(','), AadIssuers?.Split(','), IssuerSigningTokenProvider.Create(AadAuthority, logger));
case GarnetAuthenticationMode.ACL:
return new AclAuthenticationSettings(AclFile, Password);
case GarnetAuthenticationMode.AclWithAad:
var aadAuthSettings = new AadAuthenticationSettings(AuthorizedAadApplicationIds?.Split(','), AadAudiences?.Split(','), AadIssuers?.Split(','), IssuerSigningTokenProvider.Create(AadAuthority, logger));
aadAuthSettings = aadAuthSettings.WithUsernameValidation();
return new AclAuthenticationSettings(AclFile, Password, aadAuthSettings);

default:
logger?.LogError("Unsupported authentication mode: {mode}", AuthenticationMode);
throw new Exception($"Authentication mode {AuthenticationMode} is not supported.");
Expand Down
19 changes: 17 additions & 2 deletions libs/server/Auth/Aad/IssuerSigningTokenProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,19 @@ public class IssuerSigningTokenProvider : IDisposable

private readonly ILogger _logger;

private IssuerSigningTokenProvider(string authority, IReadOnlyCollection<SecurityKey> signingTokens, ILogger logger)
private IssuerSigningTokenProvider(string authority, IReadOnlyCollection<SecurityKey> signingTokens, ILogger logger, bool refreshTokens = true)
msft-paddy14 marked this conversation as resolved.
Show resolved Hide resolved
{
_authority = authority;
_refreshTimer = new Timer(RefreshSigningTokens, null, TimeSpan.Zero, TimeSpan.FromDays(1));
if (refreshTokens)
msft-paddy14 marked this conversation as resolved.
Show resolved Hide resolved
{
_refreshTimer = new Timer(RefreshSigningTokens, null, TimeSpan.Zero, TimeSpan.FromDays(1));
}
_signingTokens = signingTokens;

_logger = logger;
}


private void RefreshSigningTokens(object _)
{
try
Expand Down Expand Up @@ -105,5 +109,16 @@ public static IssuerSigningTokenProvider Create(string authority, ILogger logger
var signingTokens = RetrieveSigningTokens(authority);
return new IssuerSigningTokenProvider(authority, signingTokens, logger);
}

/// <summary>
/// [Used for testing]
/// Creates an instance of IssuerSigningTokenProvider without refresh of tokens and hence this doesn't need authority.
///
/// </summary>
/// <param name="logger">The logger</param>
public static IssuerSigningTokenProvider Create(IReadOnlyCollection<SecurityKey> signingToken, ILogger logger)
msft-paddy14 marked this conversation as resolved.
Show resolved Hide resolved
{
return new IssuerSigningTokenProvider(string.Empty, signingToken, logger, refreshTokens: false);
}
}
}
30 changes: 26 additions & 4 deletions libs/server/Auth/AuthenticationSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@ public enum GarnetAuthenticationMode
/// <summary>
/// ACL - Garnet validates new connections and commands against configured ACL users and access rules.
/// </summary>
ACL
ACL,
/// <summary>
/// ACL mode using Aad token instead of password. Here username is expected to be ObjectId and token will be validated for claims.
/// </summary>
AclWithAad
}

/// <summary>
Expand Down Expand Up @@ -116,6 +120,7 @@ public class AadAuthenticationSettings : IAuthenticationSettings
private readonly IReadOnlyCollection<string> _audiences;
private readonly IReadOnlyCollection<string> _issuers;
private IssuerSigningTokenProvider _signingTokenProvider;
private bool _validateUsername;
private bool _disposed;

msft-paddy14 marked this conversation as resolved.
Show resolved Hide resolved
/// <summary>
Expand Down Expand Up @@ -153,13 +158,27 @@ public AadAuthenticationSettings(string[] authorizedAppIds, string[] audiences,
_signingTokenProvider = signingTokenProvier;
}

public AadAuthenticationSettings WithUsernameValidation()
{
_validateUsername = true;
msft-paddy14 marked this conversation as resolved.
Show resolved Hide resolved
return this;
}

/// <summary>
/// Creates an AAD auth authenticator
/// </summary>
/// <param name="storeWrapper">The main store the authenticator will be associated with.</param>
public IGarnetAuthenticator CreateAuthenticator(StoreWrapper storeWrapper)
{
return new GarnetAadAuthenticator(_authorizedAppIds, _audiences, _issuers, _signingTokenProvider, storeWrapper.logger);
var config = new AadValidationConfig
{
_audiences = _audiences,
_authorizedAppIds = _authorizedAppIds,
_issuers = _issuers,
_signingTokenProvider = _signingTokenProvider,
ValidateUsername = _validateUsername,
};
return new GarnetAadAuthenticator(config, storeWrapper.logger);
}

/// <summary>
Expand Down Expand Up @@ -205,15 +224,18 @@ public class AclAuthenticationSettings : IAuthenticationSettings
/// </summary>
public readonly string DefaultPassword;

private IAuthenticationSettings settings;

/// <summary>
/// Creates and initializes new ACL authentication settings
/// </summary>
/// <param name="aclConfigurationFile">Location of the ACL configuration file</param>
/// <param name="defaultPassword">Optional default password, if not defined through aclConfigurationFile</param>
msft-paddy14 marked this conversation as resolved.
Show resolved Hide resolved
public AclAuthenticationSettings(string aclConfigurationFile, string defaultPassword = "")
public AclAuthenticationSettings(string aclConfigurationFile, string defaultPassword = "", IAuthenticationSettings settings = null)
{
AclConfigurationFile = aclConfigurationFile;
DefaultPassword = defaultPassword;
this.settings = settings;
}

/// <summary>
Expand All @@ -222,7 +244,7 @@ public AclAuthenticationSettings(string aclConfigurationFile, string defaultPass
/// <param name="storeWrapper">The main store the authenticator will be associated with.</param>
public IGarnetAuthenticator CreateAuthenticator(StoreWrapper storeWrapper)
{
return new GarnetACLAuthenticator(storeWrapper.accessControlList, storeWrapper.logger);
return new GarnetACLAuthenticator(storeWrapper.accessControlList, storeWrapper.logger, settings?.CreateAuthenticator(storeWrapper));
}

/// <summary>
Expand Down
22 changes: 21 additions & 1 deletion libs/server/Auth/GarnetACLAuthenticator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,19 @@ class GarnetACLAuthenticator : IGarnetAuthenticator
/// </summary>
User _user = null;


private IGarnetAuthenticator _authenticator;

/// <summary>
/// Initializes a new ACLAuthenticator instance.
/// </summary>
/// <param name="accessControlList">Access control list to authenticate against</param>
/// <param name="logger">The logger to use</param>
public GarnetACLAuthenticator(AccessControlList accessControlList, ILogger logger)
public GarnetACLAuthenticator(AccessControlList accessControlList, ILogger logger, IGarnetAuthenticator wrapperAuthenticator = null)
msft-paddy14 marked this conversation as resolved.
Show resolved Hide resolved
{
_acl = accessControlList;
_logger = logger;
_authenticator = wrapperAuthenticator;
}

/// <summary>
Expand Down Expand Up @@ -66,6 +70,22 @@ public bool Authenticate(ReadOnlySpan<byte> password, ReadOnlySpan<byte> usernam
string uname = Encoding.ASCII.GetString(username);
User user = string.IsNullOrEmpty(uname) ? _acl.GetDefaultUser() : _acl.GetUser(uname);

// Use injected authenticator if configured.
if (_authenticator != null)
msft-paddy14 marked this conversation as resolved.
Show resolved Hide resolved
{
if (user == null || password.Length == 0)
{
return false;
}

if (user.IsEnabled && _authenticator.Authenticate(password, username))
{
_user = user;
successful = true;
}
return successful;
}

// Try to authenticate user
ACLPassword passwordHash = ACLPassword.ACLPasswordFromString(Encoding.ASCII.GetString(password));
if (user.IsEnabled && user.ValidatePassword(passwordHash))
Expand Down
38 changes: 33 additions & 5 deletions libs/server/Auth/GarnetAadAuthenticator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,23 @@

namespace Garnet.server.Auth
{
internal class AadValidationConfig
msft-paddy14 marked this conversation as resolved.
Show resolved Hide resolved
{
internal IReadOnlyCollection<string> _authorizedAppIds { get; set; }
internal IReadOnlyCollection<string> _audiences { get; set; }
internal IReadOnlyCollection<string> _issuers { get; set; }
internal IssuerSigningTokenProvider _signingTokenProvider { get; set; }
internal bool ValidateUsername { get; set; }
msft-paddy14 marked this conversation as resolved.
Show resolved Hide resolved
}

class GarnetAadAuthenticator : IGarnetAuthenticator
{
private static JwtSecurityTokenHandler _tokenHandler = new JwtSecurityTokenHandler();

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";

public bool IsAuthenticated => IsAuthorized();

Expand All @@ -35,6 +45,7 @@ class GarnetAadAuthenticator : IGarnetAuthenticator
private readonly IReadOnlyCollection<string> _audiences;
private readonly IReadOnlyCollection<string> _issuers;
private readonly IssuerSigningTokenProvider _signingTokenProvider;
private readonly bool ValidateUsername;
msft-paddy14 marked this conversation as resolved.
Show resolved Hide resolved

private readonly ILogger _logger;

Expand All @@ -49,6 +60,17 @@ public GarnetAadAuthenticator(
_signingTokenProvider = signingTokenProvider;
_audiences = audiences;
_issuers = issuers;
ValidateUsername = false;
_logger = logger;
}

internal GarnetAadAuthenticator(AadValidationConfig aadValidationConfig, ILogger logger)
{
_authorizedAppIds = aadValidationConfig._authorizedAppIds;
_signingTokenProvider = aadValidationConfig._signingTokenProvider;
_audiences = aadValidationConfig._audiences;
_issuers = aadValidationConfig._issuers;
ValidateUsername = aadValidationConfig.ValidateUsername;
msft-paddy14 marked this conversation as resolved.
Show resolved Hide resolved
_logger = logger;
}

Expand All @@ -64,13 +86,12 @@ public bool Authenticate(ReadOnlySpan<byte> password, ReadOnlySpan<byte> 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();
Expand All @@ -85,20 +106,27 @@ public bool Authenticate(ReadOnlySpan<byte> password, ReadOnlySpan<byte> usernam
}
}

private bool IsIdentityAuthorized(ClaimsPrincipal identity)
private bool IsIdentityAuthorized(ClaimsPrincipal identity, ReadOnlySpan<byte> 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<string, string> claims)
{
return claims.TryGetValue(_appIdClaim, out var appId) && _authorizedAppIds.Contains(appId);
}

private bool IsUserNameAuthorized(IDictionary<string, string> claims, ReadOnlySpan<byte> userName)
{
var userNameStr = Encoding.UTF8.GetString(userName);
return claims.TryGetValue(_oidClaim, out var oid) && userNameStr.Equals(oid, StringComparison.InvariantCultureIgnoreCase);
}


private bool IsAuthorized()
{
var now = DateTime.UtcNow;
Expand Down
76 changes: 76 additions & 0 deletions test/Garnet.test.cluster/ClusterAadAuthTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Text;
using System.Threading.Tasks;
using Garnet.server.Auth;
using Garnet.server.Auth.Aad;
using Microsoft.IdentityModel.Tokens;
using NUnit.Framework;

namespace Garnet.test.cluster
{
[TestFixture]
[NonParallelizable]
class ClusterAadAuthTests
{
ClusterTestContext context;

readonly HashSet<string> monitorTests = [];

[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 ValidateClusterAuth()
{
var nodes = 2;
var audience = Guid.NewGuid().ToString();
JwtTokenGenerator tokenGenerator = new JwtTokenGenerator(audience);

var appId = Guid.NewGuid().ToString();
var objId = Guid.NewGuid().ToString();
var tokenClaims = new List<Claim>
{
new Claim("appidacr","1"),
new Claim("appid", appId),
new Claim("http://schemas.microsoft.com/identity/claims/objectidentifier",objId),
};
var authSettings = new AadAuthenticationSettings([appId], [audience], [audience], IssuerSigningTokenProvider.Create(new List<SecurityKey> { tokenGenerator.SecurityKey }, context.logger));
authSettings = authSettings.WithUsernameValidation();
var token = tokenGenerator.CreateToken(tokenClaims, DateTime.Now.AddMinutes(10));
// Generate default ACL file

var userCredential = new ServerCredential { user = objId, IsAdmin = true, IsClearText = true };
var clientCredentials = new ServerCredential { user = objId, password = token };
context.GenerateCredentials([userCredential]);
context.CreateInstances(nodes, useAcl: true, clusterCreds: clientCredentials, authenticationSettings: authSettings);


context.CreateConnection(useTLS: false, clientCreds: clientCredentials);

for (int i = 0; i < nodes; i++)
{
context.clusterTestUtils.Authenticate(i, clientCredentials.user, clientCredentials.password, context.logger);
context.clusterTestUtils.Meet(i, (i + 1) % nodes, context.logger);
var ex = Assert.Throws<AssertionException>(() => context.clusterTestUtils.Authenticate(i, "randomUserId", clientCredentials.password, context.logger));
Assert.AreEqual("WRONGPASS Invalid username/password combination", ex.Message);
}
}
}
}
7 changes: 5 additions & 2 deletions test/Garnet.test.cluster/ClusterTestContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Garnet.server.Auth;
using Microsoft.Extensions.Logging;
using NUnit.Framework;
using StackExchange.Redis;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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();
Expand Down
Loading