Skip to content

Commit 06cf1fb

Browse files
ksavosteevOlegoO
andauthored
VCST-114: decoupled login error logic between modules (#2746)
Co-authored-by: Oleg Zhuk <zhukoo@gmail.com>
1 parent 70bf9bf commit 06cf1fb

File tree

9 files changed

+258
-39
lines changed

9 files changed

+258
-39
lines changed

src/VirtoCommerce.Platform.Core/Extensions/StringExtensions.cs

+19-2
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,19 @@
88

99
namespace VirtoCommerce.Platform.Core.Common
1010
{
11-
public static class StringExtensions
11+
public static partial class StringExtensions
1212
{
13+
[GeneratedRegex(@"([A-Z]+)([A-Z][a-z])")]
14+
private static partial Regex FirstUpperCaseRegex();
15+
16+
[GeneratedRegex(@"([a-z\d])([A-Z])")]
17+
private static partial Regex FirstLowerCaseRegex();
18+
19+
[GeneratedRegex(@"[\[, \]]")]
20+
private static partial Regex IllegalRegex();
21+
1322
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));
14-
private static readonly string[] _allowedUriSchemes = new string[] { Uri.UriSchemeFile, Uri.UriSchemeFtp, Uri.UriSchemeHttp, Uri.UriSchemeHttps, Uri.UriSchemeMailto, Uri.UriSchemeNetPipe, Uri.UriSchemeNetTcp };
23+
private static readonly string[] _allowedUriSchemes = [Uri.UriSchemeFile, Uri.UriSchemeFtp, Uri.UriSchemeHttp, Uri.UriSchemeHttps, Uri.UriSchemeMailto, Uri.UriSchemeNetPipe, Uri.UriSchemeNetTcp];
1524

1625
public static bool IsAbsoluteUrl(this string url)
1726
{
@@ -318,5 +327,13 @@ public static bool IsValidEmail(this string input)
318327
return _emailRegex.IsMatch(input);
319328
}
320329

330+
public static string ToSnakeCase(this string name)
331+
{
332+
ArgumentNullException.ThrowIfNull(name);
333+
334+
name = IllegalRegex().Replace(name, "_").TrimEnd('_');
335+
// Replace any capital letters, apart from the first character, with _x, the same way Ruby does
336+
return FirstLowerCaseRegex().Replace(FirstUpperCaseRegex().Replace(name, "$1_$2"), "$1_$2").ToLower();
337+
}
321338
}
322339
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using VirtoCommerce.Platform.Core.Security;
4+
5+
namespace VirtoCommerce.Platform.Security.Model
6+
{
7+
public class SignInValidatorContext
8+
{
9+
public ApplicationUser User { get; set; }
10+
11+
public string StoreId { get; set; }
12+
13+
public bool DetailedErrors { get; set; }
14+
15+
public bool IsSucceeded { get; set; }
16+
17+
public bool IsLockedOut { get; set; }
18+
19+
public IDictionary<string, object> AdditionalParameters { get; set; } = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
20+
}
21+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
using System.Collections.Generic;
2+
using Microsoft.AspNetCore.Identity;
3+
using OpenIddict.Abstractions;
4+
5+
namespace VirtoCommerce.Platform.Security.Model
6+
{
7+
public class TokenLoginResponse : OpenIddictResponse
8+
{
9+
public string UserId { get; set; }
10+
11+
public IList<IdentityError> Errors
12+
{
13+
get
14+
{
15+
var errors = new List<IdentityError>();
16+
if (Code != null)
17+
{
18+
errors.Add(new IdentityError
19+
{
20+
Code = Code,
21+
Description = ErrorDescription
22+
});
23+
}
24+
return errors;
25+
}
26+
}
27+
}
28+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
using VirtoCommerce.Platform.Core.Common;
2+
using VirtoCommerce.Platform.Security.Model;
3+
using static OpenIddict.Abstractions.OpenIddictConstants;
4+
5+
namespace VirtoCommerce.Platform.Security
6+
{
7+
public static class SecurityErrorDescriber
8+
{
9+
public static TokenLoginResponse LoginFailed() => new()
10+
{
11+
Error = Errors.InvalidGrant,
12+
Code = nameof(LoginFailed).ToSnakeCase(),
13+
ErrorDescription = "Login attempt failed. Please check your credentials."
14+
};
15+
16+
public static TokenLoginResponse UserIsLockedOut() => new()
17+
{
18+
Error = Errors.InvalidGrant,
19+
Code = nameof(UserIsLockedOut).ToSnakeCase(),
20+
ErrorDescription = "Your account has been locked. Please contact support for assistance."
21+
};
22+
23+
public static TokenLoginResponse UserIsTemporaryLockedOut() => new()
24+
{
25+
Error = Errors.InvalidGrant,
26+
Code = nameof(UserIsLockedOut).ToSnakeCase(),
27+
ErrorDescription = "Your account has been temporarily locked. Please try again after some time."
28+
};
29+
30+
public static TokenLoginResponse PasswordExpired() => new()
31+
{
32+
Error = Errors.InvalidGrant,
33+
Code = nameof(PasswordExpired).ToSnakeCase(),
34+
ErrorDescription = "Your password has been expired and must be changed.",
35+
};
36+
37+
public static TokenLoginResponse PasswordLoginDisabled() => new()
38+
{
39+
Error = Errors.InvalidGrant,
40+
Code = nameof(PasswordLoginDisabled).ToSnakeCase(),
41+
ErrorDescription = "The username/password login is disabled."
42+
};
43+
44+
public static TokenLoginResponse TokenInvalid() => new()
45+
{
46+
Error = Errors.InvalidGrant,
47+
Code = nameof(TokenInvalid).ToSnakeCase(),
48+
ErrorDescription = "The token is no longer valid."
49+
};
50+
51+
public static TokenLoginResponse SignInNotAllowed() => new()
52+
{
53+
Error = Errors.InvalidGrant,
54+
Code = nameof(SignInNotAllowed).ToSnakeCase(),
55+
ErrorDescription = "The user is no longer allowed to sign in."
56+
};
57+
58+
public static TokenLoginResponse InvalidClient() => new()
59+
{
60+
Error = Errors.InvalidClient,
61+
Code = nameof(InvalidClient).ToSnakeCase(),
62+
ErrorDescription = "The client application was not found in the database."
63+
};
64+
65+
public static TokenLoginResponse UnsupportedGrantType() => new()
66+
{
67+
Error = Errors.UnsupportedGrantType,
68+
Code = nameof(UnsupportedGrantType).ToSnakeCase(),
69+
ErrorDescription = "The specified grant type is not supported."
70+
};
71+
}
72+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Threading.Tasks;
4+
using VirtoCommerce.Platform.Security.Model;
5+
6+
namespace VirtoCommerce.Platform.Security.Services
7+
{
8+
public class BaseUserSignInValidator : IUserSignInValidator
9+
{
10+
public int Priority { get; set; }
11+
12+
public Task<IList<TokenLoginResponse>> ValidateUserAsync(SignInValidatorContext context)
13+
{
14+
var result = new List<TokenLoginResponse>();
15+
16+
if (!context.IsSucceeded)
17+
{
18+
var error = SecurityErrorDescriber.LoginFailed();
19+
20+
if (context.DetailedErrors && context.IsLockedOut)
21+
{
22+
var permanentLockOut = context.User.LockoutEnd == DateTime.MaxValue.ToUniversalTime();
23+
error = permanentLockOut ? SecurityErrorDescriber.UserIsLockedOut() : SecurityErrorDescriber.UserIsTemporaryLockedOut();
24+
}
25+
26+
result.Add(error);
27+
}
28+
29+
return Task.FromResult<IList<TokenLoginResponse>>(result);
30+
}
31+
}
32+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
using System.Collections.Generic;
2+
using System.Threading.Tasks;
3+
using VirtoCommerce.Platform.Security.Model;
4+
5+
namespace VirtoCommerce.Platform.Security.Services
6+
{
7+
public interface IUserSignInValidator
8+
{
9+
public int Priority { get; set; }
10+
11+
Task<IList<TokenLoginResponse>> ValidateUserAsync(SignInValidatorContext context);
12+
}
13+
}

src/VirtoCommerce.Platform.Web/Controllers/Api/AuthorizationController.cs

+70-37
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.Threading.Tasks;
66
using Microsoft.AspNetCore;
77
using Microsoft.AspNetCore.Authentication;
8+
using Microsoft.AspNetCore.Authorization;
89
using Microsoft.AspNetCore.Http;
910
using Microsoft.AspNetCore.Identity;
1011
using Microsoft.AspNetCore.Mvc;
@@ -17,6 +18,9 @@
1718
using VirtoCommerce.Platform.Core.Events;
1819
using VirtoCommerce.Platform.Core.Security;
1920
using VirtoCommerce.Platform.Core.Security.Events;
21+
using VirtoCommerce.Platform.Security;
22+
using VirtoCommerce.Platform.Security.Model;
23+
using VirtoCommerce.Platform.Security.Services;
2024
using VirtoCommerce.Platform.Web.Model.Security;
2125
using static OpenIddict.Abstractions.OpenIddictConstants;
2226

@@ -30,6 +34,8 @@ public class AuthorizationController : Controller
3034
private readonly UserManager<ApplicationUser> _userManager;
3135
private readonly PasswordLoginOptions _passwordLoginOptions;
3236
private readonly IEventPublisher _eventPublisher;
37+
private readonly IEnumerable<IUserSignInValidator> _userSignInValidators;
38+
private readonly OpenIddictTokenManager<OpenIddictEntityFrameworkCoreToken> _tokenManager;
3339

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

@@ -39,14 +45,48 @@ public AuthorizationController(
3945
SignInManager<ApplicationUser> signInManager,
4046
UserManager<ApplicationUser> userManager,
4147
IOptions<PasswordLoginOptions> passwordLoginOptions,
42-
IEventPublisher eventPublisher)
48+
IEventPublisher eventPublisher,
49+
IEnumerable<IUserSignInValidator> userSignInValidators,
50+
OpenIddictTokenManager<OpenIddictEntityFrameworkCoreToken> tokenManager)
4351
{
4452
_applicationManager = applicationManager;
4553
_identityOptions = identityOptions.Value;
4654
_passwordLoginOptions = passwordLoginOptions.Value ?? new PasswordLoginOptions();
4755
_signInManager = signInManager;
4856
_userManager = userManager;
4957
_eventPublisher = eventPublisher;
58+
_userSignInValidators = userSignInValidators;
59+
_tokenManager = tokenManager;
60+
}
61+
62+
[Authorize]
63+
[HttpPost("~/revoke/token")]
64+
public async Task<ActionResult> RevokeCurrentUserToken()
65+
{
66+
var tokenId = HttpContext.User.GetClaim("oi_tkn_id");
67+
var authId = HttpContext.User.GetClaim("oi_au_id");
68+
69+
if (authId != null)
70+
{
71+
var tokens = _tokenManager.FindByAuthorizationIdAsync(authId);
72+
await foreach (var token in tokens)
73+
{
74+
await _tokenManager.TryRevokeAsync(token);
75+
}
76+
}
77+
else if (tokenId != null)
78+
{
79+
var token = await _tokenManager.FindByIdAsync(tokenId);
80+
if (token?.Authorization != null)
81+
{
82+
foreach (var authorizationToken in token.Authorization.Tokens)
83+
{
84+
await _tokenManager.TryRevokeAsync(authorizationToken);
85+
}
86+
}
87+
}
88+
89+
return Ok();
5090
}
5191

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

85125
if (user == null)
86126
{
87-
return BadRequest(new OpenIddictResponse
88-
{
89-
Error = Errors.InvalidGrant,
90-
ErrorDescription = "The username/password couple is invalid."
91-
});
127+
return BadRequest(SecurityErrorDescriber.LoginFailed());
92128
}
93129

94130
if (!_passwordLoginOptions.Enabled && !user.IsAdministrator)
95131
{
96-
return BadRequest(new OpenIddictResponse
97-
{
98-
Error = Errors.InvalidGrant,
99-
ErrorDescription = "The username/password login is disabled."
100-
});
132+
return BadRequest(SecurityErrorDescriber.PasswordLoginDisabled());
101133
}
102134

103135
// Validate the username/password parameters and ensure the account is not locked out.
104136
var result = await _signInManager.CheckPasswordSignInAsync(user, openIdConnectRequest.Password, lockoutOnFailure: true);
105-
if (!result.Succeeded)
137+
138+
var context = new SignInValidatorContext
139+
{
140+
User = user.Clone() as ApplicationUser,
141+
DetailedErrors = _passwordLoginOptions.DetailedErrors,
142+
IsSucceeded = result.Succeeded,
143+
IsLockedOut = result.IsLockedOut,
144+
};
145+
146+
var storeIdParameter = openIdConnectRequest.GetParameter("storeId");
147+
if (storeIdParameter != null)
148+
{
149+
context.StoreId = (string)storeIdParameter.GetValueOrDefault();
150+
}
151+
152+
foreach (var loginValidation in _userSignInValidators.OrderByDescending(x => x.Priority).ThenBy(x => x.GetType().Name))
106153
{
107-
return BadRequest(new OpenIddictResponse
154+
var validationErrors = await loginValidation.ValidateUserAsync(context);
155+
var error = validationErrors.FirstOrDefault();
156+
if (error != null)
108157
{
109-
Error = Errors.InvalidGrant,
110-
ErrorDescription = "The username/password couple is invalid."
111-
});
158+
return BadRequest(error);
159+
}
112160
}
113161

114162
await _eventPublisher.Publish(new BeforeUserLoginEvent(user));
@@ -133,27 +181,19 @@ public async Task<ActionResult> Exchange()
133181
var user = await _userManager.GetUserAsync(info.Principal);
134182
if (user == null)
135183
{
136-
return BadRequest(new OpenIddictResponse
137-
{
138-
Error = Errors.InvalidGrant,
139-
ErrorDescription = "The token is no longer valid."
140-
});
184+
return BadRequest(SecurityErrorDescriber.TokenInvalid());
141185
}
142186

143187
// Ensure the user is still allowed to sign in.
144188
if (!await _signInManager.CanSignInAsync(user))
145189
{
146-
return BadRequest(new OpenIddictResponse
147-
{
148-
Error = Errors.InvalidGrant,
149-
ErrorDescription = "The user is no longer allowed to sign in."
150-
});
190+
return BadRequest(SecurityErrorDescriber.SignInNotAllowed());
151191
}
152192

153193
// Create a new authentication ticket, but reuse the properties stored in the
154194
// authorization code/refresh token, including the scopes originally granted.
155195
var ticket = await CreateTicketAsync(openIdConnectRequest, user, info.Properties);
156-
return SignIn(ticket.Principal, ticket.Properties, ticket.AuthenticationScheme);
196+
return SignIn(ticket.Principal, ticket.AuthenticationScheme);
157197
}
158198
else if (openIdConnectRequest.IsClientCredentialsGrantType())
159199
{
@@ -162,24 +202,17 @@ public async Task<ActionResult> Exchange()
162202
var application = await _applicationManager.FindByClientIdAsync(openIdConnectRequest.ClientId, HttpContext.RequestAborted);
163203
if (application == null)
164204
{
165-
return BadRequest(new OpenIddictResponse
166-
{
167-
Error = Errors.InvalidClient,
168-
ErrorDescription = "The client application was not found in the database."
169-
});
205+
return BadRequest(SecurityErrorDescriber.InvalidClient());
170206
}
171207

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

177-
return BadRequest(new OpenIddictResponse
178-
{
179-
Error = Errors.UnsupportedGrantType,
180-
ErrorDescription = "The specified grant type is not supported."
181-
});
213+
return BadRequest(SecurityErrorDescriber.UnsupportedGrantType());
182214
}
215+
183216
#endregion
184217

185218
private AuthenticationTicket CreateTicket(OpenIddictEntityFrameworkCoreApplication application)

0 commit comments

Comments
 (0)