From b042873252a4cefe881c2d0642152c15b0caba2c Mon Sep 17 00:00:00 2001 From: Matteo Piovanelli Date: Fri, 14 Jan 2022 10:32:07 +0100 Subject: [PATCH] extend users and roles capabilities (#8523) * Adds the capability to set a user to forcely change its own password at next LogOn * Force user to not reuse last n passwords * Moves IPasswordService implementation to Orchard Users Creates Extensions to share management of Password operations across Services * Some refactoring * Password History Policy: - New User Evente (ChangingPassword) - Settings to enable the policy - Security service interfaces to abstract history management - User service implementations to concretely manage history * PasswordHistoryPolicy: - keep in count the password stored within the UserPart as a not reusable password * WIP automated suspension of inactive users * Disable users that have been inactive for longer than a specified number of days, except when they are SiteOwner, or they have a specific flag set to prevent their suspension. * Provider to prevent suspension of users based on assigned roles * cleanup. Refactor of migrations. * Added action to ask for the challenge email to be resent. Challenge email is sent again if a user tries to register anew with an email address they had used to create an account earlier if the email address isn't validated yet. * During registration, if a user inserts the information of an existing account and that account should still validate its email address, the user is presented a link to request a new challenge email to be sent. * Added a link to the action to request a new challenge email in the case when the nonce fails to validate. * Renamed part and corresponding record. Added ability to "protect" specific users from having to change password when it is expired / too old. Co-authored-by: HermesSbicego-Laser --- .../Bindings/UsersPermissionsAndRoles.cs | 2 +- .../Users/Services/MembershipServiceTests.cs | 22 +-- .../Users/Services/UserServiceTests.cs | 4 +- .../Providers/Users/UserEventHandler.cs | 4 + .../Orchard.Email/Orchard.Email.csproj | 4 +- .../Services/OpenIdAuthenticationService.cs | 2 +- .../RolesUserSuspensionSettingsPartDriver.cs | 82 ++++++++++ .../RolesUserSuspensionSettingsPartHandler.cs | 17 ++ .../Models/RolesUserSuspensionSettingsPart.cs | 21 +++ .../Orchard.Roles/Orchard.Roles.csproj | 15 +- .../Modules/Orchard.Roles/Placement.info | 1 + .../RolesUserSuspensionConditionProvider.cs | 58 +++++++ .../RolesUserSuspensionSettingsViewModel.cs | 22 +++ .../Parts/Roles.UserSuspensionSettings.cshtml | 26 +++ .../Modules/Orchard.Roles/packages.config | 3 +- .../Orchard.Setup/Services/SetupService.cs | 2 +- .../Activities/CreateUserActivity.cs | 3 +- .../Orchard.Users/Commands/UserCommands.cs | 4 +- .../UserPasswordValidationResults.cs | 1 + .../Controllers/AccountController.cs | 113 ++++++++++--- .../Controllers/AdminController.cs | 5 +- .../Drivers/UserPartPasswordDriver.cs | 29 ++-- .../UserSecurityConfigurationPartDriver.cs | 27 ++++ .../Orchard.Users/Events/IUserEventHandler.cs | 5 + .../Events/LoginUserEventHandler.cs | 27 +++- .../MembershipSettingsExtensions.cs | 6 + .../UserSecurityConfigurationPartHandler.cs | 14 ++ .../UserSuspensionSettingsPartHandler.cs | 26 +++ .../Handlers/WorkflowUserEventHandler.cs | 4 + .../Modules/Orchard.Users/Migrations.cs | 57 ++++++- .../Models/PasswordHistoryRecord.cs | 17 ++ .../Models/RegistrationSettingsPart.cs | 10 ++ .../Modules/Orchard.Users/Models/UserPart.cs | 8 + .../Orchard.Users/Models/UserPartRecord.cs | 3 + .../Models/UserSecurityConfigurationPart.cs | 15 ++ .../UserSecurityConfigurationPartRecord.cs | 11 ++ .../Models/UserSuspensionSettingsPart.cs | 26 +++ .../Orchard.Users/Orchard.Users.csproj | 21 ++- .../Modules/Orchard.Users/Placement.info | 1 + .../Services/AccountValidationService.cs | 2 +- .../Orchard.Users/Services/IUserService.cs | 4 +- .../IUserSuspensionConditionProvider.cs | 11 ++ .../InactiveUserSuspensionBackgroundTask.cs | 149 ++++++++++++++++++ .../Services/MembershipService.cs | 126 +++------------ .../Services/PasswordHistoryService.cs | 66 ++++++++ .../Orchard.Users/Services/PasswordService.cs | 92 +++++++++++ .../ProtectSpecificUserConditionProvider.cs | 27 ++++ .../Orchard.Users/Services/UserService.cs | 44 ++++-- .../ViewModels/UserCreateViewModel.cs | 5 +- .../ViewModels/UserEditViewModel.cs | 5 + .../Views/Account/ChallengeEmailFail.cshtml | 4 + .../Account/RequestChallengeEmail.cshtml | 24 +++ .../EditorTemplates/Parts/User.Create.cshtml | 4 + .../EditorTemplates/Parts/User.Edit.cshtml | 4 + .../User.UserSecurityConfiguration.cshtml | 13 ++ .../Parts/Users.RegistrationSettings.cshtml | 102 ++++++------ .../Parts/Users.SuspensionSettings.cshtml | 33 ++++ src/Orchard/Orchard.Framework.csproj | 5 + src/Orchard/Security/CreateUserParams.cs | 8 +- src/Orchard/Security/IMembershipSettings.cs | 2 + .../Security/IPasswordHistoryService.cs | 14 ++ src/Orchard/Security/IPasswordService.cs | 5 + src/Orchard/Security/PasswordContext.cs | 13 ++ src/Orchard/Security/PasswordExtensions.cs | 35 ++++ src/Orchard/Security/PasswordHistoryEntry.cs | 8 + 65 files changed, 1289 insertions(+), 234 deletions(-) create mode 100644 src/Orchard.Web/Modules/Orchard.Roles/Drivers/RolesUserSuspensionSettingsPartDriver.cs create mode 100644 src/Orchard.Web/Modules/Orchard.Roles/Handlers/RolesUserSuspensionSettingsPartHandler.cs create mode 100644 src/Orchard.Web/Modules/Orchard.Roles/Models/RolesUserSuspensionSettingsPart.cs create mode 100644 src/Orchard.Web/Modules/Orchard.Roles/Services/RolesUserSuspensionConditionProvider.cs create mode 100644 src/Orchard.Web/Modules/Orchard.Roles/ViewModels/RolesUserSuspensionSettingsViewModel.cs create mode 100644 src/Orchard.Web/Modules/Orchard.Roles/Views/EditorTemplates/Parts/Roles.UserSuspensionSettings.cshtml create mode 100644 src/Orchard.Web/Modules/Orchard.Users/Drivers/UserSecurityConfigurationPartDriver.cs create mode 100644 src/Orchard.Web/Modules/Orchard.Users/Handlers/UserSecurityConfigurationPartHandler.cs create mode 100644 src/Orchard.Web/Modules/Orchard.Users/Handlers/UserSuspensionSettingsPartHandler.cs create mode 100644 src/Orchard.Web/Modules/Orchard.Users/Models/PasswordHistoryRecord.cs create mode 100644 src/Orchard.Web/Modules/Orchard.Users/Models/UserSecurityConfigurationPart.cs create mode 100644 src/Orchard.Web/Modules/Orchard.Users/Models/UserSecurityConfigurationPartRecord.cs create mode 100644 src/Orchard.Web/Modules/Orchard.Users/Models/UserSuspensionSettingsPart.cs create mode 100644 src/Orchard.Web/Modules/Orchard.Users/Services/IUserSuspensionConditionProvider.cs create mode 100644 src/Orchard.Web/Modules/Orchard.Users/Services/InactiveUserSuspensionBackgroundTask.cs create mode 100644 src/Orchard.Web/Modules/Orchard.Users/Services/PasswordHistoryService.cs create mode 100644 src/Orchard.Web/Modules/Orchard.Users/Services/PasswordService.cs create mode 100644 src/Orchard.Web/Modules/Orchard.Users/Services/ProtectSpecificUserConditionProvider.cs create mode 100644 src/Orchard.Web/Modules/Orchard.Users/Views/Account/RequestChallengeEmail.cshtml create mode 100644 src/Orchard.Web/Modules/Orchard.Users/Views/EditorTemplates/Parts/User.UserSecurityConfiguration.cshtml create mode 100644 src/Orchard.Web/Modules/Orchard.Users/Views/EditorTemplates/Parts/Users.SuspensionSettings.cshtml create mode 100644 src/Orchard/Security/IPasswordHistoryService.cs create mode 100644 src/Orchard/Security/IPasswordService.cs create mode 100644 src/Orchard/Security/PasswordContext.cs create mode 100644 src/Orchard/Security/PasswordExtensions.cs create mode 100644 src/Orchard/Security/PasswordHistoryEntry.cs diff --git a/src/Orchard.Specs/Bindings/UsersPermissionsAndRoles.cs b/src/Orchard.Specs/Bindings/UsersPermissionsAndRoles.cs index 15048f9fb..f7499ac3f 100644 --- a/src/Orchard.Specs/Bindings/UsersPermissionsAndRoles.cs +++ b/src/Orchard.Specs/Bindings/UsersPermissionsAndRoles.cs @@ -40,7 +40,7 @@ namespace Orchard.Specs.Bindings { var memberShipService = environment.Resolve(); var roleService = environment.Resolve(); var userRoleRepository = environment.Resolve>(); - var user = memberShipService.CreateUser(new CreateUserParams(username, "qwerty123!", username + "@foo.com", "", "", true)); + var user = memberShipService.CreateUser(new CreateUserParams(username, "qwerty123!", username + "@foo.com", "", "", true, false)); foreach (var roleName in roles.Split(new[] { ',', ' ' }, StringSplitOptions.RemoveEmptyEntries)) { var role = roleService.GetRoleByName(roleName); diff --git a/src/Orchard.Tests.Modules/Users/Services/MembershipServiceTests.cs b/src/Orchard.Tests.Modules/Users/Services/MembershipServiceTests.cs index f5060086f..0138ddb24 100644 --- a/src/Orchard.Tests.Modules/Users/Services/MembershipServiceTests.cs +++ b/src/Orchard.Tests.Modules/Users/Services/MembershipServiceTests.cs @@ -116,14 +116,14 @@ namespace Orchard.Tests.Modules.Users.Services [Test] public void CreateUserShouldAllocateModelAndCreateRecords() { - var user = _membershipService.CreateUser(new CreateUserParams("a", "b", "c", null, null, true)); + var user = _membershipService.CreateUser(new CreateUserParams("a", "b", "c", null, null, true, false)); Assert.That(user.UserName, Is.EqualTo("a")); Assert.That(user.Email, Is.EqualTo("c")); } [Test] public void DefaultPasswordFormatShouldBeHashedAndHaveSalt() { - var user = _membershipService.CreateUser(new CreateUserParams("a", "b", "c", null, null, true)); + var user = _membershipService.CreateUser(new CreateUserParams("a", "b", "c", null, null, true, false)); var userRepository = _container.Resolve>(); var userRecord = userRepository.Get(user.Id); @@ -135,11 +135,11 @@ namespace Orchard.Tests.Modules.Users.Services [Test] public void SaltAndPasswordShouldBeDifferentEvenWithSameSourcePassword() { - var user1 = _membershipService.CreateUser(new CreateUserParams("a", "b", "c", null, null, true)); + var user1 = _membershipService.CreateUser(new CreateUserParams("a", "b", "c", null, null, true, false)); _session.Flush(); _session.Clear(); - var user2 = _membershipService.CreateUser(new CreateUserParams("d", "b", "e", null, null, true)); + var user2 = _membershipService.CreateUser(new CreateUserParams("d", "b", "e", null, null, true, false)); _session.Flush(); _session.Clear(); @@ -156,7 +156,7 @@ namespace Orchard.Tests.Modules.Users.Services [Test] public void ValidateUserShouldReturnNullIfUserOrPasswordIsIncorrect() { - _membershipService.CreateUser(new CreateUserParams("test-user", "test-password", "c", null, null, true)); + _membershipService.CreateUser(new CreateUserParams("test-user", "test-password", "c", null, null, true, false)); _session.Flush(); _session.Clear(); @@ -172,14 +172,14 @@ namespace Orchard.Tests.Modules.Users.Services [Test] public void UsersWhoHaveNeverLoggedInCanBeAuthenticated() { - var user = (UserPart)_membershipService.CreateUser(new CreateUserParams("a", "b", "c", null, null, true)); + var user = (UserPart)_membershipService.CreateUser(new CreateUserParams("a", "b", "c", null, null, true, false)); Assert.That(_membershipValidationService.CanAuthenticateWithCookie(user), Is.True); } [Test] public void UsersWhoHaveNeverLoggedOutCanBeAuthenticated() { - var user = (UserPart)_membershipService.CreateUser(new CreateUserParams("a", "b", "c", null, null, true)); + var user = (UserPart)_membershipService.CreateUser(new CreateUserParams("a", "b", "c", null, null, true, false)); user.LastLoginUtc = _clock.UtcNow; _clock.Advance(TimeSpan.FromMinutes(1)); @@ -189,7 +189,7 @@ namespace Orchard.Tests.Modules.Users.Services [Test] public void UsersWhoHaveLoggedOutCantBeAuthenticated() { - var user = (UserPart)_membershipService.CreateUser(new CreateUserParams("a", "b", "c", null, null, true)); + var user = (UserPart)_membershipService.CreateUser(new CreateUserParams("a", "b", "c", null, null, true, false)); user.LastLoginUtc = _clock.UtcNow; _clock.Advance(TimeSpan.FromMinutes(1)); @@ -201,7 +201,7 @@ namespace Orchard.Tests.Modules.Users.Services [Test] public void UsersWhoHaveLoggedInCanBeAuthenticated() { - var user = (UserPart)_membershipService.CreateUser(new CreateUserParams("a", "b", "c", null, null, true)); + var user = (UserPart)_membershipService.CreateUser(new CreateUserParams("a", "b", "c", null, null, true, false)); user.LastLogoutUtc = _clock.UtcNow; _clock.Advance(TimeSpan.FromMinutes(1)); @@ -213,7 +213,7 @@ namespace Orchard.Tests.Modules.Users.Services [Test] public void PendingUsersCantBeAuthenticated() { - var user = (UserPart)_membershipService.CreateUser(new CreateUserParams("a", "b", "c", null, null, true)); + var user = (UserPart)_membershipService.CreateUser(new CreateUserParams("a", "b", "c", null, null, true, false)); user.RegistrationStatus = UserStatus.Pending; @@ -222,7 +222,7 @@ namespace Orchard.Tests.Modules.Users.Services [Test] public void ApprovedUsersCanBeAuthenticated() { - var user = (UserPart)_membershipService.CreateUser(new CreateUserParams("a", "b", "c", null, null, true)); + var user = (UserPart)_membershipService.CreateUser(new CreateUserParams("a", "b", "c", null, null, true, false)); user.RegistrationStatus = UserStatus.Approved; diff --git a/src/Orchard.Tests.Modules/Users/Services/UserServiceTests.cs b/src/Orchard.Tests.Modules/Users/Services/UserServiceTests.cs index 4f89221ba..3f92ffee2 100644 --- a/src/Orchard.Tests.Modules/Users/Services/UserServiceTests.cs +++ b/src/Orchard.Tests.Modules/Users/Services/UserServiceTests.cs @@ -124,7 +124,7 @@ namespace Orchard.Tests.Modules.Users.Services [Test] public void NonceShouldBeDecryptable() { - var user = _membershipService.CreateUser(new CreateUserParams("foo", "66554321", "foo@bar.com", "", "", true)); + var user = _membershipService.CreateUser(new CreateUserParams("foo", "66554321", "foo@bar.com", "", "", true, false)); var nonce = _userService.CreateNonce(user, new TimeSpan(1, 0, 0)); Assert.That(nonce, Is.Not.Empty); @@ -145,7 +145,7 @@ namespace Orchard.Tests.Modules.Users.Services Thread.CurrentThread.CurrentCulture = turkishCulture; // Create user lower case - _membershipService.CreateUser(new CreateUserParams("admin", "66554321", "foo@bar.com", "", "", true)); + _membershipService.CreateUser(new CreateUserParams("admin", "66554321", "foo@bar.com", "", "", true, false)); // Verify unicity with upper case which with turkish coallition would yeld admin with an i without the dot and therefore generate a different user name Assert.That(_userService.VerifyUserUnicity("ADMIN", "differentfoo@bar.com"), Is.False); diff --git a/src/Orchard.Web/Modules/Orchard.AuditTrail/Providers/Users/UserEventHandler.cs b/src/Orchard.Web/Modules/Orchard.AuditTrail/Providers/Users/UserEventHandler.cs index 668ec72ef..95a5750b2 100644 --- a/src/Orchard.Web/Modules/Orchard.AuditTrail/Providers/Users/UserEventHandler.cs +++ b/src/Orchard.Web/Modules/Orchard.AuditTrail/Providers/Users/UserEventHandler.cs @@ -49,6 +49,9 @@ namespace Orchard.AuditTrail.Providers.Users { _auditTrailManager.CreateRecord(eventName, _wca.GetContext().CurrentUser, properties, eventData, eventFilterKey: "user", eventFilterData: user.UserName); } + public void ChangingPassword(IUser user, string password) { + } + public void Creating(UserContext context) { } @@ -72,5 +75,6 @@ namespace Orchard.AuditTrail.Providers.Users { public void Moderate(IUser user) { } + } } \ No newline at end of file diff --git a/src/Orchard.Web/Modules/Orchard.Email/Orchard.Email.csproj b/src/Orchard.Web/Modules/Orchard.Email/Orchard.Email.csproj index 86c5ec201..b0de9f776 100644 --- a/src/Orchard.Web/Modules/Orchard.Email/Orchard.Email.csproj +++ b/src/Orchard.Web/Modules/Orchard.Email/Orchard.Email.csproj @@ -100,9 +100,7 @@ - - Component - + diff --git a/src/Orchard.Web/Modules/Orchard.OpenId/Services/OpenIdAuthenticationService.cs b/src/Orchard.Web/Modules/Orchard.OpenId/Services/OpenIdAuthenticationService.cs index b4da79038..8f146ced2 100644 --- a/src/Orchard.Web/Modules/Orchard.OpenId/Services/OpenIdAuthenticationService.cs +++ b/src/Orchard.Web/Modules/Orchard.OpenId/Services/OpenIdAuthenticationService.cs @@ -97,7 +97,7 @@ namespace Orchard.OpenId.Services { var localUser = _membershipService.GetUser(userName) ?? _membershipService.CreateUser(new CreateUserParams( - userName, Membership.GeneratePassword(16, 1), userName, string.Empty, string.Empty, true + userName, Membership.GeneratePassword(16, 1), userName, string.Empty, string.Empty, true, false )); return _localAuthenticationUser = localUser; diff --git a/src/Orchard.Web/Modules/Orchard.Roles/Drivers/RolesUserSuspensionSettingsPartDriver.cs b/src/Orchard.Web/Modules/Orchard.Roles/Drivers/RolesUserSuspensionSettingsPartDriver.cs new file mode 100644 index 000000000..c69edb99f --- /dev/null +++ b/src/Orchard.Web/Modules/Orchard.Roles/Drivers/RolesUserSuspensionSettingsPartDriver.cs @@ -0,0 +1,82 @@ +using System; +using System.Linq; +using Orchard.ContentManagement; +using Orchard.ContentManagement.Drivers; +using Orchard.Localization; +using Orchard.Roles.Constants; +using Orchard.Roles.Models; +using Orchard.Roles.Services; +using Orchard.Roles.ViewModels; + +namespace Orchard.Roles.Drivers { + public class RolesUserSuspensionSettingsPartDriver : ContentPartDriver { + private readonly IRoleService _roleService; + + public RolesUserSuspensionSettingsPartDriver( + IRoleService roleService) { + + _roleService = roleService; + + T = NullLocalizer.Instance; + } + + public Localizer T { get; set; } + + protected override DriverResult Editor(RolesUserSuspensionSettingsPart part, dynamic shapeHelper) { + + return ContentShape("Parts_Roles_UserSuspensionSettings_Edit", + () => { + var vm = BuildVM(part); + // check from the part what's configured. + return shapeHelper.EditorTemplate( + TemplateName: "Parts/Roles.UserSuspensionSettings", + Model: vm, + Prefix: Prefix + ); + }).OnGroup(T("Users").Text); + } + + protected override DriverResult Editor(RolesUserSuspensionSettingsPart part, IUpdateModel updater, dynamic shapeHelper) { + + var vm = BuildVM(part); + if (updater.TryUpdateModel(vm, Prefix, null, null)) { + part.Configuration = vm.Configuration; + } + return Editor(part, shapeHelper); + } + + private RolesUserSuspensionSettingsViewModel BuildVM(RolesUserSuspensionSettingsPart part) { + var systemRoles = SystemRoles.GetSystemRoles(); + var allRoles = _roleService + .GetRoles() + // exclude system roles (Anonymous and Authenticated) + .Where(r => !systemRoles + .Any(sr => sr.Equals(r.Name, StringComparison.InvariantCultureIgnoreCase))) + ; + var vm = new RolesUserSuspensionSettingsViewModel(); + // Add the "default" element for the configuration for authenticated users + // who have no other roles. + vm.Configuration.Add( + new RoleSuspensionConfiguration { + RoleId = 0, + RoleName = T("No Role").Text, + RoleLabel = T("Protect users with no configured role.").Text, + IsSafeFromSuspension = GetConfigurationStatus(part, 0) + }); + // Add configuration elements for all existing roles. + vm.Configuration.AddRange(allRoles + .Select(rr => new RoleSuspensionConfiguration { + RoleId = rr.Id, + RoleName = rr.Name, // use the "current" role name from the records + RoleLabel = T("Protect users with the \"{0}\" user role.", rr.Name).Text, + IsSafeFromSuspension = GetConfigurationStatus(part, rr.Id) + })); + return vm; + } + + private bool GetConfigurationStatus(RolesUserSuspensionSettingsPart part, int rId) { + var config = part.Configuration?.FirstOrDefault(rsc => rsc.RoleId == rId); + return config == null ? false : config.IsSafeFromSuspension; + } + } +} \ No newline at end of file diff --git a/src/Orchard.Web/Modules/Orchard.Roles/Handlers/RolesUserSuspensionSettingsPartHandler.cs b/src/Orchard.Web/Modules/Orchard.Roles/Handlers/RolesUserSuspensionSettingsPartHandler.cs new file mode 100644 index 000000000..61102799e --- /dev/null +++ b/src/Orchard.Web/Modules/Orchard.Roles/Handlers/RolesUserSuspensionSettingsPartHandler.cs @@ -0,0 +1,17 @@ +using Orchard.ContentManagement.Handlers; +using Orchard.Localization; +using Orchard.Roles.Models; + +namespace Orchard.Roles.Handlers { + public class RolesUserSuspensionSettingsPartHandler : ContentHandler { + + public RolesUserSuspensionSettingsPartHandler() { + + T = NullLocalizer.Instance; + Filters.Add(new ActivatingFilter("Site")); + } + + public Localizer T { get; set; } + + } +} \ No newline at end of file diff --git a/src/Orchard.Web/Modules/Orchard.Roles/Models/RolesUserSuspensionSettingsPart.cs b/src/Orchard.Web/Modules/Orchard.Roles/Models/RolesUserSuspensionSettingsPart.cs new file mode 100644 index 000000000..6e08e08d4 --- /dev/null +++ b/src/Orchard.Web/Modules/Orchard.Roles/Models/RolesUserSuspensionSettingsPart.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +using Orchard.ContentManagement; +using Orchard.Roles.ViewModels; + +namespace Orchard.Roles.Models { + public class RolesUserSuspensionSettingsPart : ContentPart { + public List Configuration { + get { + return JsonConvert + .DeserializeObject>(SerializedConfiguration); + } + set { SerializedConfiguration = JsonConvert.SerializeObject(value); } + } + + public string SerializedConfiguration { + get { return this.Retrieve(x => x.SerializedConfiguration, defaultValue: string.Empty); } + set { this.Store(x => x.SerializedConfiguration, value ?? string.Empty); } + } + } +} \ No newline at end of file diff --git a/src/Orchard.Web/Modules/Orchard.Roles/Orchard.Roles.csproj b/src/Orchard.Web/Modules/Orchard.Roles/Orchard.Roles.csproj index 7a56c0812..940cabd22 100644 --- a/src/Orchard.Web/Modules/Orchard.Roles/Orchard.Roles.csproj +++ b/src/Orchard.Web/Modules/Orchard.Roles/Orchard.Roles.csproj @@ -62,6 +62,9 @@ ..\..\..\packages\Microsoft.Web.Infrastructure.1.0.0.0\lib\net40\Microsoft.Web.Infrastructure.dll + + ..\..\..\packages\Newtonsoft.Json.12.0.3\lib\net45\Newtonsoft.Json.dll + @@ -100,6 +103,7 @@ + @@ -115,6 +119,8 @@ + + @@ -134,9 +140,11 @@ + + @@ -167,6 +175,10 @@ {642a49d7-8752-4177-80d6-bfbbcfad3de0} Orchard.Forms + + {79aed36e-abd0-4747-93d3-8722b042454b} + Orchard.Users + {7059493c-8251-4764-9c1e-2368b8b485bc} Orchard.Workflows @@ -203,6 +215,7 @@ + 10.0 @@ -254,4 +267,4 @@ - + \ No newline at end of file diff --git a/src/Orchard.Web/Modules/Orchard.Roles/Placement.info b/src/Orchard.Web/Modules/Orchard.Roles/Placement.info index ef5c01389..81d4158ab 100644 --- a/src/Orchard.Web/Modules/Orchard.Roles/Placement.info +++ b/src/Orchard.Web/Modules/Orchard.Roles/Placement.info @@ -1,3 +1,4 @@  + diff --git a/src/Orchard.Web/Modules/Orchard.Roles/Services/RolesUserSuspensionConditionProvider.cs b/src/Orchard.Web/Modules/Orchard.Roles/Services/RolesUserSuspensionConditionProvider.cs new file mode 100644 index 000000000..8cdd9b300 --- /dev/null +++ b/src/Orchard.Web/Modules/Orchard.Roles/Services/RolesUserSuspensionConditionProvider.cs @@ -0,0 +1,58 @@ +using System.Linq; +using Orchard.ContentManagement; +using Orchard.Data; +using Orchard.Roles.Models; +using Orchard.Settings; +using Orchard.Users.Models; +using Orchard.Users.Services; + +namespace Orchard.Roles.Services { + public class RolesUserSuspensionConditionProvider : IUserSuspensionConditionProvider { + private readonly ISiteService _siteService; + private readonly IRepository _userRolesRepository; + + public RolesUserSuspensionConditionProvider( + ISiteService siteService, + IRepository userRolesRepository) { + + _siteService = siteService; + _userRolesRepository = userRolesRepository; + } + + public IContentQuery AlterQuery(IContentQuery query) { + return query; + } + + public bool UserIsProtected(UserPart userPart) { + // Get the user roles: we fetch them directly from the repository rather + // than through a part, because we are going to need the roles Ids and + // not just their names. + var roleIds = _userRolesRepository + .Fetch(urpr => urpr.UserId == userPart.Id) + .Select(urpr => urpr.Role.Id) + .ToList(); + // get settings + var safeRoleIds = Settings.Configuration + .Where(rsc => rsc.IsSafeFromSuspension) + .Select(rsc => rsc.RoleId); + // Case where we are "saving" users with no specific assigned role (i.e. + // these users are just Authenticated) + if (safeRoleIds.Contains(0) && !roleIds.Any()) { + return true; + } + // If the user has assigned roles we need to check whether any of those + // makes them "safe". + return roleIds.Any(i => safeRoleIds.Contains(i)); + } + + private RolesUserSuspensionSettingsPart _settingsPart; + private RolesUserSuspensionSettingsPart Settings { + get { + if (_settingsPart == null) { + _settingsPart = _siteService.GetSiteSettings().As(); + } + return _settingsPart; + } + } + } +} \ No newline at end of file diff --git a/src/Orchard.Web/Modules/Orchard.Roles/ViewModels/RolesUserSuspensionSettingsViewModel.cs b/src/Orchard.Web/Modules/Orchard.Roles/ViewModels/RolesUserSuspensionSettingsViewModel.cs new file mode 100644 index 000000000..fc1e22b04 --- /dev/null +++ b/src/Orchard.Web/Modules/Orchard.Roles/ViewModels/RolesUserSuspensionSettingsViewModel.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace Orchard.Roles.ViewModels { + public class RolesUserSuspensionSettingsViewModel { + + public RolesUserSuspensionSettingsViewModel() { + Configuration = new List(); + } + + public List Configuration { get; set; } + } + + public class RoleSuspensionConfiguration { + public int RoleId { get; set; } + [JsonIgnore] + public string RoleName { get; set; } + [JsonIgnore] + public string RoleLabel { get; set; } + public bool IsSafeFromSuspension { get; set; } + } +} \ No newline at end of file diff --git a/src/Orchard.Web/Modules/Orchard.Roles/Views/EditorTemplates/Parts/Roles.UserSuspensionSettings.cshtml b/src/Orchard.Web/Modules/Orchard.Roles/Views/EditorTemplates/Parts/Roles.UserSuspensionSettings.cshtml new file mode 100644 index 000000000..ca7b4b8f9 --- /dev/null +++ b/src/Orchard.Web/Modules/Orchard.Roles/Views/EditorTemplates/Parts/Roles.UserSuspensionSettings.cshtml @@ -0,0 +1,26 @@ +@model Orchard.Roles.ViewModels.RolesUserSuspensionSettingsViewModel + +
+ + @T("Automated User Moderation: rules on roles") + +
+

@T("Select the user roles that will be safe from automated user suspension (this only applies if automated suspension is enabled).")

+
+ @if (Model.Configuration.Any()) { + var index = 0; + foreach (var entry in Model.Configuration) { + @Html.Hidden("Configuration["+index+"].RoleId", entry.RoleId) +
+ @Html.CheckBox("Configuration["+index+"].IsSafeFromSuspension", entry.IsSafeFromSuspension) + +
+ + index++; + } + } else { +

@T("There are no roles.")

+ } +
+
+
\ No newline at end of file diff --git a/src/Orchard.Web/Modules/Orchard.Roles/packages.config b/src/Orchard.Web/Modules/Orchard.Roles/packages.config index 0938ac40a..5208f2490 100644 --- a/src/Orchard.Web/Modules/Orchard.Roles/packages.config +++ b/src/Orchard.Web/Modules/Orchard.Roles/packages.config @@ -5,4 +5,5 @@ - + + \ No newline at end of file diff --git a/src/Orchard.Web/Modules/Orchard.Setup/Services/SetupService.cs b/src/Orchard.Web/Modules/Orchard.Setup/Services/SetupService.cs index ceeeb163d..6c8a8abde 100644 --- a/src/Orchard.Web/Modules/Orchard.Setup/Services/SetupService.cs +++ b/src/Orchard.Web/Modules/Orchard.Setup/Services/SetupService.cs @@ -194,7 +194,7 @@ namespace Orchard.Setup.Services { var membershipService = environment.Resolve(); var user = membershipService.CreateUser( new CreateUserParams(context.AdminUsername, context.AdminPassword, - String.Empty, String.Empty, String.Empty, true)); + String.Empty, String.Empty, String.Empty, true, false)); // Set site owner as current user for request (it will be set as the owner of all content items). var authenticationService = environment.Resolve(); diff --git a/src/Orchard.Web/Modules/Orchard.Users/Activities/CreateUserActivity.cs b/src/Orchard.Web/Modules/Orchard.Users/Activities/CreateUserActivity.cs index 2bfcea843..58871c741 100644 --- a/src/Orchard.Web/Modules/Orchard.Users/Activities/CreateUserActivity.cs +++ b/src/Orchard.Web/Modules/Orchard.Users/Activities/CreateUserActivity.cs @@ -76,7 +76,8 @@ namespace Orchard.Users.Activities { email, isApproved: approved, passwordQuestion: null, - passwordAnswer: null)); + passwordAnswer: null, + forcePasswordChange: false)); workflowContext.Content = user; diff --git a/src/Orchard.Web/Modules/Orchard.Users/Commands/UserCommands.cs b/src/Orchard.Web/Modules/Orchard.Users/Commands/UserCommands.cs index cde58d95b..4e5bb1137 100644 --- a/src/Orchard.Web/Modules/Orchard.Users/Commands/UserCommands.cs +++ b/src/Orchard.Web/Modules/Orchard.Users/Commands/UserCommands.cs @@ -43,14 +43,14 @@ namespace Orchard.Users.Commands { } IDictionary validationErrors; - if (!_userService.PasswordMeetsPolicies(Password, out validationErrors)) { + if (!_userService.PasswordMeetsPolicies(Password, null, out validationErrors)) { foreach (var error in validationErrors) { Context.Output.WriteLine(error.Value); } return; } - var user = _membershipService.CreateUser(new CreateUserParams(UserName, Password, Email, null, null, true)); + var user = _membershipService.CreateUser(new CreateUserParams(UserName, Password, Email, null, null, true, false)); if (user == null) { Context.Output.WriteLine(T("Could not create user {0}. The authentication provider returned an error", UserName)); return; diff --git a/src/Orchard.Web/Modules/Orchard.Users/Constants/UserPasswordValidationResults.cs b/src/Orchard.Web/Modules/Orchard.Users/Constants/UserPasswordValidationResults.cs index 26427efe4..9feaf13e2 100644 --- a/src/Orchard.Web/Modules/Orchard.Users/Constants/UserPasswordValidationResults.cs +++ b/src/Orchard.Web/Modules/Orchard.Users/Constants/UserPasswordValidationResults.cs @@ -5,5 +5,6 @@ public const string PasswordDoesNotContainUppercase = "PasswordDoesNotContainUppercase"; public const string PasswordDoesNotContainLowercase = "PasswordDoesNotContainLowercase"; public const string PasswordDoesNotContainSpecialCharacters = "PasswordDoesNotContainSpecialCharacters"; + public const string PasswordDoesNotMeetHistoryPolicy = "PasswordDoesNotMeetHistoryPolicy"; } } diff --git a/src/Orchard.Web/Modules/Orchard.Users/Controllers/AccountController.cs b/src/Orchard.Web/Modules/Orchard.Users/Controllers/AccountController.cs index 83ff318b2..c18972513 100644 --- a/src/Orchard.Web/Modules/Orchard.Users/Controllers/AccountController.cs +++ b/src/Orchard.Web/Modules/Orchard.Users/Controllers/AccountController.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Text.RegularExpressions; using System.Web.Mvc; using System.Web.Security; @@ -91,7 +92,6 @@ namespace Orchard.Users.Controllers { var user = ValidateLogOn(userNameOrEmail, password); if (!ModelState.IsValid) { var shape = _orchardServices.New.LogOn().Title(T("Log On").Text); - return new ShapeResult(this, shape); } @@ -102,6 +102,9 @@ namespace Orchard.Users.Controllers { _membershipService.PasswordIsExpired(user, membershipSettings.PasswordExpirationTimeInDays)) { return RedirectToAction("ChangeExpiredPassword", new { username = user.UserName }); } + if (user != null && user.As().ForcePasswordChange) { + return RedirectToAction("ChangeExpiredPassword", new { username = user.UserName }); + } _authenticationService.SignIn(user, rememberMe); _userEventHandler.LoggedIn(user); @@ -135,7 +138,6 @@ namespace Orchard.Users.Controllers { ViewData["NumberRequirement"] = membershipSettings.GetPasswordNumberRequirement(); var shape = _orchardServices.New.Register(); - return new ShapeResult(this, shape); } @@ -158,7 +160,7 @@ namespace Orchard.Users.Controllers { if (ValidateRegistration(userName, email, password, confirmPassword)) { // Attempt to register the user // No need to report this to IUserEventHandler because _membershipService does that for us - var user = _membershipService.CreateUser(new CreateUserParams(userName, password, email, null, null, false)); + var user = _membershipService.CreateUser(new CreateUserParams(userName, password, email, null, null, false, false)); if (user != null) { if (user.As().EmailStatus == UserStatus.Pending) { @@ -230,6 +232,46 @@ namespace Orchard.Users.Controllers { return RedirectToAction("LogOn"); } + [AlwaysAccessible] + public ActionResult RequestChallengeEmail(string email = null) { + // ensure users can request lost password + var membershipSettings = _membershipService.GetSettings(); + if (!membershipSettings.UsersMustValidateEmail) { + return HttpNotFound(); + } + + return View(model: email); + } + + [HttpPost, ActionName("RequestChallengeEmail")] + [AlwaysAccessible] + public ActionResult RequestChallengeEmailPOST(string username) { + // ensure users can request lost password + var membershipSettings = _membershipService.GetSettings(); + if (!membershipSettings.UsersMustValidateEmail) { + return HttpNotFound(); + } + + if (string.IsNullOrWhiteSpace(username)) { + ModelState.AddModelError("username", T("You must specify a username or e-mail.")); + return View(); + } + // Get the user + var user = _userService.GetUserByNameOrEmail(username); + if (user != null && user.EmailStatus == UserStatus.Pending) { + var siteUrl = _orchardServices.WorkContext.CurrentSite.BaseUrl; + if (string.IsNullOrWhiteSpace(siteUrl)) { + siteUrl = HttpContext.Request.ToRootUrlString(); + } + + _userService.SendChallengeEmail(user.As(), nonce => Url.MakeAbsolute(Url.Action("ChallengeEmail", "Account", new { Area = "Orchard.Users", nonce = nonce }), siteUrl)); + + _userEventHandler.SentChallengeEmail(user); + } + + return RedirectToAction("ChallengeEmailSent"); + } + [Authorize] [AlwaysAccessible] public ActionResult ChangePassword() { @@ -265,7 +307,7 @@ namespace Orchard.Users.Controllers { .ShouldInvalidateAuthOnPasswordChanged; ViewData["InvalidateOnPasswordChange"] = shouldSignout; - if (!ValidateChangePassword(currentPassword, newPassword, confirmPassword)) { + if (!ValidateChangePassword(currentPassword, newPassword, confirmPassword, _orchardServices.WorkContext.CurrentUser)) { return View(); } @@ -288,9 +330,10 @@ namespace Orchard.Users.Controllers { [AlwaysAccessible] public ActionResult ChangeExpiredPassword(string username) { var membershipSettings = _membershipService.GetSettings(); - var lastPasswordChangeUtc = _membershipService.GetUser(username).As().LastPasswordChangeUtc; - - if (lastPasswordChangeUtc.Value.AddDays(membershipSettings.PasswordExpirationTimeInDays) > _clock.UtcNow) { + var userPart = _membershipService.GetUser(username).As(); + var lastPasswordChangeUtc = userPart.LastPasswordChangeUtc; + if (lastPasswordChangeUtc.Value.AddDays(membershipSettings.PasswordExpirationTimeInDays) > _clock.UtcNow && + !userPart.ForcePasswordChange) { return RedirectToAction("LogOn"); } @@ -316,11 +359,12 @@ namespace Orchard.Users.Controllers { SpecialCharacterRequirement: membershipSettings.GetPasswordSpecialRequirement(), NumberRequirement: membershipSettings.GetPasswordNumberRequirement()); - if (!ValidateChangePassword(currentPassword, newPassword, confirmPassword)) { + if (!ValidateChangePassword(currentPassword, newPassword, confirmPassword, _membershipService.GetUser(username))) { return View(viewModel); } if (PasswordChangeIsSuccess(currentPassword, newPassword, username)) { + return RedirectToAction("ChangePasswordSuccess"); } else { return View(viewModel); @@ -332,14 +376,15 @@ namespace Orchard.Users.Controllers { var validated = _membershipService.ValidateUser(username, currentPassword, out List validationErrors); if (validated != null) { + _userEventHandler.ChangingPassword(validated, newPassword); _membershipService.SetPassword(validated, newPassword); _userEventHandler.ChangedPassword(validated, newPassword); - // if security settings tell to invalidate on password change fire the LoggedOut event - if (_orchardServices.WorkContext.CurrentSite.As().ShouldInvalidateAuthOnPasswordChanged) { + if (_orchardServices.WorkContext + .CurrentSite.As() + .ShouldInvalidateAuthOnPasswordChanged) { _userEventHandler.LoggedOut(validated); } - return true; } @@ -348,7 +393,8 @@ namespace Orchard.Users.Controllers { return false; } - + // unknown error + ModelState.AddModelError("_FORM", T("The current password is incorrect or the new password is invalid.")); return false; } @@ -383,10 +429,12 @@ namespace Orchard.Users.Controllers { ViewData["SpecialCharacterRequirement"] = membershipSettings.GetPasswordSpecialRequirement(); ViewData["NumberRequirement"] = membershipSettings.GetPasswordNumberRequirement(); - if (!ValidatePassword(newPassword, confirmPassword)) { + if (!ValidatePassword(newPassword, confirmPassword, user)) { return View(); } + _userEventHandler.ChangingPassword(user, newPassword); + _membershipService.SetPassword(user, newPassword); _userEventHandler.ChangedPassword(user, newPassword); @@ -399,7 +447,6 @@ namespace Orchard.Users.Controllers { ViewData["InvalidateOnPasswordChange"] = _orchardServices.WorkContext .CurrentSite.As() .ShouldInvalidateAuthOnPasswordChanged; - return View(); } @@ -437,7 +484,7 @@ namespace Orchard.Users.Controllers { } #region Validation Methods - private bool ValidateChangePassword(string currentPassword, string newPassword, string confirmPassword) { + private bool ValidateChangePassword(string currentPassword, string newPassword, string confirmPassword, IUser user) { if (string.IsNullOrEmpty(currentPassword)) { ModelState.AddModelError("currentPassword", T("You must specify a current password.")); } @@ -450,7 +497,7 @@ namespace Orchard.Users.Controllers { return false; } - return ValidatePassword(newPassword, confirmPassword); + return ValidatePassword(newPassword, confirmPassword, user); } private IUser ValidateLogOn(string userNameOrEmail, string password) { @@ -496,11 +543,34 @@ namespace Orchard.Users.Controllers { if (!context.ValidationSuccessful) { foreach (var error in context.ValidationErrors) { ModelState.AddModelError(error.Key, error.Value); - } + } return false; } if (!_userService.VerifyUserUnicity(userName, email)) { + // Not a new registration, but perhaps we already have that user and they + // haven't validated their email address. This doesn't care whether there + // were other issues with the registration attempt that caused its validation + // to fail: if the user exists and still has to confirm their email, we show + // a link to the action from which the challenge email is sent again. + var membershipSettings = _membershipService.GetSettings(); + if (membershipSettings.UsersMustValidateEmail) { + var user = _userService.GetUserByNameOrEmail(email); + if (user == null) { + user = _userService.GetUserByNameOrEmail(userName); + } + if (user != null && user.EmailStatus == UserStatus.Pending) { + // We can't have links in the "text" of a ModelState Error. We are using a notifier + // to provide the user with an option to ask for a new challenge email. + _orchardServices.Notifier.Warning( + T("User with that username and/or email already exists. Follow this link if you want to receive a new email to validate your address.", + Url.Action(actionName: "RequestChallengeEmail", routeValues: new { email = email }))); + // In creating the link above we use the email that was written in the form + // rather than the actual user's email address to prevent exploiting this + // for information discovery. + } + } + // We should add the error to the ModelState anyway. context.ValidationErrors.Add("userExists", T("User with that username and/or email already exists.")); } @@ -519,9 +589,10 @@ namespace Orchard.Users.Controllers { return ModelState.IsValid; } - private bool ValidatePassword(string password) { + private bool ValidatePassword(string password, IUser user) { var context = new AccountValidationContext { - Password = password + Password = password, + User = user }; var result = _accountValidationService.ValidatePassword(context); if (!result) { @@ -532,12 +603,12 @@ namespace Orchard.Users.Controllers { return result; } - private bool ValidatePassword(string password, string confirmPassword) { + private bool ValidatePassword(string password, string confirmPassword, IUser user) { if (!string.Equals(password, confirmPassword, StringComparison.Ordinal)) { ModelState.AddModelError("_FORM", T("The new password and confirmation password do not match.")); return false; } - return ValidatePassword(password); + return ValidatePassword(password, user); } private static string ErrorCodeToString(MembershipCreateStatus createStatus) { diff --git a/src/Orchard.Web/Modules/Orchard.Users/Controllers/AdminController.cs b/src/Orchard.Web/Modules/Orchard.Users/Controllers/AdminController.cs index c262f35d0..974fee0aa 100644 --- a/src/Orchard.Web/Modules/Orchard.Users/Controllers/AdminController.cs +++ b/src/Orchard.Web/Modules/Orchard.Users/Controllers/AdminController.cs @@ -192,7 +192,7 @@ namespace Orchard.Users.Controllers { IDictionary validationErrors; - if (!_userService.PasswordMeetsPolicies(createModel.Password, out validationErrors)) { + if (!_userService.PasswordMeetsPolicies(createModel.Password, null, out validationErrors)) { ModelState.AddModelErrors(validationErrors); } @@ -202,7 +202,8 @@ namespace Orchard.Users.Controllers { createModel.UserName, createModel.Password, createModel.Email, - null, null, true)); + null, null, true, + createModel.ForcePasswordChange)); } var model = Services.ContentManager.UpdateEditor(user, this); diff --git a/src/Orchard.Web/Modules/Orchard.Users/Drivers/UserPartPasswordDriver.cs b/src/Orchard.Web/Modules/Orchard.Users/Drivers/UserPartPasswordDriver.cs index e73b8bd90..83f64530b 100644 --- a/src/Orchard.Web/Modules/Orchard.Users/Drivers/UserPartPasswordDriver.cs +++ b/src/Orchard.Web/Modules/Orchard.Users/Drivers/UserPartPasswordDriver.cs @@ -1,4 +1,6 @@ -using Orchard.ContentManagement; +using System.Collections.Generic; +using System.Web.Mvc; +using Orchard.ContentManagement; using Orchard.ContentManagement.Drivers; using Orchard.Environment.Extensions; using Orchard.Localization; @@ -6,10 +8,8 @@ using Orchard.Security; using Orchard.Users.Models; using Orchard.Users.Services; using Orchard.Users.ViewModels; -using System.Collections.Generic; -using System.Web.Mvc; -namespace Orchard.Users.Drivers{ +namespace Orchard.Users.Drivers { [OrchardFeature("Orchard.Users.PasswordEditor")] public class UserPartPasswordDriver : ContentPartDriver { @@ -17,7 +17,7 @@ namespace Orchard.Users.Drivers{ private readonly IUserService _userService; public Localizer T { get; set; } - + public UserPartPasswordDriver( MembershipService membershipService, IUserService userService) { @@ -37,27 +37,26 @@ namespace Orchard.Users.Drivers{ protected override DriverResult Editor(UserPart part, IUpdateModel updater, dynamic shapeHelper) { var editModel = new UserEditPasswordViewModel { User = part }; - if (updater != null) { - if (updater.TryUpdateModel(editModel,Prefix,null,null)) { + if (updater != null) { + if (updater.TryUpdateModel(editModel, Prefix, null, null)) { if (!(string.IsNullOrEmpty(editModel.Password) && string.IsNullOrEmpty(editModel.ConfirmPassword))) { if (string.IsNullOrEmpty(editModel.Password) || string.IsNullOrEmpty(editModel.ConfirmPassword)) { updater.AddModelError("MissingPassword", T("Password or Confirm Password field is empty.")); - } - else { - if (editModel.Password != editModel.ConfirmPassword){ + } else { + if (editModel.Password != editModel.ConfirmPassword) { updater.AddModelError("ConfirmPassword", T("Password confirmation must match.")); } var actUser = _membershipService.GetUser(part.UserName); _membershipService.SetPassword(actUser, editModel.Password); } - IDictionary validationErrors; - if (!_userService.PasswordMeetsPolicies(editModel.Password, out validationErrors)) { - updater.AddModelErrors(validationErrors); - } + IDictionary validationErrors; + if (!_userService.PasswordMeetsPolicies(editModel.Password, part, out validationErrors)) { + updater.AddModelErrors(validationErrors); + } } } } return Editor(part, shapeHelper); - } + } } } \ No newline at end of file diff --git a/src/Orchard.Web/Modules/Orchard.Users/Drivers/UserSecurityConfigurationPartDriver.cs b/src/Orchard.Web/Modules/Orchard.Users/Drivers/UserSecurityConfigurationPartDriver.cs new file mode 100644 index 000000000..4481a01cf --- /dev/null +++ b/src/Orchard.Web/Modules/Orchard.Users/Drivers/UserSecurityConfigurationPartDriver.cs @@ -0,0 +1,27 @@ +using Orchard.ContentManagement; +using Orchard.ContentManagement.Drivers; +using Orchard.Users.Models; + +namespace Orchard.Users.Drivers { + public class UserSecurityConfigurationPartDriver : ContentPartDriver { + + public UserSecurityConfigurationPartDriver() { } + + protected override DriverResult Editor( + UserSecurityConfigurationPart part, dynamic shapeHelper) { + + return ContentShape("Parts_User_UserSecurityConfiguration_Edit", + () => shapeHelper.EditorTemplate( + TemplateName: "Parts/User.UserSecurityConfiguration", + Model: part, + Prefix: Prefix + )); + } + protected override DriverResult Editor( + UserSecurityConfigurationPart part, IUpdateModel updater, dynamic shapeHelper) { + updater.TryUpdateModel(part, Prefix, null, null); + return Editor(part, shapeHelper); + } + + } +} \ No newline at end of file diff --git a/src/Orchard.Web/Modules/Orchard.Users/Events/IUserEventHandler.cs b/src/Orchard.Web/Modules/Orchard.Users/Events/IUserEventHandler.cs index 2433ee3a7..9fae027c9 100644 --- a/src/Orchard.Web/Modules/Orchard.Users/Events/IUserEventHandler.cs +++ b/src/Orchard.Web/Modules/Orchard.Users/Events/IUserEventHandler.cs @@ -38,6 +38,11 @@ namespace Orchard.Users.Events { /// void AccessDenied(IUser user); + /// + /// Called before a user has changed password + /// + void ChangingPassword(IUser user, string password); + /// /// Called after a user has changed password /// diff --git a/src/Orchard.Web/Modules/Orchard.Users/Events/LoginUserEventHandler.cs b/src/Orchard.Web/Modules/Orchard.Users/Events/LoginUserEventHandler.cs index 41c71bfbb..6115f1522 100644 --- a/src/Orchard.Web/Modules/Orchard.Users/Events/LoginUserEventHandler.cs +++ b/src/Orchard.Web/Modules/Orchard.Users/Events/LoginUserEventHandler.cs @@ -6,13 +6,17 @@ using Orchard.ContentManagement; using Orchard.Security; using Orchard.Services; using Orchard.Users.Models; +using Orchard.Users.Services; namespace Orchard.Users.Events { public class LoginUserEventHandler : IUserEventHandler { private readonly IClock _clock; + private readonly IPasswordHistoryService _passwordHistoryService; + private PasswordHistoryEntry _freezedPasswordEntry; - public LoginUserEventHandler(IClock clock) { + public LoginUserEventHandler(IClock clock, IPasswordHistoryService passwordHistoryService) { _clock = clock; + _passwordHistoryService = passwordHistoryService; } public void Creating(UserContext context) { } @@ -29,7 +33,26 @@ namespace Orchard.Users.Events { public void AccessDenied(IUser user) { } - public void ChangedPassword(IUser user, string password) { } + public void ChangingPassword(IUser user, string password) { + var userPart = user.As(); + _freezedPasswordEntry = new PasswordHistoryEntry { + User = user, + Password = userPart.Password, + PasswordSalt = userPart.PasswordSalt, + HashAlgorithm = userPart.HashAlgorithm, + PasswordFormat = userPart.PasswordFormat, + LastPasswordChangeUtc = userPart.LastPasswordChangeUtc + }; + } + + public void ChangedPassword(IUser user, string password) { + // If password has changed set to false the Force Password Change flag + if (user.As().ForcePasswordChange) + user.As().ForcePasswordChange = false; + + // Store in the password history the previous password + _passwordHistoryService.CreateEntry(_freezedPasswordEntry); + } public void SentChallengeEmail(IUser user) { } diff --git a/src/Orchard.Web/Modules/Orchard.Users/Extensions/MembershipSettingsExtensions.cs b/src/Orchard.Web/Modules/Orchard.Users/Extensions/MembershipSettingsExtensions.cs index a8cb23034..496574fe7 100644 --- a/src/Orchard.Web/Modules/Orchard.Users/Extensions/MembershipSettingsExtensions.cs +++ b/src/Orchard.Web/Modules/Orchard.Users/Extensions/MembershipSettingsExtensions.cs @@ -15,5 +15,11 @@ public static bool GetPasswordSpecialRequirement(this IMembershipSettings membershipSettings) { return membershipSettings.EnableCustomPasswordPolicy ? membershipSettings.EnablePasswordSpecialRequirement : false; } + public static bool GetPasswordHistoryRequirement(this IMembershipSettings membershipSettings) { + return membershipSettings.EnablePasswordHistoryPolicy ? membershipSettings.EnablePasswordHistoryPolicy : false; + } + public static int GetPasswordReuseLimit(this IMembershipSettings membershipSettings) { + return membershipSettings.EnablePasswordHistoryPolicy ? membershipSettings.PasswordReuseLimit : 5; + } } } \ No newline at end of file diff --git a/src/Orchard.Web/Modules/Orchard.Users/Handlers/UserSecurityConfigurationPartHandler.cs b/src/Orchard.Web/Modules/Orchard.Users/Handlers/UserSecurityConfigurationPartHandler.cs new file mode 100644 index 000000000..61e3ef453 --- /dev/null +++ b/src/Orchard.Web/Modules/Orchard.Users/Handlers/UserSecurityConfigurationPartHandler.cs @@ -0,0 +1,14 @@ +using Orchard.ContentManagement.Handlers; +using Orchard.Data; +using Orchard.Users.Models; + +namespace Orchard.Users.Handlers { + public class UserSecurityConfigurationPartHandler : ContentHandler { + public UserSecurityConfigurationPartHandler( + IRepository repository) { + + Filters.Add(new ActivatingFilter("User")); + Filters.Add(StorageFilter.For(repository)); + } + } +} \ No newline at end of file diff --git a/src/Orchard.Web/Modules/Orchard.Users/Handlers/UserSuspensionSettingsPartHandler.cs b/src/Orchard.Web/Modules/Orchard.Users/Handlers/UserSuspensionSettingsPartHandler.cs new file mode 100644 index 000000000..3692b194f --- /dev/null +++ b/src/Orchard.Web/Modules/Orchard.Users/Handlers/UserSuspensionSettingsPartHandler.cs @@ -0,0 +1,26 @@ +using Orchard.ContentManagement; +using Orchard.ContentManagement.Handlers; +using Orchard.Localization; +using Orchard.Users.Models; + +namespace Orchard.Users.Handlers { + public class UserSuspensionSettingsPartHandler : ContentHandler { + + public UserSuspensionSettingsPartHandler() { + T = NullLocalizer.Instance; + Filters.Add(new ActivatingFilter("Site")); + Filters.Add( + new TemplateFilterForPart("SuspensionSettings", "Parts/Users.SuspensionSettings", "users") + .Position("6")); + } + + public Localizer T { get; set; } + + protected override void GetItemMetadata(GetContentItemMetadataContext context) { + if (context.ContentItem.ContentType != "Site") + return; + base.GetItemMetadata(context); + context.Metadata.EditorGroupInfo.Add(new GroupInfo(T("Users"))); + } + } +} \ No newline at end of file diff --git a/src/Orchard.Web/Modules/Orchard.Users/Handlers/WorkflowUserEventHandler.cs b/src/Orchard.Web/Modules/Orchard.Users/Handlers/WorkflowUserEventHandler.cs index d81c24a46..93f02594d 100644 --- a/src/Orchard.Web/Modules/Orchard.Users/Handlers/WorkflowUserEventHandler.cs +++ b/src/Orchard.Web/Modules/Orchard.Users/Handlers/WorkflowUserEventHandler.cs @@ -89,5 +89,9 @@ namespace Orchard.Users.Handlers { user, () => new Dictionary { { "User", user } }); } + + //TODO evaluate if we need a workflow event for this + public void ChangingPassword(Security.IUser user, string password) { + } } } diff --git a/src/Orchard.Web/Modules/Orchard.Users/Migrations.cs b/src/Orchard.Web/Modules/Orchard.Users/Migrations.cs index b20088ebb..0fea74550 100644 --- a/src/Orchard.Web/Modules/Orchard.Users/Migrations.cs +++ b/src/Orchard.Web/Modules/Orchard.Users/Migrations.cs @@ -23,13 +23,37 @@ namespace Orchard.Users { .Column("CreatedUtc") .Column("LastLoginUtc") .Column("LastLogoutUtc") - .Column("LastPasswordChangeUtc", c => c.WithDefault(new DateTime(1990, 1, 1)))) + .Column("LastPasswordChangeUtc", c => c.WithDefault(new DateTime(1990, 1, 1))) + .Column("ForcePasswordChange")) .AlterTable("UserPartRecord", table => table - .CreateIndex("IDX_UserPartRecord_NormalizedUserName", "NormalizedUserName")); + .CreateIndex("IDX_UserPartRecord_NormalizedUserName", "NormalizedUserName")) + // users are most commonly searched by NormalizedUserName and or Email + .AlterTable("UserPartRecord", table => table + .CreateIndex($"IDX_UserPartRecord_NameAndEmail", "NormalizedUserName", "Email")); + + //Password History Table + SchemaBuilder + .CreateTable("PasswordHistoryRecord", table => table + .Column("Id", col => col.PrimaryKey().Identity()) + .Column("UserPartRecord_Id") + .Column("Password") + .Column("PasswordFormat") + .Column("HashAlgorithm") + .Column("PasswordSalt") + .Column("LastPasswordChangeUtc", c => c.WithDefault(new DateTime(1990, 1, 1)))) + .AlterTable("PasswordHistoryRecord", table => table + .CreateIndex($"IDX_UserPartRecord_Id", "UserPartRecord_Id")); + + // Queryable bool to tell which users should not be suspended automatically + SchemaBuilder + .CreateTable("UserSecurityConfigurationPartRecord", table => table + .ContentPartRecord() + .Column("SaveFromSuspension") + .Column("PreventPasswordExpiration")); ContentDefinitionManager.AlterTypeDefinition("User", cfg => cfg.Creatable(false)); - return 6; + return 9; } public int UpdateFrom1() { @@ -79,5 +103,32 @@ namespace Orchard.Users { }); return 7; } + public int UpdateFrom7() { + SchemaBuilder.AlterTable("UserPartRecord", table => { + table.AddColumn("ForcePasswordChange"); + }); + SchemaBuilder + .CreateTable("PasswordHistoryRecord", table => table + .Column("Id", col => col.PrimaryKey().Identity()) + .Column("UserPartRecord_Id") + .Column("Password") + .Column("PasswordFormat") + .Column("HashAlgorithm") + .Column("PasswordSalt") + .Column("LastPasswordChangeUtc")) + .AlterTable("PasswordHistoryRecord", table => table + .CreateIndex($"IDX_UserPartRecord_Id", "UserPartRecord_Id")); + return 8; + } + + public int UpdateFrom8() { + SchemaBuilder + .CreateTable("UserSecurityConfigurationPartRecord", table => table + .ContentPartRecord() + .Column("SaveFromSuspension") + .Column("PreventPasswordExpiration")); + + return 9; + } } } \ No newline at end of file diff --git a/src/Orchard.Web/Modules/Orchard.Users/Models/PasswordHistoryRecord.cs b/src/Orchard.Web/Modules/Orchard.Users/Models/PasswordHistoryRecord.cs new file mode 100644 index 000000000..7ca586fb9 --- /dev/null +++ b/src/Orchard.Web/Modules/Orchard.Users/Models/PasswordHistoryRecord.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Web; +using System.Web.Security; + +namespace Orchard.Users.Models { + public class PasswordHistoryRecord { + public virtual int Id { get; set; } + public virtual UserPartRecord UserPartRecord { get; set; } + public virtual string Password { get; set; } + public virtual MembershipPasswordFormat PasswordFormat { get; set; } + public virtual string HashAlgorithm { get; set; } + public virtual string PasswordSalt { get; set; } + public virtual DateTime? LastPasswordChangeUtc { get; set; } + } +} \ 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 1d5bdb030..001264e0a 100644 --- a/src/Orchard.Web/Modules/Orchard.Users/Models/RegistrationSettingsPart.cs +++ b/src/Orchard.Web/Modules/Orchard.Users/Models/RegistrationSettingsPart.cs @@ -91,5 +91,15 @@ namespace Orchard.Users.Models { get { return this.Retrieve(x => x.PasswordFormat, MembershipPasswordFormat.Hashed); } set { this.Store(x => x.PasswordFormat, value); } } + + public bool EnablePasswordHistoryPolicy { + get { return this.Retrieve(x => x.EnablePasswordHistoryPolicy); } + set { this.Store(x => x.EnablePasswordHistoryPolicy, value); } + } + [Range(1, int.MaxValue, ErrorMessage = "The minimum password reuse limit must be at least 1.")] + public int PasswordReuseLimit { + get { return this.Retrieve(x => x.PasswordReuseLimit, 5); } + set { this.Store(x => x.PasswordReuseLimit, value); } + } } } \ No newline at end of file diff --git a/src/Orchard.Web/Modules/Orchard.Users/Models/UserPart.cs b/src/Orchard.Web/Modules/Orchard.Users/Models/UserPart.cs index 45722af93..2038cd55c 100644 --- a/src/Orchard.Web/Modules/Orchard.Users/Models/UserPart.cs +++ b/src/Orchard.Web/Modules/Orchard.Users/Models/UserPart.cs @@ -1,6 +1,7 @@ using Orchard.ContentManagement; using Orchard.Security; using System; +using System.Linq; using System.Web.Security; namespace Orchard.Users.Models { @@ -81,5 +82,12 @@ namespace Orchard.Users.Models { get { return Retrieve(x => x.LastPasswordChangeUtc); } set { Store(x => x.LastPasswordChangeUtc, value); } } + /// + /// Set to true means that the user must change the password at next logon + /// + public bool ForcePasswordChange { + get { return Retrieve(x => x.ForcePasswordChange); } + set { Store(x => x.ForcePasswordChange, value); } + } } } diff --git a/src/Orchard.Web/Modules/Orchard.Users/Models/UserPartRecord.cs b/src/Orchard.Web/Modules/Orchard.Users/Models/UserPartRecord.cs index b8aa5ffce..684c8e8bc 100644 --- a/src/Orchard.Web/Modules/Orchard.Users/Models/UserPartRecord.cs +++ b/src/Orchard.Web/Modules/Orchard.Users/Models/UserPartRecord.cs @@ -1,5 +1,7 @@ using Orchard.ContentManagement.Records; using System; +using System.Collections.Generic; +using System.Linq; using System.Web.Security; namespace Orchard.Users.Models { @@ -20,5 +22,6 @@ namespace Orchard.Users.Models { public virtual DateTime? LastLoginUtc { get; set; } public virtual DateTime? LastLogoutUtc { get; set; } public virtual DateTime? LastPasswordChangeUtc { get; set; } + public virtual bool ForcePasswordChange { get; set; } } } \ No newline at end of file diff --git a/src/Orchard.Web/Modules/Orchard.Users/Models/UserSecurityConfigurationPart.cs b/src/Orchard.Web/Modules/Orchard.Users/Models/UserSecurityConfigurationPart.cs new file mode 100644 index 000000000..1afc6c6be --- /dev/null +++ b/src/Orchard.Web/Modules/Orchard.Users/Models/UserSecurityConfigurationPart.cs @@ -0,0 +1,15 @@ +using Orchard.ContentManagement; + +namespace Orchard.Users.Models { + public class UserSecurityConfigurationPart : ContentPart { + public bool SaveFromSuspension { + get { return Retrieve(x => x.SaveFromSuspension); } + set { Store(x => x.SaveFromSuspension, value); } + } + + public bool PreventPasswordExpiration { + get { return Retrieve(x => x.PreventPasswordExpiration); } + set { Store(x => x.PreventPasswordExpiration, value); } + } + } +} \ No newline at end of file diff --git a/src/Orchard.Web/Modules/Orchard.Users/Models/UserSecurityConfigurationPartRecord.cs b/src/Orchard.Web/Modules/Orchard.Users/Models/UserSecurityConfigurationPartRecord.cs new file mode 100644 index 000000000..49ad4014e --- /dev/null +++ b/src/Orchard.Web/Modules/Orchard.Users/Models/UserSecurityConfigurationPartRecord.cs @@ -0,0 +1,11 @@ +using Orchard.ContentManagement.Records; + +namespace Orchard.Users.Models { + public class UserSecurityConfigurationPartRecord : ContentPartRecord { + // We are creating a record for this rather than making do with the infoset + // because this will allow us to explicitly query for those users that must + // (or not) be saved from suspension. + public virtual bool SaveFromSuspension { get; set; } + public virtual bool PreventPasswordExpiration { get; set; } + } +} \ No newline at end of file diff --git a/src/Orchard.Web/Modules/Orchard.Users/Models/UserSuspensionSettingsPart.cs b/src/Orchard.Web/Modules/Orchard.Users/Models/UserSuspensionSettingsPart.cs new file mode 100644 index 000000000..4f948a2fa --- /dev/null +++ b/src/Orchard.Web/Modules/Orchard.Users/Models/UserSuspensionSettingsPart.cs @@ -0,0 +1,26 @@ +using System; +using Orchard.ContentManagement; + +namespace Orchard.Users.Models { + public class UserSuspensionSettingsPart : ContentPart { + public bool SuspendInactiveUsers { + get { return this.Retrieve(x => x.SuspendInactiveUsers); } + set { this.Store(x => x.SuspendInactiveUsers, value); } + } + + public int AllowedInactivityDays { + get { return this.Retrieve(x => x.AllowedInactivityDays, 90); } + set { this.Store(x => x.AllowedInactivityDays, value); } + } + + public int MinimumSweepInterval { + get { return this.Retrieve(x => x.MinimumSweepInterval, 12); } + set { this.Store(x => x.MinimumSweepInterval, value); } + } + + public DateTime? LastSweepUtc { + get { return this.Retrieve(x => x.LastSweepUtc); } + set { this.Store(x => x.LastSweepUtc, 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 a61420923..108af12e9 100644 --- a/src/Orchard.Web/Modules/Orchard.Users/Orchard.Users.csproj +++ b/src/Orchard.Web/Modules/Orchard.Users/Orchard.Users.csproj @@ -104,6 +104,7 @@ + @@ -114,7 +115,9 @@ + + @@ -122,20 +125,29 @@ + + + + + + + + + @@ -230,10 +242,15 @@ - + + Designer + + + + @@ -294,4 +311,4 @@ - + \ No newline at end of file diff --git a/src/Orchard.Web/Modules/Orchard.Users/Placement.info b/src/Orchard.Web/Modules/Orchard.Users/Placement.info index 96d11e639..1bb40fcca 100644 --- a/src/Orchard.Web/Modules/Orchard.Users/Placement.info +++ b/src/Orchard.Web/Modules/Orchard.Users/Placement.info @@ -1,6 +1,7 @@  + diff --git a/src/Orchard.Web/Modules/Orchard.Users/Services/AccountValidationService.cs b/src/Orchard.Web/Modules/Orchard.Users/Services/AccountValidationService.cs index 6bde8ab8a..9d3a379cc 100644 --- a/src/Orchard.Web/Modules/Orchard.Users/Services/AccountValidationService.cs +++ b/src/Orchard.Web/Modules/Orchard.Users/Services/AccountValidationService.cs @@ -24,7 +24,7 @@ namespace Orchard.Users.Services { public bool ValidatePassword(AccountValidationContext context) { IDictionary validationErrors; - _userService.PasswordMeetsPolicies(context.Password, out validationErrors); + _userService.PasswordMeetsPolicies(context.Password, context.User, out validationErrors); if (validationErrors != null && validationErrors.Any()) { foreach (var err in validationErrors) { if (!context.ValidationErrors.ContainsKey(err.Key)) { diff --git a/src/Orchard.Web/Modules/Orchard.Users/Services/IUserService.cs b/src/Orchard.Web/Modules/Orchard.Users/Services/IUserService.cs index 5fa87c5b9..5d0720647 100644 --- a/src/Orchard.Web/Modules/Orchard.Users/Services/IUserService.cs +++ b/src/Orchard.Web/Modules/Orchard.Users/Services/IUserService.cs @@ -1,5 +1,6 @@ using Orchard.Localization; using Orchard.Security; +using Orchard.Users.Models; using System; using System.Collections.Generic; @@ -7,6 +8,7 @@ namespace Orchard.Users.Services { public interface IUserService : IDependency { bool VerifyUserUnicity(string userName, string email); bool VerifyUserUnicity(int id, string userName, string email); + UserPart GetUserByNameOrEmail(string usernameOrEmail); void SendChallengeEmail(IUser user, Func createUrl); IUser ValidateChallenge(string challengeToken); @@ -17,6 +19,6 @@ namespace Orchard.Users.Services { string CreateNonce(IUser user, TimeSpan delay); bool DecryptNonce(string challengeToken, out string username, out DateTime validateByUtc); - bool PasswordMeetsPolicies(string password, out IDictionary validationErrors); + bool PasswordMeetsPolicies(string password, IUser user, out IDictionary validationErrors); } } \ No newline at end of file diff --git a/src/Orchard.Web/Modules/Orchard.Users/Services/IUserSuspensionConditionProvider.cs b/src/Orchard.Web/Modules/Orchard.Users/Services/IUserSuspensionConditionProvider.cs new file mode 100644 index 000000000..a3c434ca9 --- /dev/null +++ b/src/Orchard.Web/Modules/Orchard.Users/Services/IUserSuspensionConditionProvider.cs @@ -0,0 +1,11 @@ +using Orchard.ContentManagement; +using Orchard.Users.Models; + +namespace Orchard.Users.Services { + public interface IUserSuspensionConditionProvider : IDependency { + IContentQuery AlterQuery( + IContentQuery query); + + bool UserIsProtected(UserPart userPart); + } +} diff --git a/src/Orchard.Web/Modules/Orchard.Users/Services/InactiveUserSuspensionBackgroundTask.cs b/src/Orchard.Web/Modules/Orchard.Users/Services/InactiveUserSuspensionBackgroundTask.cs new file mode 100644 index 000000000..229ec0731 --- /dev/null +++ b/src/Orchard.Web/Modules/Orchard.Users/Services/InactiveUserSuspensionBackgroundTask.cs @@ -0,0 +1,149 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Orchard.ContentManagement; +using Orchard.Logging; +using Orchard.Security; +using Orchard.Services; +using Orchard.Settings; +using Orchard.Tasks; +using Orchard.Tasks.Locking.Services; +using Orchard.Users.Events; +using Orchard.Users.Models; + +namespace Orchard.Users.Services { + public class InactiveUserSuspensionBackgroundTask : Component, IBackgroundTask { + + private readonly IDistributedLockService _distributedLockService; + private readonly ISiteService _siteService; + private readonly IClock _clock; + private readonly IContentManager _contentManager; + private readonly IUserEventHandler _userEventHandlers; + private readonly IAuthorizationService _authorizationService; + private readonly IEnumerable _userSuspensionConditionProviders; + + public InactiveUserSuspensionBackgroundTask( + IDistributedLockService distributedLockService, + ISiteService siteService, + IClock clock, + IContentManager contentManager, + IUserEventHandler userEventHandlers, + IAuthorizationService authorizationService, + IEnumerable userSuspensionConditionProviders) { + + _distributedLockService = distributedLockService; + _siteService = siteService; + _clock = clock; + _contentManager = contentManager; + _userEventHandlers = userEventHandlers; + _authorizationService = authorizationService; + _userSuspensionConditionProviders = userSuspensionConditionProviders; + } + + + public void Sweep() { + Logger.Debug("Beginning sweep to suspend inactive users."); + try { + // Only allow this task to run on one farm node at a time. + IDistributedLock @lock; + if (_distributedLockService.TryAcquireLock(GetType().FullName, TimeSpan.FromHours(1), out @lock)) { + using (@lock) { + // Check whether it's time to do another sweep. + if (!IsItTimeToSweep()) { + return; // too soon + } + + Logger.Debug("Checking for inactive users."); + // Get all inactive users (last logon older than the configured timespan) + var thresholdDate = _clock.UtcNow - TimeSpan.FromDays(GetSettings().AllowedInactivityDays); + IContentQuery inactiveUsersQuery = _contentManager + .Query() + .Where(upr => + // user is enabled + upr.RegistrationStatus == UserStatus.Approved + && upr.EmailStatus == UserStatus.Approved) + .Where(upr => + // The last login happened a long time ago + (upr.LastLoginUtc != null && upr.LastLoginUtc <= thresholdDate) + // The user never logged in AND was created a long time ago + || (upr.LastLoginUtc == null && upr.CreatedUtc <= thresholdDate) + ); + // If providers could alter the query we'd be able to immediately limit the number + // of ContentItems we'll fetch, and as a consequence the number of operations later. + // However, such conditions would make users immune from being suspended. + foreach (var provider in _userSuspensionConditionProviders) { + inactiveUsersQuery = provider.AlterQuery(inactiveUsersQuery); + } + var inactiveUsers = inactiveUsersQuery.List(); + // By default, all inactive users should be suspended, except SiteOwner. + foreach (var userUnderTest in inactiveUsers.Where(up => !IsSiteOwner(up))) { + + // Ask providers whether users should be processed/disabled + var saveTheUser = _userSuspensionConditionProviders + .Aggregate(false, (prev, scp) => prev || scp.UserIsProtected(userUnderTest)); + + // Suspend the users that have gotten this far. + if (!saveTheUser) { + DisableUser(userUnderTest); + } + } + // Done! + // Mark the time that we have done this check. + GetSettings().LastSweepUtc = _clock.UtcNow; + Logger.Debug("Done checking for inactive users."); + } + } else { + Logger.Debug("Distributed lock could not be acquired; going back to sleep."); + } + + } catch (Exception ex) { + Logger.Error(ex, "Error during sweep to suspend inactive users."); + } finally { + Logger.Debug("Ending sweep to suspend inactive users."); + } + } + + private bool IsSiteOwner(UserPart userPart) { + return _authorizationService + .TryCheckAccess(StandardPermissions.SiteOwner, + userPart, null); + } + + private void DisableUser(UserPart userPart) { + userPart.RegistrationStatus = UserStatus.Pending; + Logger.Information(T("User {0} disabled by automatic moderation", userPart.UserName).Text); + _userEventHandlers.Moderate(userPart); + } + + private bool IsItTimeToSweep() { + var settings = GetSettings(); + if (settings.SuspendInactiveUsers) { + if (settings.MinimumSweepInterval <= 0) { + return true; + } + var lastSweep = settings.LastSweepUtc ?? DateTime.MinValue; + var now = _clock.UtcNow; + var interval = TimeSpan.FromHours(settings.MinimumSweepInterval); + return now - lastSweep >= interval; + } + return false; + } + + #region Memorize settings + private ISite _siteSettings; + private ISite GetSiteSettings() { + if (_siteSettings == null) { + _siteSettings = _siteService.GetSiteSettings(); + } + return _siteSettings; + } + private UserSuspensionSettingsPart _settingsPart; + private UserSuspensionSettingsPart GetSettings() { + if (_settingsPart == null) { + _settingsPart = GetSiteSettings().As(); + } + return _settingsPart; + } + #endregion + } +} \ No newline at end of file diff --git a/src/Orchard.Web/Modules/Orchard.Users/Services/MembershipService.cs b/src/Orchard.Web/Modules/Orchard.Users/Services/MembershipService.cs index 284780b89..333c216ff 100644 --- a/src/Orchard.Web/Modules/Orchard.Users/Services/MembershipService.cs +++ b/src/Orchard.Web/Modules/Orchard.Users/Services/MembershipService.cs @@ -30,6 +30,7 @@ namespace Orchard.Users.Services { private readonly IShapeFactory _shapeFactory; private readonly IShapeDisplay _shapeDisplay; private readonly IAppConfigurationAccessor _appConfigurationAccessor; + private readonly IPasswordService _passwordService; private readonly IClock _clock; public MembershipService( @@ -40,7 +41,8 @@ namespace Orchard.Users.Services { IEncryptionService encryptionService, IShapeFactory shapeFactory, IShapeDisplay shapeDisplay, - IAppConfigurationAccessor appConfigurationAccessor) { + IAppConfigurationAccessor appConfigurationAccessor, + IPasswordService passwordService) { _orchardServices = orchardServices; _messageService = messageService; _userEventHandlers = userEventHandlers; @@ -48,6 +50,7 @@ namespace Orchard.Users.Services { _shapeFactory = shapeFactory; _shapeDisplay = shapeDisplay; _appConfigurationAccessor = appConfigurationAccessor; + _passwordService = passwordService; _clock = clock; Logger = NullLogger.Instance; T = NullLocalizer.Instance; @@ -72,14 +75,15 @@ namespace Orchard.Users.Services { user.NormalizedUserName = createUserParams.Username.ToLowerInvariant(); user.HashAlgorithm = PBKDF2; user.CreatedUtc = _clock.UtcNow; + user.ForcePasswordChange = createUserParams.ForcePasswordChange; SetPassword(user, createUserParams.Password); - if ( registrationSettings != null) { + if (registrationSettings != null) { user.RegistrationStatus = registrationSettings.UsersAreModerated ? UserStatus.Pending : UserStatus.Approved; user.EmailStatus = registrationSettings.UsersMustValidateEmail ? UserStatus.Pending : UserStatus.Approved; } - if(createUserParams.IsApproved) { + if (createUserParams.IsApproved) { user.RegistrationStatus = UserStatus.Approved; user.EmailStatus = UserStatus.Approved; } @@ -87,7 +91,7 @@ namespace Orchard.Users.Services { var userContext = new UserContext { User = user, Cancel = false, UserParameters = createUserParams }; _userEventHandlers.Creating(userContext); - if(userContext.Cancel) { + if (userContext.Cancel) { return null; } @@ -98,15 +102,15 @@ namespace Orchard.Users.Services { _userEventHandlers.Approved(user); } - if ( registrationSettings != null + if (registrationSettings != null && registrationSettings.UsersAreModerated && registrationSettings.NotifyModeration && !createUserParams.IsApproved) { var usernames = String.IsNullOrWhiteSpace(registrationSettings.NotificationsRecipients) ? new string[0] - : registrationSettings.NotificationsRecipients.Split(new[] {',', ' '}, StringSplitOptions.RemoveEmptyEntries); + : registrationSettings.NotificationsRecipients.Split(new[] { ',', ' ' }, StringSplitOptions.RemoveEmptyEntries); - foreach ( var userName in usernames ) { + foreach (var userName in usernames) { if (String.IsNullOrWhiteSpace(userName)) { continue; } @@ -142,7 +146,12 @@ namespace Orchard.Users.Services { if (user == null) user = _orchardServices.ContentManager.Query().Where(u => u.Email == lowerName).List().FirstOrDefault(); - if (user == null || ValidatePassword(user.As(), password) == false) { + if (user == null || !_passwordService.IsMatch(new PasswordContext { + Password = user.Password, + HashAlgorithm = user.HashAlgorithm, + PasswordFormat = user.PasswordFormat, + PasswordSalt = user.PasswordSalt + }, password)) { validationErrors.Add(T("The username or e-mail or password provided is incorrect.")); return null; } @@ -157,7 +166,12 @@ namespace Orchard.Users.Services { } public bool PasswordIsExpired(IUser user, int days) { - return user.As().LastPasswordChangeUtc.Value.AddDays(days) < _clock.UtcNow; + // TODO: add providers to extend this + var passwordIsExpired = user.As().LastPasswordChangeUtc.Value.AddDays(days) < _clock.UtcNow; + var securityPart = user.As(); + var preventExpiration = securityPart != null && securityPart.PreventPasswordExpiration; + return passwordIsExpired + && !preventExpiration; } public void SetPassword(IUser user, string password) { @@ -182,118 +196,28 @@ namespace Orchard.Users.Services { userPart.LastPasswordChangeUtc = _clock.UtcNow; } - private bool ValidatePassword(UserPart userPart, string password) { - // Note - the password format stored with the record is used - // otherwise changing the password format on the site would invalidate - // all logins - switch (userPart.PasswordFormat) { - case MembershipPasswordFormat.Clear: - return ValidatePasswordClear(userPart, password); - case MembershipPasswordFormat.Hashed: - return ValidatePasswordHashed(userPart, password); - case MembershipPasswordFormat.Encrypted: - return ValidatePasswordEncrypted(userPart, password); - default: - throw new ApplicationException("Unexpected password format value"); - } - } - private static void SetPasswordClear(UserPart userPart, string password) { userPart.PasswordFormat = MembershipPasswordFormat.Clear; userPart.Password = password; userPart.PasswordSalt = null; } - private static bool ValidatePasswordClear(UserPart userPart, string password) { - return userPart.Password == password; - } - - private static void SetPasswordHashed(UserPart userPart, string password) { + private void SetPasswordHashed(UserPart userPart, string password) { var saltBytes = new byte[0x10]; using (var random = new RNGCryptoServiceProvider()) { random.GetBytes(saltBytes); } userPart.PasswordFormat = MembershipPasswordFormat.Hashed; - userPart.Password = ComputeHashBase64(userPart.HashAlgorithm, saltBytes, password); + userPart.Password = PasswordExtensions.ComputeHashBase64(userPart.HashAlgorithm, saltBytes, password); userPart.PasswordSalt = Convert.ToBase64String(saltBytes); } - private bool ValidatePasswordHashed(UserPart userPart, string password) { - var saltBytes = Convert.FromBase64String(userPart.PasswordSalt); - - bool isValid; - if (userPart.HashAlgorithm == PBKDF2) { - // We can't reuse ComputeHashBase64 as the internally generated salt repeated calls to Crypto.HashPassword() return different results. - isValid = Crypto.VerifyHashedPassword(userPart.Password, Encoding.Unicode.GetString(CombineSaltAndPassword(saltBytes, password))); - } - else { - isValid = SecureStringEquality(userPart.Password, ComputeHashBase64(userPart.HashAlgorithm, saltBytes, password)); - } - - // Migrating older password hashes to Default algorithm if necessary and enabled. - if (isValid && userPart.HashAlgorithm != DefaultHashAlgorithm) { - var keepOldConfiguration = _appConfigurationAccessor.GetConfiguration("Orchard.Users.KeepOldPasswordHash"); - if (String.IsNullOrEmpty(keepOldConfiguration) || keepOldConfiguration.Equals("false", StringComparison.OrdinalIgnoreCase)) { - userPart.HashAlgorithm = DefaultHashAlgorithm; - userPart.Password = ComputeHashBase64(userPart.HashAlgorithm, saltBytes, password); - } - } - - return isValid; - } - - private static string ComputeHashBase64(string hashAlgorithmName, byte[] saltBytes, string password) { - var combinedBytes = CombineSaltAndPassword(saltBytes, password); - - // Extending HashAlgorithm would be too complicated: http://stackoverflow.com/questions/6460711/adding-a-custom-hashalgorithmtype-in-c-sharp-asp-net?lq=1 - if (hashAlgorithmName == PBKDF2) { - // HashPassword() already returns a base64 string. - return Crypto.HashPassword(Encoding.Unicode.GetString(combinedBytes)); - } - else { - using (var hashAlgorithm = HashAlgorithm.Create(hashAlgorithmName)) { - return Convert.ToBase64String(hashAlgorithm.ComputeHash(combinedBytes)); - } - } - } - - /// - /// Compares two strings without giving hint about the time it takes to do so. - /// - /// The first string to compare. - /// The second string to compare. - /// true if both strings are equal, false. - private bool SecureStringEquality(string a, string b) { - if (a == null || b == null || (a.Length != b.Length)) { - return false; - } - - var aBytes = Encoding.Unicode.GetBytes(a); - var bBytes = Encoding.Unicode.GetBytes(b); - - var bytesAreEqual = true; - for (int i = 0; i < a.Length; i++) { - bytesAreEqual &= (aBytes[i] == bBytes[i]); - } - - return bytesAreEqual; - } - - private static byte[] CombineSaltAndPassword(byte[] saltBytes, string password) { - var passwordBytes = Encoding.Unicode.GetBytes(password); - return saltBytes.Concat(passwordBytes).ToArray(); - } - private void SetPasswordEncrypted(UserPart userPart, string password) { userPart.Password = Convert.ToBase64String(_encryptionService.Encode(Encoding.UTF8.GetBytes(password))); userPart.PasswordSalt = null; userPart.PasswordFormat = MembershipPasswordFormat.Encrypted; } - private bool ValidatePasswordEncrypted(UserPart userPart, string password) { - return String.Equals(password, Encoding.UTF8.GetString(_encryptionService.Decode(Convert.FromBase64String(userPart.Password))), StringComparison.Ordinal); - } - } } diff --git a/src/Orchard.Web/Modules/Orchard.Users/Services/PasswordHistoryService.cs b/src/Orchard.Web/Modules/Orchard.Users/Services/PasswordHistoryService.cs new file mode 100644 index 000000000..ae4293b0b --- /dev/null +++ b/src/Orchard.Web/Modules/Orchard.Users/Services/PasswordHistoryService.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Web; +using Orchard.ContentManagement; +using Orchard.Data; +using Orchard.Security; +using Orchard.Users.Models; + +namespace Orchard.Users.Services { + public class PasswordHistoryService : IPasswordHistoryService { + private readonly IEnumerable emptyPasswordHistoryEntries = new List(); + private readonly IRepository _historyRepository; + private readonly IPasswordService _passwordService; + + public PasswordHistoryService(IRepository historyRepository, IPasswordService passwordService) { + _historyRepository = historyRepository; + _passwordService = passwordService; + } + + public void CreateEntry(PasswordHistoryEntry context) { + _historyRepository.Create(new PasswordHistoryRecord { + UserPartRecord = context.User?.As()?.Record, + HashAlgorithm = context.HashAlgorithm, + Password = context.Password, + PasswordFormat = context.PasswordFormat, + PasswordSalt = context.PasswordSalt, + LastPasswordChangeUtc = context.LastPasswordChangeUtc, + }); + } + + public IEnumerable GetLastPasswords(IUser user, int count) { + if (user == null) + return emptyPasswordHistoryEntries; + var lastPasswords = _historyRepository + .Fetch(x => x.UserPartRecord.Id == user.Id) + .OrderByDescending(x => x.LastPasswordChangeUtc) + //because we append the last password (stored within the UserPart) we take 1 less password from history + .Take(count - 1) + .Select(x => new PasswordHistoryEntry { + Password = x.Password, + PasswordSalt = x.PasswordSalt, + HashAlgorithm = x.HashAlgorithm, + PasswordFormat = x.PasswordFormat, + LastPasswordChangeUtc = x.LastPasswordChangeUtc, + User = user + }); + return lastPasswords + //we append the last used password stored within the UserPart + .Append(new PasswordHistoryEntry { + Password = user.As().Password, + PasswordSalt = user.As().PasswordSalt, + HashAlgorithm = user.As().HashAlgorithm, + PasswordFormat = user.As().PasswordFormat, + LastPasswordChangeUtc = user.As().LastPasswordChangeUtc, + User = user }); + } + + public bool PasswordMatchLastOnes(string password, IUser user, int count) { + if (user == null) + return false; + return GetLastPasswords(user, count) + .Any(x => _passwordService.IsMatch(x, password)); + } + } +} \ No newline at end of file diff --git a/src/Orchard.Web/Modules/Orchard.Users/Services/PasswordService.cs b/src/Orchard.Web/Modules/Orchard.Users/Services/PasswordService.cs new file mode 100644 index 000000000..3094417b1 --- /dev/null +++ b/src/Orchard.Web/Modules/Orchard.Users/Services/PasswordService.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Web; +using System.Web.Helpers; +using System.Web.Security; +using Orchard.Environment.Configuration; +using Orchard.Security; + +namespace Orchard.Users.Services { + public class PasswordService : IPasswordService { + private const string DefaultHashAlgorithm = PasswordExtensions.PBKDF2; + private readonly IEncryptionService _encryptionService; + private readonly IAppConfigurationAccessor _appConfigurationAccessor; + + public PasswordService( + IEncryptionService encryptionService, + IAppConfigurationAccessor appConfigurationAccessor) { + _encryptionService = encryptionService; + _appConfigurationAccessor = appConfigurationAccessor; + } + + public bool IsMatch(PasswordContext context, string plaintextPassword) { + switch (context.PasswordFormat) { + case MembershipPasswordFormat.Clear: + return EqualsClear(context, plaintextPassword); + case MembershipPasswordFormat.Hashed: + return EqualsHashed(context, plaintextPassword); + case MembershipPasswordFormat.Encrypted: + return EqualsEncrypted(context, plaintextPassword); + default: + throw new ApplicationException("Unexpected password format value"); + } + } + + private bool EqualsClear(PasswordContext context, string plaintextPassword) { + return context.Password == plaintextPassword; + } + private bool EqualsHashed(PasswordContext context, string plaintextPassword) { + var saltBytes = Convert.FromBase64String(context.PasswordSalt); + + bool isValid; + if (context.HashAlgorithm == PasswordExtensions.PBKDF2) { + // We can't reuse ComputeHashBase64 as the internally generated salt repeated calls to Crypto.HashPassword() return different results. + isValid = Crypto.VerifyHashedPassword(context.Password, Encoding.Unicode.GetString(PasswordExtensions.CombineSaltAndPassword(saltBytes, plaintextPassword))); + } + else { + isValid = SecureStringEquality(context.Password, PasswordExtensions.ComputeHashBase64(context.HashAlgorithm, saltBytes, plaintextPassword)); + } + + // Migrating older password hashes to Default algorithm if necessary and enabled. + if (isValid && context.HashAlgorithm != DefaultHashAlgorithm) { + var keepOldConfiguration = _appConfigurationAccessor.GetConfiguration("Orchard.Users.KeepOldPasswordHash"); + if (String.IsNullOrEmpty(keepOldConfiguration) || keepOldConfiguration.Equals("false", StringComparison.OrdinalIgnoreCase)) { + context.HashAlgorithm = DefaultHashAlgorithm; + context.Password = PasswordExtensions.ComputeHashBase64(context.HashAlgorithm, saltBytes, plaintextPassword); + } + } + + return isValid; + } + private bool EqualsEncrypted(PasswordContext context, string plaintextPassword) { + return String.Equals(plaintextPassword, Encoding.UTF8.GetString(_encryptionService.Decode(Convert.FromBase64String(context.Password))), StringComparison.Ordinal); + } + + + /// + /// Compares two strings without giving hint about the time it takes to do so. + /// + /// The first string to compare. + /// The second string to compare. + /// true if both strings are equal, false. + private bool SecureStringEquality(string a, string b) { + if (a == null || b == null || (a.Length != b.Length)) { + return false; + } + + var aBytes = Encoding.Unicode.GetBytes(a); + var bBytes = Encoding.Unicode.GetBytes(b); + + var bytesAreEqual = true; + for (int i = 0; i < a.Length; i++) { + bytesAreEqual &= (aBytes[i] == bBytes[i]); + } + + return bytesAreEqual; + } + + } +} \ No newline at end of file diff --git a/src/Orchard.Web/Modules/Orchard.Users/Services/ProtectSpecificUserConditionProvider.cs b/src/Orchard.Web/Modules/Orchard.Users/Services/ProtectSpecificUserConditionProvider.cs new file mode 100644 index 000000000..c79bc4e52 --- /dev/null +++ b/src/Orchard.Web/Modules/Orchard.Users/Services/ProtectSpecificUserConditionProvider.cs @@ -0,0 +1,27 @@ +using Orchard.ContentManagement; +using Orchard.Users.Models; + +namespace Orchard.Users.Services { + public class ProtectSpecificUserConditionProvider : IUserSuspensionConditionProvider { + + // Method to add conditions to the query that fetches the users that we may + // try to suspend + public IContentQuery AlterQuery( + IContentQuery query) { + + // Don't fetch the users that are protected from suspension + query = query + .Where(pr => !pr.SaveFromSuspension); + + return query; + } + + // Method to tell whether a specific user should be "saved" from suspension + public bool UserIsProtected(UserPart userPart) { + + return userPart + .As() + .SaveFromSuspension; + } + } +} \ 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 0f1b46c1d..022b50790 100644 --- a/src/Orchard.Web/Modules/Orchard.Users/Services/UserService.cs +++ b/src/Orchard.Web/Modules/Orchard.Users/Services/UserService.cs @@ -4,6 +4,7 @@ using Orchard.Environment.Configuration; using Orchard.Localization; using Orchard.Logging; using Orchard.Messaging.Services; +using Orchard.Mvc.Html; using Orchard.Security; using Orchard.Services; using Orchard.Settings; @@ -30,17 +31,19 @@ namespace Orchard.Users.Services { private readonly IShapeFactory _shapeFactory; private readonly IShapeDisplay _shapeDisplay; private readonly ISiteService _siteService; + private readonly IPasswordHistoryService _passwordHistoryService; public UserService( - IContentManager contentManager, - IMembershipService membershipService, - IClock clock, - IMessageService messageService, - ShellSettings shellSettings, + IContentManager contentManager, + IMembershipService membershipService, + IClock clock, + IMessageService messageService, + ShellSettings shellSettings, IEncryptionService encryptionService, IShapeFactory shapeFactory, IShapeDisplay shapeDisplay, - ISiteService siteService) { + ISiteService siteService, + IPasswordHistoryService passwordHistoryService) { _contentManager = contentManager; _membershipService = membershipService; @@ -50,7 +53,7 @@ namespace Orchard.Users.Services { _shapeFactory = shapeFactory; _shapeDisplay = shapeDisplay; _siteService = siteService; - + _passwordHistoryService = passwordHistoryService; Logger = NullLogger.Instance; T = NullLocalizer.Instance; } @@ -62,8 +65,8 @@ namespace Orchard.Users.Services { string normalizedUserName = userName.ToLowerInvariant(); if (_contentManager.Query() - .Where(user => - user.NormalizedUserName == normalizedUserName || + .Where(user => + user.NormalizedUserName == normalizedUserName || user.Email == email) .List().Any()) { return false; @@ -143,7 +146,7 @@ namespace Orchard.Users.Services { ChallengeUrl = url })); template.Metadata.Wrappers.Add("Template_User_Wrapper"); - + var parameters = new Dictionary { {"Subject", T("Verification E-Mail").Text}, {"Body", _shapeDisplay.Display(template)}, @@ -155,8 +158,7 @@ namespace Orchard.Users.Services { } public bool SendLostPasswordEmail(string usernameOrEmail, Func createUrl) { - var lowerName = usernameOrEmail.ToLowerInvariant(); - var user = _contentManager.Query().Where(u => u.NormalizedUserName == lowerName || u.Email == lowerName).List().FirstOrDefault(); + var user = GetUserByNameOrEmail(usernameOrEmail); if (user != null) { string nonce = CreateNonce(user, DelayToResetPassword); @@ -199,7 +201,7 @@ namespace Orchard.Users.Services { return user; } - public bool PasswordMeetsPolicies(string password, out IDictionary validationErrors) { + public bool PasswordMeetsPolicies(string password, IUser user, out IDictionary validationErrors) { validationErrors = new Dictionary(); var settings = _siteService.GetSiteSettings().As(); @@ -231,9 +233,25 @@ namespace Orchard.Users.Services { validationErrors.Add(UserPasswordValidationResults.PasswordDoesNotContainSpecialCharacters, T("The password must contain at least one special character.")); } + if (settings.EnablePasswordHistoryPolicy) { + var enforcePasswordHistory = settings.GetPasswordReuseLimit(); + if (_passwordHistoryService.PasswordMatchLastOnes(password, user, enforcePasswordHistory)) { + validationErrors.Add(UserPasswordValidationResults.PasswordDoesNotMeetHistoryPolicy, + T.Plural("You cannot reuse the last password.", "You cannot reuse none of last {0} passwords.", enforcePasswordHistory)); + } + } } return validationErrors.Count == 0; } + + public UserPart GetUserByNameOrEmail(string usernameOrEmail) { + var lowerName = usernameOrEmail.ToLowerInvariant(); + return _contentManager + .Query() + .Where(u => u.NormalizedUserName == lowerName || u.Email == lowerName) + .Slice(0, 1) + .FirstOrDefault(); + } } } \ No newline at end of file diff --git a/src/Orchard.Web/Modules/Orchard.Users/ViewModels/UserCreateViewModel.cs b/src/Orchard.Web/Modules/Orchard.Users/ViewModels/UserCreateViewModel.cs index 5b3646e15..c59353a90 100644 --- a/src/Orchard.Web/Modules/Orchard.Users/ViewModels/UserCreateViewModel.cs +++ b/src/Orchard.Web/Modules/Orchard.Users/ViewModels/UserCreateViewModel.cs @@ -3,7 +3,7 @@ using Orchard.ContentManagement; using Orchard.Users.Models; namespace Orchard.Users.ViewModels { - public class UserCreateViewModel { + public class UserCreateViewModel { [Required] public string UserName { get; set; } @@ -15,5 +15,8 @@ namespace Orchard.Users.ViewModels { [Required, DataType(DataType.Password)] public string ConfirmPassword { get; set; } + + public bool ForcePasswordChange { get; set; } + } } \ No newline at end of file diff --git a/src/Orchard.Web/Modules/Orchard.Users/ViewModels/UserEditViewModel.cs b/src/Orchard.Web/Modules/Orchard.Users/ViewModels/UserEditViewModel.cs index 3b6ffbe92..12331ada0 100644 --- a/src/Orchard.Web/Modules/Orchard.Users/ViewModels/UserEditViewModel.cs +++ b/src/Orchard.Web/Modules/Orchard.Users/ViewModels/UserEditViewModel.cs @@ -16,6 +16,11 @@ namespace Orchard.Users.ViewModels { set { User.As().Email = value; } } + public bool ForcePasswordChange { + get { return User.As().ForcePasswordChange; } + set { User.As().ForcePasswordChange = value; } + } + public IContent User { get; set; } } } \ No newline at end of file diff --git a/src/Orchard.Web/Modules/Orchard.Users/Views/Account/ChallengeEmailFail.cshtml b/src/Orchard.Web/Modules/Orchard.Users/Views/Account/ChallengeEmailFail.cshtml index a7094056f..0c51b0df2 100644 --- a/src/Orchard.Web/Modules/Orchard.Users/Views/Account/ChallengeEmailFail.cshtml +++ b/src/Orchard.Web/Modules/Orchard.Users/Views/Account/ChallengeEmailFail.cshtml @@ -1,3 +1,7 @@ @model dynamic

