Skip to content

Commit

Permalink
Merge commit from fork
Browse files Browse the repository at this point in the history
* Add TimedScope

* Use TimedScope in login endpoint

* Use seperate default duration and only calculate average of actual successful responses

* Only return detailed error responses if credentials are valid

* Cancel timed scope when credentials are valid

* Add UserDefaultFailedLoginDuration and UserMinimumFailedLoginDuration settings
  • Loading branch information
ronaldbarendse authored and Zeegaan committed Jan 20, 2025
1 parent d4f8754 commit 559c6c9
Show file tree
Hide file tree
Showing 3 changed files with 272 additions and 57 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ namespace Umbraco.Cms.Api.Management.Controllers.Security;
[ApiExplorerSettings(IgnoreApi = true)]
public class BackOfficeController : SecurityControllerBase
{
private static long? _loginDurationAverage;

private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IBackOfficeSignInManager _backOfficeSignInManager;
private readonly IBackOfficeUserManager _backOfficeUserManager;
Expand Down Expand Up @@ -75,45 +77,65 @@ public BackOfficeController(
[Authorize(Policy = AuthorizationPolicies.DenyLocalLoginIfConfigured)]
public async Task<IActionResult> Login(CancellationToken cancellationToken, LoginRequestModel model)
{
IdentitySignInResult result = await _backOfficeSignInManager.PasswordSignInAsync(
model.Username, model.Password, true, true);
// Start a timed scope to ensure failed responses return is a consistent time
var loginDuration = Math.Max(_loginDurationAverage ?? _securitySettings.Value.UserDefaultFailedLoginDurationInMilliseconds, _securitySettings.Value.UserMinimumFailedLoginDurationInMilliseconds);
await using var timedScope = new TimedScope(loginDuration, cancellationToken);

if (result.IsNotAllowed)
IdentitySignInResult result = await _backOfficeSignInManager.PasswordSignInAsync(model.Username, model.Password, true, true);
if (result.Succeeded is false)
{
return StatusCode(StatusCodes.Status403Forbidden, new ProblemDetailsBuilder()
.WithTitle("User is not allowed")
.WithDetail("The operation is not allowed on the user")
.Build());
}
// TODO: The result should include the user and whether the credentials were valid to avoid these additional checks
BackOfficeIdentityUser? user = await _backOfficeUserManager.FindByNameAsync(model.Username.Trim()); // Align with UmbracoSignInManager and trim username!
if (user is not null &&
await _backOfficeUserManager.CheckPasswordAsync(user, model.Password))
{
// The credentials were correct, so cancel timed scope and provide a more detailed failure response
await timedScope.CancelAsync();

if (result.IsLockedOut)
{
return StatusCode(StatusCodes.Status403Forbidden, new ProblemDetailsBuilder()
.WithTitle("User is locked")
.WithDetail("The user is locked, and need to be unlocked before more login attempts can be executed.")
if (result.IsNotAllowed)
{
return StatusCode(StatusCodes.Status403Forbidden, new ProblemDetailsBuilder()
.WithTitle("User is not allowed")
.WithDetail("The operation is not allowed on the user")
.Build());
}

if (result.IsLockedOut)
{
return StatusCode(StatusCodes.Status403Forbidden, new ProblemDetailsBuilder()
.WithTitle("User is locked")
.WithDetail("The user is locked, and need to be unlocked before more login attempts can be executed.")
.Build());
}

if (result.RequiresTwoFactor)
{
string? twofactorView = _backOfficeTwoFactorOptions.GetTwoFactorView(model.Username);
IEnumerable<string> enabledProviders = (await _userTwoFactorLoginService.GetProviderNamesAsync(user.Key)).Result.Where(x => x.IsEnabledOnUser).Select(x => x.ProviderName);

return StatusCode(StatusCodes.Status402PaymentRequired, new RequiresTwoFactorResponseModel()
{
TwoFactorLoginView = twofactorView,
EnabledTwoFactorProviderNames = enabledProviders
});
}
}

return StatusCode(StatusCodes.Status401Unauthorized, new ProblemDetailsBuilder()
.WithTitle("Invalid credentials")
.WithDetail("The provided credentials are invalid. User has not been signed in.")
.Build());
}

if(result.RequiresTwoFactor)
{
string? twofactorView = _backOfficeTwoFactorOptions.GetTwoFactorView(model.Username);
BackOfficeIdentityUser? attemptingUser = await _backOfficeUserManager.FindByNameAsync(model.Username);
IEnumerable<string> enabledProviders = (await _userTwoFactorLoginService.GetProviderNamesAsync(attemptingUser!.Key)).Result.Where(x=>x.IsEnabledOnUser).Select(x=>x.ProviderName);
return StatusCode(StatusCodes.Status402PaymentRequired, new RequiresTwoFactorResponseModel()
{
TwoFactorLoginView = twofactorView,
EnabledTwoFactorProviderNames = enabledProviders
});
}
// Set initial or update average (successful) login duration
_loginDurationAverage = _loginDurationAverage is long average
? (average + (long)timedScope.Elapsed.TotalMilliseconds) / 2
: (long)timedScope.Elapsed.TotalMilliseconds;

if (result.Succeeded)
{
return Ok();
}
return StatusCode(StatusCodes.Status401Unauthorized, new ProblemDetailsBuilder()
.WithTitle("Invalid credentials")
.WithDetail("The provided credentials are invalid. User has not been signed in.")
.Build());
// Cancel the timed scope (we don't want to unnecessarily wait on a successful response)
await timedScope.CancelAsync();

return Ok();
}

[AllowAnonymous]
Expand Down Expand Up @@ -171,7 +193,8 @@ public async Task<IActionResult> Authorize(CancellationToken cancellationToken)
{
return BadRequest(new OpenIddictResponse
{
Error = "No context found", ErrorDescription = "Unable to obtain context from the current request."
Error = "No context found",
ErrorDescription = "Unable to obtain context from the current request."
});
}

Expand All @@ -180,7 +203,8 @@ public async Task<IActionResult> Authorize(CancellationToken cancellationToken)
{
return BadRequest(new OpenIddictResponse
{
Error = "Invalid 'client ID'", ErrorDescription = "The specified 'client_id' is not valid."
Error = "Invalid 'client ID'",
ErrorDescription = "The specified 'client_id' is not valid."
});
}

Expand All @@ -200,7 +224,8 @@ public async Task<IActionResult> Token()
{
return BadRequest(new OpenIddictResponse
{
Error = "No context found", ErrorDescription = "Unable to obtain context from the current request."
Error = "No context found",
ErrorDescription = "Unable to obtain context from the current request."
});
}

Expand All @@ -213,35 +238,36 @@ public async Task<IActionResult> Token()
? new SignInResult(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, authenticateResult.Principal)
: BadRequest(new OpenIddictResponse
{
Error = "Authorization failed", ErrorDescription = "The supplied authorization could not be verified."
Error = "Authorization failed",
ErrorDescription = "The supplied authorization could not be verified."
});
}

if (request.IsClientCredentialsGrantType())
// ensure the client ID and secret are valid (verified by OpenIddict)
if (!request.IsClientCredentialsGrantType())
{
// if we get here, the client ID and secret are valid (verified by OpenIddict)

// grab the user associated with the client ID
BackOfficeIdentityUser? associatedUser = await _backOfficeUserClientCredentialsManager.FindUserAsync(request.ClientId!);

if (associatedUser is not null)
{
// log current datetime as last login (this also ensures that the user is not flagged as inactive)
associatedUser.LastLoginDateUtc = DateTime.UtcNow;
await _backOfficeUserManager.UpdateAsync(associatedUser);
throw new InvalidOperationException("The requested grant type is not supported.");
}

return await SignInBackOfficeUser(associatedUser, request);
}
// grab the user associated with the client ID
BackOfficeIdentityUser? associatedUser = await _backOfficeUserClientCredentialsManager.FindUserAsync(request.ClientId!);
if (associatedUser is not null)
{
// log current datetime as last login (this also ensures that the user is not flagged as inactive)
associatedUser.LastLoginDateUtc = DateTime.UtcNow;
await _backOfficeUserManager.UpdateAsync(associatedUser);

// if this happens, the OpenIddict applications have somehow gone out of sync with the Umbraco users
_logger.LogError("The user associated with the client ID ({clientId}) could not be found", request.ClientId);
return BadRequest(new OpenIddictResponse
{
Error = "Authorization failed", ErrorDescription = "The user associated with the supplied 'client_id' could not be found."
});
return await SignInBackOfficeUser(associatedUser, request);
}

throw new InvalidOperationException("The requested grant type is not supported.");
// if this happens, the OpenIddict applications have somehow gone out of sync with the Umbraco users
_logger.LogError("The user associated with the client ID ({clientId}) could not be found", request.ClientId);

return BadRequest(new OpenIddictResponse
{
Error = "Authorization failed",
ErrorDescription = "The user associated with the supplied 'client_id' could not be found."
});
}

[AllowAnonymous]
Expand Down Expand Up @@ -489,7 +515,7 @@ private async Task<IActionResult> SignInBackOfficeUser(BackOfficeIdentityUser ba

private static IActionResult DefaultChallengeResult() => new ChallengeResult(Constants.Security.BackOfficeAuthenticationType);

private RedirectResult CallbackErrorRedirectWithStatus( string flowType, string status, IEnumerable<IdentityError> identityErrors)
private RedirectResult CallbackErrorRedirectWithStatus(string flowType, string status, IEnumerable<IdentityError> identityErrors)
{
var redirectUrl = _securitySettings.Value.BackOfficeHost + "/" +
_securitySettings.Value.AuthorizeCallbackErrorPathName.TrimStart('/').AppendQueryStringToUrl(
Expand Down
27 changes: 27 additions & 0 deletions src/Umbraco.Core/Configuration/Models/SecuritySettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// See LICENSE for more details.

using System.ComponentModel;
using System.ComponentModel.DataAnnotations;

namespace Umbraco.Cms.Core.Configuration.Models;

Expand All @@ -25,6 +26,8 @@ public class SecuritySettings

internal const int StaticMemberDefaultLockoutTimeInMinutes = 30 * 24 * 60;
internal const int StaticUserDefaultLockoutTimeInMinutes = 30 * 24 * 60;
private const long StaticUserDefaultFailedLoginDurationInMilliseconds = 1000;
private const long StaticUserMinimumFailedLoginDurationInMilliseconds = 250;
internal const string StaticAuthorizeCallbackPathName = "/umbraco/oauth_complete";
internal const string StaticAuthorizeCallbackLogoutPathName = "/umbraco/logout";
internal const string StaticAuthorizeCallbackErrorPathName = "/umbraco/error";
Expand Down Expand Up @@ -101,6 +104,30 @@ public class SecuritySettings
[DefaultValue(StaticAllowConcurrentLogins)]
public bool AllowConcurrentLogins { get; set; } = StaticAllowConcurrentLogins;

/// <summary>
/// Gets or sets the default duration (in milliseconds) of failed login attempts.
/// </summary>
/// <value>
/// The default duration (in milliseconds) of failed login attempts.
/// </value>
/// <remarks>
/// The user login endpoint ensures that failed login attempts take at least as long as the average successful login.
/// However, if no successful logins have occurred, this value is used as the default duration.
/// </remarks>
[Range(0, long.MaxValue)]
[DefaultValue(StaticUserDefaultFailedLoginDurationInMilliseconds)]
public long UserDefaultFailedLoginDurationInMilliseconds { get; set; } = StaticUserDefaultFailedLoginDurationInMilliseconds;

/// <summary>
/// Gets or sets the minimum duration (in milliseconds) of failed login attempts.
/// </summary>
/// <value>
/// The minimum duration (in milliseconds) of failed login attempts.
/// </value>
[Range(0, long.MaxValue)]
[DefaultValue(StaticUserMinimumFailedLoginDurationInMilliseconds)]
public long UserMinimumFailedLoginDurationInMilliseconds { get; set; } = StaticUserMinimumFailedLoginDurationInMilliseconds;

/// <summary>
/// Gets or sets a value of the back-office host URI. Use this when running the back-office client and the Management API on different hosts. Leave empty when running both on the same host.
/// </summary>
Expand Down
Loading

0 comments on commit 559c6c9

Please sign in to comment.