@@ -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 ;
@@ -75,45 +77,65 @@ public BackOfficeController(
7577 [ Authorize ( Policy = AuthorizationPolicies . DenyLocalLoginIfConfigured ) ]
7678 public async Task < IActionResult > Login ( CancellationToken cancellationToken , LoginRequestModel model )
7779 {
78- IdentitySignInResult result = await _backOfficeSignInManager . PasswordSignInAsync (
79- model . Username , model . Password , true , true ) ;
80+ // Start a timed scope to ensure failed responses return is a consistent time
81+ var loginDuration = Math . Max ( _loginDurationAverage ?? _securitySettings . Value . UserDefaultFailedLoginDurationInMilliseconds , _securitySettings . Value . UserMinimumFailedLoginDurationInMilliseconds ) ;
82+ await using var timedScope = new TimedScope ( loginDuration , cancellationToken ) ;
8083
81- if ( result . IsNotAllowed )
84+ IdentitySignInResult result = await _backOfficeSignInManager . PasswordSignInAsync ( model . Username , model . Password , true , true ) ;
85+ if ( result . Succeeded is false )
8286 {
83- return StatusCode ( StatusCodes . Status403Forbidden , new ProblemDetailsBuilder ( )
84- . WithTitle ( "User is not allowed" )
85- . WithDetail ( "The operation is not allowed on the user" )
86- . Build ( ) ) ;
87- }
87+ // TODO: The result should include the user and whether the credentials were valid to avoid these additional checks
88+ BackOfficeIdentityUser ? user = await _backOfficeUserManager . FindByNameAsync ( model . Username . Trim ( ) ) ; // Align with UmbracoSignInManager and trim username!
89+ if ( user is not null &&
90+ await _backOfficeUserManager . CheckPasswordAsync ( user , model . Password ) )
91+ {
92+ // The credentials were correct, so cancel timed scope and provide a more detailed failure response
93+ await timedScope . CancelAsync ( ) ;
8894
89- if ( result . IsLockedOut )
90- {
91- return StatusCode ( StatusCodes . Status403Forbidden , new ProblemDetailsBuilder ( )
92- . WithTitle ( "User is locked" )
93- . WithDetail ( "The user is locked, and need to be unlocked before more login attempts can be executed." )
95+ if ( result . IsNotAllowed )
96+ {
97+ return StatusCode ( StatusCodes . Status403Forbidden , new ProblemDetailsBuilder ( )
98+ . WithTitle ( "User is not allowed" )
99+ . WithDetail ( "The operation is not allowed on the user" )
100+ . Build ( ) ) ;
101+ }
102+
103+ if ( result . IsLockedOut )
104+ {
105+ return StatusCode ( StatusCodes . Status403Forbidden , new ProblemDetailsBuilder ( )
106+ . WithTitle ( "User is locked" )
107+ . WithDetail ( "The user is locked, and need to be unlocked before more login attempts can be executed." )
108+ . Build ( ) ) ;
109+ }
110+
111+ if ( result . RequiresTwoFactor )
112+ {
113+ string ? twofactorView = _backOfficeTwoFactorOptions . GetTwoFactorView ( model . Username ) ;
114+ IEnumerable < string > enabledProviders = ( await _userTwoFactorLoginService . GetProviderNamesAsync ( user . Key ) ) . Result . Where ( x => x . IsEnabledOnUser ) . Select ( x => x . ProviderName ) ;
115+
116+ return StatusCode ( StatusCodes . Status402PaymentRequired , new RequiresTwoFactorResponseModel ( )
117+ {
118+ TwoFactorLoginView = twofactorView ,
119+ EnabledTwoFactorProviderNames = enabledProviders
120+ } ) ;
121+ }
122+ }
123+
124+ return StatusCode ( StatusCodes . Status401Unauthorized , new ProblemDetailsBuilder ( )
125+ . WithTitle ( "Invalid credentials" )
126+ . WithDetail ( "The provided credentials are invalid. User has not been signed in." )
94127 . Build ( ) ) ;
95128 }
96129
97- if ( result . RequiresTwoFactor )
98- {
99- string ? twofactorView = _backOfficeTwoFactorOptions . GetTwoFactorView ( model . Username ) ;
100- BackOfficeIdentityUser ? attemptingUser = await _backOfficeUserManager . FindByNameAsync ( model . Username ) ;
101- IEnumerable < string > enabledProviders = ( await _userTwoFactorLoginService . GetProviderNamesAsync ( attemptingUser ! . Key ) ) . Result . Where ( x=> x . IsEnabledOnUser ) . Select ( x=> x . ProviderName ) ;
102- return StatusCode ( StatusCodes . Status402PaymentRequired , new RequiresTwoFactorResponseModel ( )
103- {
104- TwoFactorLoginView = twofactorView ,
105- EnabledTwoFactorProviderNames = enabledProviders
106- } ) ;
107- }
130+ // Set initial or update average (successful) login duration
131+ _loginDurationAverage = _loginDurationAverage is long average
132+ ? ( average + ( long ) timedScope . Elapsed . TotalMilliseconds ) / 2
133+ : ( long ) timedScope . Elapsed . TotalMilliseconds ;
108134
109- if ( result . Succeeded )
110- {
111- return Ok ( ) ;
112- }
113- return StatusCode ( StatusCodes . Status401Unauthorized , new ProblemDetailsBuilder ( )
114- . WithTitle ( "Invalid credentials" )
115- . WithDetail ( "The provided credentials are invalid. User has not been signed in." )
116- . Build ( ) ) ;
135+ // Cancel the timed scope (we don't want to unnecessarily wait on a successful response)
136+ await timedScope . CancelAsync ( ) ;
137+
138+ return Ok ( ) ;
117139 }
118140
119141 [ AllowAnonymous ]
@@ -171,7 +193,8 @@ public async Task<IActionResult> Authorize(CancellationToken cancellationToken)
171193 {
172194 return BadRequest ( new OpenIddictResponse
173195 {
174- Error = "No context found" , ErrorDescription = "Unable to obtain context from the current request."
196+ Error = "No context found" ,
197+ ErrorDescription = "Unable to obtain context from the current request."
175198 } ) ;
176199 }
177200
@@ -180,7 +203,8 @@ public async Task<IActionResult> Authorize(CancellationToken cancellationToken)
180203 {
181204 return BadRequest ( new OpenIddictResponse
182205 {
183- Error = "Invalid 'client ID'" , ErrorDescription = "The specified 'client_id' is not valid."
206+ Error = "Invalid 'client ID'" ,
207+ ErrorDescription = "The specified 'client_id' is not valid."
184208 } ) ;
185209 }
186210
@@ -200,7 +224,8 @@ public async Task<IActionResult> Token()
200224 {
201225 return BadRequest ( new OpenIddictResponse
202226 {
203- Error = "No context found" , ErrorDescription = "Unable to obtain context from the current request."
227+ Error = "No context found" ,
228+ ErrorDescription = "Unable to obtain context from the current request."
204229 } ) ;
205230 }
206231
@@ -213,35 +238,36 @@ public async Task<IActionResult> Token()
213238 ? new SignInResult ( OpenIddictServerAspNetCoreDefaults . AuthenticationScheme , authenticateResult . Principal )
214239 : BadRequest ( new OpenIddictResponse
215240 {
216- Error = "Authorization failed" , ErrorDescription = "The supplied authorization could not be verified."
241+ Error = "Authorization failed" ,
242+ ErrorDescription = "The supplied authorization could not be verified."
217243 } ) ;
218244 }
219245
220- if ( request . IsClientCredentialsGrantType ( ) )
246+ // ensure the client ID and secret are valid (verified by OpenIddict)
247+ if ( ! request . IsClientCredentialsGrantType ( ) )
221248 {
222- // if we get here, the client ID and secret are valid (verified by OpenIddict)
223-
224- // grab the user associated with the client ID
225- BackOfficeIdentityUser ? associatedUser = await _backOfficeUserClientCredentialsManager . FindUserAsync ( request . ClientId ! ) ;
226-
227- if ( associatedUser is not null )
228- {
229- // log current datetime as last login (this also ensures that the user is not flagged as inactive)
230- associatedUser . LastLoginDateUtc = DateTime . UtcNow ;
231- await _backOfficeUserManager . UpdateAsync ( associatedUser ) ;
249+ throw new InvalidOperationException ( "The requested grant type is not supported." ) ;
250+ }
232251
233- return await SignInBackOfficeUser ( associatedUser , request ) ;
234- }
252+ // grab the user associated with the client ID
253+ BackOfficeIdentityUser ? associatedUser = await _backOfficeUserClientCredentialsManager . FindUserAsync ( request . ClientId ! ) ;
254+ if ( associatedUser is not null )
255+ {
256+ // log current datetime as last login (this also ensures that the user is not flagged as inactive)
257+ associatedUser . LastLoginDateUtc = DateTime . UtcNow ;
258+ await _backOfficeUserManager . UpdateAsync ( associatedUser ) ;
235259
236- // if this happens, the OpenIddict applications have somehow gone out of sync with the Umbraco users
237- _logger . LogError ( "The user associated with the client ID ({clientId}) could not be found" , request . ClientId ) ;
238- return BadRequest ( new OpenIddictResponse
239- {
240- Error = "Authorization failed" , ErrorDescription = "The user associated with the supplied 'client_id' could not be found."
241- } ) ;
260+ return await SignInBackOfficeUser ( associatedUser , request ) ;
242261 }
243262
244- throw new InvalidOperationException ( "The requested grant type is not supported." ) ;
263+ // if this happens, the OpenIddict applications have somehow gone out of sync with the Umbraco users
264+ _logger . LogError ( "The user associated with the client ID ({clientId}) could not be found" , request . ClientId ) ;
265+
266+ return BadRequest ( new OpenIddictResponse
267+ {
268+ Error = "Authorization failed" ,
269+ ErrorDescription = "The user associated with the supplied 'client_id' could not be found."
270+ } ) ;
245271 }
246272
247273 [ AllowAnonymous ]
@@ -489,7 +515,7 @@ private async Task<IActionResult> SignInBackOfficeUser(BackOfficeIdentityUser ba
489515
490516 private static IActionResult DefaultChallengeResult ( ) => new ChallengeResult ( Constants . Security . BackOfficeAuthenticationType ) ;
491517
492- private RedirectResult CallbackErrorRedirectWithStatus ( string flowType , string status , IEnumerable < IdentityError > identityErrors )
518+ private RedirectResult CallbackErrorRedirectWithStatus ( string flowType , string status , IEnumerable < IdentityError > identityErrors )
493519 {
494520 var redirectUrl = _securitySettings . Value . BackOfficeHost + "/" +
495521 _securitySettings . Value . AuthorizeCallbackErrorPathName . TrimStart ( '/' ) . AppendQueryStringToUrl (
0 commit comments