@@ -74,6 +74,9 @@ public class AuthenticationController : UmbracoApiControllerBase
7474 private readonly IUserService _userService ;
7575 private readonly WebRoutingSettings _webRoutingSettings ;
7676
77+ private const int FailedLoginDurationRandomOffsetInMilliseconds = 100 ;
78+ private static long ? _loginDurationAverage ;
79+
7780 // TODO: We need to review all _userManager.Raise calls since many/most should be on the usermanager or signinmanager, very few should be here
7881 [ ActivatorUtilitiesConstructor ]
7982 public AuthenticationController (
@@ -342,47 +345,83 @@ public async Task<bool> IsAuthenticated()
342345 [ Authorize ( Policy = AuthorizationPolicies . DenyLocalLoginIfConfigured ) ]
343346 public async Task < ActionResult < UserDetail ? > > PostLogin ( LoginModel loginModel )
344347 {
348+ // Start a timed scope to ensure failed responses return is a consistent time
349+ await using var timedScope = new TimedScope ( GetLoginDuration ( ) , CancellationToken . None ) ;
350+
345351 // Sign the user in with username/password, this also gives a chance for developers to
346352 // custom verify the credentials and auto-link user accounts with a custom IBackOfficePasswordChecker
347353 SignInResult result = await _signInManager . PasswordSignInAsync (
348354 loginModel . Username , loginModel . Password , true , true ) ;
349355
350- if ( result . Succeeded )
356+ if ( result . Succeeded is false )
351357 {
352- // return the user detail
353- return GetUserDetail ( _userService . GetByUsername ( loginModel . Username ) ) ;
354- }
358+ BackOfficeIdentityUser ? user = await _userManager . FindByNameAsync ( loginModel . Username . Trim ( ) ) ;
355359
356- if ( result . RequiresTwoFactor )
357- {
358- var twofactorView = _backOfficeTwoFactorOptions . GetTwoFactorView ( loginModel . Username ) ;
359- if ( twofactorView . IsNullOrWhiteSpace ( ) )
360+ if ( user is not null &&
361+ await _userManager . CheckPasswordAsync ( user , loginModel . Password ) )
360362 {
361- return new ValidationErrorResult (
362- $ "The registered { typeof ( IBackOfficeTwoFactorOptions ) } of type { _backOfficeTwoFactorOptions . GetType ( ) } did not return a view for two factor auth ") ;
363- }
364-
365- IUser ? attemptedUser = _userService . GetByUsername ( loginModel . Username ) ;
363+ // The credentials were correct, so cancel timed scope and provide a more detailed failure response
364+ timedScope . Cancel ( ) ;
366365
367- // create a with information to display a custom two factor send code view
368- var verifyResponse =
369- new ObjectResult ( new { twoFactorView = twofactorView , userId = attemptedUser ? . Id } )
366+ if ( result . RequiresTwoFactor )
370367 {
371- StatusCode = StatusCodes . Status402PaymentRequired
372- } ;
368+ var twofactorView = _backOfficeTwoFactorOptions . GetTwoFactorView ( loginModel . Username ) ;
369+ if ( twofactorView . IsNullOrWhiteSpace ( ) )
370+ {
371+ return new ValidationErrorResult (
372+ $ "The registered { typeof ( IBackOfficeTwoFactorOptions ) } of type { _backOfficeTwoFactorOptions . GetType ( ) } did not return a view for two factor auth ") ;
373+ }
374+
375+ IUser ? attemptedUser = _userService . GetByUsername ( loginModel . Username ) ;
376+
377+ // create a with information to display a custom two factor send code view
378+ var verifyResponse =
379+ new ObjectResult ( new { twoFactorView = twofactorView , userId = attemptedUser ? . Id } )
380+ {
381+ StatusCode = StatusCodes . Status402PaymentRequired
382+ } ;
383+
384+ return verifyResponse ;
385+ }
386+
387+ // TODO: We can check for these and respond differently if we think it's important
388+ // result.IsLockedOut
389+ // result.IsNotAllowed
390+ }
373391
374- return verifyResponse ;
392+ // Return BadRequest (400), we don't want to return a 401 because that get's intercepted
393+ // by our angular helper because it thinks that we need to re-perform the request once we are
394+ // authorized and we don't want to return a 403 because angular will show a warning message indicating
395+ // that the user doesn't have access to perform this function, we just want to return a normal invalid message.
396+ return BadRequest ( ) ;
375397 }
376398
377- // TODO: We can check for these and respond differently if we think it's important
378- // result.IsLockedOut
379- // result.IsNotAllowed
399+ // Set initial or update average (successful) login duration
400+ _loginDurationAverage = _loginDurationAverage is long average
401+ ? ( average + ( long ) timedScope . Elapsed . TotalMilliseconds ) / 2
402+ : ( long ) timedScope . Elapsed . TotalMilliseconds ;
403+
404+ // Cancel the timed scope (we don't want to unnecessarily wait on a successful response)
405+ timedScope . Cancel ( ) ;
406+
407+ // Return the user detail
408+ return GetUserDetail ( _userService . GetByUsername ( loginModel . Username ) ) ;
409+ }
410+
411+ private long GetLoginDuration ( )
412+ {
413+ var loginDuration = Math . Max ( _loginDurationAverage ?? _securitySettings . UserDefaultFailedLoginDurationInMilliseconds , _securitySettings . UserMinimumFailedLoginDurationInMilliseconds ) ;
414+ var random = new Random ( ) ;
415+ var randomDelay = random . Next ( - FailedLoginDurationRandomOffsetInMilliseconds , FailedLoginDurationRandomOffsetInMilliseconds ) ;
416+ loginDuration += randomDelay ;
417+
418+ // Just be sure we don't get a negative number - possible if someone has configured a very low UserMinimumFailedLoginDurationInMilliseconds value.
419+ if ( loginDuration < 0 )
420+ {
421+ loginDuration = 0 ;
422+ }
380423
381- // return BadRequest (400), we don't want to return a 401 because that get's intercepted
382- // by our angular helper because it thinks that we need to re-perform the request once we are
383- // authorized and we don't want to return a 403 because angular will show a warning message indicating
384- // that the user doesn't have access to perform this function, we just want to return a normal invalid message.
385- return BadRequest ( ) ;
424+ return loginDuration ;
386425 }
387426
388427 /// <summary>
0 commit comments