case 4710 - 2FA step requires the temp token from the password step

Same fix as AyaNova (raven 141bdeb8): the 2FA step matched the user by temp
token alone and the token was left empty after a 2FA login, so an empty or
missing token skipped the password step. Now an empty or missing temp token is
refused, the token is cleared to null after success, and after 5 wrong codes
the password must be entered again.

Checked on the local server with a temporary user (removed afterwards): no or
empty temp token 401; normal sign in 200; 5 wrong codes then the right code 401;
new password entry 200.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 11:36:45 -07:00
parent b49bcaecff
commit f7c1d7c856

View File

@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using Microsoft.AspNetCore.Mvc;
using Asp.Versioning;
@@ -35,6 +36,10 @@ namespace Sockeye.Api.Controllers
private readonly ApiServerState serverState;
private const int JWT_LIFETIME_DAYS = 5;
//case 4710: wrong 2fa codes allowed per password entry, counted per user (in memory, a restart only resets the count)
private const int MAX_TFA_FAILURES = 5;
private static readonly ConcurrentDictionary<long, int> TfaFailures = new ConcurrentDictionary<long, int>();
/// <summary>
/// ctor
/// </summary>
@@ -100,6 +105,7 @@ namespace Sockeye.Api.Controllers
//Generate a temporary token to identify and verify this is the same user
u.TempToken = Hasher.GenerateSalt().Replace("=", "").Replace("+", "");
await ct.SaveChangesAsync();
TfaFailures.TryRemove(u.Id, out _);
var UOpt = await ct.UserOptions.AsNoTracking().FirstAsync(z => z.UserId == u.Id);
List<string> TranslationKeysToFetch = new List<string> { "AuthTwoFactor", "AuthEnterPin", "AuthVerifyCode", "Cancel", "AuthPinInvalid" };
@@ -164,7 +170,14 @@ namespace Sockeye.Api.Controllers
return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED));
}
//Match to temp token that would have been set by initial credentialed login for 2fa User
//case 4710: the temp token is the only proof the password step was passed, so it must be present
if (string.IsNullOrWhiteSpace(pin.TempToken))
{
await Task.Delay(Sockeye.Util.ServerBootConfig.FAILED_AUTH_DELAY);
return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED));
}
//Match to temp token that would have been set by initial credentialed login for 2fa User
var user = await ct.User.Where(z => z.TempToken == pin.TempToken && z.Active == true && z.AllowLogin==true && z.TwoFactorEnabled == true).FirstOrDefaultAsync();
@@ -181,14 +194,22 @@ namespace Sockeye.Api.Controllers
var tfa = new TwoFactorAuth("Sockeye");
if (!tfa.VerifyCode(user.TotpSecret, pin.Pin.Replace(" ", "").Trim()))
{
//case 4710: after too many wrong codes the password must be entered again
if (TfaFailures.AddOrUpdate(user.Id, 1, (k, n) => n + 1) >= MAX_TFA_FAILURES)
{
TfaFailures.TryRemove(user.Id, out _);
user.TempToken = null;
await ct.SaveChangesAsync();
}
await Task.Delay(Sockeye.Util.ServerBootConfig.FAILED_AUTH_DELAY);
return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED));
}
//User is valid and authenticated
//clear temp token
user.TempToken = string.Empty;
//clear temp token (case 4710: null, not empty, so no value is left to match)
user.TempToken = null;
await ct.SaveChangesAsync();
TfaFailures.TryRemove(user.Id, out _);
return await ReturnUserCredsOnSuccessfulAuthentication(user);
}