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 <hermes.sbicego@laser-group.com>
This commit is contained in:
Matteo Piovanelli
2022-01-14 10:32:07 +01:00
committed by GitHub
parent 1e1668fdc2
commit b042873252
65 changed files with 1289 additions and 234 deletions

View File

@@ -40,7 +40,7 @@ namespace Orchard.Specs.Bindings {
var memberShipService = environment.Resolve<IMembershipService>();
var roleService = environment.Resolve<IRoleService>();
var userRoleRepository = environment.Resolve<IRepository<UserRolesPartRecord>>();
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);

View File

@@ -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<IRepository<UserPartRecord>>();
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;

View File

@@ -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);

View File

@@ -49,6 +49,9 @@ namespace Orchard.AuditTrail.Providers.Users {
_auditTrailManager.CreateRecord<UserAuditTrailEventProvider>(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) {
}
}
}

View File

@@ -100,9 +100,7 @@
<Compile Include="Activities\EmailActivity.cs" />
<Compile Include="Controllers\EmailAdminController.cs" />
<Compile Include="Forms\MailForms.cs" />
<Compile Include="Forms\EmailForm.cs">
<SubType>Component</SubType>
</Compile>
<Compile Include="Forms\EmailForm.cs" />
<Compile Include="Migrations.cs" />
<Compile Include="Drivers\SmtpSettingsPartDriver.cs" />
<Compile Include="Handlers\SmtpSettingsPartHandler.cs" />

View File

@@ -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;

View File

@@ -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<RolesUserSuspensionSettingsPart> {
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;
}
}
}

View File

@@ -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<RolesUserSuspensionSettingsPart>("Site"));
}
public Localizer T { get; set; }
}
}

View File

@@ -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<RoleSuspensionConfiguration> Configuration {
get {
return JsonConvert
.DeserializeObject<List<RoleSuspensionConfiguration>>(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); }
}
}
}

View File

@@ -62,6 +62,9 @@
<Reference Include="Microsoft.Web.Infrastructure, Version=1.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35, processorArchitecture=MSIL">
<HintPath>..\..\..\packages\Microsoft.Web.Infrastructure.1.0.0.0\lib\net40\Microsoft.Web.Infrastructure.dll</HintPath>
</Reference>
<Reference Include="Newtonsoft.Json, Version=12.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL">
<HintPath>..\..\..\packages\Newtonsoft.Json.12.0.3\lib\net45\Newtonsoft.Json.dll</HintPath>
</Reference>
<Reference Include="System" />
<Reference Include="System.ComponentModel.DataAnnotations" />
<Reference Include="System.Data.DataSetExtensions" />
@@ -100,6 +103,7 @@
<Compile Include="Commands\RoleCommands.cs" />
<Compile Include="Constants\SystemRoles.cs" />
<Compile Include="Controllers\AdminController.cs" />
<Compile Include="Drivers\RolesUserSuspensionSettingsPartDriver.cs" />
<Compile Include="Drivers\UserTaskDriver.cs" />
<Compile Include="Events\IRoleEventHandler.cs" />
<Compile Include="Events\PermissionAddedContext.cs" />
@@ -115,6 +119,8 @@
<Compile Include="Extensions\UserRolesExtensions.cs" />
<Compile Include="Forms\SelectRolesForms.cs" />
<Compile Include="Forms\UserTaskForms.cs" />
<Compile Include="Handlers\RolesUserSuspensionSettingsPartHandler.cs" />
<Compile Include="Models\RolesUserSuspensionSettingsPart.cs" />
<Compile Include="Recipes\Builders\RolesStep.cs" />
<Compile Include="Recipes\Executors\RolesStep.cs" />
<Compile Include="Migrations.cs" />
@@ -134,9 +140,11 @@
<Compile Include="Services\IRoleService.cs" />
<Compile Include="Services\RolesBasedAuthorizationService.cs" />
<Compile Include="Services\RoleService.cs" />
<Compile Include="Services\RolesUserSuspensionConditionProvider.cs" />
<Compile Include="ViewModels\RoleCreateViewModel.cs" />
<Compile Include="ViewModels\RoleEditViewModel.cs" />
<Compile Include="ViewModels\RolesIndexViewModel.cs" />
<Compile Include="ViewModels\RolesUserSuspensionSettingsViewModel.cs" />
<Compile Include="ViewModels\UserRolesViewModel.cs" />
</ItemGroup>
<ItemGroup>
@@ -167,6 +175,10 @@
<Project>{642a49d7-8752-4177-80d6-bfbbcfad3de0}</Project>
<Name>Orchard.Forms</Name>
</ProjectReference>
<ProjectReference Include="..\Orchard.Users\Orchard.Users.csproj">
<Project>{79aed36e-abd0-4747-93d3-8722b042454b}</Project>
<Name>Orchard.Users</Name>
</ProjectReference>
<ProjectReference Include="..\Orchard.Workflows\Orchard.Workflows.csproj">
<Project>{7059493c-8251-4764-9c1e-2368b8b485bc}</Project>
<Name>Orchard.Workflows</Name>
@@ -203,6 +215,7 @@
</ItemGroup>
<ItemGroup>
<None Include="packages.config" />
<Content Include="Views\EditorTemplates\Parts\Roles.UserSuspensionSettings.cshtml" />
</ItemGroup>
<PropertyGroup>
<VisualStudioVersion Condition="'$(VisualStudioVersion)' == ''">10.0</VisualStudioVersion>

