From f7c1d7c8569b260695a63ced0e62b20a4ef4b1c1 Mon Sep 17 00:00:00 2001 From: John Cardinal Date: Sat, 24 Oct 2026 11:36:45 -0700 Subject: [PATCH] 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 --- server/Controllers/AuthController.cs | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/server/Controllers/AuthController.cs b/server/Controllers/AuthController.cs index 58b1de2..bb553e9 100644 --- a/server/Controllers/AuthController.cs +++ b/server/Controllers/AuthController.cs @@ -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 TfaFailures = new ConcurrentDictionary(); + /// /// ctor /// @@ -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 TranslationKeysToFetch = new List { "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); }