Skip to content

Commit

Permalink
VCST-114: decoupled login error logic between modules (#2746)
Browse files Browse the repository at this point in the history
Co-authored-by: Oleg Zhuk <[email protected]>
  • Loading branch information
ksavosteev and OlegoO authored Feb 26, 2024
1 parent 70bf9bf commit 06cf1fb
Show file tree
Hide file tree
Showing 9 changed files with 258 additions and 39 deletions.
21 changes: 19 additions & 2 deletions src/VirtoCommerce.Platform.Core/Extensions/StringExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,19 @@

namespace VirtoCommerce.Platform.Core.Common
{
public static class StringExtensions
public static partial class StringExtensions
{
[GeneratedRegex(@"([A-Z]+)([A-Z][a-z])")]
private static partial Regex FirstUpperCaseRegex();

[GeneratedRegex(@"([a-z\d])([A-Z])")]
private static partial Regex FirstLowerCaseRegex();

[GeneratedRegex(@"[\[, \]]")]
private static partial Regex IllegalRegex();

private static readonly Regex _emailRegex = new Regex(@"^((([a-z]|\d|[!#\$%&'\*\+\-\/=\?\^_`{\|}~]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])+(\.([a-z]|\d|[!#\$%&'\*\+\-\/=\?\^_`{\|}~]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])+)*)|((\x22)((((\x20|\x09)*(\x0d\x0a))?(\x20|\x09)+)?(([\x01-\x08\x0b\x0c\x0e-\x1f\x7f]|\x21|[\x23-\x5b]|[\x5d-\x7e]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(\\([\x01-\x09\x0b\x0c\x0d-\x7f]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))))*(((\x20|\x09)*(\x0d\x0a))?(\x20|\x09)+)?(\x22)))@((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-||_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])+|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])+([a-z]+|\d|-|\.{0,1}|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])?([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))$", RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1));
private static readonly string[] _allowedUriSchemes = new string[] { Uri.UriSchemeFile, Uri.UriSchemeFtp, Uri.UriSchemeHttp, Uri.UriSchemeHttps, Uri.UriSchemeMailto, Uri.UriSchemeNetPipe, Uri.UriSchemeNetTcp };
private static readonly string[] _allowedUriSchemes = [Uri.UriSchemeFile, Uri.UriSchemeFtp, Uri.UriSchemeHttp, Uri.UriSchemeHttps, Uri.UriSchemeMailto, Uri.UriSchemeNetPipe, Uri.UriSchemeNetTcp];

public static bool IsAbsoluteUrl(this string url)
{
Expand Down Expand Up @@ -318,5 +327,13 @@ public static bool IsValidEmail(this string input)
return _emailRegex.IsMatch(input);
}

public static string ToSnakeCase(this string name)
{
ArgumentNullException.ThrowIfNull(name);

name = IllegalRegex().Replace(name, "_").TrimEnd('_');
// Replace any capital letters, apart from the first character, with _x, the same way Ruby does
return FirstLowerCaseRegex().Replace(FirstUpperCaseRegex().Replace(name, "$1_$2"), "$1_$2").ToLower();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using System;
using System.Collections.Generic;
using VirtoCommerce.Platform.Core.Security;

namespace VirtoCommerce.Platform.Security.Model
{
public class SignInValidatorContext
{
public ApplicationUser User { get; set; }

public string StoreId { get; set; }

public bool DetailedErrors { get; set; }

public bool IsSucceeded { get; set; }

public bool IsLockedOut { get; set; }

public IDictionary<string, object> AdditionalParameters { get; set; } = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
}
}
28 changes: 28 additions & 0 deletions src/VirtoCommerce.Platform.Security/Model/TokenLoginResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using System.Collections.Generic;
using Microsoft.AspNetCore.Identity;
using OpenIddict.Abstractions;

namespace VirtoCommerce.Platform.Security.Model
{
public class TokenLoginResponse : OpenIddictResponse
{
public string UserId { get; set; }

public IList<IdentityError> Errors
{
get
{
var errors = new List<IdentityError>();
if (Code != null)
{
errors.Add(new IdentityError
{
Code = Code,
Description = ErrorDescription
});
}
return errors;
}
}
}
}
72 changes: 72 additions & 0 deletions src/VirtoCommerce.Platform.Security/SecurityErrorDescriber.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
using VirtoCommerce.Platform.Core.Common;
using VirtoCommerce.Platform.Security.Model;
using static OpenIddict.Abstractions.OpenIddictConstants;

namespace VirtoCommerce.Platform.Security
{
public static class SecurityErrorDescriber
{
public static TokenLoginResponse LoginFailed() => new()
{
Error = Errors.InvalidGrant,
Code = nameof(LoginFailed).ToSnakeCase(),
ErrorDescription = "Login attempt failed. Please check your credentials."
};

public static TokenLoginResponse UserIsLockedOut() => new()
{
Error = Errors.InvalidGrant,
Code = nameof(UserIsLockedOut).ToSnakeCase(),
ErrorDescription = "Your account has been locked. Please contact support for assistance."
};

public static TokenLoginResponse UserIsTemporaryLockedOut() => new()
{
Error = Errors.InvalidGrant,
Code = nameof(UserIsLockedOut).ToSnakeCase(),
ErrorDescription = "Your account has been temporarily locked. Please try again after some time."
};

public static TokenLoginResponse PasswordExpired() => new()
{
Error = Errors.InvalidGrant,
Code = nameof(PasswordExpired).ToSnakeCase(),
ErrorDescription = "Your password has been expired and must be changed.",
};

public static TokenLoginResponse PasswordLoginDisabled() => new()
{
Error = Errors.InvalidGrant,
Code = nameof(PasswordLoginDisabled).ToSnakeCase(),
ErrorDescription = "The username/password login is disabled."
};

public static TokenLoginResponse TokenInvalid() => new()
{
Error = Errors.InvalidGrant,
Code = nameof(TokenInvalid).ToSnakeCase(),
ErrorDescription = "The token is no longer valid."
};

public static TokenLoginResponse SignInNotAllowed() => new()
{
Error = Errors.InvalidGrant,
Code = nameof(SignInNotAllowed).ToSnakeCase(),
ErrorDescription = "The user is no longer allowed to sign in."
};

public static TokenLoginResponse InvalidClient() => new()
{
Error = Errors.InvalidClient,
Code = nameof(InvalidClient).ToSnakeCase(),
ErrorDescription = "The client application was not found in the database."
};

public static TokenLoginResponse UnsupportedGrantType() => new()
{
Error = Errors.UnsupportedGrantType,
Code = nameof(UnsupportedGrantType).ToSnakeCase(),
ErrorDescription = "The specified grant type is not supported."
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using VirtoCommerce.Platform.Security.Model;

namespace VirtoCommerce.Platform.Security.Services
{
public class BaseUserSignInValidator : IUserSignInValidator
{
public int Priority { get; set; }

public Task<IList<TokenLoginResponse>> ValidateUserAsync(SignInValidatorContext context)
{
var result = new List<TokenLoginResponse>();

if (!context.IsSucceeded)
{
var error = SecurityErrorDescriber.LoginFailed();

if (context.DetailedErrors && context.IsLockedOut)
{
var permanentLockOut = context.User.LockoutEnd == DateTime.MaxValue.ToUniversalTime();
error = permanentLockOut ? SecurityErrorDescriber.UserIsLockedOut() : SecurityErrorDescriber.UserIsTemporaryLockedOut();
}

result.Add(error);
}

return Task.FromResult<IList<TokenLoginResponse>>(result);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using VirtoCommerce.Platform.Security.Model;

namespace VirtoCommerce.Platform.Security.Services
{
public interface IUserSignInValidator
{
public int Priority { get; set; }

Task<IList<TokenLoginResponse>> ValidateUserAsync(SignInValidatorContext context);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
Expand All @@ -17,6 +18,9 @@
using VirtoCommerce.Platform.Core.Events;
using VirtoCommerce.Platform.Core.Security;
using VirtoCommerce.Platform.Core.Security.Events;
using VirtoCommerce.Platform.Security;
using VirtoCommerce.Platform.Security.Model;
using VirtoCommerce.Platform.Security.Services;
using VirtoCommerce.Platform.Web.Model.Security;
using static OpenIddict.Abstractions.OpenIddictConstants;

Expand All @@ -30,6 +34,8 @@ public class AuthorizationController : Controller
private readonly UserManager<ApplicationUser> _userManager;
private readonly PasswordLoginOptions _passwordLoginOptions;
private readonly IEventPublisher _eventPublisher;
private readonly IEnumerable<IUserSignInValidator> _userSignInValidators;
private readonly OpenIddictTokenManager<OpenIddictEntityFrameworkCoreToken> _tokenManager;

private UserManager<ApplicationUser> UserManager => _signInManager.UserManager;

Expand All @@ -39,14 +45,48 @@ public AuthorizationController(
SignInManager<ApplicationUser> signInManager,
UserManager<ApplicationUser> userManager,
IOptions<PasswordLoginOptions> passwordLoginOptions,
IEventPublisher eventPublisher)
IEventPublisher eventPublisher,
IEnumerable<IUserSignInValidator> userSignInValidators,
OpenIddictTokenManager<OpenIddictEntityFrameworkCoreToken> tokenManager)
{
_applicationManager = applicationManager;
_identityOptions = identityOptions.Value;
_passwordLoginOptions = passwordLoginOptions.Value ?? new PasswordLoginOptions();
_signInManager = signInManager;
_userManager = userManager;
_eventPublisher = eventPublisher;
_userSignInValidators = userSignInValidators;
_tokenManager = tokenManager;
}

[Authorize]
[HttpPost("~/revoke/token")]
public async Task<ActionResult> RevokeCurrentUserToken()
{
var tokenId = HttpContext.User.GetClaim("oi_tkn_id");
var authId = HttpContext.User.GetClaim("oi_au_id");

if (authId != null)
{
var tokens = _tokenManager.FindByAuthorizationIdAsync(authId);
await foreach (var token in tokens)
{
await _tokenManager.TryRevokeAsync(token);
}
}
else if (tokenId != null)
{
var token = await _tokenManager.FindByIdAsync(tokenId);
if (token?.Authorization != null)
{
foreach (var authorizationToken in token.Authorization.Tokens)
{
await _tokenManager.TryRevokeAsync(authorizationToken);
}
}
}

return Ok();
}

#region Password, authorization code and refresh token flows
Expand Down Expand Up @@ -84,31 +124,39 @@ public async Task<ActionResult> Exchange()

if (user == null)
{
return BadRequest(new OpenIddictResponse
{
Error = Errors.InvalidGrant,
ErrorDescription = "The username/password couple is invalid."
});
return BadRequest(SecurityErrorDescriber.LoginFailed());
}

if (!_passwordLoginOptions.Enabled && !user.IsAdministrator)
{
return BadRequest(new OpenIddictResponse
{
Error = Errors.InvalidGrant,
ErrorDescription = "The username/password login is disabled."
});
return BadRequest(SecurityErrorDescriber.PasswordLoginDisabled());
}

// Validate the username/password parameters and ensure the account is not locked out.
var result = await _signInManager.CheckPasswordSignInAsync(user, openIdConnectRequest.Password, lockoutOnFailure: true);
if (!result.Succeeded)

var context = new SignInValidatorContext
{
User = user.Clone() as ApplicationUser,
DetailedErrors = _passwordLoginOptions.DetailedErrors,
IsSucceeded = result.Succeeded,
IsLockedOut = result.IsLockedOut,
};

var storeIdParameter = openIdConnectRequest.GetParameter("storeId");
if (storeIdParameter != null)
{
context.StoreId = (string)storeIdParameter.GetValueOrDefault();
}

foreach (var loginValidation in _userSignInValidators.OrderByDescending(x => x.Priority).ThenBy(x => x.GetType().Name))
{
return BadRequest(new OpenIddictResponse
var validationErrors = await loginValidation.ValidateUserAsync(context);
var error = validationErrors.FirstOrDefault();
if (error != null)
{
Error = Errors.InvalidGrant,
ErrorDescription = "The username/password couple is invalid."
});
return BadRequest(error);
}
}

await _eventPublisher.Publish(new BeforeUserLoginEvent(user));
Expand All @@ -133,27 +181,19 @@ public async Task<ActionResult> Exchange()
var user = await _userManager.GetUserAsync(info.Principal);
if (user == null)
{
return BadRequest(new OpenIddictResponse
{
Error = Errors.InvalidGrant,
ErrorDescription = "The token is no longer valid."
});
return BadRequest(SecurityErrorDescriber.TokenInvalid());
}

// Ensure the user is still allowed to sign in.
if (!await _signInManager.CanSignInAsync(user))
{
return BadRequest(new OpenIddictResponse
{
Error = Errors.InvalidGrant,
ErrorDescription = "The user is no longer allowed to sign in."
});
return BadRequest(SecurityErrorDescriber.SignInNotAllowed());
}

// Create a new authentication ticket, but reuse the properties stored in the
// authorization code/refresh token, including the scopes originally granted.
var ticket = await CreateTicketAsync(openIdConnectRequest, user, info.Properties);
return SignIn(ticket.Principal, ticket.Properties, ticket.AuthenticationScheme);
return SignIn(ticket.Principal, ticket.AuthenticationScheme);
}
else if (openIdConnectRequest.IsClientCredentialsGrantType())
{
Expand All @@ -162,24 +202,17 @@ public async Task<ActionResult> Exchange()
var application = await _applicationManager.FindByClientIdAsync(openIdConnectRequest.ClientId, HttpContext.RequestAborted);
if (application == null)
{
return BadRequest(new OpenIddictResponse
{
Error = Errors.InvalidClient,
ErrorDescription = "The client application was not found in the database."
});
return BadRequest(SecurityErrorDescriber.InvalidClient());
}

// Create a new authentication ticket.
var ticket = CreateTicket(application);
return SignIn(ticket.Principal, ticket.Properties, ticket.AuthenticationScheme);
}

return BadRequest(new OpenIddictResponse
{
Error = Errors.UnsupportedGrantType,
ErrorDescription = "The specified grant type is not supported."
});
return BadRequest(SecurityErrorDescriber.UnsupportedGrantType());
}

#endregion

private AuthenticationTicket CreateTicket(OpenIddictEntityFrameworkCoreApplication application)
Expand Down
Loading

0 comments on commit 06cf1fb

Please sign in to comment.