View File

@@ -1,3 +1,4 @@
<Placement>
<Place Parts_Roles_UserRoles_Edit="Content:10"/>
<Place Parts_Roles_UserSuspensionSettings_Edit="Content:6.1"/>
</Placement>

View File

@@ -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<UserRolesPartRecord> _userRolesRepository;
public RolesUserSuspensionConditionProvider(
ISiteService siteService,
IRepository<UserRolesPartRecord> userRolesRepository) {
_siteService = siteService;
_userRolesRepository = userRolesRepository;
}
public IContentQuery<UserPart> AlterQuery(IContentQuery<UserPart> 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<RolesUserSuspensionSettingsPart>();
}
return _settingsPart;
}
}
}
}

View File

@@ -0,0 +1,22 @@
using System.Collections.Generic;
using Newtonsoft.Json;
namespace Orchard.Roles.ViewModels {
public class RolesUserSuspensionSettingsViewModel {
public RolesUserSuspensionSettingsViewModel() {
Configuration = new List<RoleSuspensionConfiguration>();
}
public List<RoleSuspensionConfiguration> 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; }
}
}

View File

@@ -0,0 +1,26 @@
@model Orchard.Roles.ViewModels.RolesUserSuspensionSettingsViewModel
<fieldset>
<legend>
@T("Automated User Moderation: rules on roles")
</legend>
<div>
<p>@T("Select the user roles that will be safe from automated user suspension (this only applies if automated suspension is enabled).")</p>
<div style="margin-left: 30px;">
@if (Model.Configuration.Any()) {
var index = 0;
foreach (var entry in Model.Configuration) {
@Html.Hidden("Configuration["+index+"].RoleId", entry.RoleId)
<div>
@Html.CheckBox("Configuration["+index+"].IsSafeFromSuspension", entry.IsSafeFromSuspension)
<label class="forcheckbox" for="@Html.FieldIdFor(m => m.Configuration[index].IsSafeFromSuspension)">@entry.RoleLabel</label>
</div>
index++;
}
} else {
<p>@T("There are no roles.")</p>
}
</div>
</div>
</fieldset>

View File

@@ -5,4 +5,5 @@
<package id="Microsoft.AspNet.WebPages" version="3.2.7" targetFramework="net48" />
<package id="Microsoft.CodeDom.Providers.DotNetCompilerPlatform" version="2.0.1" targetFramework="net48" />
<package id="Microsoft.Web.Infrastructure" version="1.0.0.0" targetFramework="net48" />
<package id="Newtonsoft.Json" version="12.0.3" targetFramework="net48" />
</packages>

View File

@@ -194,7 +194,7 @@ namespace Orchard.Setup.Services {
var membershipService = environment.Resolve<IMembershipService>();
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<IAuthenticationService>();

View File

@@ -76,7 +76,8 @@ namespace Orchard.Users.Activities {
email,
isApproved: approved,
passwordQuestion: null,
passwordAnswer: null));
passwordAnswer: null,
forcePasswordChange: false));
workflowContext.Content = user;

View File

@@ -43,14 +43,14 @@ namespace Orchard.Users.Commands {
}
IDictionary<string, LocalizedString> 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;

View File

@@ -5,5 +5,6 @@
public const string PasswordDoesNotContainUppercase = "PasswordDoesNotContainUppercase";
public const string PasswordDoesNotContainLowercase = "PasswordDoesNotContainLowercase";
public const string PasswordDoesNotContainSpecialCharacters = "PasswordDoesNotContainSpecialCharacters";
public const string PasswordDoesNotMeetHistoryPolicy = "PasswordDoesNotMeetHistoryPolicy";
}
}

View File

@@ -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<UserPart>().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<UserPart>().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<UserPart>(), 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<UserPart>().LastPasswordChangeUtc;
if (lastPasswordChangeUtc.Value.AddDays(membershipSettings.PasswordExpirationTimeInDays) > _clock.UtcNow) {
var userPart = _membershipService.GetUser(username).As<UserPart>();
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<LocalizedString> 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<SecuritySettingsPart>().ShouldInvalidateAuthOnPasswordChanged) {
if (_orchardServices.WorkContext
.CurrentSite.As<SecuritySettingsPart>()
.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<SecuritySettingsPart>()
.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 <a href=\"{0}\">this link</a> 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) {

View File

@@ -192,7 +192,7 @@ namespace Orchard.Users.Controllers {
IDictionary<string, LocalizedString> 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);

View File

@@ -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<UserPart> {
@@ -38,22 +38,21 @@ 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.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<string, LocalizedString> validationErrors;
if (!_userService.PasswordMeetsPolicies(editModel.Password, out validationErrors)) {
updater.AddModelErrors(validationErrors);
}
IDictionary<string, LocalizedString> validationErrors;
if (!_userService.PasswordMeetsPolicies(editModel.Password, part, out validationErrors)) {
updater.AddModelErrors(validationErrors);
}
}
}
}