@Html.TitleForPage(T("Challenge Email").ToString())

@T("Your email address could not be validated.")

+

+ @T("Follow this link to request a new challenge email.", + Url.Action("RequestChallengeEmail")) +

diff --git a/src/Orchard.Web/Modules/Orchard.Users/Views/Account/RequestChallengeEmail.cshtml b/src/Orchard.Web/Modules/Orchard.Users/Views/Account/RequestChallengeEmail.cshtml new file mode 100644 index 000000000..2d7628236 --- /dev/null +++ b/src/Orchard.Web/Modules/Orchard.Users/Views/Account/RequestChallengeEmail.cshtml @@ -0,0 +1,24 @@ +@model string +@{ + var prefill = (string)Model; +} + +

@Html.TitleForPage(T("Confirm Email Address").ToString())

+

@T("Please enter your username or email address. You will receive a link via email to validate your address.")

+@using (Html.BeginFormAntiForgeryPost()) { +
+ @T("Account Information") +
+ + @if (string.IsNullOrWhiteSpace(prefill)) { + @Html.TextBox("username") + } else { + @Html.TextBox("username", value: prefill) + } + @Html.ValidationMessage("username") +
+
+ +
+
+ } \ No newline at end of file diff --git a/src/Orchard.Web/Modules/Orchard.Users/Views/EditorTemplates/Parts/User.Create.cshtml b/src/Orchard.Web/Modules/Orchard.Users/Views/EditorTemplates/Parts/User.Create.cshtml index 46d148323..1b0a2966e 100644 --- a/src/Orchard.Web/Modules/Orchard.Users/Views/EditorTemplates/Parts/User.Create.cshtml +++ b/src/Orchard.Web/Modules/Orchard.Users/Views/EditorTemplates/Parts/User.Create.cshtml @@ -18,4 +18,8 @@ @Html.LabelFor(m => m.ConfirmPassword, T("Confirm Password")) @Html.PasswordFor(m=>m.ConfirmPassword, new { @class = "text medium" }) @Html.ValidationMessageFor(m=>m.ConfirmPassword, "*") + +
+ @Html.CheckBoxFor(m => m.ForcePasswordChange) + @Html.LabelFor(m => m.ForcePasswordChange, T("User must change password at next logon").Text, new { @class = "forcheckbox" })
\ No newline at end of file diff --git a/src/Orchard.Web/Modules/Orchard.Users/Views/EditorTemplates/Parts/User.Edit.cshtml b/src/Orchard.Web/Modules/Orchard.Users/Views/EditorTemplates/Parts/User.Edit.cshtml index 1a32eb3bc..9fbe5367a 100644 --- a/src/Orchard.Web/Modules/Orchard.Users/Views/EditorTemplates/Parts/User.Edit.cshtml +++ b/src/Orchard.Web/Modules/Orchard.Users/Views/EditorTemplates/Parts/User.Edit.cshtml @@ -8,4 +8,8 @@ @Html.LabelFor(m => m.Email, T("Email")) @Html.TextBoxFor(m=>m.Email, new { @class = "text medium" }) @Html.ValidationMessageFor(m=>m.Email, "*") + +
+ @Html.CheckBoxFor(m => m.ForcePasswordChange) + @Html.LabelFor(m => m.ForcePasswordChange, T("User must change password at next logon").Text, new { @class = "forcheckbox" })
\ No newline at end of file diff --git a/src/Orchard.Web/Modules/Orchard.Users/Views/EditorTemplates/Parts/User.UserSecurityConfiguration.cshtml b/src/Orchard.Web/Modules/Orchard.Users/Views/EditorTemplates/Parts/User.UserSecurityConfiguration.cshtml new file mode 100644 index 000000000..e46815e76 --- /dev/null +++ b/src/Orchard.Web/Modules/Orchard.Users/Views/EditorTemplates/Parts/User.UserSecurityConfiguration.cshtml @@ -0,0 +1,13 @@ +@model Orchard.Users.Models.UserSecurityConfigurationPart +
+ @Html.CheckBoxFor(m => m.SaveFromSuspension) + @Html.LabelFor(m => m.SaveFromSuspension, + T("User will not be automatically suspended by periodic sweeps.").Text, + new { @class = "forcheckbox" }) +
+
+ @Html.CheckBoxFor(m => m.PreventPasswordExpiration) + @Html.LabelFor(m => m.PreventPasswordExpiration, + T("User will not be required to renew their password when it's expired.").Text, + new { @class = "forcheckbox" }) +
\ 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 7006c7712..170b2d1b8 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 @@ -66,56 +66,64 @@ new SelectListItem { Text = T("Encrypted").Text, Value = MembershipPasswordFormat.Encrypted.ToString() } }, "Value", "Text")) + +
+ @Html.EditorFor(m => m.EnablePasswordHistoryPolicy) + +
+ + @Html.TextBoxFor(m => m.PasswordReuseLimit, new { @class = "text medium", @Value = Model.PasswordReuseLimit }) + @Html.ValidationMessage("PasswordReuseLimit", "*") +
+
- +
+ + + -
- - - - - @if(!emailEnabled) { -
@T("This option is available when an email module is activated.")
- } -
-
- - - + @if (!emailEnabled) { +
@T("This option is available when an email module is activated.")
+ } +
+
+ + + - @if(!emailEnabled) { -
@T("This option is available when an email module is activated.")
- } -
-
- - @Html.TextBoxFor(m => m.ValidateEmailRegisteredWebsite, new { @class = "text medium" } ) - @Html.ValidationMessage("ValidateEmailRegisteredWebsite", "*") - @T("The name of your website as it will appear in the verification e-mail.") - - - @Html.TextBoxFor(m => m.ValidateEmailContactEMail, new { @class = "text medium" } ) - @Html.ValidationMessage("ValidateEmailContactEMail", "*") - @T("The e-mail address displayed in the verification e-mail for a Contact Us link. Leave empty for no link.") -
-
- @Html.EditorFor(m => m.UsersAreModerated) - -
-
- - - + @if (!emailEnabled) { +
@T("This option is available when an email module is activated.")
+ } +
+
+ + @Html.TextBoxFor(m => m.ValidateEmailRegisteredWebsite, new { @class = "text medium" }) + @Html.ValidationMessage("ValidateEmailRegisteredWebsite", "*") + @T("The name of your website as it will appear in the verification e-mail.") - @if(!emailEnabled) { -
@T("This option is available when an email module is activated.")
- } + + @Html.TextBoxFor(m => m.ValidateEmailContactEMail, new { @class = "text medium" }) + @Html.ValidationMessage("ValidateEmailContactEMail", "*") + @T("The e-mail address displayed in the verification e-mail for a Contact Us link. Leave empty for no link.") +
+
+ @Html.EditorFor(m => m.UsersAreModerated) + +
+
+ + + -
-
- - @Html.TextBoxFor(m => m.NotificationsRecipients, new { @class = "text medium" } ) - @Html.ValidationMessage("NotificationsRecipients", "*") - @T("The usernames to send the notifications to (e.g., \"admin, user1, ...\").") -
+ @if (!emailEnabled) { +
@T("This option is available when an email module is activated.")
+ } + +
+
+ + @Html.TextBoxFor(m => m.NotificationsRecipients, new { @class = "text medium" }) + @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.Web/Modules/Orchard.Users/Views/EditorTemplates/Parts/Users.SuspensionSettings.cshtml b/src/Orchard.Web/Modules/Orchard.Users/Views/EditorTemplates/Parts/Users.SuspensionSettings.cshtml new file mode 100644 index 000000000..9fdf77f8f --- /dev/null +++ b/src/Orchard.Web/Modules/Orchard.Users/Views/EditorTemplates/Parts/Users.SuspensionSettings.cshtml @@ -0,0 +1,33 @@ +@using Orchard.Localization.Models; +@using Orchard.Localization.Services; +@model Orchard.Users.Models.UserSuspensionSettingsPart +@{ + var _dateLocalizationServices = WorkContext.Resolve(); + var _dateTimeLocalization = WorkContext.Resolve(); + var lastSweepDateString = _dateLocalizationServices + .ConvertToLocalizedString( + Model.LastSweepUtc, + _dateTimeLocalization.ShortDateTimeFormat, + new DateLocalizationOptions() { NullText = T("Never").Text }); +} +
+ @T("Automated User Moderation") +
+ @Html.EditorFor(m => m.SuspendInactiveUsers) + +
+
+ + @Html.TextBoxFor(m => m.AllowedInactivityDays, new { @class = "text medium", @Value = Model.AllowedInactivityDays }) + @Html.ValidationMessage("AllowedInactivityDays", "*") +
+
+ + @Html.TextBoxFor(m => m.MinimumSweepInterval, new { @class = "text medium", @Value = Model.MinimumSweepInterval }) + @Html.ValidationMessage("MinimumSweepInterval", "*") +
+
+
+
diff --git a/src/Orchard/Orchard.Framework.csproj b/src/Orchard/Orchard.Framework.csproj index 6d3e1306d..31a5b016f 100644 --- a/src/Orchard/Orchard.Framework.csproj +++ b/src/Orchard/Orchard.Framework.csproj @@ -210,11 +210,16 @@ + + + + + diff --git a/src/Orchard/Security/CreateUserParams.cs b/src/Orchard/Security/CreateUserParams.cs index ae47bae64..6dba06ce9 100644 --- a/src/Orchard/Security/CreateUserParams.cs +++ b/src/Orchard/Security/CreateUserParams.cs @@ -7,14 +7,16 @@ namespace Orchard.Security { private readonly string _passwordQuestion; private readonly string _passwordAnswer; private readonly bool _isApproved; + private readonly bool _forcePasswordChange; - public CreateUserParams(string username, string password, string email, string passwordQuestion, string passwordAnswer, bool isApproved) { + public CreateUserParams(string username, string password, string email, string passwordQuestion, string passwordAnswer, bool isApproved, bool forcePasswordChange) { _username = username; _password = password; _email = email; _passwordQuestion = passwordQuestion; _passwordAnswer = passwordAnswer; _isApproved = isApproved; + _forcePasswordChange = forcePasswordChange; } public string Username { @@ -40,5 +42,9 @@ namespace Orchard.Security { public bool IsApproved { get { return _isApproved; } } + + public bool ForcePasswordChange { + get { return _forcePasswordChange; } + } } } \ No newline at end of file diff --git a/src/Orchard/Security/IMembershipSettings.cs b/src/Orchard/Security/IMembershipSettings.cs index bf2748c65..ed7b68fcf 100644 --- a/src/Orchard/Security/IMembershipSettings.cs +++ b/src/Orchard/Security/IMembershipSettings.cs @@ -19,5 +19,7 @@ namespace Orchard.Security { bool EnablePasswordExpiration { get; set; } int PasswordExpirationTimeInDays { get; set; } MembershipPasswordFormat PasswordFormat { get; set; } + bool EnablePasswordHistoryPolicy { get; set; } + int PasswordReuseLimit { get; set; } } } \ No newline at end of file diff --git a/src/Orchard/Security/IPasswordHistoryService.cs b/src/Orchard/Security/IPasswordHistoryService.cs new file mode 100644 index 000000000..5704e9b68 --- /dev/null +++ b/src/Orchard/Security/IPasswordHistoryService.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Orchard.Security; + +namespace Orchard.Security { + public interface IPasswordHistoryService : IDependency { + void CreateEntry(PasswordHistoryEntry context); + IEnumerable GetLastPasswords(IUser user, int count); + bool PasswordMatchLastOnes(string Password, IUser user, int count); + } +} diff --git a/src/Orchard/Security/IPasswordService.cs b/src/Orchard/Security/IPasswordService.cs new file mode 100644 index 000000000..bcde4d22b --- /dev/null +++ b/src/Orchard/Security/IPasswordService.cs @@ -0,0 +1,5 @@ +namespace Orchard.Security { + public interface IPasswordService : IDependency { + bool IsMatch(PasswordContext context, string plaintextPassword); + } +} \ No newline at end of file diff --git a/src/Orchard/Security/PasswordContext.cs b/src/Orchard/Security/PasswordContext.cs new file mode 100644 index 000000000..634399a41 --- /dev/null +++ b/src/Orchard/Security/PasswordContext.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using System.Linq; +using System.Web; +using System.Web.Security; + +namespace Orchard.Security { + public class PasswordContext { + public string Password { get; set; } + public string PasswordSalt { get; set; } + public string HashAlgorithm { get; set; } + public MembershipPasswordFormat PasswordFormat { get; set; } + } +} \ No newline at end of file diff --git a/src/Orchard/Security/PasswordExtensions.cs b/src/Orchard/Security/PasswordExtensions.cs new file mode 100644 index 000000000..783ff5b1d --- /dev/null +++ b/src/Orchard/Security/PasswordExtensions.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using System.Web.Helpers; + +namespace Orchard.Security { + public static class PasswordExtensions { + + public const string PBKDF2 = "PBKDF2"; + + public static string ComputeHashBase64(string hashAlgorithmName, byte[] saltBytes, string password) { + var combinedBytes = CombineSaltAndPassword(saltBytes, password); + + // Extending HashAlgorithm would be too complicated: http://stackoverflow.com/questions/6460711/adding-a-custom-hashalgorithmtype-in-c-sharp-asp-net?lq=1 + if (hashAlgorithmName == PBKDF2) { + // HashPassword() already returns a base64 string. + return Crypto.HashPassword(Encoding.Unicode.GetString(combinedBytes)); + } + else { + using (var hashAlgorithm = HashAlgorithm.Create(hashAlgorithmName)) { + return Convert.ToBase64String(hashAlgorithm.ComputeHash(combinedBytes)); + } + } + } + + public static byte[] CombineSaltAndPassword(byte[] saltBytes, string password) { + var passwordBytes = Encoding.Unicode.GetBytes(password); + return saltBytes.Concat(passwordBytes).ToArray(); + } + + } +} diff --git a/src/Orchard/Security/PasswordHistoryEntry.cs b/src/Orchard/Security/PasswordHistoryEntry.cs new file mode 100644 index 000000000..da0c2672d --- /dev/null +++ b/src/Orchard/Security/PasswordHistoryEntry.cs @@ -0,0 +1,8 @@ +using System; + +namespace Orchard.Security { + public class PasswordHistoryEntry : PasswordContext { + public IUser User { get; set; } + public DateTime? LastPasswordChangeUtc { get; set; } + } +} \ No newline at end of file