diff --git a/src/Orchard.Web/Modules/Orchard.Users/Constants/UsernameValidationResults.cs b/src/Orchard.Web/Modules/Orchard.Users/Constants/UsernameValidationResults.cs new file mode 100644 index 000000000..986332de6 --- /dev/null +++ b/src/Orchard.Web/Modules/Orchard.Users/Constants/UsernameValidationResults.cs @@ -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"; + } +} + diff --git a/src/Orchard.Web/Modules/Orchard.Users/Controllers/AccountController.cs b/src/Orchard.Web/Modules/Orchard.Users/Controllers/AccountController.cs index 1b9bd7403..f26e5a400 100644 --- a/src/Orchard.Web/Modules/Orchard.Users/Controllers/AccountController.cs +++ b/src/Orchard.Web/Modules/Orchard.Users/Controllers/AccountController.cs @@ -661,4 +661,4 @@ namespace Orchard.Users.Controllers { #endregion } -} \ No newline at end of file +} diff --git a/src/Orchard.Web/Modules/Orchard.Users/Controllers/AdminController.cs b/src/Orchard.Web/Modules/Orchard.Users/Controllers/AdminController.cs index 9c2ab60cf..8645d62b5 100644 --- a/src/Orchard.Web/Modules/Orchard.Users/Controllers/AdminController.cs +++ b/src/Orchard.Web/Modules/Orchard.Users/Controllers/AdminController.cs @@ -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 _userManagementActionsProviders; + private readonly UrlHelper _urlHelper; public AdminController( IOrchardServices services, @@ -36,7 +39,8 @@ namespace Orchard.Users.Controllers { IShapeFactory shapeFactory, IUserEventHandler userEventHandlers, ISiteService siteService, - IEnumerable userManagementActionsProviders) { + IEnumerable 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 validationErrors; + List usernameValidationErrors = new List(); + + + bool usernameMeetsPolicies = true; + var settings = _siteService.GetSiteSettings().As(); + 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 validationErrors; - + if (!_userService.PasswordMeetsPolicies(createModel.Password, null, out validationErrors)) { ModelState.AddModelErrors(validationErrors); } + var user = Services.ContentManager.New("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 {1} 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 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 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 validationErrors; + bool usernameMeetsPolicies = _userService.UsernameMeetsPolicies(editModel.UserName, editModel.Email, out validationErrors); + var settings = _siteService.GetSiteSettings().As(); + // 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 {1} 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 {1} 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 { } } + diff --git a/src/Orchard.Web/Modules/Orchard.Users/Extensions/MembershipSettingsExtensions.cs b/src/Orchard.Web/Modules/Orchard.Users/Extensions/MembershipSettingsExtensions.cs index 496574fe7..fab430cf3 100644 --- a/src/Orchard.Web/Modules/Orchard.Users/Extensions/MembershipSettingsExtensions.cs +++ b/src/Orchard.Web/Modules/Orchard.Users/Extensions/MembershipSettingsExtensions.cs @@ -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; + } } -} \ No newline at end of file +} diff --git a/src/Orchard.Web/Modules/Orchard.Users/Models/RegistrationSettingsPart.cs b/src/Orchard.Web/Modules/Orchard.Users/Models/RegistrationSettingsPart.cs index 001264e0a..32c7f8fd5 100644 --- a/src/Orchard.Web/Modules/Orchard.Users/Models/RegistrationSettingsPart.cs +++ b/src/Orchard.Web/Modules/Orchard.Users/Models/RegistrationSettingsPart.cs @@ -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); } + } + + } -} \ No newline at end of file +} + diff --git a/src/Orchard.Web/Modules/Orchard.Users/Orchard.Users.csproj b/src/Orchard.Web/Modules/Orchard.Users/Orchard.Users.csproj index 52cb86c1f..2603ee618 100644 --- a/src/Orchard.Web/Modules/Orchard.Users/Orchard.Users.csproj +++ b/src/Orchard.Web/Modules/Orchard.Users/Orchard.Users.csproj @@ -101,6 +101,7 @@ + @@ -155,6 +156,7 @@ + diff --git a/src/Orchard.Web/Modules/Orchard.Users/Services/AccountValidationService.cs b/src/Orchard.Web/Modules/Orchard.Users/Services/AccountValidationService.cs index 9d3a379cc..1c8741dbf 100644 --- a/src/Orchard.Web/Modules/Orchard.Users/Services/AccountValidationService.cs +++ b/src/Orchard.Web/Modules/Orchard.Users/Services/AccountValidationService.cs @@ -35,13 +35,16 @@ namespace Orchard.Users.Services { return context.ValidationSuccessful; } - public bool ValidateUserName(AccountValidationContext context) { + List 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 { } } -} \ No newline at end of file +} + diff --git a/src/Orchard.Web/Modules/Orchard.Users/Services/IUserService.cs b/src/Orchard.Web/Modules/Orchard.Users/Services/IUserService.cs index 5d0720647..ed0c79602 100644 --- a/src/Orchard.Web/Modules/Orchard.Users/Services/IUserService.cs +++ b/src/Orchard.Web/Modules/Orchard.Users/Services/IUserService.cs @@ -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 validationErrors); + bool UsernameMeetsPolicies(string username, string email, out List validationErrors); } -} \ No newline at end of file +} diff --git a/src/Orchard.Web/Modules/Orchard.Users/Services/UserService.cs b/src/Orchard.Web/Modules/Orchard.Users/Services/UserService.cs index 022b50790..290e5923c 100644 --- a/src/Orchard.Web/Modules/Orchard.Users/Services/UserService.cs +++ b/src/Orchard.Web/Modules/Orchard.Users/Services/UserService.cs @@ -245,6 +245,66 @@ namespace Orchard.Users.Services { return validationErrors.Count == 0; } + + + public bool UsernameMeetsPolicies(string username, string email, out List validationErrors) { + validationErrors = new List(); + var settings = _siteService.GetSiteSettings().As(); + + 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(); } } -} \ No newline at end of file +} + diff --git a/src/Orchard.Web/Modules/Orchard.Users/Services/UsernameValidationError.cs b/src/Orchard.Web/Modules/Orchard.Users/Services/UsernameValidationError.cs new file mode 100644 index 000000000..64e766fc3 --- /dev/null +++ b/src/Orchard.Web/Modules/Orchard.Users/Services/UsernameValidationError.cs @@ -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; } + + + + } +} \ No newline at end of file diff --git a/src/Orchard.Web/Modules/Orchard.Users/Views/EditorTemplates/Parts/Users.RegistrationSettings.cshtml b/src/Orchard.Web/Modules/Orchard.Users/Views/EditorTemplates/Parts/Users.RegistrationSettings.cshtml index 170b2d1b8..07382edc6 100644 --- a/src/Orchard.Web/Modules/Orchard.Users/Views/EditorTemplates/Parts/Users.RegistrationSettings.cshtml +++ b/src/Orchard.Web/Modules/Orchard.Users/Views/EditorTemplates/Parts/Users.RegistrationSettings.cshtml @@ -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(); @@ -13,6 +14,45 @@ @Html.EditorFor(m => m.UsersCanRegister) + +
+ @Html.EditorFor(m => m.EnableCustomUsernamePolicy) + +
+
+ @Html.EditorFor(m => m.BypassPoliciesFromBackoffice) + +
+
+ + @Html.TextBoxFor(m => m.MinimumUsernameLength, new { @class = "text medium", @Value = Model.MinimumUsernameLength }) + @T("Minimum value allowed is 1") + @T("If this value is greater than the value set for Maximum Username length this setting will be ignored.") + @Html.ValidationMessage("MinimumUsernameLength", "*") +
+
+ + @Html.TextBoxFor(m => m.MaximumUsernameLength, new { @class = "text medium", @Value = Model.MaximumUsernameLength }) + @T("Maximum value allowed is {0}", UserPart.MaxUserNameLength) + @T("If this value is smaller than the value set for Minimum Username length this setting will be ignored.") + @Html.ValidationMessage("MaximumUsernameLength", "*") +
+
+ @Html.EditorFor(m => m.ForbidUsernameWhitespace) + +
+
+ @Html.EditorFor(m => m.ForbidUsernameSpecialChars) + +
+ @Html.EditorFor(m => m.AllowEmailAsUsername) + +
+
+
+
+ +
@Html.EditorFor(m => m.EnableCustomPasswordPolicy) @@ -126,4 +166,5 @@ @Html.ValidationMessage("NotificationsRecipients", "*") @T("The usernames to send the notifications to (e.g., \"admin, user1, ...\").")
- \ No newline at end of file + + diff --git a/src/Orchard/Security/IMembershipSettings.cs b/src/Orchard/Security/IMembershipSettings.cs index ed7b68fcf..97c4642b2 100644 --- a/src/Orchard/Security/IMembershipSettings.cs +++ b/src/Orchard/Security/IMembershipSettings.cs @@ -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; } + } -} \ No newline at end of file +}