View File

@@ -0,0 +1,27 @@
using Orchard.ContentManagement;
using Orchard.ContentManagement.Drivers;
using Orchard.Users.Models;
namespace Orchard.Users.Drivers {
public class UserSecurityConfigurationPartDriver : ContentPartDriver<UserSecurityConfigurationPart> {
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);
}
}
}

View File

@@ -38,6 +38,11 @@ namespace Orchard.Users.Events {
/// </summary>
void AccessDenied(IUser user);
/// <summary>
/// Called before a user has changed password
/// </summary>
void ChangingPassword(IUser user, string password);
/// <summary>
/// Called after a user has changed password
/// </summary>

View File

@@ -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<UserPart>();
_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<UserPart>().ForcePasswordChange)
user.As<UserPart>().ForcePasswordChange = false;
// Store in the password history the previous password
_passwordHistoryService.CreateEntry(_freezedPasswordEntry);
}
public void SentChallengeEmail(IUser user) { }

View File

@@ -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;
}
}
}

View File

@@ -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<UserSecurityConfigurationPartRecord> repository) {
Filters.Add(new ActivatingFilter<UserSecurityConfigurationPart>("User"));
Filters.Add(StorageFilter.For(repository));
}
}
}

View File

@@ -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<UserSuspensionSettingsPart>("Site"));
Filters.Add(
new TemplateFilterForPart<UserSuspensionSettingsPart>("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")));
}
}
}

View File

@@ -89,5 +89,9 @@ namespace Orchard.Users.Handlers {
user,
() => new Dictionary<string, object> { { "User", user } });
}
//TODO evaluate if we need a workflow event for this
public void ChangingPassword(Security.IUser user, string password) {
}
}
}

View File

@@ -23,13 +23,37 @@ namespace Orchard.Users {
.Column<DateTime>("CreatedUtc")
.Column<DateTime>("LastLoginUtc")
.Column<DateTime>("LastLogoutUtc")
.Column<DateTime>("LastPasswordChangeUtc", c => c.WithDefault(new DateTime(1990, 1, 1))))
.Column<DateTime>("LastPasswordChangeUtc", c => c.WithDefault(new DateTime(1990, 1, 1)))
.Column<bool>("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<int>("Id", col => col.PrimaryKey().Identity())
.Column<int>("UserPartRecord_Id")
.Column<string>("Password")
.Column<string>("PasswordFormat")
.Column<string>("HashAlgorithm")
.Column<string>("PasswordSalt")
.Column<DateTime>("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<bool>("SaveFromSuspension")
.Column<bool>("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<bool>("ForcePasswordChange");
});
SchemaBuilder
.CreateTable("PasswordHistoryRecord", table => table
.Column<int>("Id", col => col.PrimaryKey().Identity())
.Column<int>("UserPartRecord_Id")
.Column<string>("Password")
.Column<string>("PasswordFormat")
.Column<string>("HashAlgorithm")
.Column<string>("PasswordSalt")
.Column<DateTime>("LastPasswordChangeUtc"))
.AlterTable("PasswordHistoryRecord", table => table
.CreateIndex($"IDX_UserPartRecord_Id", "UserPartRecord_Id"));
return 8;
}
public int UpdateFrom8() {
SchemaBuilder
.CreateTable("UserSecurityConfigurationPartRecord", table => table
.ContentPartRecord()
.Column<bool>("SaveFromSuspension")
.Column<bool>("PreventPasswordExpiration"));
return 9;
}
}
}

View File

@@ -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; }
}
}

View File

@@ -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); }
}
}
}

View File

@@ -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); }
}
/// <summary>
/// Set to true means that the user must change the password at next logon
/// </summary>
public bool ForcePasswordChange {
get { return Retrieve(x => x.ForcePasswordChange); }
set { Store(x => x.ForcePasswordChange, value); }
}
}
}

View File

@@ -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; }
}
}

View File

@@ -0,0 +1,15 @@
using Orchard.ContentManagement;
namespace Orchard.Users.Models {
public class UserSecurityConfigurationPart : ContentPart<UserSecurityConfigurationPartRecord> {
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); }
}
}
}

View File

@@ -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; }
}
}

View File

@@ -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); }
}
}
}

View File

