mirror of
https://github.com/bitwarden/server
synced 2025-12-15 15:53:59 +00:00
* feat(pm-19055) : - Add generic OTP generator. This OTP generator is not linked to .NET Identity giving us flexibility. - Update `OtpTokenProvider` to accept configuration object to keep interface clean. - Implement `OtpTokenProvider` in DI as open generic for flexibility. * test: 100% test coverage for `OtpTokenProvider` * doc: Added readme for `OtpTokenProvider`
460 lines
16 KiB
C#
460 lines
16 KiB
C#
using System.Text;
|
|
using Bit.Core.Auth.Identity.TokenProviders;
|
|
using Bit.Test.Common.AutoFixture;
|
|
using Bit.Test.Common.AutoFixture.Attributes;
|
|
using Microsoft.Extensions.Caching.Distributed;
|
|
using Microsoft.Extensions.Options;
|
|
using NSubstitute;
|
|
using Xunit;
|
|
|
|
namespace Bit.Core.Test.Auth.Identity;
|
|
|
|
[SutProviderCustomize]
|
|
public class OtpTokenProviderTests
|
|
{
|
|
private readonly string _defaultTokenProviderName = "DefaultOtpProvider";
|
|
|
|
private readonly DefaultOtpTokenProviderOptions _defaultOtpTokenProviderOptions = new()
|
|
{
|
|
TokenLength = 6,
|
|
TokenAlpha = false,
|
|
TokenNumeric = true
|
|
};
|
|
|
|
[Theory, BitAutoData]
|
|
public async Task GenerateTokenAsync_Success_ReturnsToken(
|
|
SutProvider<OtpTokenProvider<DefaultOtpTokenProviderOptions>> sutProvider,
|
|
string purpose,
|
|
string uniqueIdentifier)
|
|
{
|
|
// Arrange
|
|
sutProvider.GetDependency<IOptions<DefaultOtpTokenProviderOptions>>()
|
|
.Value.Returns(_defaultOtpTokenProviderOptions);
|
|
sutProvider.Create();
|
|
|
|
// Act
|
|
var result = await sutProvider.Sut.GenerateTokenAsync(_defaultTokenProviderName, purpose, uniqueIdentifier);
|
|
|
|
// Assert
|
|
Assert.NotNull(result);
|
|
Assert.NotEmpty(result);
|
|
Assert.Equal(6, result.Length); // Default length
|
|
Assert.True(result.All(char.IsDigit)); // Default is numeric only
|
|
|
|
// Verify cache was called with correct key
|
|
var expectedCacheKey = $"{_defaultTokenProviderName}_{purpose}_{uniqueIdentifier}";
|
|
await sutProvider.GetDependency<IDistributedCache>()
|
|
.Received(1)
|
|
.SetAsync(expectedCacheKey, Arg.Any<byte[]>(), Arg.Any<DistributedCacheEntryOptions>());
|
|
}
|
|
|
|
[Theory, BitAutoData]
|
|
public async Task GenerateTokenAsync_CustomConfiguration_ReturnsCorrectFormat(
|
|
SutProvider<OtpTokenProvider<DefaultOtpTokenProviderOptions>> sutProvider,
|
|
string tokenProviderName,
|
|
string purpose,
|
|
string uniqueIdentifier)
|
|
{
|
|
// Arrange
|
|
var otpConfig = new DefaultOtpTokenProviderOptions
|
|
{
|
|
TokenLength = 8,
|
|
TokenAlpha = true,
|
|
TokenNumeric = true
|
|
};
|
|
|
|
sutProvider.GetDependency<IOptions<DefaultOtpTokenProviderOptions>>()
|
|
.Value.Returns(otpConfig);
|
|
sutProvider.Create();
|
|
|
|
// Act
|
|
var result = await sutProvider.Sut.GenerateTokenAsync(tokenProviderName, purpose, uniqueIdentifier);
|
|
|
|
// Assert
|
|
Assert.NotNull(result);
|
|
Assert.Equal(8, result.Length);
|
|
Assert.Contains(result, char.IsLetterOrDigit);
|
|
}
|
|
|
|
|
|
[Theory, BitAutoData]
|
|
public async Task GenerateTokenAsync_NumericOnly_ReturnsOnlyDigits(
|
|
SutProvider<OtpTokenProvider<DefaultOtpTokenProviderOptions>> sutProvider,
|
|
string purpose,
|
|
string uniqueIdentifier)
|
|
{
|
|
// Arrange
|
|
var otpConfig = new DefaultOtpTokenProviderOptions
|
|
{
|
|
TokenLength = 10,
|
|
TokenAlpha = false,
|
|
TokenNumeric = true
|
|
};
|
|
|
|
sutProvider.GetDependency<IOptions<DefaultOtpTokenProviderOptions>>()
|
|
.Value.Returns(otpConfig);
|
|
sutProvider.Create();
|
|
|
|
// Act
|
|
var result = await sutProvider.Sut.GenerateTokenAsync(_defaultTokenProviderName, purpose, uniqueIdentifier);
|
|
|
|
// Assert
|
|
Assert.Equal(10, result.Length);
|
|
Assert.True(result.All(char.IsDigit));
|
|
}
|
|
|
|
[Theory, BitAutoData]
|
|
public async Task ValidateTokenAsync_ValidToken_ReturnsTrue(
|
|
SutProvider<OtpTokenProvider<DefaultOtpTokenProviderOptions>> sutProvider,
|
|
string purpose,
|
|
string uniqueIdentifier,
|
|
string token)
|
|
{
|
|
// Arrange
|
|
var expectedCacheKey = $"{_defaultTokenProviderName}_{purpose}_{uniqueIdentifier}";
|
|
var tokenBytes = Encoding.UTF8.GetBytes(token);
|
|
|
|
sutProvider.GetDependency<IDistributedCache>()
|
|
.GetAsync(expectedCacheKey)
|
|
.Returns(tokenBytes);
|
|
|
|
// Act
|
|
var result = await sutProvider.Sut.ValidateTokenAsync(token, _defaultTokenProviderName, purpose, uniqueIdentifier);
|
|
|
|
// Assert
|
|
Assert.True(result);
|
|
|
|
// Verify token was removed from cache after successful validation
|
|
await sutProvider.GetDependency<IDistributedCache>()
|
|
.Received(1)
|
|
.RemoveAsync(expectedCacheKey);
|
|
}
|
|
|
|
[Theory, BitAutoData]
|
|
public async Task ValidateTokenAsync_InvalidToken_ReturnsFalse(
|
|
SutProvider<OtpTokenProvider<DefaultOtpTokenProviderOptions>> sutProvider,
|
|
string purpose,
|
|
string uniqueIdentifier,
|
|
string token,
|
|
string wrongToken)
|
|
{
|
|
// Arrange
|
|
var expectedCacheKey = $"{_defaultTokenProviderName}_{purpose}_{uniqueIdentifier}";
|
|
var tokenBytes = Encoding.UTF8.GetBytes(wrongToken); // Different token in cache
|
|
|
|
sutProvider.GetDependency<IDistributedCache>()
|
|
.GetAsync(expectedCacheKey)
|
|
.Returns(tokenBytes);
|
|
|
|
// Act
|
|
var result = await sutProvider.Sut.ValidateTokenAsync(token, _defaultTokenProviderName, purpose, uniqueIdentifier);
|
|
|
|
// Assert
|
|
Assert.False(result);
|
|
|
|
// Verify token was NOT removed from cache for invalid validation
|
|
await sutProvider.GetDependency<IDistributedCache>()
|
|
.DidNotReceive()
|
|
.RemoveAsync(expectedCacheKey);
|
|
}
|
|
|
|
[Theory, BitAutoData]
|
|
public async Task ValidateTokenAsync_TokenNotFound_ReturnsFalse(
|
|
SutProvider<OtpTokenProvider<DefaultOtpTokenProviderOptions>> sutProvider,
|
|
string purpose,
|
|
string uniqueIdentifier,
|
|
string token)
|
|
{
|
|
// Arrange
|
|
var expectedCacheKey = $"{_defaultTokenProviderName}_{purpose}_{uniqueIdentifier}";
|
|
|
|
sutProvider.GetDependency<IDistributedCache>()
|
|
.GetAsync(expectedCacheKey)
|
|
.Returns((byte[])null); // Token not found in cache
|
|
|
|
// Act
|
|
var result = await sutProvider.Sut.ValidateTokenAsync(token, _defaultTokenProviderName, purpose, uniqueIdentifier);
|
|
|
|
// Assert
|
|
Assert.False(result);
|
|
|
|
// Verify removal was not attempted
|
|
await sutProvider.GetDependency<IDistributedCache>()
|
|
.DidNotReceive()
|
|
.RemoveAsync(Arg.Any<string>());
|
|
}
|
|
|
|
[Theory, BitAutoData]
|
|
public async Task ValidateTokenAsync_EmptyToken_ReturnsFalse(
|
|
SutProvider<OtpTokenProvider<DefaultOtpTokenProviderOptions>> sutProvider,
|
|
string purpose,
|
|
string uniqueIdentifier)
|
|
{
|
|
// Act
|
|
var result = await sutProvider.Sut.ValidateTokenAsync("", _defaultTokenProviderName, purpose, uniqueIdentifier);
|
|
|
|
// Assert
|
|
Assert.False(result);
|
|
}
|
|
|
|
[Theory, BitAutoData]
|
|
public async Task ValidateTokenAsync_NullToken_ReturnsFalse(
|
|
SutProvider<OtpTokenProvider<DefaultOtpTokenProviderOptions>> sutProvider,
|
|
string purpose,
|
|
string uniqueIdentifier)
|
|
{
|
|
// Act
|
|
var result = await sutProvider.Sut.ValidateTokenAsync(null, _defaultTokenProviderName, purpose, uniqueIdentifier);
|
|
|
|
// Assert
|
|
Assert.False(result);
|
|
}
|
|
|
|
// Tests for null/empty purpose and uniqueIdentifier parameters
|
|
[Theory, BitAutoData]
|
|
public async Task GenerateTokenAsync_NullPurpose_ReturnsNull(
|
|
SutProvider<OtpTokenProvider<DefaultOtpTokenProviderOptions>> sutProvider,
|
|
string uniqueIdentifier)
|
|
{
|
|
// Act
|
|
var result = await sutProvider.Sut.GenerateTokenAsync(_defaultTokenProviderName, null, uniqueIdentifier);
|
|
|
|
// Assert
|
|
Assert.Null(result);
|
|
|
|
// Verify cache was not called
|
|
await sutProvider.GetDependency<IDistributedCache>()
|
|
.DidNotReceive()
|
|
.SetAsync(Arg.Any<string>(), Arg.Any<byte[]>(), Arg.Any<DistributedCacheEntryOptions>());
|
|
}
|
|
|
|
[Theory, BitAutoData]
|
|
public async Task GenerateTokenAsync_EmptyPurpose_ReturnsNull(
|
|
SutProvider<OtpTokenProvider<DefaultOtpTokenProviderOptions>> sutProvider,
|
|
string uniqueIdentifier)
|
|
{
|
|
// Act
|
|
var result = await sutProvider.Sut.GenerateTokenAsync(_defaultTokenProviderName, "", uniqueIdentifier);
|
|
|
|
// Assert
|
|
Assert.Null(result);
|
|
|
|
// Verify cache was not called
|
|
await sutProvider.GetDependency<IDistributedCache>()
|
|
.DidNotReceive()
|
|
.SetAsync(Arg.Any<string>(), Arg.Any<byte[]>(), Arg.Any<DistributedCacheEntryOptions>());
|
|
}
|
|
|
|
[Theory, BitAutoData]
|
|
public async Task GenerateTokenAsync_NullUniqueIdentifier_ReturnsNull(
|
|
SutProvider<OtpTokenProvider<DefaultOtpTokenProviderOptions>> sutProvider,
|
|
string purpose)
|
|
{
|
|
// Act
|
|
var result = await sutProvider.Sut.GenerateTokenAsync(_defaultTokenProviderName, purpose, null);
|
|
|
|
// Assert
|
|
Assert.Null(result);
|
|
|
|
// Verify cache was not called
|
|
await sutProvider.GetDependency<IDistributedCache>()
|
|
.DidNotReceive()
|
|
.SetAsync(Arg.Any<string>(), Arg.Any<byte[]>(), Arg.Any<DistributedCacheEntryOptions>());
|
|
}
|
|
|
|
[Theory, BitAutoData]
|
|
public async Task GenerateTokenAsync_EmptyUniqueIdentifier_ReturnsNull(
|
|
SutProvider<OtpTokenProvider<DefaultOtpTokenProviderOptions>> sutProvider,
|
|
string purpose)
|
|
{
|
|
// Act
|
|
var result = await sutProvider.Sut.GenerateTokenAsync(_defaultTokenProviderName, purpose, "");
|
|
|
|
// Assert
|
|
Assert.Null(result);
|
|
|
|
// Verify cache was not called
|
|
await sutProvider.GetDependency<IDistributedCache>()
|
|
.DidNotReceive()
|
|
.SetAsync(Arg.Any<string>(), Arg.Any<byte[]>(), Arg.Any<DistributedCacheEntryOptions>());
|
|
}
|
|
|
|
[Theory, BitAutoData]
|
|
public async Task ValidateTokenAsync_NullPurpose_ReturnsFalse(
|
|
SutProvider<OtpTokenProvider<DefaultOtpTokenProviderOptions>> sutProvider,
|
|
string token,
|
|
string uniqueIdentifier)
|
|
{
|
|
// Act
|
|
var result = await sutProvider.Sut.ValidateTokenAsync(token, _defaultTokenProviderName, null, uniqueIdentifier);
|
|
|
|
// Assert
|
|
Assert.False(result);
|
|
|
|
// Verify cache was not called
|
|
await sutProvider.GetDependency<IDistributedCache>()
|
|
.DidNotReceive()
|
|
.GetAsync(Arg.Any<string>());
|
|
}
|
|
|
|
[Theory, BitAutoData]
|
|
public async Task ValidateTokenAsync_EmptyPurpose_ReturnsFalse(
|
|
SutProvider<OtpTokenProvider<DefaultOtpTokenProviderOptions>> sutProvider,
|
|
string token,
|
|
string uniqueIdentifier)
|
|
{
|
|
// Act
|
|
var result = await sutProvider.Sut.ValidateTokenAsync(token, _defaultTokenProviderName, "", uniqueIdentifier);
|
|
|
|
// Assert
|
|
Assert.False(result);
|
|
|
|
// Verify cache was not called
|
|
await sutProvider.GetDependency<IDistributedCache>()
|
|
.DidNotReceive()
|
|
.GetAsync(Arg.Any<string>());
|
|
}
|
|
|
|
[Theory, BitAutoData]
|
|
public async Task ValidateTokenAsync_NullUniqueIdentifier_ReturnsFalse(
|
|
SutProvider<OtpTokenProvider<DefaultOtpTokenProviderOptions>> sutProvider,
|
|
string token,
|
|
string purpose)
|
|
{
|
|
// Act
|
|
var result = await sutProvider.Sut.ValidateTokenAsync(token, _defaultTokenProviderName, purpose, null);
|
|
|
|
// Assert
|
|
Assert.False(result);
|
|
|
|
// Verify cache was not called
|
|
await sutProvider.GetDependency<IDistributedCache>()
|
|
.DidNotReceive()
|
|
.GetAsync(Arg.Any<string>());
|
|
}
|
|
|
|
[Theory, BitAutoData]
|
|
public async Task ValidateTokenAsync_EmptyUniqueIdentifier_ReturnsFalse(
|
|
SutProvider<OtpTokenProvider<DefaultOtpTokenProviderOptions>> sutProvider,
|
|
string token,
|
|
string purpose)
|
|
{
|
|
// Act
|
|
var result = await sutProvider.Sut.ValidateTokenAsync(token, _defaultTokenProviderName, purpose, "");
|
|
|
|
// Assert
|
|
Assert.False(result);
|
|
|
|
// Verify cache was not called
|
|
await sutProvider.GetDependency<IDistributedCache>()
|
|
.DidNotReceive()
|
|
.GetAsync(Arg.Any<string>());
|
|
}
|
|
|
|
[Theory, BitAutoData]
|
|
public async Task GenerateTokenAsync_OverwritesExistingToken(
|
|
SutProvider<OtpTokenProvider<DefaultOtpTokenProviderOptions>> sutProvider,
|
|
string purpose,
|
|
string uniqueIdentifier)
|
|
{
|
|
// Arrange
|
|
sutProvider.GetDependency<IOptions<DefaultOtpTokenProviderOptions>>()
|
|
.Value.Returns(_defaultOtpTokenProviderOptions);
|
|
sutProvider.Create();
|
|
|
|
// Act - Generate token twice with same parameters
|
|
var firstToken = await sutProvider.Sut.GenerateTokenAsync(_defaultTokenProviderName, purpose, uniqueIdentifier);
|
|
var secondToken = await sutProvider.Sut.GenerateTokenAsync(_defaultTokenProviderName, purpose, uniqueIdentifier);
|
|
|
|
// Assert
|
|
Assert.NotEqual(firstToken, secondToken); // Should be different tokens
|
|
|
|
var expectedCacheKey = $"{_defaultTokenProviderName}_{purpose}_{uniqueIdentifier}";
|
|
await sutProvider.GetDependency<IDistributedCache>()
|
|
.Received(2) // Called twice - once for each generation
|
|
.SetAsync(expectedCacheKey, Arg.Any<byte[]>(), Arg.Any<DistributedCacheEntryOptions>());
|
|
}
|
|
|
|
[Theory, BitAutoData]
|
|
public async Task CacheKeyFormat_IsCorrect(
|
|
SutProvider<OtpTokenProvider<DefaultOtpTokenProviderOptions>> sutProvider,
|
|
string purpose,
|
|
string uniqueIdentifier)
|
|
{
|
|
// Arrange
|
|
sutProvider.GetDependency<IOptions<DefaultOtpTokenProviderOptions>>()
|
|
.Value.Returns(_defaultOtpTokenProviderOptions);
|
|
sutProvider.Create();
|
|
|
|
// Act
|
|
await sutProvider.Sut.GenerateTokenAsync(_defaultTokenProviderName, purpose, uniqueIdentifier);
|
|
|
|
// Assert
|
|
var expectedCacheKey = $"{_defaultTokenProviderName}_{purpose}_{uniqueIdentifier}";
|
|
await sutProvider.GetDependency<IDistributedCache>()
|
|
.Received(1)
|
|
.SetAsync(expectedCacheKey, Arg.Any<byte[]>(), Arg.Any<DistributedCacheEntryOptions>());
|
|
}
|
|
|
|
[Theory, BitAutoData]
|
|
public async Task ValidateTokenAsync_CaseSensitive(
|
|
SutProvider<OtpTokenProvider<DefaultOtpTokenProviderOptions>> sutProvider,
|
|
string purpose,
|
|
string uniqueIdentifier)
|
|
{
|
|
// Arrange
|
|
var token = "ABC123";
|
|
var expectedCacheKey = $"{_defaultTokenProviderName}_{purpose}_{uniqueIdentifier}";
|
|
var tokenBytes = Encoding.UTF8.GetBytes(token);
|
|
|
|
sutProvider.GetDependency<IDistributedCache>()
|
|
.GetAsync(expectedCacheKey)
|
|
.Returns(tokenBytes);
|
|
|
|
// Act & Assert
|
|
var validResult = await sutProvider.Sut.ValidateTokenAsync("ABC123", _defaultTokenProviderName, purpose, uniqueIdentifier);
|
|
Assert.True(validResult);
|
|
|
|
// Reset the cache mock to return the token again
|
|
sutProvider.GetDependency<IDistributedCache>()
|
|
.GetAsync(expectedCacheKey)
|
|
.Returns(tokenBytes);
|
|
|
|
var invalidResult = await sutProvider.Sut.ValidateTokenAsync("abc123", _defaultTokenProviderName, purpose, uniqueIdentifier);
|
|
Assert.False(invalidResult);
|
|
}
|
|
|
|
[Theory, BitAutoData]
|
|
public async Task RoundTrip_GenerateAndValidate_Success(
|
|
SutProvider<OtpTokenProvider<DefaultOtpTokenProviderOptions>> sutProvider,
|
|
string purpose,
|
|
string uniqueIdentifier)
|
|
{
|
|
// Arrange
|
|
sutProvider.GetDependency<IOptions<DefaultOtpTokenProviderOptions>>()
|
|
.Value.Returns(_defaultOtpTokenProviderOptions);
|
|
sutProvider.Create();
|
|
|
|
var expectedCacheKey = $"{_defaultTokenProviderName}_{purpose}_{uniqueIdentifier}";
|
|
byte[] storedToken = null;
|
|
|
|
// Setup cache to capture stored token and return it on get
|
|
sutProvider.GetDependency<IDistributedCache>()
|
|
.When(x => x.SetAsync(expectedCacheKey, Arg.Any<byte[]>(), Arg.Any<DistributedCacheEntryOptions>()))
|
|
.Do(callInfo => storedToken = callInfo.ArgAt<byte[]>(1));
|
|
|
|
sutProvider.GetDependency<IDistributedCache>()
|
|
.GetAsync(expectedCacheKey)
|
|
.Returns(callInfo => storedToken);
|
|
|
|
// Act
|
|
var generatedToken = await sutProvider.Sut.GenerateTokenAsync(_defaultTokenProviderName, purpose, uniqueIdentifier);
|
|
var isValid = await sutProvider.Sut.ValidateTokenAsync(generatedToken, _defaultTokenProviderName, purpose, uniqueIdentifier);
|
|
|
|
// Assert
|
|
Assert.True(isValid);
|
|
Assert.NotNull(generatedToken);
|
|
Assert.NotEmpty(generatedToken);
|
|
}
|
|
}
|