I'm facing an issue where both the authenticator app and email verification codes are marked as "invalid" even though they are correct.
Before adding the email authentication, the 2FA via the authenticator app was working fine, but after adding the email version, they are both broken. Both the authenticator and email codes are being flagged as "invalid," even though the correct codes are being entered.
What I’ve tried: Checked the code generation for both email and authenticator app (tokens are generated and sent correctly). Double-checked that the input codes have spaces and dashes removed before verification.
Any debugging tips or areas to investigate? Thanks in advance for any help!
The relevant parts of my code are:
AccountController.cs:
[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Login(LoginViewModel model, string? returnUrl = null)
{
ViewData["ReturnUrl"] = returnUrl;
model.Username = model.Username?[..Math.Min(model.Username.Length, 256)] ?? string.Empty;
var user = await _userManager.Users.FirstOrDefaultAsync(t => t.UserName == model.Username && t.AccessEnabled);
// Password attempt: lockout if failure threshold reached
var result = await _signInManager.PasswordSignInAsync(user, model.Password, model.RememberMe, lockoutOnFailure: true);
if (result.Succeeded)
{
await LogSuccessfulLogin(user);
// Check if user needs to enable 2FA
if (user.Is2FArequired)
{
return RedirectToAction(nameof(TwoStepVerification), new { userId = user.Id, rememberMe = model.RememberMe, returnUrl });
}
return RedirectToLocal(returnUrl);
}
if (result.RequiresTwoFactor)
{
return RedirectToAction(nameof(TwoStepVerification), new { userId = user.Id, rememberMe = model.RememberMe, returnUrl });
}
if (result.IsLockedOut)
{
await LogAccountLocked(user);
return RedirectToAction(nameof(Lockout));
}
// Log failed attempt with generic message
await LogFailedLoginAttempt(user.UserName);
ModelState.AddModelError("CustomError", "Login failed. Please try again.");
return View(model);
}
// GET: Two-step verification method selection
[HttpGet]
[AllowAnonymous]
public async Task<IActionResult> TwoStepVerification(string userId, bool rememberMe, string? returnUrl = null)
{
var user = await _userManager.Users.FirstOrDefaultAsync(t => t.Id.ToLower() == userId.ToLower() && t.AccessEnabled);
if (user == null)
{
return RedirectToAction("Login");
}
var model = new TwoStepVerificationViewModel
{
Email = user.Email, // Assume user has an email
TwoFactorMethod = string.Empty,
UserId = user.Id,
RememberMe = rememberMe,
};
ViewData["ReturnUrl"] = returnUrl;
return View(model); // This view will allow the user to choose the 2FA method (email or authenticator)
}
[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public async Task<IActionResult> TwoStepVerification(TwoStepVerificationViewModel model, string? returnUrl = null)
{
var user = await _userManager.Users.FirstOrDefaultAsync(t => t.Id.ToLower() == model.UserId.ToLower() && t.AccessEnabled);
// Check the selected method
if (model.TwoFactorMethod == "email")
{
return RedirectToAction(nameof(LoginWith2faEmail), new { userId = model.UserId, rememberMe = model.RememberMe, returnUrl });
}
else if (model.TwoFactorMethod == "authenticator")
{
return RedirectToAction(nameof(LoginWith2fa), new { userId = model.UserId, returnUrl });
}
return View(model);
}
[HttpGet]
[AllowAnonymous]
public async Task<IActionResult> LoginWith2faEmail(string userId, bool rememberMe, string? returnUrl = null)
{
ViewData["ReturnUrl"] = returnUrl;
var user = await _userManager.Users.FirstOrDefaultAsync(t => t.Id.ToLower() == userId.ToLower() && t.AccessEnabled);
LoginWith2faViewModel model = new()
{
RememberMe = rememberMe,
UserId = user.Id,
};
// Generate the token for email
var token = await _userManager.GenerateTwoFactorTokenAsync(user, "Email");
// Prepare the email body
return View(model);
}
[HttpPost]
[AllowAnonymous]
public async Task<IActionResult> LoginWith2faEmail(LoginWith2faViewModel model, string? returnUrl = null)
{
// Remove spaces and dashes from the code input
var verificationCode = model.TwoFactorCode?.Replace(" ", string.Empty).Replace("-", string.Empty);
var result = await _signInManager.TwoFactorSignInAsync("Email", verificationCode, model.RememberMe, model.RememberMachine);
if (result.Succeeded)
{
_logger.LogInformation("User {UserId} successfully logged in with 2FA.", model.UserId);
var user = await _userManager.Users.FirstOrDefaultAsync(t => t.Id.ToLower() == model.UserId.ToLower() && t.AccessEnabled);
return RedirectToLocal(returnUrl);
}
if (result.IsLockedOut)
{
var user = await _userManager.GetUserAsync(User);
_logger.LogWarning("User with ID {UserId} account locked after 2FA attempt.", model.UserId);
return RedirectToAction(nameof(Lockout));
}
_logger.LogWarning("Invalid authenticator code entered");
return View(model);
}
[HttpGet]
[AllowAnonymous]
public async Task<IActionResult> LoginWith2fa(string userId, bool rememberMe = false, string? returnUrl = null)
{
// Ensure the user ID is valid and access is enabled
var user = await _userManager.Users.FirstOrDefaultAsync(t => t.Id.ToLower() == userId.ToLower() && t.AccessEnabled);
var model = new LoginWith2faViewModel
{
RememberMe = rememberMe,
UserId = user.Id
};
ViewData["ReturnUrl"] = returnUrl;
return View(model);
}
[HttpPost]
[AllowAnonymous]
public async Task<IActionResult> LoginWith2fa(LoginWith2faViewModel model, string? returnUrl = null)
{
try
{
// Load the user by ID and ensure access is enabled
var user = await _userManager.Users.FirstOrDefaultAsync(t => t.Id.ToLower() == model.UserId.ToLower() && t.AccessEnabled);
// Remove spaces and dashes from the code input
var verificationCode = model.TwoFactorCode?.Replace(" ", string.Empty).Replace("-", string.Empty);
var result = await _signInManager.TwoFactorAuthenticatorSignInAsync(verificationCode, model.RememberMe, model.RememberMachine);
if (result.Succeeded)
{
_logger.LogInformation("User {UserId} successfully logged in with 2FA.", model.UserId);
return RedirectToLocal(returnUrl);
}
else if (result.IsLockedOut)
{
_logger.LogWarning("User with ID {UserId} account locked after 2FA attempt.", user.Id);
return RedirectToAction(nameof(Lockout));
}
else
{
_logger.LogWarning("Invalid authenticator code entered for user '{UserId}'.", user.Id);
ModelState.AddModelError(string.Empty, "Invalid code. Please try again.");
return View(model);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Exception occurred during 2FA login attempt for user {UserId}.", model.UserId);
ModelState.AddModelError(string.Empty, "An error occurred during the login process. Please try again.");
}
ModelState.AddModelError(string.Empty, "There was a problem logging in. Please try again.");
return View(model);
}
Program.cs
builder.Services.AddDbContext<IdentityContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"),
sqlServerOptionsAction: sqlOptions =>
{
sqlOptions.EnableRetryOnFailure(
maxRetryCount: 5,
maxRetryDelay: TimeSpan.FromSeconds(30),
errorNumbersToAdd: null);
})
);
builder.Services.AddIdentity<ApplicationUser, IdentityRole>()
.AddEntityFrameworkStores<IdentityContext>()
.AddDefaultTokenProviders();
#region Identity Configuration
builder.Services.Configure<IdentityOptions>(options =>
{
options.Password.RequireDigit = true;
options.Password.RequiredLength = 8;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequireUppercase = true;
options.Password.RequireLowercase = true;
options.Password.RequiredUniqueChars = 6;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(10);
options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.AllowedForNewUsers = true;
options.User.RequireUniqueEmail = true;
options.Tokens.AuthenticatorTokenProvider = TokenOptions.DefaultAuthenticatorProvider;
});
After examining your code I can't see anything wrong, however, the issue might be in the inner working's of the _signInManager
.
A couple of potential issues come to mind.
Both methods below require a cookie from the TwoFactorUserIdScheme
to be present in the request headers.
_signInManager.TwoFactorSignInAsync()
_signInManager.TwoFactorAuthenticatorSignInAsync()
You have correctly configured the Identity in the Program.cs
so this cookie should be passed in the headers since you called the _signInManager.PasswordSignInAsync()
method. Which either sets the ApplicationScheme
or TwoFactorUserIdScheme
cookie based on the SignInResult
.
I'm assuming the _signInManager
cannot retrieve the 2FA data from the cookie, not being able to find the user to authenticate and therefore returns a failed SignInResult
.
You could test this by avoiding the _signInManager
and directly verifying the verificationCode
by calling:
await _userManager.VerifyTwoFactorTokenAsync(user, "Email", verificationCode);
As mentioned in the case above, the _signInManager
requires cookies to retrieve 2FA data and authenticate a user.
If you want to use the data passed in the ViewModels to retrieve 2FA data and authenticate a user you'll have to implement your own Sign-In logic. For example:
[HttpPost]
[AllowAnonymous]
public async Task<IActionResult> LoginWith2faEmail(LoginWith2faViewModel model, string? returnUrl = null)
{
// Remove spaces and dashes from the code input
var verificationCode = model.TwoFactorCode?.Replace(" ", string.Empty).Replace("-", string.Empty);
// Find the user by Id.
var user = await _userManager.FindByIdAsync(model.UserId);
if (user == null)
{
// Do something when user isn't found.
}
// Check if user is allowed to sign in.
if (!await _signInmanager.CanSignInAsync(user) && await _userManager.IsLockedOutAsync(user))
{
// Do something when user isn't allowed to sign in.
}
if (!await _userManager.VerifyTwoFactorTokenAsync(user, "Email", verificationCode))
{
// Do something when the user inputs the wrong code.
}
// Finally when the user is allowed to sign in and the code is correct, sign them in.
await _signInmanager.SignInAsync(user, model.RememberMe, "Your schema name");
return View(model);
}
I hope this helps resolve your issue!