@@ -104,6 +104,7 @@
<Compile Include="Constants\UserPasswordValidationResults.cs" />
<Compile Include="Controllers\AccountController.cs" />
<Compile Include="Controllers\AdminController.cs" />
<Compile Include="Drivers\UserSecurityConfigurationPartDriver.cs" />
<Compile Include="Drivers\UserApprovePartDriver.cs" />
<Compile Include="Drivers\UserPartDriver.cs" />
<Compile Include="Drivers\UserPartPasswordDriver.cs" />
@@ -114,7 +115,9 @@
<Compile Include="Forms\VerifyUserUnicityForm.cs" />
<Compile Include="Forms\CreateUserForm.cs" />
<Compile Include="Handlers\ApproveUserHandler.cs" />
<Compile Include="Handlers\UserSecurityConfigurationPartHandler.cs" />
<Compile Include="Handlers\SecuritySettingsPartHandler.cs" />
<Compile Include="Handlers\UserSuspensionSettingsPartHandler.cs" />
<Compile Include="Handlers\WorkflowUserEventHandler.cs" />
<Compile Include="Extensions\ModelStateDictionaryExtensions.cs" />
<Compile Include="Migrations.cs" />
@@ -122,20 +125,29 @@
<Compile Include="Handlers\RegistrationSettingsPartHandler.cs" />
<Compile Include="Events\IUserEventHandler.cs" />
<Compile Include="Models\MessageTypes.cs" />
<Compile Include="Models\PasswordHistoryRecord.cs" />
<Compile Include="Models\UserSecurityConfigurationPart.cs" />
<Compile Include="Models\UserSecurityConfigurationPartRecord.cs" />
<Compile Include="Models\RegistrationSettingsPart.cs" />
<Compile Include="Models\SecuritySettingsPart.cs" />
<Compile Include="Models\UserPart.cs" />
<Compile Include="Handlers\UserPartHandler.cs" />
<Compile Include="Models\UserPartRecord.cs" />
<Compile Include="Models\UserStatus.cs" />
<Compile Include="Models\UserSuspensionSettingsPart.cs" />
<Compile Include="Permissions.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="Services\ApproveUserService.cs" />
<Compile Include="Services\AccountValidationService.cs" />
<Compile Include="Services\AuthenticationRedirectionFilter.cs" />
<Compile Include="Services\InactiveUserSuspensionBackgroundTask.cs" />
<Compile Include="Services\IUserService.cs" />
<Compile Include="Services\IUserSuspensionConditionProvider.cs" />
<Compile Include="Services\MembershipValidationService.cs" />
<Compile Include="Services\PasswordChangedDateUserDataProvider.cs" />
<Compile Include="Services\PasswordHistoryService.cs" />
<Compile Include="Services\PasswordService.cs" />
<Compile Include="Services\ProtectSpecificUserConditionProvider.cs" />
<Compile Include="Services\UserResolverSelector.cs" />
<Compile Include="Services\MembershipService.cs" />
<Compile Include="AdminMenu.cs" />
@@ -230,10 +242,15 @@
<Content Include="Views\Template.User.Wrapper.cshtml" />
</ItemGroup>
<ItemGroup>
<Content Include="Placement.info" />
<Content Include="Placement.info">
<SubType>Designer</SubType>
</Content>
</ItemGroup>
<ItemGroup>
<None Include="packages.config" />
<Content Include="Views\EditorTemplates\Parts\Users.SuspensionSettings.cshtml" />
<Content Include="Views\EditorTemplates\Parts\User.UserSecurityConfiguration.cshtml" />
<Content Include="Views\Account\RequestChallengeEmail.cshtml" />
</ItemGroup>
<ItemGroup>
<Content Include="Views\Account\ChangeExpiredPassword.cshtml" />

View File

@@ -1,6 +1,7 @@
<Placement>
<Match Path="~/Admin/Users/Edit/*">
<Place Parts_User_EditPassword_Edit="Content:1"/>
<Place Parts_User_UserSecurityConfiguration_Edit="Content:1.1"/>
<Place Parts_UserApprove_Edit="Sidebar:25"/> <!-- immediately following the contents module's Approve Now button -->
</Match>
</Placement>

View File

@@ -24,7 +24,7 @@ namespace Orchard.Users.Services {
public bool ValidatePassword(AccountValidationContext context) {
IDictionary<string, LocalizedString> 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)) {

View File

@@ -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<string, string> 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<string, LocalizedString> validationErrors);
bool PasswordMeetsPolicies(string password, IUser user, out IDictionary<string, LocalizedString> validationErrors);
}
}

View File

@@ -0,0 +1,11 @@
using Orchard.ContentManagement;
using Orchard.Users.Models;
namespace Orchard.Users.Services {
public interface IUserSuspensionConditionProvider : IDependency {
IContentQuery<UserPart> AlterQuery(
IContentQuery<UserPart> query);
bool UserIsProtected(UserPart userPart);
}
}

View File

