1
0
mirror of https://github.com/bitwarden/server.git synced 2025-07-18 16:11:28 -05:00

feat(OTP): [PM-18612] Change email OTP to six digits

* Change email OTP to 6 digits

* Added comment on base class

* Added tests

* Renamed tests.

* Fixed tests

* Renamed file to match class
This commit is contained in:
Todd Martin
2025-07-14 10:23:30 -04:00
committed by GitHub
parent 9b65e9f4cc
commit 2f8460f4db
8 changed files with 144 additions and 8 deletions

View File

@ -1,5 +1,6 @@
using System.Text; using System.Text;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Services;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Distributed;
@ -7,6 +8,9 @@ using Microsoft.Extensions.DependencyInjection;
namespace Bit.Core.Auth.Identity.TokenProviders; namespace Bit.Core.Auth.Identity.TokenProviders;
/// <summary>
/// Generates and validates tokens for email OTPs.
/// </summary>
public class EmailTokenProvider : IUserTwoFactorTokenProvider<User> public class EmailTokenProvider : IUserTwoFactorTokenProvider<User>
{ {
private const string CacheKeyFormat = "EmailToken_{0}_{1}_{2}"; private const string CacheKeyFormat = "EmailToken_{0}_{1}_{2}";
@ -16,16 +20,25 @@ public class EmailTokenProvider : IUserTwoFactorTokenProvider<User>
public EmailTokenProvider( public EmailTokenProvider(
[FromKeyedServices("persistent")] [FromKeyedServices("persistent")]
IDistributedCache distributedCache) IDistributedCache distributedCache,
IFeatureService featureService)
{ {
_distributedCache = distributedCache; _distributedCache = distributedCache;
_distributedCacheEntryOptions = new DistributedCacheEntryOptions _distributedCacheEntryOptions = new DistributedCacheEntryOptions
{ {
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
}; };
if (featureService.IsEnabled(FeatureFlagKeys.Otp6Digits))
{
TokenLength = 6;
}
else
{
TokenLength = 8;
}
} }
public int TokenLength { get; protected set; } = 8; public int TokenLength { get; protected set; }
public bool TokenAlpha { get; protected set; } = false; public bool TokenAlpha { get; protected set; } = false;
public bool TokenNumeric { get; protected set; } = true; public bool TokenNumeric { get; protected set; } = true;

View File

@ -4,19 +4,27 @@
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models; using Bit.Core.Auth.Models;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Services;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace Bit.Core.Auth.Identity.TokenProviders; namespace Bit.Core.Auth.Identity.TokenProviders;
/// <summary>
/// Generates tokens for email two-factor authentication.
/// It inherits from the EmailTokenProvider class, which manages the persistence and validation of tokens,
/// and adds additional validation to ensure that 2FA is enabled for the user.
/// </summary>
public class EmailTwoFactorTokenProvider : EmailTokenProvider public class EmailTwoFactorTokenProvider : EmailTokenProvider
{ {
public EmailTwoFactorTokenProvider( public EmailTwoFactorTokenProvider(
[FromKeyedServices("persistent")] [FromKeyedServices("persistent")]
IDistributedCache distributedCache) : IDistributedCache distributedCache,
base(distributedCache) IFeatureService featureService) :
base(distributedCache, featureService)
{ {
// This can be removed when the pm-18612-otp-6-digits feature flag is removed because the base implementation will match.
TokenAlpha = false; TokenAlpha = false;
TokenNumeric = true; TokenNumeric = true;
TokenLength = 6; TokenLength = 6;

View File

@ -124,6 +124,7 @@ public static class FeatureFlagKeys
public const string SetInitialPasswordRefactor = "pm-16117-set-initial-password-refactor"; public const string SetInitialPasswordRefactor = "pm-16117-set-initial-password-refactor";
public const string ChangeExistingPasswordRefactor = "pm-16117-change-existing-password-refactor"; public const string ChangeExistingPasswordRefactor = "pm-16117-change-existing-password-refactor";
public const string RecoveryCodeLogin = "pm-17128-recovery-code-login"; public const string RecoveryCodeLogin = "pm-17128-recovery-code-login";
public const string Otp6Digits = "pm-18612-otp-6-digits";
/* Autofill Team */ /* Autofill Team */
public const string IdpAutoSubmitLogin = "idp-auto-submit-login"; public const string IdpAutoSubmitLogin = "idp-auto-submit-login";

View File

@ -7,7 +7,7 @@ using Xunit;
namespace Bit.Core.Test.Auth.Identity; namespace Bit.Core.Test.Auth.Identity;
public class AuthenticationTokenProviderTests : BaseTokenProviderTests<AuthenticatorTokenProvider> public class AuthenticationTwoFactorTokenProviderTests : BaseTwoFactorTokenProviderTests<AuthenticatorTokenProvider>
{ {
public override TwoFactorProviderType TwoFactorProviderType => TwoFactorProviderType.Authenticator; public override TwoFactorProviderType TwoFactorProviderType => TwoFactorProviderType.Authenticator;

View File

@ -14,7 +14,7 @@ using Xunit;
namespace Bit.Core.Test.Auth.Identity; namespace Bit.Core.Test.Auth.Identity;
[SutProviderCustomize] [SutProviderCustomize]
public abstract class BaseTokenProviderTests<T> public abstract class BaseTwoFactorTokenProviderTests<T>
where T : IUserTwoFactorTokenProvider<User> where T : IUserTwoFactorTokenProvider<User>
{ {
public abstract TwoFactorProviderType TwoFactorProviderType { get; } public abstract TwoFactorProviderType TwoFactorProviderType { get; }

View File

@ -12,7 +12,7 @@ using Duo = DuoUniversal;
namespace Bit.Core.Test.Auth.Identity; namespace Bit.Core.Test.Auth.Identity;
public class DuoUniversalTwoFactorTokenProviderTests : BaseTokenProviderTests<DuoUniversalTokenProvider> public class DuoUniversalTwoFactorTokenProviderTests : BaseTwoFactorTokenProviderTests<DuoUniversalTokenProvider>
{ {
private readonly IDuoUniversalTokenService _duoUniversalTokenService = Substitute.For<IDuoUniversalTokenService>(); private readonly IDuoUniversalTokenService _duoUniversalTokenService = Substitute.For<IDuoUniversalTokenService>();
public override TwoFactorProviderType TwoFactorProviderType => TwoFactorProviderType.Duo; public override TwoFactorProviderType TwoFactorProviderType => TwoFactorProviderType.Duo;

View File

@ -0,0 +1,68 @@
using Bit.Core;
using Bit.Core.Auth.Identity.TokenProviders;
using Bit.Core.Entities;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NSubstitute;
using Xunit;
[SutProviderCustomize]
public class EmailTokenProviderTests
{
private readonly IDistributedCache _cache;
public EmailTokenProviderTests()
{
_cache = Substitute.For<IDistributedCache>();
}
[Theory]
[BitAutoData]
public async Task GenerateAsync_GeneratesSixDigitToken_WhenFeatureFlagIsEnabled(User user)
{
// Arrange
var purpose = "test-purpose";
var featureService = Substitute.For<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.Otp6Digits).Returns(true);
var tokenProvider = new EmailTokenProvider(_cache, featureService);
// Act
var code = await tokenProvider.GenerateAsync(purpose, SubstituteUserManager(), user);
// Assert
Assert.Equal(6, code.Length);
}
[Theory]
[BitAutoData]
public async Task GenerateAsync_GeneratesEightDigitToken_WhenFeatureFlagIsDisabled(User user)
{
// Arrange
var purpose = "test-purpose";
var featureService = Substitute.For<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.Otp6Digits).Returns(false);
var tokenProvider = new EmailTokenProvider(_cache, featureService);
// Act
var code = await tokenProvider.GenerateAsync(purpose, SubstituteUserManager(), user);
// Assert
Assert.Equal(8, code.Length);
}
protected static UserManager<User> SubstituteUserManager()
{
return new UserManager<User>(Substitute.For<IUserStore<User>>(),
Substitute.For<IOptions<IdentityOptions>>(),
Substitute.For<IPasswordHasher<User>>(),
Enumerable.Empty<IUserValidator<User>>(),
Enumerable.Empty<IPasswordValidator<User>>(),
Substitute.For<ILookupNormalizer>(),
Substitute.For<IdentityErrorDescriber>(),
Substitute.For<IServiceProvider>(),
Substitute.For<ILogger<UserManager<User>>>());
}
}

View File

@ -1,13 +1,15 @@
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Identity.TokenProviders; using Bit.Core.Auth.Identity.TokenProviders;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit; using Xunit;
namespace Bit.Core.Test.Auth.Identity; namespace Bit.Core.Test.Auth.Identity;
public class EmailTwoFactorTokenProviderTests : BaseTokenProviderTests<EmailTwoFactorTokenProvider> public class EmailTwoFactorTokenProviderTests : BaseTwoFactorTokenProviderTests<EmailTwoFactorTokenProvider>
{ {
public override TwoFactorProviderType TwoFactorProviderType => TwoFactorProviderType.Email; public override TwoFactorProviderType TwoFactorProviderType => TwoFactorProviderType.Email;
@ -42,4 +44,48 @@ public class EmailTwoFactorTokenProviderTests : BaseTokenProviderTests<EmailTwoF
{ {
await base.RunCanGenerateTwoFactorTokenAsync(metaData, expectedResponse, user, sutProvider); await base.RunCanGenerateTwoFactorTokenAsync(metaData, expectedResponse, user, sutProvider);
} }
[Theory]
[BitAutoData]
public async Task GenerateAsync_ShouldReturnSixDigitToken_WithFeatureFlagEnabled(
User user, SutProvider<EmailTwoFactorTokenProvider> sutProvider)
{
// Arrange
user.TwoFactorProviders = GetTwoFactorEmailProvidersJson();
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.Otp6Digits)
.Returns(true);
// Act
var token = await sutProvider.Sut.GenerateAsync("purpose", SubstituteUserManager(), user);
// Assert
Assert.NotNull(token);
Assert.Equal(6, token.Length);
}
[Theory]
[BitAutoData]
public async Task GenerateAsync_ShouldReturnSixDigitToken_WithFeatureFlagDisabled(
User user, SutProvider<EmailTwoFactorTokenProvider> sutProvider)
{
// Arrange
user.TwoFactorProviders = GetTwoFactorEmailProvidersJson();
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.Otp6Digits)
.Returns(false);
// Act
var token = await sutProvider.Sut.GenerateAsync("purpose", SubstituteUserManager(), user);
// Assert
Assert.NotNull(token);
Assert.Equal(6, token.Length);
}
private string GetTwoFactorEmailProvidersJson()
{
return
"{\"1\":{\"Enabled\":true,\"MetaData\":{\"Email\":\"test@email.com\"}}}";
}
} }