Skip to content

Commit cc400c8

Browse files
Ticket #897 : Can configure password requirements
1 parent 3190fd1 commit cc400c8

19 files changed

Lines changed: 403 additions & 23 deletions

File tree

formbuilder/FormBuilder/Components/Form/FormViewer.razor

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -51,24 +51,33 @@
5151

5252
@if (Context.Execution != null)
5353
{
54-
if (Context.Execution.ErrorMessages != null)
54+
if (Context.Execution.ErrorMessages != null && Context.Execution.ErrorMessages.Any())
5555
{
56-
@foreach (var errorMessage in Context.Execution.ErrorMessages)
57-
{
58-
<RadzenAlert AlertStyle="AlertStyle.Danger" Variant="Variant.Flat" Shade="Shade.Lighter">
59-
@TranslateErrorMessage(errorMessage)
60-
</RadzenAlert>
61-
}
56+
<RadzenAlert AlertStyle="AlertStyle.Danger" Variant="Variant.Flat" Shade="Shade.Lighter">
57+
<ul>
58+
@foreach (var errorMessage in Context.Execution.ErrorMessages)
59+
{
60+
<li>
61+
@TranslateErrorMessage(errorMessage)
62+
</li>
63+
64+
}
65+
</ul>
66+
</RadzenAlert>
6267
}
6368

64-
if (Context.Execution.SuccessMessages != null)
69+
if (Context.Execution.SuccessMessages != null && Context.Execution.SuccessMessages.Any())
6570
{
66-
@foreach (var successMessage in Context.Execution.SuccessMessages)
67-
{
68-
<RadzenAlert AlertStyle="AlertStyle.Success" Variant="Variant.Flat" Shade="Shade.Lighter">
69-
@TranslateSuccessMessage(successMessage)
70-
</RadzenAlert>
71-
}
71+
<RadzenAlert AlertStyle="AlertStyle.Success" Variant="Variant.Flat" Shade="Shade.Lighter">
72+
<ul>
73+
@foreach (var successMessage in Context.Execution.SuccessMessages)
74+
{
75+
<li>
76+
@TranslateSuccessMessage(successMessage)
77+
</li>
78+
}
79+
</ul>
80+
</RadzenAlert>
7281
}
7382
}
7483

@@ -203,25 +212,27 @@
203212
private string TranslateSuccessMessage(string message)
204213
{
205214
var currentCulture = Thread.CurrentThread.CurrentUICulture.TwoLetterISOLanguageName;
206-
var translation = Form.SuccessMessageTranslations.SingleOrDefault(t => t.Code == message && t.Language == currentCulture);
215+
var translationKey = TranslationKeyParser.Parse(message);
216+
var translation = Form.SuccessMessageTranslations.SingleOrDefault(t => t.Code == translationKey.Code && t.Language == currentCulture);
207217
if(translation == null)
208218
{
209219
return message;
210220
}
211221

212-
return translation.Value;
222+
return string.Format(translation.Value, translationKey.Args);
213223
}
214224

215225
private string TranslateErrorMessage(string message)
216226
{
217227
var currentCulture = Thread.CurrentThread.CurrentUICulture.TwoLetterISOLanguageName;
218-
var translation = Form.ErrorMessageTranslations.SingleOrDefault(t => t.Code == message && t.Language == currentCulture);
228+
var translationKey = TranslationKeyParser.Parse(message);
229+
var translation = Form.ErrorMessageTranslations.SingleOrDefault(t => t.Code == translationKey.Code && t.Language == currentCulture);
219230
if (translation == null)
220231
{
221232
return message;
222233
}
223234

224-
return translation.Value;
235+
return string.Format(translation.Value, translationKey.Args);
225236
}
226237

227238
private void Init()
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
namespace FormBuilder.Helpers
2+
{
3+
public class TranslationKeyParser
4+
{
5+
public static string Serialize(string code, params string[] args)
6+
{
7+
var lst = new List<string> { code };
8+
if(args != null)
9+
{
10+
lst.AddRange(args);
11+
}
12+
13+
return string.Join(".", lst);
14+
}
15+
16+
public static (string Code, string[] Args) Parse(string translationKey)
17+
{
18+
var parts = translationKey.Split('.');
19+
var code = parts[0];
20+
if (parts.Length == 1)
21+
{
22+
return (code, Array.Empty<string>());
23+
}
24+
25+
var args = parts.Skip(1).ToArray();
26+
return (code, args);
27+
}
28+
}
29+
}