@@ -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<IUserSuspensionConditionProvider> _userSuspensionConditionProviders;
public InactiveUserSuspensionBackgroundTask(
IDistributedLockService distributedLockService,
ISiteService siteService,
IClock clock,
IContentManager contentManager,
IUserEventHandler userEventHandlers,
IAuthorizationService authorizationService,
IEnumerable<IUserSuspensionConditionProvider> 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<UserPart> inactiveUsersQuery = _contentManager
.Query<UserPart>()
.Where<UserPartRecord>(upr =>
// user is enabled
upr.RegistrationStatus == UserStatus.Approved
&& upr.EmailStatus == UserStatus.Approved)
.Where<UserPartRecord>(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<UserSuspensionSettingsPart>();
}
return _settingsPart;
}
#endregion
}
}

View File

@@ -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<UserPart, UserPartRecord>().Where(u => u.Email == lowerName).List().FirstOrDefault();
if (user == null || ValidatePassword(user.As<UserPart>(), 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<UserPart>().LastPasswordChangeUtc.Value.AddDays(days) < _clock.UtcNow;
// TODO: add providers to extend this
var passwordIsExpired = user.As<UserPart>().LastPasswordChangeUtc.Value.AddDays(days) < _clock.UtcNow;
var securityPart = user.As<UserSecurityConfigurationPart>();
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));
}
}
}
/// <summary>
/// Compares two strings without giving hint about the time it takes to do so.
/// </summary>
/// <param name="a">The first string to compare.</param>
/// <param name="b">The second string to compare.</param>
/// <returns><c>true</c> if both strings are equal, <c>false</c>.</returns>
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);
}
}
}

View File

@@ -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<PasswordHistoryEntry> emptyPasswordHistoryEntries = new List<PasswordHistoryEntry>();
private readonly IRepository<PasswordHistoryRecord> _historyRepository;
private readonly IPasswordService _passwordService;
public PasswordHistoryService(IRepository<PasswordHistoryRecord> historyRepository, IPasswordService passwordService) {
_historyRepository = historyRepository;
_passwordService = passwordService;
}
public void CreateEntry(PasswordHistoryEntry context) {
_historyRepository.Create(new PasswordHistoryRecord {
UserPartRecord = context.User?.As<UserPart>()?.Record,
HashAlgorithm = context.HashAlgorithm,
Password = context.Password,
PasswordFormat = context.PasswordFormat,
PasswordSalt = context.PasswordSalt,
LastPasswordChangeUtc = context.LastPasswordChangeUtc,
});
}
public IEnumerable<PasswordHistoryEntry> 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<UserPart>().Password,
PasswordSalt = user.As<UserPart>().PasswordSalt,
HashAlgorithm = user.As<UserPart>().HashAlgorithm,
PasswordFormat = user.As<UserPart>().PasswordFormat,
LastPasswordChangeUtc = user.As<UserPart>().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));
}
}
}

View File

@@ -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);
}
/// <summary>
/// Compares two strings without giving hint about the time it takes to do so.
/// </summary>
/// <param name="a">The first string to compare.</param>
/// <param name="b">The second string to compare.</param>
/// <returns><c>true</c> if both strings are equal, <c>false</c>.</returns>
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;
}
}
}

View File

@@ -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<UserPart> AlterQuery(
IContentQuery<UserPart> query) {
// Don't fetch the users that are protected from suspension
query = query
.Where<UserSecurityConfigurationPartRecord>(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<UserSecurityConfigurationPart>()
.SaveFromSuspension;
}
}
}

View File

@@ -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,6 +31,7 @@ namespace Orchard.Users.Services {
private readonly IShapeFactory _shapeFactory;
private readonly IShapeDisplay _shapeDisplay;
private readonly ISiteService _siteService;
private readonly IPasswordHistoryService _passwordHistoryService;
public UserService(
IContentManager contentManager,
@@ -40,7 +42,8 @@ namespace Orchard.Users.Services {
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;
}
@@ -155,8 +158,7 @@ namespace Orchard.Users.Services {
}
public bool SendLostPasswordEmail(string usernameOrEmail, Func<string, string> createUrl) {
var lowerName = usernameOrEmail.ToLowerInvariant();
var user = _contentManager.Query<UserPart, UserPartRecord>().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<string, LocalizedString> validationErrors) {
public bool PasswordMeetsPolicies(string password, IUser user, out IDictionary<string, LocalizedString> validationErrors) {
validationErrors = new Dictionary<string, LocalizedString>();
var settings = _siteService.GetSiteSettings().As<RegistrationSettingsPart>();
@@ -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<UserPart, UserPartRecord>()
.Where(u => u.NormalizedUserName == lowerName || u.Email == lowerName)
.Slice(0, 1)
.FirstOrDefault();
}
}
}

View File

@@ -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; }
}
}

View File

@@ -16,6 +16,11 @@ namespace Orchard.Users.ViewModels {
set { User.As<UserPart>().Email = value; }
}
public bool ForcePasswordChange {
get { return User.As<UserPart>().ForcePasswordChange; }
set { User.As<UserPart>().ForcePasswordChange = value; }
}
public IContent User { get; set; }
}
}

View File

@@ -1,3 +1,7 @@
@model dynamic
<h1>@Html.TitleForPage(T("Challenge Email").ToString()) </h1>
<p>@T("Your email address could not be validated.") </p>
<p>
@T("Follow <a href=\"{0}\">this link</a> to request a new challenge email.",
Url.Action("RequestChallengeEmail"))
</p>

