@@ -34,6 +34,8 @@ namespace Umbraco.Cms.Api.Management.Controllers.Security;
34
34
[ ApiExplorerSettings ( IgnoreApi = true ) ]
35
35
public class BackOfficeController : SecurityControllerBase
36
36
{
37
+ private static long ? _loginDurationAverage ;
38
+
37
39
private readonly IHttpContextAccessor _httpContextAccessor ;
38
40
private readonly IBackOfficeSignInManager _backOfficeSignInManager ;
39
41
private readonly IBackOfficeUserManager _backOfficeUserManager ;
@@ -75,45 +77,65 @@ public BackOfficeController(
75
77
[ Authorize ( Policy = AuthorizationPolicies . DenyLocalLoginIfConfigured ) ]
76
78
public async Task < IActionResult > Login ( CancellationToken cancellationToken , LoginRequestModel model )
77
79
{
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 ) ;
80
83
81
- if ( result . IsNotAllowed )
84
+ IdentitySignInResult result = await _backOfficeSignInManager . PasswordSignInAsync ( model . Username , model . Password , true , true ) ;
85
+ if ( result . Succeeded is false )
82
86
{
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 ( ) ;
88
94
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." )
94
127
. Build ( ) ) ;
95
128
}
96
129
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 ;
108
134
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 ( ) ;
117
139
}
118
140
119
141
[ AllowAnonymous ]
@@ -171,7 +193,8 @@ public async Task<IActionResult> Authorize(CancellationToken cancellationToken)
171
193
{
172
194
return BadRequest ( new OpenIddictResponse
173
195
{
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."
175
198
} ) ;
176
199
}
177
200
@@ -180,7 +203,8 @@ public async Task<IActionResult> Authorize(CancellationToken cancellationToken)
180
203
{
181
204
return BadRequest ( new OpenIddictResponse
182
205
{
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."
184
208
} ) ;
185
209
}
186
210
@@ -200,7 +224,8 @@ public async Task<IActionResult> Token()
200
224
{
201
225
return BadRequest ( new OpenIddictResponse
202
226
{
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."
204
229
} ) ;
205
230
}
206
231
@@ -213,35 +238,36 @@ public async Task<IActionResult> Token()
213
238
? new SignInResult ( OpenIddictServerAspNetCoreDefaults . AuthenticationScheme , authenticateResult . Principal )
214
239
: BadRequest ( new OpenIddictResponse
215
240
{
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."
217
243
} ) ;
218
244
}
219
245
220
- if ( request . IsClientCredentialsGrantType ( ) )
246
+ // ensure the client ID and secret are valid (verified by OpenIddict)
247
+ if ( ! request . IsClientCredentialsGrantType ( ) )
221
248
{
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
+ }
232
251
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 ) ;
235
259
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 ) ;
242
261
}
243
262
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
+ } ) ;
245
271
}
246
272
247
273
[ AllowAnonymous ]
@@ -489,7 +515,7 @@ private async Task<IActionResult> SignInBackOfficeUser(BackOfficeIdentityUser ba
489
515
490
516
private static IActionResult DefaultChallengeResult ( ) => new ChallengeResult ( Constants . Security . BackOfficeAuthenticationType ) ;
491
517
492
- private RedirectResult CallbackErrorRedirectWithStatus ( string flowType , string status , IEnumerable < IdentityError > identityErrors )
518
+ private RedirectResult CallbackErrorRedirectWithStatus ( string flowType , string status , IEnumerable < IdentityError > identityErrors )
493
519
{
494
520
var redirectUrl = _securitySettings . Value . BackOfficeHost + "/" +
495
521
_securitySettings . Value . AuthorizeCallbackErrorPathName . TrimStart ( '/' ) . AppendQueryStringToUrl (
0 commit comments