Added Username policies (#8638)

* Added Username policies

* Added newline at the end of files

# Conflicts:
#	src/Orchard.Web/Modules/Orchard.Users/Services/AccountValidationService.cs

* Added check for username length that must be under 255 characters (even if username policies are disabled).
If username isn't modified, policies are not enforced when saving the user inside backoffice.
Default length limits are 1 and 255.

* Added UsernameValidationError.cs
Added a setting to bypass non fatal errors and show them as warning when creating/editing users from the backoffice
Added the relative checkbox in RegistrationSettings.cshtml
Modified the UsernameMeetsPolicies method to use the new class
Modified AdminController (CreatePOST, EditPOST) and AccountController (Register)

* If username is an email check that it matches the specified email

* Added hints to UserRegistrationSettings view
Changed the severity of some custom policies errors

* Removed UsernameValidLengthAttribute.cs, if MinimumUsernameLength and MaximumUsernameLength settings don't make sense these settings are ignored

* bugfix. The admin could change the a username setting an already existing username.

Co-authored-by: Andrea Piovanelli <andrea.piovanelli@laser-group.com>
This commit is contained in:
Alessandro Agostini
2023-01-27 11:23:22 +01:00
committed by GitHub
parent 028e2e413b
commit c515ce1917
12 changed files with 311 additions and 21 deletions

View File

@@ -0,0 +1,10 @@
namespace Orchard.Users.Constants {
public static class UsernameValidationResults {
public const string UsernameIsTooShort = "UsernameIsTooShort";
public const string UsernameIsTooLong = "UsernameIsTooLong";
public const string UsernameContainsWhitespaces = "UsernameContainsWhitespaces";
public const string UsernameContainsSpecialChars = "UsernameContainsSpecialChars";
public const string UsernameAndEmailMustMatch = "UsernameAndEmailMustMatch";
}
}

View File

@@ -661,4 +661,4 @@ namespace Orchard.Users.Controllers {
#endregion
}
}
}

View File