View File

@@ -0,0 +1,24 @@
@model string
@{
var prefill = (string)Model;
}
<h1>@Html.TitleForPage(T("Confirm Email Address").ToString())</h1>
<p>@T("Please enter your username or email address. You will receive a link via email to validate your address.")</p>
@using (Html.BeginFormAntiForgeryPost()) {
<fieldset>
<legend>@T("Account Information")</legend>
<div>
<label for="username">@T("Username or E-mail:")</label>
@if (string.IsNullOrWhiteSpace(prefill)) {
@Html.TextBox("username")
} else {
@Html.TextBox("username", value: prefill)
}
@Html.ValidationMessage("username")
</div>
<div>
<button class="primaryAction" type="submit">@T("Send Request")</button>
</div>
</fieldset>
}

View File

@@ -19,3 +19,7 @@
@Html.PasswordFor(m=>m.ConfirmPassword, new { @class = "text medium" })
@Html.ValidationMessageFor(m=>m.ConfirmPassword, "*")
</fieldset>
<fieldset>
@Html.CheckBoxFor(m => m.ForcePasswordChange)
@Html.LabelFor(m => m.ForcePasswordChange, T("User must change password at next logon").Text, new { @class = "forcheckbox" })
</fieldset>

View File

@@ -9,3 +9,7 @@
@Html.TextBoxFor(m=>m.Email, new { @class = "text medium" })
@Html.ValidationMessageFor(m=>m.Email, "*")
</fieldset>
<fieldset>
@Html.CheckBoxFor(m => m.ForcePasswordChange)
@Html.LabelFor(m => m.ForcePasswordChange, T("User must change password at next logon").Text, new { @class = "forcheckbox" })
</fieldset>

View File

@@ -0,0 +1,13 @@
@model Orchard.Users.Models.UserSecurityConfigurationPart
<fieldset>
@Html.CheckBoxFor(m => m.SaveFromSuspension)
@Html.LabelFor(m => m.SaveFromSuspension,
T("User will not be automatically suspended by periodic sweeps.").Text,
new { @class = "forcheckbox" })
</fieldset>
<fieldset>
@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" })
</fieldset>

View File

