Skip to content

Commit 095a731

Browse files
Review: Allow Duplicate Email for Members (#16202)
* init * Aligned default values on security settings. * Added validator for security settings. * Provide default implementation for get members by email. * Refactored constructor of MemberController. * Validate on unique member email only when configured to do so. * Further code tidy and use of DI in constructor. * Used new constructor in tests. * Add unit test for modified behaviour. * Removed validator for security settings (it's not necessary, I got confused with users and members). * Spelling. --------- Co-authored-by: Andy Butland <[email protected]>
1 parent cfb0fc2 commit 095a731

File tree

8 files changed

+219
-49
lines changed

8 files changed

+219
-49
lines changed

src/Umbraco.Core/Configuration/Models/SecuritySettings.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ public class SecuritySettings
1919
internal const bool StaticAllowEditInvariantFromNonDefault = false;
2020
internal const bool StaticAllowConcurrentLogins = false;
2121
internal const string StaticAuthCookieName = "UMB_UCONTEXT";
22+
internal const bool StaticUsernameIsEmail = true;
23+
internal const bool StaticMemberRequireUniqueEmail = true;
2224

2325
internal const string StaticAllowedUserNameCharacters =
2426
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+\\";
@@ -58,7 +60,14 @@ public class SecuritySettings
5860
/// <summary>
5961
/// Gets or sets a value indicating whether the user's email address is to be considered as their username.
6062
/// </summary>
61-
public bool UsernameIsEmail { get; set; } = true;
63+
[DefaultValue(StaticUsernameIsEmail)]
64+
public bool UsernameIsEmail { get; set; } = StaticUsernameIsEmail;
65+
66+
/// <summary>
67+
/// Gets or sets a value indicating whether the member's email address must be unique.
68+
/// </summary>
69+
[DefaultValue(StaticMemberRequireUniqueEmail)]
70+
public bool MemberRequireUniqueEmail { get; set; } = StaticMemberRequireUniqueEmail;
6271

6372
/// <summary>
6473
/// Gets or sets the set of allowed characters for a username

src/Umbraco.Core/Services/IMemberService.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,21 @@ IMember CreateMemberWithIdentity(string username, string email, string name, str
210210
/// </returns>
211211
IMember? GetById(int id);
212212

213+
/// <summary>
214+
/// Get an list of <see cref="IMember"/> for all members with the specified email.
215+
/// </summary>
216+
//// <param name="email">Email to use for retrieval</param>
217+
/// <returns>
218+
/// <see cref="IEnumerable{IMember}" />
219+
/// </returns>
220+
IEnumerable<IMember> GetMembersByEmail(string email)
221+
=>
222+
// TODO (V16): Remove this default implementation.
223+
// The following is very inefficient, but will return the correct data, so probably better than throwing a NotImplementedException
224+
// in the default implentation here, for, presumably rare, cases where a custom IMemberService implementation has been registered and
225+
// does not override this method.
226+
GetAllMembers().Where(x => x.Email.Equals(email));
227+
213228
/// <summary>
214229
/// Gets all Members for the specified MemberType alias
215230
/// </summary>

src/Umbraco.Core/Services/MemberService.cs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -389,16 +389,23 @@ public IEnumerable<IMember> GetAll(
389389
}
390390

391391
/// <summary>
392-
/// Get an <see cref="IMember"/> by email
392+
/// Get an <see cref="IMember"/> by email. If RequireUniqueEmailForMembers is set to false, then the first member found with the specified email will be returned.
393393
/// </summary>
394394
/// <param name="email">Email to use for retrieval</param>
395395
/// <returns><see cref="IMember"/></returns>
396-
public IMember? GetByEmail(string email)
396+
public IMember? GetByEmail(string email) => GetMembersByEmail(email).FirstOrDefault();
397+
398+
/// <summary>
399+
/// Get an list of <see cref="IMember"/> for all members with the specified email.
400+
/// </summary>
401+
/// <param name="email">Email to use for retrieval</param>
402+
/// <returns><see cref="IEnumerable{IMember}"/></returns>
403+
public IEnumerable<IMember> GetMembersByEmail(string email)
397404
{
398405
using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
399406
scope.ReadLock(Constants.Locks.MemberTree);
400407
IQuery<IMember> query = Query<IMember>().Where(x => x.Email.Equals(email));
401-
return _memberRepository.Get(query)?.FirstOrDefault();
408+
return _memberRepository.Get(query);
402409
}
403410

404411
/// <summary>

src/Umbraco.Web.BackOffice/Controllers/MemberController.cs

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@
88
using Microsoft.AspNetCore.Mvc;
99
using Microsoft.Extensions.DependencyInjection;
1010
using Microsoft.Extensions.Logging;
11+
using Microsoft.Extensions.Options;
1112
using Umbraco.Cms.Core;
13+
using Umbraco.Cms.Core.Configuration.Models;
1214
using Umbraco.Cms.Core.ContentApps;
15+
using Umbraco.Cms.Core.DependencyInjection;
1316
using Umbraco.Cms.Core.Dictionary;
1417
using Umbraco.Cms.Core.Events;
1518
using Umbraco.Cms.Core.Mapping;
@@ -26,7 +29,6 @@
2629
using Umbraco.Cms.Web.BackOffice.ModelBinders;
2730
using Umbraco.Cms.Web.Common.Attributes;
2831
using Umbraco.Cms.Web.Common.Authorization;
29-
using Umbraco.Cms.Web.Common.DependencyInjection;
3032
using Umbraco.Cms.Web.Common.Filters;
3133
using Umbraco.Cms.Web.Common.Security;
3234
using Umbraco.Extensions;
@@ -55,6 +57,7 @@ public class MemberController : ContentControllerBase
5557
private readonly ITwoFactorLoginService _twoFactorLoginService;
5658
private readonly IShortStringHelper _shortStringHelper;
5759
private readonly IUmbracoMapper _umbracoMapper;
60+
private readonly SecuritySettings _securitySettings;
5861

5962
/// <summary>
6063
/// Initializes a new instance of the <see cref="MemberController" /> class.
@@ -75,6 +78,7 @@ public class MemberController : ContentControllerBase
7578
/// <param name="passwordChanger">The password changer</param>
7679
/// <param name="scopeProvider">The core scope provider</param>
7780
/// <param name="twoFactorLoginService">The two factor login service</param>
81+
/// <param name="securitySettings">The security settings</param>
7882
[ActivatorUtilitiesConstructor]
7983
public MemberController(
8084
ICultureDictionary cultureDictionary,
@@ -92,7 +96,8 @@ public MemberController(
9296
IJsonSerializer jsonSerializer,
9397
IPasswordChanger<MemberIdentityUser> passwordChanger,
9498
ICoreScopeProvider scopeProvider,
95-
ITwoFactorLoginService twoFactorLoginService)
99+
ITwoFactorLoginService twoFactorLoginService,
100+
IOptions<SecuritySettings> securitySettings)
96101
: base(cultureDictionary, loggerFactory, shortStringHelper, eventMessages, localizedTextService, jsonSerializer)
97102
{
98103
_propertyEditors = propertyEditors;
@@ -108,9 +113,49 @@ public MemberController(
108113
_passwordChanger = passwordChanger;
109114
_scopeProvider = scopeProvider;
110115
_twoFactorLoginService = twoFactorLoginService;
116+
_securitySettings = securitySettings.Value;
111117
}
112118

113-
[Obsolete("Use constructor that also takes an ITwoFactorLoginService. Scheduled for removal in V13")]
119+
[Obsolete("Please use the constructor that takes all paramters. Scheduled for removal in V14")]
120+
public MemberController(
121+
ICultureDictionary cultureDictionary,
122+
ILoggerFactory loggerFactory,
123+
IShortStringHelper shortStringHelper,
124+
IEventMessagesFactory eventMessages,
125+
ILocalizedTextService localizedTextService,
126+
PropertyEditorCollection propertyEditors,
127+
IUmbracoMapper umbracoMapper,
128+
IMemberService memberService,
129+
IMemberTypeService memberTypeService,
130+
IMemberManager memberManager,
131+
IDataTypeService dataTypeService,
132+
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
133+
IJsonSerializer jsonSerializer,
134+
IPasswordChanger<MemberIdentityUser> passwordChanger,
135+
ICoreScopeProvider scopeProvider,
136+
ITwoFactorLoginService twoFactorLoginService)
137+
: this(
138+
cultureDictionary,
139+
loggerFactory,
140+
shortStringHelper,
141+
eventMessages,
142+
localizedTextService,
143+
propertyEditors,
144+
umbracoMapper,
145+
memberService,
146+
memberTypeService,
147+
memberManager,
148+
dataTypeService,
149+
backOfficeSecurityAccessor,
150+
jsonSerializer,
151+
passwordChanger,
152+
scopeProvider,
153+
twoFactorLoginService,
154+
StaticServiceProvider.Instance.GetRequiredService<IOptions<SecuritySettings>>())
155+
{
156+
}
157+
158+
[Obsolete("Please use the constructor that takes all paramters. Scheduled for removal in V14")]
114159
public MemberController(
115160
ICultureDictionary cultureDictionary,
116161
ILoggerFactory loggerFactory,
@@ -461,7 +506,7 @@ private async Task<ActionResult<bool>> CreateMemberAsync(MemberSave contentItem)
461506
}
462507

463508
// now re-look up the member, which will now exist
464-
IMember? member = _memberService.GetByEmail(contentItem.Email);
509+
IMember? member = _memberService.GetByUsername(contentItem.Username);
465510

466511
if (member is null)
467512
{
@@ -699,13 +744,16 @@ private async Task<bool> ValidateMemberDataAsync(MemberSave contentItem)
699744
return false;
700745
}
701746

702-
IMember? byEmail = _memberService.GetByEmail(contentItem.Email);
703-
if (byEmail != null && byEmail.Key != contentItem.Key)
747+
if (_securitySettings.MemberRequireUniqueEmail)
704748
{
705-
ModelState.AddPropertyError(
706-
new ValidationResult("Email address is already in use", new[] { "value" }),
707-
$"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}email");
708-
return false;
749+
IMember? byEmail = _memberService.GetByEmail(contentItem.Email);
750+
if (byEmail != null && byEmail.Key != contentItem.Key)
751+
{
752+
ModelState.AddPropertyError(
753+
new ValidationResult("Email address is already in use", new[] { "value" }),
754+
$"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}email");
755+
return false;
756+
}
709757
}
710758

711759
return true;

src/Umbraco.Web.BackOffice/Filters/MemberSaveModelValidator.cs

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@
22
using Microsoft.AspNetCore.Mvc;
33
using Microsoft.AspNetCore.Mvc.Filters;
44
using Microsoft.AspNetCore.Mvc.ModelBinding;
5+
using Microsoft.Extensions.DependencyInjection;
56
using Microsoft.Extensions.Logging;
7+
using Microsoft.Extensions.Options;
68
using Umbraco.Cms.Core;
9+
using Umbraco.Cms.Core.Configuration.Models;
10+
using Umbraco.Cms.Core.DependencyInjection;
711
using Umbraco.Cms.Core.Models;
812
using Umbraco.Cms.Core.Models.ContentEditing;
913
using Umbraco.Cms.Core.Security;
@@ -16,27 +20,29 @@ namespace Umbraco.Cms.Web.BackOffice.Filters;
1620
/// <summary>
1721
/// Validator for <see cref="ContentItemSave" />
1822
/// </summary>
19-
internal class
20-
MemberSaveModelValidator : ContentModelValidator<IMember, MemberSave, IContentProperties<ContentPropertyBasic>>
23+
internal class MemberSaveModelValidator : ContentModelValidator<IMember, MemberSave, IContentProperties<ContentPropertyBasic>>
2124
{
2225
private readonly IBackOfficeSecurity? _backofficeSecurity;
2326
private readonly IMemberService _memberService;
2427
private readonly IMemberTypeService _memberTypeService;
2528
private readonly IShortStringHelper _shortStringHelper;
29+
private readonly SecuritySettings _securitySettings;
2630

2731
public MemberSaveModelValidator(
28-
ILogger<MemberSaveModelValidator> logger,
29-
IBackOfficeSecurity? backofficeSecurity,
30-
IMemberTypeService memberTypeService,
31-
IMemberService memberService,
32-
IShortStringHelper shortStringHelper,
33-
IPropertyValidationService propertyValidationService)
34-
: base(logger, propertyValidationService)
32+
ILogger<MemberSaveModelValidator> logger,
33+
IBackOfficeSecurity? backofficeSecurity,
34+
IMemberTypeService memberTypeService,
35+
IMemberService memberService,
36+
IShortStringHelper shortStringHelper,
37+
IPropertyValidationService propertyValidationService,
38+
SecuritySettings securitySettings)
39+
: base(logger, propertyValidationService)
3540
{
3641
_backofficeSecurity = backofficeSecurity;
3742
_memberTypeService = memberTypeService ?? throw new ArgumentNullException(nameof(memberTypeService));
3843
_memberService = memberService ?? throw new ArgumentNullException(nameof(memberService));
3944
_shortStringHelper = shortStringHelper ?? throw new ArgumentNullException(nameof(shortStringHelper));
45+
_securitySettings = securitySettings;
4046
}
4147

4248
public override bool ValidatePropertiesData(
@@ -64,8 +70,7 @@ public override bool ValidatePropertiesData(
6470
$"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}email");
6571
}
6672

67-
var validEmail = ValidateUniqueEmail(model);
68-
if (validEmail == false)
73+
if (_securitySettings.MemberRequireUniqueEmail && ValidateUniqueEmail(model) is false)
6974
{
7075
modelState.AddPropertyError(
7176
new ValidationResult("Email address is already in use", new[] { "value" }),

src/Umbraco.Web.BackOffice/Filters/MemberSaveValidationAttribute.cs

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
using Microsoft.AspNetCore.Mvc;
22
using Microsoft.AspNetCore.Mvc.Filters;
33
using Microsoft.Extensions.Logging;
4+
using Microsoft.Extensions.Options;
5+
using Umbraco.Cms.Core.Configuration.Models;
46
using Umbraco.Cms.Core.Models.ContentEditing;
57
using Umbraco.Cms.Core.Security;
68
using Umbraco.Cms.Core.Services;
@@ -25,23 +27,24 @@ private sealed class MemberSaveValidationFilter : IActionFilter
2527
private readonly IMemberTypeService _memberTypeService;
2628
private readonly IPropertyValidationService _propertyValidationService;
2729
private readonly IShortStringHelper _shortStringHelper;
30+
private readonly SecuritySettings _securitySettings;
2831

2932
public MemberSaveValidationFilter(
3033
ILoggerFactory loggerFactory,
3134
IBackOfficeSecurityAccessor backofficeSecurityAccessor,
3235
IMemberTypeService memberTypeService,
3336
IMemberService memberService,
3437
IShortStringHelper shortStringHelper,
35-
IPropertyValidationService propertyValidationService)
38+
IPropertyValidationService propertyValidationService,
39+
IOptions<SecuritySettings> securitySettings)
3640
{
37-
_loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
38-
_backofficeSecurityAccessor = backofficeSecurityAccessor ??
39-
throw new ArgumentNullException(nameof(backofficeSecurityAccessor));
40-
_memberTypeService = memberTypeService ?? throw new ArgumentNullException(nameof(memberTypeService));
41-
_memberService = memberService ?? throw new ArgumentNullException(nameof(memberService));
42-
_shortStringHelper = shortStringHelper ?? throw new ArgumentNullException(nameof(shortStringHelper));
43-
_propertyValidationService = propertyValidationService ??
44-
throw new ArgumentNullException(nameof(propertyValidationService));
41+
_loggerFactory = loggerFactory;
42+
_backofficeSecurityAccessor = backofficeSecurityAccessor;
43+
_memberTypeService = memberTypeService;
44+
_memberService = memberService;
45+
_shortStringHelper = shortStringHelper;
46+
_propertyValidationService = propertyValidationService;
47+
_securitySettings = securitySettings.Value;
4548
}
4649

4750
public void OnActionExecuting(ActionExecutingContext context)
@@ -53,7 +56,8 @@ public void OnActionExecuting(ActionExecutingContext context)
5356
_memberTypeService,
5457
_memberService,
5558
_shortStringHelper,
56-
_propertyValidationService);
59+
_propertyValidationService,
60+
_securitySettings);
5761
//now do each validation step
5862
if (contentItemValidator.ValidateExistingContent(model, context))
5963
{

src/Umbraco.Web.Common/Security/ConfigureMemberIdentityOptions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ public void Configure(IdentityOptions options)
2424
options.SignIn.RequireConfirmedEmail = false; // not implemented
2525
options.SignIn.RequireConfirmedPhoneNumber = false; // not implemented
2626

27-
options.User.RequireUniqueEmail = true;
27+
options.User.RequireUniqueEmail = _securitySettings.MemberRequireUniqueEmail;
2828

2929
// Support validation of member names using Down-Level Logon Name format
3030
options.User.AllowedUserNameCharacters = _securitySettings.AllowedUserNameCharacters;

0 commit comments

Comments
 (0)