@@ -34,6 +34,8 @@ namespace Umbraco.Cms.Api.Management.Controllers.Security;
3434[ ApiExplorerSettings ( IgnoreApi = true ) ]
3535public class BackOfficeController : SecurityControllerBase
3636{
37+ private static long ? _loginDurationAverage ;
38+
3739 private readonly IHttpContextAccessor _httpContextAccessor ;
3840 private readonly IBackOfficeSignInManager _backOfficeSignInManager ;
3941 private readonly IBackOfficeUserManager _backOfficeUserManager ;
@@ -72,45 +74,65 @@ public BackOfficeController(
7274 [ Authorize ( Policy = AuthorizationPolicies . DenyLocalLoginIfConfigured ) ]
7375 public async Task < IActionResult > Login ( CancellationToken cancellationToken , LoginRequestModel model )
7476 {
75- IdentitySignInResult result = await _backOfficeSignInManager . PasswordSignInAsync (
76- model . Username , model . Password , true , true ) ;
77+ // Start a timed scope to ensure failed responses return is a consistent time
78+ var loginDuration = Math . Max ( _loginDurationAverage ?? _securitySettings . Value . UserDefaultFailedLoginDurationInMilliseconds , _securitySettings . Value . UserMinimumFailedLoginDurationInMilliseconds ) ;
79+ await using var timedScope = new TimedScope ( loginDuration , cancellationToken ) ;
7780
78- if ( result . IsNotAllowed )
81+ IdentitySignInResult result = await _backOfficeSignInManager . PasswordSignInAsync ( model . Username , model . Password , true , true ) ;
82+ if ( result . Succeeded is false )
7983 {
80- return StatusCode ( StatusCodes . Status403Forbidden , new ProblemDetailsBuilder ( )
81- . WithTitle ( "User is not allowed" )
82- . WithDetail ( "The operation is not allowed on the user" )
83- . Build ( ) ) ;
84- }
84+ // TODO: The result should include the user and whether the credentials were valid to avoid these additional checks
85+ BackOfficeIdentityUser ? user = await _backOfficeUserManager . FindByNameAsync ( model . Username . Trim ( ) ) ; // Align with UmbracoSignInManager and trim username!
86+ if ( user is not null &&
87+ await _backOfficeUserManager . CheckPasswordAsync ( user , model . Password ) )
88+ {
89+ // The credentials were correct, so cancel timed scope and provide a more detailed failure response
90+ await timedScope . CancelAsync ( ) ;
91+
92+ if ( result . IsNotAllowed )
93+ {
94+ return StatusCode ( StatusCodes . Status403Forbidden , new ProblemDetailsBuilder ( )
95+ . WithTitle ( "User is not allowed" )
96+ . WithDetail ( "The operation is not allowed on the user" )
97+ . Build ( ) ) ;
98+ }
99+
100+ if ( result . IsLockedOut )
101+ {
102+ return StatusCode ( StatusCodes . Status403Forbidden , new ProblemDetailsBuilder ( )
103+ . WithTitle ( "User is locked" )
104+ . WithDetail ( "The user is locked, and need to be unlocked before more login attempts can be executed." )
105+ . Build ( ) ) ;
106+ }
107+
108+ if ( result . RequiresTwoFactor )
109+ {
110+ string ? twofactorView = _backOfficeTwoFactorOptions . GetTwoFactorView ( model . Username ) ;
111+ IEnumerable < string > enabledProviders = ( await _userTwoFactorLoginService . GetProviderNamesAsync ( user . Key ) ) . Result . Where ( x => x . IsEnabledOnUser ) . Select ( x => x . ProviderName ) ;
112+
113+ return StatusCode ( StatusCodes . Status402PaymentRequired , new RequiresTwoFactorResponseModel ( )
114+ {
115+ TwoFactorLoginView = twofactorView ,
116+ EnabledTwoFactorProviderNames = enabledProviders
117+ } ) ;
118+ }
119+ }
85120
86- if ( result . IsLockedOut )
87- {
88- return StatusCode ( StatusCodes . Status403Forbidden , new ProblemDetailsBuilder ( )
89- . WithTitle ( "User is locked" )
90- . WithDetail ( "The user is locked, and need to be unlocked before more login attempts can be executed." )
121+ return StatusCode ( StatusCodes . Status401Unauthorized , new ProblemDetailsBuilder ( )
122+ . WithTitle ( "Invalid credentials" )
123+ . WithDetail ( "The provided credentials are invalid. User has not been signed in." )
91124 . Build ( ) ) ;
92125 }
93126
94- if ( result . RequiresTwoFactor )
95- {
96- string ? twofactorView = _backOfficeTwoFactorOptions . GetTwoFactorView ( model . Username ) ;
97- BackOfficeIdentityUser ? attemptingUser = await _backOfficeUserManager . FindByNameAsync ( model . Username ) ;
98- IEnumerable < string > enabledProviders = ( await _userTwoFactorLoginService . GetProviderNamesAsync ( attemptingUser ! . Key ) ) . Result . Where ( x=> x . IsEnabledOnUser ) . Select ( x=> x . ProviderName ) ;
99- return StatusCode ( StatusCodes . Status402PaymentRequired , new RequiresTwoFactorResponseModel ( )
100- {
101- TwoFactorLoginView = twofactorView ,
102- EnabledTwoFactorProviderNames = enabledProviders
103- } ) ;
104- }
127+ // Set initial or update average (successful) login duration
128+ _loginDurationAverage = _loginDurationAverage is long average
129+ ? ( average + ( long ) timedScope . Elapsed . TotalMilliseconds ) / 2
130+ : ( long ) timedScope . Elapsed . TotalMilliseconds ;
105131
106- if ( result . Succeeded )
107- {
108- return Ok ( ) ;
109- }
110- return StatusCode ( StatusCodes . Status401Unauthorized , new ProblemDetailsBuilder ( )
111- . WithTitle ( "Invalid credentials" )
112- . WithDetail ( "The provided credentials are invalid. User has not been signed in." )
113- . Build ( ) ) ;
132+ // Cancel the timed scope (we don't want to unnecessarily wait on a successful response)
133+ await timedScope . CancelAsync ( ) ;
134+
135+ return Ok ( ) ;
114136 }
115137
116138 [ AllowAnonymous ]
0 commit comments