@@ -66,56 +66,64 @@
new SelectListItem { Text = T("Encrypted").Text, Value = MembershipPasswordFormat.Encrypted.ToString() }
}, "Value", "Text"))
</div>
<div>
@Html.EditorFor(m => m.EnablePasswordHistoryPolicy)
<label class="forcheckbox" for="@Html.FieldIdFor(m => m.EnablePasswordHistoryPolicy)">@T("Enable password history policy")</label>
<div data-controllerid="@Html.FieldIdFor(m => m.EnablePasswordHistoryPolicy)" style="margin-left: 30px;">
<label for="@Html.FieldIdFor(m => m.PasswordReuseLimit)">@T("Block reuse of last n passwords")</label>
@Html.TextBoxFor(m => m.PasswordReuseLimit, new { @class = "text medium", @Value = Model.PasswordReuseLimit })
@Html.ValidationMessage("PasswordReuseLimit", "*")
</div>
</div>
</div>
</div>
<div>
<input type="checkbox" value="true" class="check-box" id="@Html.FieldIdFor(m => m.EnableLostPassword)" name="@Html.FieldNameFor(m => m.EnableLostPassword)" @(Model.EnableLostPassword ? "checked=\"checked\"" : "") @(emailEnabled ? "" : "disabled=\"disabled\"") />
<input name="@Html.FieldNameFor(m => m.EnableLostPassword)" type="hidden" value="false">
<label class="forcheckbox" for="@Html.FieldIdFor(m => m.EnableLostPassword)">@T("Display a link to enable users to reset their password")</label>
<div>
<input type="checkbox" value="true" class="check-box" id="@Html.FieldIdFor(m => m.EnableLostPassword)" name="@Html.FieldNameFor(m => m.EnableLostPassword)" @(Model.EnableLostPassword ? "checked=\"checked\"" : "") @(emailEnabled ? "" : "disabled=\"disabled\"")/>
<input name="@Html.FieldNameFor(m => m.EnableLostPassword)" type="hidden" value="false">
<label class="forcheckbox" for="@Html.FieldIdFor(m => m.EnableLostPassword)">@T("Display a link to enable users to reset their password")</label>
@if (!emailEnabled) {
<div class="message message-Warning">@T("This option is available when an email module is activated.")</div>
}
</div>
<div>
<input type="checkbox" value="true" class="check-box" id="@Html.FieldIdFor(m => m.UsersMustValidateEmail)" name="@Html.FieldNameFor(m => m.UsersMustValidateEmail)" @(Model.UsersMustValidateEmail ? "checked=\"checked\"" : "") @(emailEnabled ? "" : "disabled=\"disabled\"") />
<input name="@Html.FieldNameFor(m => m.UsersMustValidateEmail)" type="hidden" value="false">
<label class="forcheckbox" for="@Html.FieldIdFor(m => m.UsersMustValidateEmail)">@T("Users must verify their email address")</label>
@if(!emailEnabled) {
<div class="message message-Warning">@T("This option is available when an email module is activated.")</div>
}
</div>
<div>
<input type="checkbox" value="true" class="check-box" id="@Html.FieldIdFor(m => m.UsersMustValidateEmail)" name="@Html.FieldNameFor(m => m.UsersMustValidateEmail)" @(Model.UsersMustValidateEmail ? "checked=\"checked\"" : "") @(emailEnabled ? "" : "disabled=\"disabled\"")/>
<input name="@Html.FieldNameFor(m => m.UsersMustValidateEmail)" type="hidden" value="false">
<label class="forcheckbox" for="@Html.FieldIdFor(m => m.UsersMustValidateEmail)">@T("Users must verify their email address")</label>
@if (!emailEnabled) {
<div class="message message-Warning">@T("This option is available when an email module is activated.")</div>
}
</div>
<div data-controllerid="@Html.FieldIdFor(m => m.UsersMustValidateEmail)">
<label for="@Html.FieldIdFor(m => m.ValidateEmailRegisteredWebsite)">@T("Website public name")</label>
@Html.TextBoxFor(m => m.ValidateEmailRegisteredWebsite, new { @class = "text medium" })
@Html.ValidationMessage("ValidateEmailRegisteredWebsite", "*")
<span class="hint">@T("The name of your website as it will appear in the verification e-mail.")</span>
@if(!emailEnabled) {
<div class="message message-Warning">@T("This option is available when an email module is activated.")</div>
}
</div>
<div data-controllerid="@Html.FieldIdFor(m => m.UsersMustValidateEmail)">
<label for="@Html.FieldIdFor(m => m.ValidateEmailRegisteredWebsite)">@T("Website public name")</label>
@Html.TextBoxFor(m => m.ValidateEmailRegisteredWebsite, new { @class = "text medium" } )
@Html.ValidationMessage("ValidateEmailRegisteredWebsite", "*")
<span class="hint">@T("The name of your website as it will appear in the verification e-mail.")</span>
<label for="@Html.FieldIdFor(m => m.ValidateEmailContactEMail)">@T("Contact Us E-Mail address")</label>
@Html.TextBoxFor(m => m.ValidateEmailContactEMail, new { @class = "text medium" })
@Html.ValidationMessage("ValidateEmailContactEMail", "*")
<span class="hint">@T("The e-mail address displayed in the verification e-mail for a Contact Us link. Leave empty for no link.")</span>
</div>
<div>
@Html.EditorFor(m => m.UsersAreModerated)
<label class="forcheckbox" for="@Html.FieldIdFor(m => m.UsersAreModerated)">@T("Users must be approved before they can log in")</label>
</div>
<div data-controllerid="@Html.FieldIdFor(m => m.UsersAreModerated)">
<input type="checkbox" value="true" class="check-box" id="@Html.FieldIdFor(m => m.NotifyModeration)" name="@Html.FieldNameFor(m => m.NotifyModeration)" @(Model.NotifyModeration ? "checked=\"checked\"" : "") @(emailEnabled ? "" : "disabled=\"disabled\"") />
<input name="@Html.FieldNameFor(m => m.NotifyModeration)" type="hidden" value="false">
<label class="forcheckbox" for="@Html.FieldIdFor(m => m.NotifyModeration)">@T("Send a notification when a user needs moderation")</label>
<label for="@Html.FieldIdFor(m => m.ValidateEmailContactEMail)">@T("Contact Us E-Mail address")</label>
@Html.TextBoxFor(m => m.ValidateEmailContactEMail, new { @class = "text medium" } )
@Html.ValidationMessage("ValidateEmailContactEMail", "*")
<span class="hint">@T("The e-mail address displayed in the verification e-mail for a Contact Us link. Leave empty for no link.")</span>
</div>
<div>
@Html.EditorFor(m => m.UsersAreModerated)
<label class="forcheckbox" for="@Html.FieldIdFor(m => m.UsersAreModerated)">@T("Users must be approved before they can log in")</label>
</div>
<div data-controllerid="@Html.FieldIdFor(m => m.UsersAreModerated)">
<input type="checkbox" value="true" class="check-box" id="@Html.FieldIdFor(m => m.NotifyModeration)" name="@Html.FieldNameFor(m => m.NotifyModeration)" @(Model.NotifyModeration ? "checked=\"checked\"" : "") @(emailEnabled ? "" : "disabled=\"disabled\"")/>
<input name="@Html.FieldNameFor(m => m.NotifyModeration)" type="hidden" value="false">
<label class="forcheckbox" for="@Html.FieldIdFor(m => m.NotifyModeration)">@T("Send a notification when a user needs moderation")</label>
@if (!emailEnabled) {
<div class="message message-Warning">@T("This option is available when an email module is activated.")</div>
}
@if(!emailEnabled) {
<div class="message message-Warning">@T("This option is available when an email module is activated.")</div>
}
</div>
<div data-controllerid="@Html.FieldIdFor(m => m.NotifyModeration)">
<label for="@Html.FieldIdFor(m => m.NotificationsRecipients)">@T("Moderators")</label>
@Html.TextBoxFor(m => m.NotificationsRecipients, new { @class = "text medium" } )
@Html.ValidationMessage("NotificationsRecipients", "*")
<span class="hint">@T("The usernames to send the notifications to (e.g., \"admin, user1, ...\").")</span>
</div>
</div>
<div data-controllerid="@Html.FieldIdFor(m => m.NotifyModeration)">
<label for="@Html.FieldIdFor(m => m.NotificationsRecipients)">@T("Moderators")</label>
@Html.TextBoxFor(m => m.NotificationsRecipients, new { @class = "text medium" })
@Html.ValidationMessage("NotificationsRecipients", "*")
<span class="hint">@T("The usernames to send the notifications to (e.g., \"admin, user1, ...\").")</span>
</div>
</fieldset>

