By default, .NET uses the Rfc6238AuthenticationService class for generating and validating TOTP (Time-Based One-Time Password) codes. The issue with this class is that the generated TOTP code remains valid for 9 minutes, a timeframe hardcoded and unchangeable. issue link
The functionality of this service operates as follows: it first retrieves the Ticks corresponding to 3 minutes, which equals 1800000000. Then, it subtracts the Ticks corresponding to the current time from the UnixEpoch time.
private static ulong GetCurrentTimeStepNumber()
{
#if NETSTANDARD2_0 || NET461
var delta = DateTime.UtcNow - _unixEpoch;
#else
var delta = DateTimeOffset.UtcNow - DateTimeOffset.UnixEpoch;
#endif
return (ulong)(delta.Ticks / _timestep.Ticks);
}
Then, it divides the obtained delta value by 1800000000 and returns it as GetCurrentTimeStepNumber. This number represents the current three-minute interval. For instance, it displays one number for the interval from 12:00 to 12:03. (If this number changes from 3 minutes to 5 minutes, it provides a new number every 5 minutes. For example, the time interval from 12:40 to 12:45 returns one number.) Then, this number is sent to the next method as GetCurrentTimeStepNumber for encryption, and the code is sent to the user.
In the next step, when the sent code from the user's side needs to be validated, we must check whether the code is valid in the current interval or not.
The issue with this method is that it's possible for a user to request a code at 12:42, and the generated code for the user is valid only in the interval from 12:40 to 12:43. Essentially, the user has only 1 minute to enter the code; otherwise, the time moves to the next interval, 12:44 to 12:46, and the sent code becomes invalid. To solve this problem, in the Rfc6238AuthenticationService class, they have implemented a method ValidateCode in such a way that the code for 12:42 is valid within the time interval from 12:34 to 12:49. This is accomplished within the ValidateCode method. They have used a for loop that retrieves the current time from the GetCurrentTimeStepNumber method and then adds -2 to 2 intervals. If validation is successful with any of these numbers, it returns true. For example, the number 456825 represents the interval from 12:40 to 12:43, and adding one number to it transitions to the interval from 12:43 to 12:46.
The issue with this method lies in its fixed time interval, which is not adjustable and has a relatively long duration.
public static bool ValidateCode(byte[] securityToken, int code, string modifier = null)
{
if (securityToken == null)
{
throw new ArgumentNullException(nameof(securityToken));
}
// Allow a variance of no greater than 9 minutes in either direction
var currentTimeStep = GetCurrentTimeStepNumber();
using (var hashAlgorithm = new HMACSHA1(securityToken))
{
for (var i = -2; i <= 2; i++)
{
var computedTotp = ComputeTotp(hashAlgorithm, (ulong)((long)currentTimeStep + i), modifier);
if (computedTotp == code)
{
return true;
}
}
}
// No match
return false;
}
To address this issue, we modified the relevant method as follows: instead of comparing intervals (12:34, 12:37, 12:40, 12:43, 12:46) with the sent code, we used one-minute intervals instead of three-minute intervals when generating the code in the GetCurrentTimeStepNumber method. Then, in the ValidateCode method, we employed a for loop starting from the negative expiration time of the user and continuing until 1. We then add i to the UTC time in the GetNextTimeStepNumber method. Subsequently, if the code is valid within any of these intervals, true is returned. Essentially, by using one-minute intervals instead of three-minute intervals, we enhance security, and the code remains valid only until its expiration time. In fact, in this approach, the process is as follows: for example, if a code is generated at 12:44 and the code's expiration time is 3 minutes, the code remains valid at 12:44, 12:45, 12:46, 12:47. In this method, only one additional minute is considered so that when a user requests a code at 12:44:59, they don't lose a minute for the remaining 1 second. Consequently, the user's code remains valid in the time intervals 12:44, 12:45, 12:46, 12:47. In the worst-case scenario, if a user requests a code at 12:44:01, the code remains valid until 12:47:59.
The ValidateCode method has been modified as follows:
public static bool ValidateCode(
string securityStampToken,
int code,
string modifier,
int expirationInMinutes)
{
if (securityStampToken == null)
{
throw new ArgumentNullException(nameof(securityStampToken));
}
byte[] securityTokenBytes = GetBytes(securityStampToken);
using (var hashAlgorithm = new HMACSHA1(securityTokenBytes))
{
for (var i = -Math.Abs(expirationInMinutes); i <= 1; i++)
{
var currentTimeStep = GetNextTimeStepNumber(i);
var computedTotp = ComputeTotp(hashAlgorithm, (ulong)((long)currentTimeStep), modifier);
if (computedTotp == code)
{
return true;
}
}
}
// No match
return false;
}
In the written class, you only have access to two methods: GenerateCode and ValidateCode.
The GenerateCode method requires a SecurityStamp related to the user and a modifier as a string. The modifier should include a unique user ID and the purpose of generating the code. For example, if a user with ID 1 wants to enable 2FA, their modifier can be as follows:
Totp:1:TwoFactorActivation
The ValidateCode method requires the user's SecurityStamp, the code sent to the user, the modifier, and the token expiration time.
public async ValueTask<bool> ValidateTwoFactorTotpCode(string email, int totpCode)
{
var user = await GetUserByEmail(email);
string modifier = GetModifier(user, TotpEnum.TwoFactor);
var isValid = TotpService.ValidateCode(
user.SecurityStamp.ToString(),
totpCode,
modifier,
_userOption.TwoFactorTotpExpiration);
return isValid;
}
The usage of this service is as follows:
//Generate totp code
var totpCode = TotpService.GenerateCode(user.SecurityStamp.ToString(), modifier);
//Verify totp code
var isValid = TotpService.ValidateCode(
user.SecurityStamp.ToString(),
totpCode,
modifier,
_userOption.TwoFactorTotpExpiration);
You can download the codes for this article from GitHub. Additionally, the project related to the usage of the service is located at this path.
Note: It's better to use a separate modifier for each operation. For example, when a user wants to enable 2FA, use a modifier like "Totp:1:TwoFactorActivation", and for two-step login, use "Totp:1:TwoFactorAuthentication". By doing so, each code can only perform one task, and you cannot disable 2FA or log in with the activation code.
If you want to modify the TOTP code method generated by Identity, you need to follow these steps:
First, create a class that inherits from the TotpSecurityStampBasedTokenProvider class. Then, you should override the GenerateAsync and ValidateAsync methods and use TotpService instead of Rfc6238AuthenticationService .
GenerateAsync method:
public override async Task<string> GenerateAsync(
string purpose,
UserManager<ApplicationUser> manager,
ApplicationUser user)
{
if (manager == null)
{
throw new ArgumentNullException(nameof(manager));
}
var token = await manager.CreateSecurityTokenAsync(user);
var modifier = await GetUserModifierAsync(purpose, manager, user);
return TotpService.GenerateCode(token, modifier).ToString("D6", CultureInfo.InvariantCulture);
}
ValidateAsync method:
public override async Task<bool> ValidateAsync(
string purpose,
string token,
UserManager<ApplicationUser> manager,
ApplicationUser user)
{
if (manager == null)
{
throw new ArgumentNullException(nameof(manager));
}
int code;
if (!int.TryParse(token, out code))
{
return false;
}
var securityToken = await manager.CreateSecurityTokenAsync(user);
var modifier = await GetUserModifierAsync(purpose, manager, user);
return securityToken != null && TotpService.ValidateCode(securityToken, code, modifier, _userOption.TwoFactorTotpExpiration);
}
Then, introduce the created class to Identity:
services.AddIdentity<ApplicationUser, ApplicationRole>()
.AddEntityFrameworkStores<IdentityContext>()
.AddDefaultTokenProviders()
.AddTokenProvider<CustomTotpSecurityStampBasedTokenProvider >("CustomTotp");
And when generating and validating the code, you should send its provider name to the UserManager:
var verificationCode = await _userManager.GenerateTwoFactorTokenAsync(user, "CustomTotp");
var verify = await _userManager.VerifyTwoFactorTokenAsync(user, "CustomTotp", request.VerificationCode);
Furthermore, the relevant service has been uploaded to NuGet, and you can add it to your project.
<PackageReference Include="TotpGenerator" Version="1.0.2" />
Update:
If you want the generated code to expire exactly at its expiration time, you just need to make the following changes in the TotpService class:
1 - Change the TimeStep value.
private static readonly TimeSpan _timeStep = TimeSpan.FromSeconds(1);
2 - Change the ValidateAsync method.
public static bool ValidateCode(
string securityStampToken,
int code,
string modifier,
int expirationInMinutes)
{
if (securityStampToken == null)
{
throw new ArgumentNullException(nameof(securityStampToken));
}
byte[] securityTokenBytes = GetBytes(securityStampToken);
using (var hashAlgorithm = new HMACSHA1(securityTokenBytes))
{
for (var i = -Math.Abs(expirationInMinutes * 60); i <= 1; i++)
{
var currentTimeStep = GetNextTimeStepNumber(i);
var computedTotp = ComputeTotp(hashAlgorithm, (ulong)((long)currentTimeStep), modifier);
if (computedTotp == code)
{
return true;
}
}
}
// No match
return false;
}
3 - Modify the GetNextTimeStepNumber method. Add seconds instead of minutes.
private static ulong GetNextTimeStepNumber(int seconds)
{
#if NETSTANDARD2_0
var delta = DateTime.UtcNow.AddSeconds(seconds) - _unixEpoch;
#else
var delta = DateTimeOffset.UtcNow.AddSeconds(seconds) - DateTimeOffset.UnixEpoch;
#endif
return (ulong)(delta.Ticks / _timeStep.Ticks);
}
By changing the time interval from minutes to seconds, we have precisely adjusted the code to the stored validity period, eliminating the extra minute-related time.
;)
Powered by Froala Editor