src/IdServer/SimpleIdServer.IdServer.Pwd/IdServerBuilderExtensions.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using DataSeeder;
55
using FormBuilder;
6+
using Microsoft.Extensions.DependencyInjection.Extensions;
67
using SimpleIdServer.IdServer;
78
using SimpleIdServer.IdServer.Options;
89
using SimpleIdServer.IdServer.Pwd;
@@ -26,6 +27,7 @@ public static class IdServerBuilderExtensions
2627
public static IdServerBuilder AddPwdAuthentication(this IdServerBuilder idServerBuilder, bool isDefaultAuthMethod = false)
2728
{
2829
idServerBuilder.MvcBuilder.AddApplicationPart(typeof(AuthenticateController).Assembly);
30+
idServerBuilder.Services.RemoveAll<IPasswordValidationService>();
2931
idServerBuilder.Services.AddTransient<IAuthenticationMethodService, PwdAuthenticationMethodService>();
3032
idServerBuilder.Services.AddTransient<IPasswordAuthenticationService, PasswordAuthenticationService>();
3133
idServerBuilder.Services.AddTransient<IUserAuthenticationService, PasswordAuthenticationService>();
@@ -39,6 +41,7 @@ public static IdServerBuilder AddPwdAuthentication(this IdServerBuilder idServer
3941
idServerBuilder.Services.AddTransient<IDataSeeder, InitPwdConfigurationDefDataseeder>();
4042
idServerBuilder.Services.AddTransient<IDataSeeder, UpdatePwdFormDataseeder>();
4143
idServerBuilder.Services.AddTransient<IFakerDataService, PwdAuthFakerService>();
44+
idServerBuilder.Services.AddTransient<IPasswordValidationService, PasswordValidationService>();
4245
idServerBuilder.AutomaticConfigurationOptions.Add(typeof(IdServerPasswordOptions));
4346
if (isDefaultAuthMethod)
4447
{

src/IdServer/SimpleIdServer.IdServer.Pwd/IdServerPasswordOptions.cs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,50 @@ public class IdServerPasswordOptions
2626
/// </summary>
2727
[ConfigurationRecord("Expiration time", "Expiration time in seconds of the reset link", order: 3)]
2828
public int ResetPasswordLinkExpirationInSeconds { get; set; } = 30;
29+
30+
/// <summary>
31+
/// Enable password validation.
32+
/// </summary>
33+
[ConfigurationRecord("Password validation", "Enable or disable the password validation", order: 4)]
34+
public bool EnableValidation { get; set; } = false;
35+
36+
/// <summary>
37+
/// Gets or sets the minimum length a password must be. Defaults to 6.
38+
/// </summary>
39+
[ConfigurationRecord("Required length", "Gets or sets the minimum length a password must be", order: 5, displayCondition: "EnableValidation=true")]
40+
public int RequiredLength { get; set; } = 6;
41+
42+
/// <summary>
43+
/// Gets or sets the minimum number of unique characters which a password must contain. Defaults to 1.
44+
/// </summary>
45+
[ConfigurationRecord("Required unique chars", "Gets or sets the minimum number of unique characters which a password must contain", order: 6, displayCondition: "EnableValidation=true")]
46+
public int RequiredUniqueChars { get; set; } = 1;
47+
48+
/// <summary>
49+
/// Gets or sets a flag indicating if passwords must contain a non-alphanumeric character. Defaults to true.
50+
/// </summary>
51+
/// <value>True if passwords must contain a non-alphanumeric character, otherwise false.</value>
52+
[ConfigurationRecord("Require non alpha numeric", "Gets or sets a flag indicating if passwords must contain a non-alphanumeric character", order: 7, displayCondition: "EnableValidation=true")]
53+
public bool RequireNonAlphanumeric { get; set; } = true;
54+
55+
/// <summary>
56+
/// Gets or sets a flag indicating if passwords must contain a lower case ASCII character. Defaults to true.
57+
/// </summary>
58+
/// <value>True if passwords must contain a lower case ASCII character.</value>
59+
[ConfigurationRecord("Require lower case", "Gets or sets a flag indicating if passwords must contain a lower case ASCII character.", order: 8, displayCondition: "EnableValidation=true")]
60+
public bool RequireLowercase { get; set; } = true;
61+
62+
/// <summary>
63+
/// Gets or sets a flag indicating if passwords must contain a upper case ASCII character. Defaults to true.
64+
/// </summary>
65+
/// <value>True if passwords must contain a upper case ASCII character.</value>
66+
[ConfigurationRecord("Require upper case", "Gets or sets a flag indicating if passwords must contain a upper case ASCII character.", order: 9, displayCondition: "EnableValidation=true")]
67+
public bool RequireUppercase { get; set; } = true;
68+
69+
/// <summary>
70+
/// Gets or sets a flag indicating if passwords must contain a digit. Defaults to true.
71+
/// </summary>
72+
/// <value>True if passwords must contain a digit.</value>
73+
[ConfigurationRecord("Require digit", "Gets or sets a flag indicating if passwords must contain a digit.", order: 10, displayCondition: "EnableValidation=true")]
74+
public bool RequireDigit { get; set; } = true;
2975
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// Copyright (c) SimpleIdServer. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
3+
using FormBuilder.Helpers;
4+
using Microsoft.AspNetCore.Identity;
5+
using Microsoft.Extensions.Configuration;
6+
using SimpleIdServer.IdServer.Layout.AuthFormLayout;
7+
using SimpleIdServer.IdServer.Resources;
8+
9+
namespace SimpleIdServer.IdServer.Pwd.Services
10+
{
11+
public class PasswordValidationService : IPasswordValidationService
12+
{
13+
private readonly IConfiguration _configuration;
14+
15+
public PasswordValidationService(IConfiguration configuration)
16+
{
17+
_configuration = configuration;
18+
}
19+
20+
public IdentityErrorDescriber Describer { get; private set; }
21+
22+
public List<(string code, string errorMessage)>? Validate(string password)
23+
{
24+
var options = GetOptions();
25+
if (!options.EnableValidation)
26+
{
27+
return null;
28+
}
29+
30+
var pwdOptions = new PasswordOptions
31+
{
32+
RequiredLength = options.RequiredLength,
33+
RequiredUniqueChars = options.RequiredUniqueChars,
34+
RequireNonAlphanumeric = options.RequireNonAlphanumeric,
35+
RequireLowercase = options.RequireLowercase,
36+
RequireUppercase = options.RequireUppercase,
37+
RequireDigit = options.RequireDigit
38+
};
39+
return Validate(password, pwdOptions);
40+
}
41+
42+
private List<(string code, string errorMessage)>? Validate(string password, PasswordOptions options)
43+
{
44+
List<(string code, string errorMessage)>? errors = null;
45+
if (string.IsNullOrWhiteSpace(password) || password.Length < options.RequiredLength)
46+
{
47+
errors ??= new List<(string code, string errorMessage)>();
48+
errors.Add((TranslationKeyParser.Serialize(AuthFormErrorMessages.PasswordTooShort, options.RequiredLength.ToString()), string.Format(Global.PasswordTooShort, options.RequiredLength)));
49+
}
50+
if (options.RequireNonAlphanumeric && password.All(IsLetterOrDigit))
51+
{
52+
errors ??= new List<(string code, string errorMessage)>();
53+
errors.Add((AuthFormErrorMessages.PasswordRequiresNonAlphanumeric, Global.PasswordRequiresNonAlphanumeric));
54+
}
55+
if (options.RequireDigit && !password.Any(IsDigit))
56+
{
57+
errors ??= new List<(string code, string errorMessage)>();
58+
errors.Add((AuthFormErrorMessages.PasswordRequiresDigit, Global.PasswordRequiresDigit));
59+
}
60+
if (options.RequireLowercase && !password.Any(IsLower))
61+
{
62+
errors ??= new List<(string code, string errorMessage)>();
63+
errors.Add((AuthFormErrorMessages.PasswordRequiresLower, Global.PasswordRequiresLower));
64+
}
65+
if (options.RequireUppercase && !password.Any(IsUpper))
66+
{
67+
errors ??= new List<(string code, string errorMessage)>();
68+
errors.Add((AuthFormErrorMessages.PasswordRequiresUpper, Global.PasswordRequiresUpper));
69+
}
70+
if (options.RequiredUniqueChars >= 1 && password.Distinct().Count() < options.RequiredUniqueChars)
71+
{
72+
errors ??= new List<(string code, string errorMessage)>();
73+
errors.Add((TranslationKeyParser.Serialize(AuthFormErrorMessages.RequiredUniqueChars, options.RequiredLength.ToString()), string.Format(Global.RequiredUniqueChars, options.RequiredUniqueChars)));
74+
}
75+
76+
return errors;
77+
}
78+
79+
public virtual bool IsDigit(char c)
80+
{
81+
return c >= '0' && c <= '9';
82+
}
83+
84+
public virtual bool IsLower(char c)
85+
{
86+
return c >= 'a' && c <= 'z';
87+
}
88+
89+
public virtual bool IsUpper(char c)
90+
{
91+
return c >= 'A' && c <= 'Z';
92+
}
93+
94+
public virtual bool IsLetterOrDigit(char c)
95+
{
96+
return IsUpper(c) || IsLower(c) || IsDigit(c);
97+
}
98+
99+
private IdServerPasswordOptions GetOptions()
100+
{
101+
var section = _configuration.GetSection(typeof(IdServerPasswordOptions).Name);
102+
return section.Get<IdServerPasswordOptions>() ?? new IdServerPasswordOptions();
103+
}
104+
}
105+
}

src/IdServer/SimpleIdServer.IdServer.Pwd/StandardPwdAuthForms.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,12 @@ public class StandardPwdAuthForms
253253
.AddErrorMessage(AuthFormErrorMessages.MissingConfirmedPassword, Global.MissingConfirmedPassword)
254254
.AddErrorMessage(AuthFormErrorMessages.PasswordMismatch, Global.PasswordMismatch)
255255
.AddErrorMessage(AuthFormErrorMessages.OtpCodeIsInvalid, Global.OtpCodeIsInvalid)
256+
.AddErrorMessage(AuthFormErrorMessages.PasswordTooShort, Global.PasswordTooShort)
257+
.AddErrorMessage(AuthFormErrorMessages.PasswordRequiresNonAlphanumeric, Global.PasswordRequiresNonAlphanumeric)
258+
.AddErrorMessage(AuthFormErrorMessages.PasswordRequiresDigit, Global.PasswordRequiresDigit)
259+
.AddErrorMessage(AuthFormErrorMessages.PasswordRequiresLower, Global.PasswordRequiresLower)
260+
.AddErrorMessage(AuthFormErrorMessages.PasswordRequiresUpper, Global.PasswordRequiresUpper)
261+
.AddErrorMessage(AuthFormErrorMessages.RequiredUniqueChars, Global.RequiredUniqueChars)
256262
.AddSuccessMessage(AuthFormSuccessMessages.PasswordIsUpdated, Global.PasswordIsUpdated)
257263
.Build();
258264

@@ -303,5 +309,11 @@ public class StandardPwdAuthForms
303309
.AddErrorMessage(AuthFormErrorMessages.CannotResolveUser, Global.CannotResolveUser)
304310
.AddErrorMessage(AuthFormErrorMessages.NoActivePassword, Global.NoActivePassword)
305311
.AddErrorMessage(AuthFormErrorMessages.PasswordIsNotTemporary, Global.PasswordIsNotTemporary)
312+
.AddErrorMessage(AuthFormErrorMessages.PasswordTooShort, Global.PasswordTooShort)
313+
.AddErrorMessage(AuthFormErrorMessages.PasswordRequiresNonAlphanumeric, Global.PasswordRequiresNonAlphanumeric)
314+
.AddErrorMessage(AuthFormErrorMessages.PasswordRequiresDigit, Global.PasswordRequiresDigit)
315+
.AddErrorMessage(AuthFormErrorMessages.PasswordRequiresLower, Global.PasswordRequiresLower)
316+
.AddErrorMessage(AuthFormErrorMessages.PasswordRequiresUpper, Global.PasswordRequiresUpper)
317+
.AddErrorMessage(AuthFormErrorMessages.RequiredUniqueChars, Global.RequiredUniqueChars)
306318
.Build();
307319
}

src/IdServer/SimpleIdServer.IdServer.Pwd/StandardPwdRegisterForms.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using FormBuilder.Components.FormElements.StackLayout;
44
using FormBuilder.Models;
55
using SimpleIdServer.IdServer.Layout;
6+
using SimpleIdServer.IdServer.Layout.AuthFormLayout;
67
using SimpleIdServer.IdServer.Layout.RegisterFormLayout;
78
using SimpleIdServer.IdServer.Pwd.UI.ViewModels;
89
using SimpleIdServer.IdServer.Resources;
@@ -45,6 +46,12 @@ public static class StandardPwdRegisterForms
4546
.AddErrorMessage(RegisterFormErrorMessages.MissingConfirmedPassword, Global.MissingConfirmedPassword)
4647
.AddErrorMessage(RegisterFormErrorMessages.PasswordMismatch, Global.PasswordMismatch)
4748
.AddErrorMessage(RegisterFormErrorMessages.UserWithSameLoginAlreadyExists, Global.UserWithSameLoginAlreadyExists)
49+
.AddErrorMessage(AuthFormErrorMessages.PasswordTooShort, Global.PasswordTooShort)
50+
.AddErrorMessage(AuthFormErrorMessages.PasswordRequiresNonAlphanumeric, Global.PasswordRequiresNonAlphanumeric)
51+
.AddErrorMessage(AuthFormErrorMessages.PasswordRequiresDigit, Global.PasswordRequiresDigit)
52+
.AddErrorMessage(AuthFormErrorMessages.PasswordRequiresLower, Global.PasswordRequiresLower)
53+
.AddErrorMessage(AuthFormErrorMessages.PasswordRequiresUpper, Global.PasswordRequiresUpper)
54+
.AddErrorMessage(AuthFormErrorMessages.RequiredUniqueChars, Global.RequiredUniqueChars)
4855
.AddSuccessMessage(RegisterFormSuccessMessages.UserIsUpdated, Global.UserIsUpdated)
4956
.AddSuccessMessage(RegisterFormSuccessMessages.UserIsCreated, Global.UserIsCreated)
5057
.Build(DateTime.UtcNow);

src/IdServer/SimpleIdServer.IdServer.Pwd/UI/RegisterController.cs

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
using SimpleIdServer.IdServer.Layout.RegisterFormLayout;
1616
using SimpleIdServer.IdServer.Options;
1717
using SimpleIdServer.IdServer.Pwd.UI.ViewModels;
18-
using SimpleIdServer.IdServer.Resources;
1918
using SimpleIdServer.IdServer.Stores;
2019
using SimpleIdServer.IdServer.UI;
2120
using SimpleIdServer.IdServer.UI.ViewModels;
@@ -26,6 +25,8 @@ namespace SimpleIdServer.IdServer.Pwd;
2625
[Area(Constants.AreaPwd)]
2726
public class RegisterController : BaseRegisterController<PwdRegisterViewModel>
2827
{
28+
private readonly IPasswordValidationService _passwordValidationService;
29+
2930
public RegisterController(
3031
IOptions<IdServerHostOptions> options,
3132
IOptions<FormBuilderOptions> formOptions,
@@ -40,8 +41,10 @@ public RegisterController(
4041
ILanguageRepository languageRepository,
4142
IRealmStore realmStore,
4243
ITemplateStore templateStore,
43-
IWorkflowHelper workflowHelper) : base(options, formOptions, distributedCache, userRepository, tokenRepository, transactionBuilder, jwtBuilder, antiforgery, formStore, workflowStore, languageRepository, realmStore, templateStore, workflowHelper)
44+
IWorkflowHelper workflowHelper,
45+
IPasswordValidationService passwordValidationService) : base(options, formOptions, distributedCache, userRepository, tokenRepository, transactionBuilder, jwtBuilder, antiforgery, formStore, workflowStore, languageRepository, realmStore, templateStore, workflowHelper)
4446
{
47+
_passwordValidationService = passwordValidationService;
4548
}
4649

4750
protected override string Amr => Constants.AreaPwd;
@@ -105,12 +108,21 @@ public async Task<IActionResult> Index([FromRoute] string prefix, PwdRegisterVie
105108
return View(result);
106109
}
107110

111+
// 4. Validate the password.
112+
var validationResult = _passwordValidationService.Validate(viewModel.Password);
113+
if(validationResult != null)
114+
{
115+
result.SetInput(viewModel);
116+
result.SetErrorMessages(validationResult.Select(v => v.code).ToList());
117+
return View(result);
118+
}
119+
108120
if (!isAuthenticated) return await CreateUser();
109121
return await UpdateUser();
110122

111123
async Task<IActionResult> CreateUser()
112124
{
113-
// 4. Check a user already exists.
125+
// 5. Check a user already exists.
114126
var isUserExists = await UserRepository.IsSubjectExists(viewModel.Login, prefix, cancellationToken);
115127
if(isUserExists)
116128
{

0 commit comments

Comments
 (0)