View File

@@ -0,0 +1,33 @@
@using Orchard.Localization.Models;
@using Orchard.Localization.Services;
@model Orchard.Users.Models.UserSuspensionSettingsPart
@{
var _dateLocalizationServices = WorkContext.Resolve<IDateLocalizationServices>();
var _dateTimeLocalization = WorkContext.Resolve<IDateTimeFormatProvider>();
var lastSweepDateString = _dateLocalizationServices
.ConvertToLocalizedString(
Model.LastSweepUtc,
_dateTimeLocalization.ShortDateTimeFormat,
new DateLocalizationOptions() { NullText = T("Never").Text });
}
<fieldset>
<legend>@T("Automated User Moderation")</legend>
<div>
@Html.EditorFor(m => m.SuspendInactiveUsers)
<label class="forcheckbox" for="@Html.FieldIdFor(m => m.SuspendInactiveUsers)">
@T("Automatically suspend users after a period of inactivity.")
</label>
<div data-controllerid="@Html.FieldIdFor(m => m.SuspendInactiveUsers)" style="margin-left: 30px;">
<div>
<label for="@Html.FieldIdFor(m => m.AllowedInactivityDays)">@T("Allowed inactivity days. Users will be suspended only if this period is more than 0 days.")</label>
@Html.TextBoxFor(m => m.AllowedInactivityDays, new { @class = "text medium", @Value = Model.AllowedInactivityDays })
@Html.ValidationMessage("AllowedInactivityDays", "*")
</div>
<div>
<label for="@Html.FieldIdFor(m => m.MinimumSweepInterval)">@T("Minimum time in hours between runs checking for users to suspend. (Last time checked: {0})", lastSweepDateString)</label>
@Html.TextBoxFor(m => m.MinimumSweepInterval, new { @class = "text medium", @Value = Model.MinimumSweepInterval })
@Html.ValidationMessage("MinimumSweepInterval", "*")
</div>
</div>
</div>
</fieldset>

View File

@@ -210,11 +210,16 @@
<Compile Include="Localization\Services\ILocalizationStreamParser.cs" />
<Compile Include="Localization\Services\LocalizationStreamParser.cs" />
<Compile Include="Mvc\Html\LinkExtensions.cs" />
<Compile Include="Security\IPasswordHistoryService.cs" />
<Compile Include="Security\IPasswordService.cs" />
<Compile Include="Security\ISecurityService.cs" />
<Compile Include="Security\ISslSettingsProvider.cs" />
<Compile Include="Security\IUserDataProvider.cs" />
<Compile Include="Security\NullAccountValidationService.cs" />
<Compile Include="Security\NullMembershipService.cs" />
<Compile Include="Security\PasswordContext.cs" />
<Compile Include="Security\PasswordExtensions.cs" />
<Compile Include="Security\PasswordHistoryEntry.cs" />
<Compile Include="Security\Providers\BaseUserDataProvider.cs" />
<Compile Include="Security\Providers\DefaultSecurityService.cs" />
<Compile Include="Security\Providers\DefaultSslSettingsProvider.cs" />

View File

@@ -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; }
}
}
}

View File

@@ -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; }
}
}

View File

@@ -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<PasswordHistoryEntry> GetLastPasswords(IUser user, int count);
bool PasswordMatchLastOnes(string Password, IUser user, int count);
}
}

View File

@@ -0,0 +1,5 @@
namespace Orchard.Security {
public interface IPasswordService : IDependency {
bool IsMatch(PasswordContext context, string plaintextPassword);
}
}

View File

@@ -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; }
}
}

View File

@@ -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();
}
}
}

View File

@@ -0,0 +1,8 @@
using System;
namespace Orchard.Security {
public class PasswordHistoryEntry : PasswordContext {
public IUser User { get; set; }
public DateTime? LastPasswordChangeUtc { get; set; }
}
}