@@ -19,6 +19,8 @@ using Orchard.Users.Models;
using Orchard.Users.Services;
using Orchard.Users.ViewModels;
using Orchard.Utility.Extensions;
using Orchard.Mvc.Html;
using Orchard.Users.Constants;
namespace Orchard.Users.Controllers {
[ValidateInput(false)]
@@ -28,6 +30,7 @@ namespace Orchard.Users.Controllers {
private readonly IUserEventHandler _userEventHandlers;
private readonly ISiteService _siteService;
private readonly IEnumerable<IUserManagementActionsProvider> _userManagementActionsProviders;
private readonly UrlHelper _urlHelper;
public AdminController(
IOrchardServices services,
@@ -36,7 +39,8 @@ namespace Orchard.Users.Controllers {
IShapeFactory shapeFactory,
IUserEventHandler userEventHandlers,
ISiteService siteService,
IEnumerable<IUserManagementActionsProvider> userManagementActionsProviders) {
IEnumerable<IUserManagementActionsProvider> userManagementActionsProviders,
UrlHelper urlHelper) {
Services = services;
_membershipService = membershipService;
@@ -44,6 +48,7 @@ namespace Orchard.Users.Controllers {
_userEventHandlers = userEventHandlers;
_siteService = siteService;
_userManagementActionsProviders = userManagementActionsProviders;
_urlHelper = urlHelper;
T = NullLocalizer.Instance;
Shape = shapeFactory;
@@ -183,12 +188,35 @@ namespace Orchard.Users.Controllers {
if (!Services.Authorizer.Authorize(Permissions.ManageUsers, T("Not authorized to manage users")))
return new HttpUnauthorizedResult();
IDictionary<string, LocalizedString> validationErrors;
List<UsernameValidationError> usernameValidationErrors = new List<UsernameValidationError>();
bool usernameMeetsPolicies = true;
var settings = _siteService.GetSiteSettings().As<RegistrationSettingsPart>();
if (!string.IsNullOrEmpty(createModel.UserName)) {
usernameMeetsPolicies = _userService.UsernameMeetsPolicies(createModel.UserName, createModel.Email, out usernameValidationErrors);
if (!usernameMeetsPolicies) {
// If this setting is enabled we'd like to show the warning message but we can't right now
// because we didn't create the user yet (and maybe we won't if some other validation fails)
// and we can't generate the link to the edit page properly so here we only handle the
// situation where we have to show warnings as errors.
if (!settings.BypassPoliciesFromBackoffice) {
ShowWarningAsErrors(usernameValidationErrors);
}
// Show fatal errors anyway
ShowFatalErrors(usernameValidationErrors);
}
if (!_userService.VerifyUserUnicity(createModel.UserName, createModel.Email)) {
AddModelError("NotUniqueUserName", T("User with that username and/or email already exists."));
}
}
else {
AddModelError(UsernameValidationResults.UsernameIsTooShort, T("The username must not be empty."));
}
if (!Regex.IsMatch(createModel.Email ?? "", UserPart.EmailPattern, RegexOptions.IgnoreCase)) {
// http://haacked.com/archive/2007/08/21/i-knew-how-to-validate-an-email-address-until-i.aspx
ModelState.AddModelError("Email", T("You must specify a valid email address."));
@@ -198,12 +226,12 @@ namespace Orchard.Users.Controllers {
AddModelError("ConfirmPassword", T("Password confirmation must match"));
}
IDictionary<string, LocalizedString> validationErrors;
if (!_userService.PasswordMeetsPolicies(createModel.Password, null, out validationErrors)) {
ModelState.AddModelErrors(validationErrors);
}
var user = Services.ContentManager.New<IUser>("User");
if (ModelState.IsValid) {
user = _membershipService.CreateUser(new CreateUserParams(
@@ -214,6 +242,12 @@ namespace Orchard.Users.Controllers {
createModel.ForcePasswordChange));
}
// Now that the user has been created we check if we have to show the warning since now we can generate the link
// to the user edit page
if (!usernameMeetsPolicies && settings.BypassPoliciesFromBackoffice && usernameValidationErrors.Any(uve => uve.Severity == Severity.Warning)) {
Services.Notifier.Warning(T("The username <a href=\"{0}\">{1}</a> doesn't meet the custom requirements.", _urlHelper.ItemEditUrl(user), createModel.UserName));
}
var model = Services.ContentManager.UpdateEditor(user, this);
if (!ModelState.IsValid) {
@@ -253,6 +287,22 @@ namespace Orchard.Users.Controllers {
return View(model);
}
private void ShowWarningAsErrors(List<UsernameValidationError> validationErrors) {
if (validationErrors.Any(uve => uve.Severity == Severity.Warning)) {
foreach (var uve in validationErrors.Where(uve => uve.Severity == Severity.Warning)) {
AddModelError(uve.Key, uve.ErrorMessage);
}
}
}
private void ShowFatalErrors(List<UsernameValidationError> validationErrors) {
if (validationErrors.Any(uve => uve.Severity == Severity.Fatal)) {
foreach (var uve in validationErrors.Where(uve => uve.Severity == Severity.Fatal)) {
AddModelError(uve.Key, uve.ErrorMessage);
}
}
}
[HttpPost, ActionName("Edit")]
public ActionResult EditPOST(int id) {
if (!Services.Authorizer.Authorize(Permissions.ManageUsers, T("Not authorized to manage users")))
@@ -274,10 +324,34 @@ namespace Orchard.Users.Controllers {
var editModel = new UserEditViewModel { User = user };
if (TryUpdateModel(editModel)) {
if (!_userService.VerifyUserUnicity(id, editModel.UserName, editModel.Email)) {
AddModelError("NotUniqueUserName", T("User with that username and/or email already exists."));
List<UsernameValidationError> validationErrors;
bool usernameMeetsPolicies = _userService.UsernameMeetsPolicies(editModel.UserName, editModel.Email, out validationErrors);
var settings = _siteService.GetSiteSettings().As<RegistrationSettingsPart>();
// Username has been modified
if (!previousName.Equals(editModel.UserName)) {
if (!usernameMeetsPolicies && settings.BypassPoliciesFromBackoffice) {
// If warnings have to be bypassed and there's at least one warning we show a generic warning message
if (validationErrors.Any(uve => uve.Severity == Severity.Warning)) {
Services.Notifier.Warning(T("The username <a href=\"{0}\">{1}</a> doesn't meet the custom requirements.", _urlHelper.ItemEditUrl(user), editModel.UserName));
}
}
else if (!usernameMeetsPolicies) {
// If warnings don't have to be bypassed we show everyone of them as errors
ShowWarningAsErrors(validationErrors);
}
}
else if (!Regex.IsMatch(editModel.Email ?? "", UserPart.EmailPattern, RegexOptions.IgnoreCase)) {
else {
if (!usernameMeetsPolicies && settings.BypassPoliciesFromBackoffice) {
// If warnings have to be bypassed and there's at least one warning we show a generic warning message
if (validationErrors.Any(uve => uve.Severity == Severity.Warning)) {
Services.Notifier.Warning(T("The username <a href=\"{0}\">{1}</a> doesn't meet the custom requirements.", _urlHelper.ItemEditUrl(user), editModel.UserName));
}
}
}
// Show every Fatal validation error
ShowFatalErrors(validationErrors);
if (!Regex.IsMatch(editModel.Email ?? "", UserPart.EmailPattern, RegexOptions.IgnoreCase)) {
// http://haacked.com/archive/2007/08/21/i-knew-how-to-validate-an-email-address-until-i.aspx
ModelState.AddModelError("Email", T("You must specify a valid email address."));
}
@@ -289,6 +363,10 @@ namespace Orchard.Users.Controllers {
user.NormalizedUserName = editModel.UserName.ToLowerInvariant();
}
if (!_userService.VerifyUserUnicity(id, editModel.UserName, editModel.Email)) {
AddModelError("NotUniqueUserName", T("User with that username and/or email already exists."));
}
}
if (!ModelState.IsValid) {
@@ -422,3 +500,4 @@ namespace Orchard.Users.Controllers {
}
}

View File

@@ -1,4 +1,6 @@
namespace Orchard.Security {
using Orchard.Users.Models;
namespace Orchard.Security {
public static class MembershipSettingsExtensions {
public static int GetMinimumPasswordLength(this IMembershipSettings membershipSettings) {
return membershipSettings.EnableCustomPasswordPolicy ? membershipSettings.MinimumPasswordLength : 7;
@@ -21,5 +23,13 @@
public static int GetPasswordReuseLimit(this IMembershipSettings membershipSettings) {
return membershipSettings.EnablePasswordHistoryPolicy ? membershipSettings.PasswordReuseLimit : 5;
}
public static int GetMinimumUsernameLength(this IMembershipSettings membershipSettings) {
return membershipSettings.EnableCustomUsernamePolicy ? membershipSettings.MinimumUsernameLength : 3;
}
public static int GetMaximumUsernameLength(this IMembershipSettings membershipSettings) {
return membershipSettings.EnableCustomUsernamePolicy ? membershipSettings.MaximumUsernameLength : UserPart.MaxUserNameLength;
}
}
}
}

View File

@@ -101,5 +101,46 @@ namespace Orchard.Users.Models {
get { return this.Retrieve(x => x.PasswordReuseLimit, 5); }
set { this.Store(x => x.PasswordReuseLimit, value); }
}
public bool EnableCustomUsernamePolicy {
get { return this.Retrieve(x => x.EnableCustomUsernamePolicy); }
set { this.Store(x => x.EnableCustomUsernamePolicy, value); }
}
[Range(1, UserPart.MaxUserNameLength, ErrorMessage = "The minimum username length must be between 1 and 255.")]
public int MinimumUsernameLength {
get { return this.Retrieve(x => x.MinimumUsernameLength, 1); }
set { this.Store(x => x.MinimumUsernameLength, value); }
}
[Range(1, UserPart.MaxUserNameLength, ErrorMessage = "The maximum username length must be between 1 and 255.")]
public int MaximumUsernameLength {
get { return this.Retrieve(x => x.MaximumUsernameLength, UserPart.MaxUserNameLength); }
set { this.Store(x => x.MaximumUsernameLength, value); }
}
public bool ForbidUsernameSpecialChars {
get { return this.Retrieve(x => x.ForbidUsernameSpecialChars); }
set { this.Store(x => x.ForbidUsernameSpecialChars, value); }
}
public bool AllowEmailAsUsername {
get { return this.Retrieve(x => x.AllowEmailAsUsername); }
set { this.Store(x => x.AllowEmailAsUsername, value); }
}
public bool ForbidUsernameWhitespace {
get { return this.Retrieve(x => x.ForbidUsernameWhitespace); }
set { this.Store(x => x.ForbidUsernameWhitespace, value); }
}
public bool BypassPoliciesFromBackoffice {
get { return this.Retrieve(x => x.BypassPoliciesFromBackoffice); }
set { this.Store(x => x.BypassPoliciesFromBackoffice, value); }
}
}
}
}

View File

@@ -101,6 +101,7 @@
<Compile Include="Activities\UserActivity.cs" />
<Compile Include="Activities\UserIsApprovedActivity.cs" />
<Compile Include="Commands\UserCommands.cs" />
<Compile Include="Constants\UsernameValidationResults.cs" />
<Compile Include="Constants\UserPasswordValidationResults.cs" />
<Compile Include="Controllers\AccountController.cs" />
<Compile Include="Controllers\AdminController.cs" />
@@ -155,6 +156,7 @@
<Compile Include="AdminMenu.cs" />
<Compile Include="Services\MissingSettingsBanner.cs" />
<Compile Include="Services\UserService.cs" />
<Compile Include="Services\UsernameValidationError.cs" />
<Compile Include="ViewModels\UserCreateViewModel.cs" />
<Compile Include="ViewModels\UserEditPasswordViewModel.cs" />
<Compile Include="ViewModels\UserEditViewModel.cs" />

View File

@@ -35,13 +35,16 @@ namespace Orchard.Users.Services {
return context.ValidationSuccessful;
}
public bool ValidateUserName(AccountValidationContext context) {
List <UsernameValidationError> validationErrors;
_userService.UsernameMeetsPolicies(context.UserName, context.Email, out validationErrors);
if (string.IsNullOrWhiteSpace(context.UserName)) {
context.ValidationErrors.Add("username", T("You must specify a username."));
} else if (context.UserName.Length >= UserPart.MaxUserNameLength) {
context.ValidationErrors.Add("username", T("The username you provided is too long."));
if (validationErrors != null && validationErrors.Any()) {
foreach (var err in validationErrors) {
if (!context.ValidationErrors.ContainsKey(err.Key)) {
context.ValidationErrors.Add(err.Key, err.ErrorMessage);
}
}
}
return context.ValidationSuccessful;
@@ -62,4 +65,5 @@ namespace Orchard.Users.Services {
}
}
}
}

View File

@@ -20,5 +20,6 @@ namespace Orchard.Users.Services {
bool DecryptNonce(string challengeToken, out string username, out DateTime validateByUtc);
bool PasswordMeetsPolicies(string password, IUser user, out IDictionary<string, LocalizedString> validationErrors);
bool UsernameMeetsPolicies(string username, string email, out List<UsernameValidationError> validationErrors);
}
}
}

View File

@@ -245,6 +245,66 @@ namespace Orchard.Users.Services {
return validationErrors.Count == 0;
}
public bool UsernameMeetsPolicies(string username, string email, out List<UsernameValidationError> validationErrors) {
validationErrors = new List<UsernameValidationError>();
var settings = _siteService.GetSiteSettings().As<RegistrationSettingsPart>();
if (string.IsNullOrEmpty(username)) {
validationErrors.Add(new UsernameValidationError(Severity.Fatal, UsernameValidationResults.UsernameIsTooShort,
T("The username must not be empty.")));
return false;
}
// Validate username length to check it's not over 255.
if (username.Length > UserPart.MaxUserNameLength) {
validationErrors.Add(new UsernameValidationError(Severity.Fatal, UsernameValidationResults.UsernameIsTooLong,
T("The username can't be longer than {0} characters.", UserPart.MaxUserNameLength)));
return false;
}
var usernameIsEmail = Regex.IsMatch(username, UserPart.EmailPattern, RegexOptions.IgnoreCase);
if (usernameIsEmail && !username.Equals(email, StringComparison.OrdinalIgnoreCase)){
validationErrors.Add(new UsernameValidationError(Severity.Fatal, UsernameValidationResults.UsernameAndEmailMustMatch,
T("If the username is an email it must match the specified email address.")));
return false;
}
if (settings.EnableCustomUsernamePolicy) {
/// If the Maximum username length is smaller than the Minimum username length settings ignore this setting
if (settings.GetMaximumUsernameLength() >= settings.GetMinimumUsernameLength() && username.Length < settings.GetMinimumUsernameLength()) {
if (!settings.AllowEmailAsUsername || !usernameIsEmail) {
validationErrors.Add(new UsernameValidationError(Severity.Warning, UsernameValidationResults.UsernameIsTooShort,
T("You must specify a username of {0} or more characters.", settings.GetMinimumUsernameLength())));
}
}
/// If the Minimum username length is greater than the Maximum username length settings ignore this setting
if (settings.GetMaximumUsernameLength() >= settings.GetMinimumUsernameLength() && username.Length > settings.GetMaximumUsernameLength()) {
if (!settings.AllowEmailAsUsername || !usernameIsEmail) {
validationErrors.Add(new UsernameValidationError(Severity.Warning, UsernameValidationResults.UsernameIsTooLong,
T("You must specify a username of at most {0} characters.", settings.GetMaximumUsernameLength())));
}
}
if (settings.ForbidUsernameWhitespace && username.Any(x => char.IsWhiteSpace(x))) {
validationErrors.Add(new UsernameValidationError(Severity.Warning, UsernameValidationResults.UsernameContainsWhitespaces,
T("The username must not contain whitespaces.")));
}
if (settings.ForbidUsernameSpecialChars && Regex.Match(username, "[^a-zA-Z0-9]").Success) {
if (!settings.AllowEmailAsUsername || !usernameIsEmail) {
validationErrors.Add(new UsernameValidationError(Severity.Warning, UsernameValidationResults.UsernameContainsSpecialChars,
T("The username must not contain special characters.")));
}
}
}
return validationErrors.Count == 0;
}
public UserPart GetUserByNameOrEmail(string usernameOrEmail) {
var lowerName = usernameOrEmail.ToLowerInvariant();
return _contentManager
@@ -254,4 +314,5 @@ namespace Orchard.Users.Services {
.FirstOrDefault();
}
}
}
}

View File

@@ -0,0 +1,33 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using Orchard.Localization;
namespace Orchard.Users.Services {
public enum Severity {
Warning,
Fatal
}
public class UsernameValidationError {
private Severity _severity;
private string _key;
private LocalizedString _errorMessage;
public UsernameValidationError(Severity severity, string key, LocalizedString errorMessage) {
Severity = severity;
Key = key;
ErrorMessage = errorMessage;
}
public Severity Severity { get => _severity; set => _severity = value; }
public string Key { get => _key; set => _key = value; }
public LocalizedString ErrorMessage { get => _errorMessage; set => _errorMessage = value; }
}
}

View File

@@ -1,6 +1,7 @@
@model Orchard.Users.Models.RegistrationSettingsPart
@using Orchard.Messaging.Services;
@using System.Web.Security;
@using Orchard.Users.Models;
@{
var messageManager = WorkContext.Resolve<IMessageManager>();
@@ -13,6 +14,45 @@
@Html.EditorFor(m => m.UsersCanRegister)
<label class="forcheckbox" for="@Html.FieldIdFor(m => m.UsersCanRegister)">@T("Users can create new accounts on the site")</label>
</div>
<div>
@Html.EditorFor(m => m.EnableCustomUsernamePolicy)
<label class="forcheckbox" for="@Html.FieldIdFor(m => m.EnableCustomUsernamePolicy)">@T("Username must meet custom requirements")</label>
<div data-controllerid="@Html.FieldIdFor(m => m.EnableCustomUsernamePolicy)" style="margin-left: 30px;">
<div>
@Html.EditorFor(m => m.BypassPoliciesFromBackoffice)
<label class="forcheckbox" for="@Html.FieldIdFor(m => m.BypassPoliciesFromBackoffice)">@T("Allow backend users to bypass the custom policies and show a warning when that happens.")</label>
</div>
<div>
<label for="@Html.FieldIdFor(m => m.MinimumUsernameLength)">@T("Minimum Username length")</label>
@Html.TextBoxFor(m => m.MinimumUsernameLength, new { @class = "text medium", @Value = Model.MinimumUsernameLength })
<span class="hint">@T("Minimum value allowed is 1")</span>
<span class="hint">@T("If this value is greater than the value set for Maximum Username length this setting will be ignored.")</span>
@Html.ValidationMessage("MinimumUsernameLength", "*")
</div>
<div>
<label for="@Html.FieldIdFor(m => m.MaximumUsernameLength)">@T("Maximum Username length")</label>
@Html.TextBoxFor(m => m.MaximumUsernameLength, new { @class = "text medium", @Value = Model.MaximumUsernameLength })
<span class="hint">@T("Maximum value allowed is {0}", UserPart.MaxUserNameLength)</span>
<span class="hint">@T("If this value is smaller than the value set for Minimum Username length this setting will be ignored.")</span>
@Html.ValidationMessage("MaximumUsernameLength", "*")
</div>
<div>
@Html.EditorFor(m => m.ForbidUsernameWhitespace)
<label class="forcheckbox" for="@Html.FieldIdFor(m => m.ForbidUsernameWhitespace)">@T("Username must not contain whitespaces")</label>
</div>
<div>
@Html.EditorFor(m => m.ForbidUsernameSpecialChars)
<label class="forcheckbox" for="@Html.FieldIdFor(m => m.ForbidUsernameSpecialChars)">@T("Username must not contain special characters")</label>
<div data-controllerid="@Html.FieldIdFor(m => m.ForbidUsernameSpecialChars)" style="margin-left: 30px;">
@Html.EditorFor(m => m.AllowEmailAsUsername)
<label class="forcheckbox" for="@Html.FieldIdFor(m => m.AllowEmailAsUsername)">@T("Username may be an email")</label>
</div>
</div>
</div>
</div>
<div>
@Html.EditorFor(m => m.EnableCustomPasswordPolicy)
<label class="forcheckbox" for="@Html.FieldIdFor(m => m.EnableCustomPasswordPolicy)">@T("Passwords must meet custom requirements")</label>
@@ -126,4 +166,5 @@
@Html.ValidationMessage("NotificationsRecipients", "*")
<span class="hint">@T("The usernames to send the notifications to (e.g., \"admin, user1, ...\").")</span>
</div>
</fieldset>
</div>
</fieldset>

View File

@@ -21,5 +21,13 @@ namespace Orchard.Security {
MembershipPasswordFormat PasswordFormat { get; set; }
bool EnablePasswordHistoryPolicy { get; set; }
int PasswordReuseLimit { get; set; }
bool EnableCustomUsernamePolicy { get; set; }
int MinimumUsernameLength { get; set; }
int MaximumUsernameLength { get; set; }
bool ForbidUsernameSpecialChars { get; set; }
bool AllowEmailAsUsername {get; set;}
bool ForbidUsernameWhitespace { get; set; }
bool BypassPoliciesFromBackoffice { get; set; }
}
}
}