Search code examples
.netmodel-view-controllerasp.net-identity.net-6.0

Invalid 2FA Codes (Authenticator App and Email)


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;
});

Solution

  • 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.

    1: Identity cookies are not present

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

    2: Using ViewModels instead of cookies

    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!