Merge pull request #186 from Sea-Haven-Industries/feat/ab/sh-386-password-policy

feat(auth): enforce one password policy on every password-setting path
This commit is contained in:
Alexandre Brandizzi 2026-09-25 16:27:36 +00:00 • committed by GitHub
commit a8d05776a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 415 additions and 28 deletions

View file

@ -117,11 +117,11 @@ public class AuthenticationControllerTests
{ {
var service = new Mock<IAuthenticationService>(); var service = new Mock<IAuthenticationService>();
service.Setup(s => s.ChangePasswordAsync("42", "old", "new", It.IsAny<CancellationToken>())) service.Setup(s => s.ChangePasswordAsync("42", "old", "new", It.IsAny<CancellationToken>()))
.ReturnsAsync(true); .ReturnsAsync(new ChangePasswordResultDTO { Status = ChangePasswordStatus.Succeeded });
var controller = NewController(service, "42"); var controller = NewController(service, "42");
var result = await controller.ChangePassword(new ChangePasswords { Currentpassword = "old", Confirmpassword = "new" }, CancellationToken.None); var result = await controller.ChangePassword(new ChangePasswords { Currentpassword = "old", Newpassword = "new", Confirmpassword = "new" }, CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject; var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var response = ok.Value.Should().BeOfType<Response>().Subject; var response = ok.Value.Should().BeOfType<Response>().Subject;
@ -130,11 +130,11 @@ public class AuthenticationControllerTests
} }
[Fact] [Fact]
public async Task ChangePassword_Failure_ReturnsOldPasswordIncorrectStatus() public async Task ChangePassword_WrongCurrentPassword_ReturnsOldPasswordIncorrectStatus()
{ {
var service = new Mock<IAuthenticationService>(); var service = new Mock<IAuthenticationService>();
service.Setup(s => s.ChangePasswordAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>())) service.Setup(s => s.ChangePasswordAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false); .ReturnsAsync(new ChangePasswordResultDTO { Status = ChangePasswordStatus.CurrentPasswordIncorrect });
var controller = NewController(service, "42"); var controller = NewController(service, "42");
@ -143,6 +143,70 @@ public class AuthenticationControllerTests
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject; var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
var response = bad.Value.Should().BeOfType<Response>().Subject; var response = bad.Value.Should().BeOfType<Response>().Subject;
response.Status.Should().Be("Old Password is incorrect"); response.Status.Should().Be("Old Password is incorrect");
response.Message.Should().Be("Current password is incorrect");
}
[Fact]
public async Task ChangePassword_PolicyRejection_ReturnsPasswordRequirementsMessage()
{
var service = new Mock<IAuthenticationService>();
service.Setup(s => s.ChangePasswordAsync("42", "Current1!", "weak", It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChangePasswordResultDTO { Status = ChangePasswordStatus.PasswordRejected });
var controller = NewController(service, "42");
var result = await controller.ChangePassword(
new ChangePasswords { Currentpassword = "Current1!", Newpassword = "weak", Confirmpassword = "weak" },
CancellationToken.None);
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
var response = bad.Value.Should().BeOfType<Response>().Subject;
response.Status.Should().Be("Password does not meet requirements");
response.Message.Should().Be(
"Password must be at least 6 characters and include one uppercase letter, one number, and one special character.");
}
[Fact]
public async Task ChangePassword_ConfirmationMismatch_IsRejectedWithoutChangingThePassword()
{
var service = new Mock<IAuthenticationService>();
var controller = NewController(service, "42");
var result = await controller.ChangePassword(
new ChangePasswords { Currentpassword = "Current1!", Newpassword = "Next2@x", Confirmpassword = "Next2@y" },
CancellationToken.None);
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
bad.Value.Should().BeOfType<Response>().Subject.Message.Should().Be("Passwords don't match");
service.Verify(
s => s.ChangePasswordAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task ChangePassword_NonPolicyFailure_ReturnsTheGenericMessage()
{
var service = new Mock<IAuthenticationService>();
service.Setup(s => s.ChangePasswordAsync("42", "Current1!", "Next2@x", It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChangePasswordResultDTO { Status = ChangePasswordStatus.Failed });
var controller = NewController(service, "42");
var result = await controller.ChangePassword(
new ChangePasswords { Currentpassword = "Current1!", Newpassword = "Next2@x", Confirmpassword = "Next2@x" },
CancellationToken.None);
var response = result.Should().BeOfType<BadRequestObjectResult>().Subject.Value.Should().BeOfType<Response>().Subject;
response.Message.Should().Be("Your password could not be changed. Try again.");
response.Message.Should().NotContain("at least 6 characters");
}
[Fact]
public void ChangePassword_RequiresAuthenticatedCaller()
{
var method = typeof(AuthenticationController).GetMethod(nameof(AuthenticationController.ChangePassword))!;
method.GetCustomAttributes(typeof(Microsoft.AspNetCore.Authorization.AuthorizeAttribute), inherit: true)
.Should().NotBeEmpty();
} }
[Fact] [Fact]

View file

@ -0,0 +1,218 @@
using Api.SeaHavenIndustries.Infrastructure;
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Moq;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
/// <summary>
/// Exercises the password rule through the same Identity registration the API
/// host uses, so these assertions describe the rule that runs in production.
/// </summary>
public sealed class PasswordPolicyTests : IAsyncDisposable
{
private const string CurrentPassword = "Current1!";
private readonly ServiceProvider _provider;
private readonly AsyncServiceScope _scope;
public PasswordPolicyTests()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddDbContext<ApplicationDbContext>(options =>
options.UseInMemoryDatabase(Guid.NewGuid().ToString()));
services.AddSeaHavenIdentity();
_provider = services.BuildServiceProvider();
_scope = _provider.CreateAsyncScope();
}
private UserManager<ApplicationUser> UserManager =>
_scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
[Theory]
[InlineData("Ab1!x", "PasswordTooShort")]
[InlineData("abc12!", "PasswordRequiresUpper")]
[InlineData("Abcde!", "PasswordRequiresDigit")]
[InlineData("Abcde1", "PasswordRequiresNonAlphanumeric")]
public async Task Policy_RejectsPasswordMissingOneRule(string password, string expectedCode)
{
var user = new ApplicationUser { UserName = "policy@example.com", Email = "policy@example.com" };
var errors = await ValidateAsync(user, password);
errors.Select(error => error.Code).Should().Equal(expectedCode);
}
[Theory]
[InlineData("Abc1!x")]
[InlineData("ABC12!")]
public async Task Policy_AcceptsSixCharacterPasswordMeetingEveryRule(string password)
{
var user = new ApplicationUser { UserName = "policy@example.com", Email = "policy@example.com" };
var errors = await ValidateAsync(user, password);
errors.Should().BeEmpty();
}
[Fact]
public void Registration_AppliesSharedPolicyOptions()
{
var options = _scope.ServiceProvider.GetRequiredService<IOptions<IdentityOptions>>().Value.Password;
options.RequiredLength.Should().Be(6);
options.RequireUppercase.Should().BeTrue();
options.RequireDigit.Should().BeTrue();
options.RequireNonAlphanumeric.Should().BeTrue();
options.RequireLowercase.Should().BeFalse();
}
[Fact]
public async Task ChangePassword_WrongCurrentPassword_IsRejectedBeforeNewPasswordIsEvaluated()
{
var user = await CreateUserAsync();
var service = NewAuthenticationService();
var result = await service.ChangePasswordAsync(user.Id, "Wrong1!", "weak", CancellationToken.None);
result.Status.Should().Be(ChangePasswordStatus.CurrentPasswordIncorrect);
(await UserManager.CheckPasswordAsync(user, CurrentPassword)).Should().BeTrue();
}
[Fact]
public async Task ChangePassword_CorrectCurrentPasswordAndWeakNewPassword_IsRejectedByPolicy()
{
var user = await CreateUserAsync();
var service = NewAuthenticationService();
var result = await service.ChangePasswordAsync(user.Id, CurrentPassword, "abcdef", CancellationToken.None);
result.Status.Should().Be(ChangePasswordStatus.PasswordRejected);
(await UserManager.CheckPasswordAsync(user, CurrentPassword)).Should().BeTrue();
}
[Fact]
public async Task ChangePassword_CorrectCurrentPasswordAndCompliantNewPassword_ChangesPassword()
{
var user = await CreateUserAsync();
var service = NewAuthenticationService();
var result = await service.ChangePasswordAsync(user.Id, CurrentPassword, "Next2@x", CancellationToken.None);
result.Status.Should().Be(ChangePasswordStatus.Succeeded);
var reloaded = await UserManager.FindByIdAsync(user.Id);
(await UserManager.CheckPasswordAsync(reloaded!, "Next2@x")).Should().BeTrue();
}
[Theory]
[InlineData("ConcurrencyFailure")]
[InlineData("PasswordMismatch")]
[InlineData("DefaultError")]
public async Task ChangePassword_NonPolicyIdentityFailure_IsNotReportedAsAWeakPassword(string code)
{
var user = new ApplicationUser { Id = "member-1", UserName = "member@example.com" };
var userManager = new Mock<UserManager<ApplicationUser>>(
Mock.Of<IUserStore<ApplicationUser>>(), null!, null!, null!, null!, null!, null!, null!, null!);
userManager.Setup(m => m.FindByIdAsync(user.Id)).ReturnsAsync(user);
userManager.Setup(m => m.CheckPasswordAsync(user, CurrentPassword)).ReturnsAsync(true);
userManager.Setup(m => m.ChangePasswordAsync(user, CurrentPassword, "Next2@x"))
.ReturnsAsync(IdentityResult.Failed(new IdentityError { Code = code, Description = "failed" }));
var service = new AuthenticationService(
userManager.Object,
Microsoft.Extensions.Options.Options.Create(new JwtOptions { Secret = new string('x', 64) }),
Mock.Of<IUserDataService>(),
Mock.Of<IForgetPasswordDataService>(),
Mock.Of<IEmailSender>());
var result = await service.ChangePasswordAsync(user.Id, CurrentPassword, "Next2@x", CancellationToken.None);
result.Status.Should().Be(ChangePasswordStatus.Failed);
}
[Theory]
[InlineData("PasswordTooShort", true)]
[InlineData("PasswordRequiresUpper", true)]
[InlineData("PasswordRequiresDigit", true)]
[InlineData("PasswordRequiresNonAlphanumeric", true)]
[InlineData("ConcurrencyFailure", false)]
[InlineData("PasswordMismatch", false)]
public void IsPolicyRejection_MatchesOnlyThePasswordRuleCodes(string code, bool expected)
{
IdentityPasswordPolicy.IsPolicyRejection(IdentityResult.Failed(new IdentityError { Code = code }))
.Should().Be(expected);
}
[Fact]
public async Task ChangePassword_CancelledToken_Throws()
{
var service = NewAuthenticationService();
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
var act = () => service.ChangePasswordAsync("any", CurrentPassword, "Next2@x", cancellation.Token);
await act.Should().ThrowAsync<OperationCanceledException>();
}
[Fact]
public async Task ResetPassword_WeakPassword_IsRejectedAndKeepsCode()
{
var user = await CreateUserAsync();
var forget = new Mock<IForgetPasswordDataService>();
forget.Setup(f => f.ExistsByEmailAndCodeAsync(user.Email!, "123456", It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
forget.Setup(f => f.GetByEmailAsync(user.Email!, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ForgetPasswordCode { Email = user.Email!, UserId = user.Id, Code = "123456" });
var service = NewAuthenticationService(forget);
var reset = await service.ResetPasswordAsync(user.Email!, "123456", "abcdef", CancellationToken.None);
reset.Should().BeFalse();
(await UserManager.CheckPasswordAsync(user, CurrentPassword)).Should().BeTrue();
forget.Verify(f => f.RemoveByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
private async Task<IReadOnlyList<IdentityError>> ValidateAsync(ApplicationUser user, string password)
{
var errors = new List<IdentityError>();
foreach (var validator in UserManager.PasswordValidators)
{
var result = await validator.ValidateAsync(UserManager, user, password);
errors.AddRange(result.Errors);
}
return errors;
}
private async Task<ApplicationUser> CreateUserAsync()
{
var user = new ApplicationUser { UserName = "member@example.com", Email = "member@example.com" };
var created = await UserManager.CreateAsync(user, CurrentPassword);
created.Succeeded.Should().BeTrue();
return user;
}
private AuthenticationService NewAuthenticationService(Mock<IForgetPasswordDataService>? forget = null) =>
new(
UserManager,
Microsoft.Extensions.Options.Options.Create(new JwtOptions { Secret = new string('x', 64) }),
Mock.Of<IUserDataService>(),
(forget ?? new Mock<IForgetPasswordDataService>()).Object,
Mock.Of<IEmailSender>());
public async ValueTask DisposeAsync()
{
await _scope.DisposeAsync();
await _provider.DisposeAsync();
}
}

View file

@ -55,20 +55,33 @@ namespace Api.SeaHavenIndustries.Controllers
} }
[Authorize]
[Route("ChangePassword")] [Route("ChangePassword")]
[HttpPost] [HttpPost]
public async Task<IActionResult> ChangePassword(ChangePasswords usermodel, CancellationToken cancellationToken) public async Task<IActionResult> ChangePassword(ChangePasswords usermodel, CancellationToken cancellationToken)
{ {
// [Compare] already rejects this during model validation; the check here keeps the
// unconfirmed password from ever being set if that validation is bypassed.
if (!string.Equals(usermodel.Newpassword, usermodel.Confirmpassword, StringComparison.Ordinal))
return BadRequest(new Response { Status = "Password confirmation does not match", Message = "Passwords don't match" });
var userid = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; var userid = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
var succeeded = await _authenticationService.ChangePasswordAsync(userid, usermodel.Currentpassword, usermodel.Confirmpassword, cancellationToken); var result = await _authenticationService.ChangePasswordAsync(userid, usermodel.Currentpassword, usermodel.Newpassword, cancellationToken);
if (succeeded) return result.Status switch
{ {
return Ok(new Response { Status = "Success ", Message = "Password successfully changed" }); ChangePasswordStatus.Succeeded =>
} Ok(new Response { Status = "Success ", Message = "Password successfully changed" }),
else ChangePasswordStatus.PasswordRejected =>
return BadRequest(new Response { Status = "Old Password is incorrect" }); BadRequest(new Response { Status = "Password does not meet requirements", Message = PasswordRequirementsMessage }),
ChangePasswordStatus.Failed =>
BadRequest(new Response { Status = "Password not changed", Message = "Your password could not be changed. Try again." }),
_ => BadRequest(new Response { Status = "Old Password is incorrect", Message = "Current password is incorrect" })
};
} }
private const string PasswordRequirementsMessage =
"Password must be at least 6 characters and include one uppercase letter, one number, and one special character.";
[HttpPost] [HttpPost]
[Route("UpdateProfile")] [Route("UpdateProfile")]
public async Task<IActionResult> UserProfileUpdate([FromForm] User_DTO model, CancellationToken cancellationToken) public async Task<IActionResult> UserProfileUpdate([FromForm] User_DTO model, CancellationToken cancellationToken)

View file

@ -0,0 +1,24 @@
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Identity;
namespace Api.SeaHavenIndustries.Infrastructure
{
public static class IdentityRegistration
{
/// <summary>
/// Registers ASP.NET Identity for the API with the shared password policy.
/// Program.cs and the behavior tests both compose Identity through this
/// method so the rule under test is the rule that runs.
/// </summary>
public static IdentityBuilder AddSeaHavenIdentity(this IServiceCollection services)
{
return services.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
options.User.RequireUniqueEmail = false;
IdentityPasswordPolicy.Apply(options.Password);
})
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
}
}
}

View file

@ -1,5 +1,6 @@
using Api.SeaHavenIndustries.Helper; using Api.SeaHavenIndustries.Helper;
using Api.SeaHavenIndustries.HostedServices; using Api.SeaHavenIndustries.HostedServices;
using Api.SeaHavenIndustries.Infrastructure;
using Api.SeaHavenIndustries.Middleware; using Api.SeaHavenIndustries.Middleware;
using Api.SeaHavenIndustries.Observability; using Api.SeaHavenIndustries.Observability;
using Api.SeaHavenIndustries.Options; using Api.SeaHavenIndustries.Options;
@ -43,12 +44,7 @@ ConfigurationManager configuration = builder.Configuration;
builder.Services.AddDbContext<ApplicationDbContext>(options => options.UseSqlServer(configuration.GetConnectionString("DefaultConnection"))); builder.Services.AddDbContext<ApplicationDbContext>(options => options.UseSqlServer(configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options => builder.Services.AddSeaHavenIdentity();
{
options.User.RequireUniqueEmail = false;
})
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
builder.Services.AddControllers(options => builder.Services.AddControllers(options =>
{ {

View file

@ -0,0 +1,47 @@
using Microsoft.AspNetCore.Identity;
namespace Data.SeaHavenIndustries
{
/// <summary>
/// The single password rule for every surface that sets a password: at least
/// six characters with one uppercase letter, one number, and one special
/// character. Lowercase letters are deliberately not required so the server
/// accepts exactly what the four-item checklist in the web app marks as met.
/// </summary>
public static class IdentityPasswordPolicy
{
public const int MinimumLength = 6;
public static void Apply(PasswordOptions options)
{
ArgumentNullException.ThrowIfNull(options);
options.RequiredLength = MinimumLength;
options.RequireUppercase = true;
options.RequireDigit = true;
options.RequireNonAlphanumeric = true;
options.RequireLowercase = false;
options.RequiredUniqueChars = 1;
}
private static readonly HashSet<string> PolicyErrorCodes = new(StringComparer.Ordinal)
{
nameof(IdentityErrorDescriber.PasswordTooShort),
nameof(IdentityErrorDescriber.PasswordRequiresUpper),
nameof(IdentityErrorDescriber.PasswordRequiresLower),
nameof(IdentityErrorDescriber.PasswordRequiresDigit),
nameof(IdentityErrorDescriber.PasswordRequiresNonAlphanumeric),
nameof(IdentityErrorDescriber.PasswordRequiresUniqueChars)
};
/// <summary>
/// True when Identity refused the password itself. Other failures, such as a
/// concurrency conflict, must not be reported to the user as a weak password.
/// </summary>
public static bool IsPolicyRejection(IdentityResult result)
{
ArgumentNullException.ThrowIfNull(result);
return result.Errors.Any(error => PolicyErrorCodes.Contains(error.Code));
}
}
}

View file

@ -11,6 +11,20 @@ namespace SeaHaven.Services.DTOs
public string Id { get; set; } = string.Empty; public string Id { get; set; } = string.Empty;
} }
public enum ChangePasswordStatus
{
Succeeded,
CurrentPasswordIncorrect,
PasswordRejected,
/// <summary>Identity failed for a reason other than the password policy.</summary>
Failed
}
public sealed class ChangePasswordResultDTO
{
public ChangePasswordStatus Status { get; init; }
}
public class UpdateProfileRequestDTO public class UpdateProfileRequestDTO
{ {
public string? Name { get; set; } public string? Name { get; set; }

View file

@ -80,16 +80,30 @@ namespace SeaHaven.Services.Implementation
return null; return null;
} }
public async Task<bool> ChangePasswordAsync(string userId, string? currentPassword, string? confirmPassword, CancellationToken cancellationToken) public async Task<ChangePasswordResultDTO> ChangePasswordAsync(string userId, string? currentPassword, string? newPassword, CancellationToken cancellationToken)
{ {
cancellationToken.ThrowIfCancellationRequested();
var user = await _userManager.FindByIdAsync(userId); var user = await _userManager.FindByIdAsync(userId);
if (user == null) if (user == null || user.IsDeleted == true)
return false; return ChangePasswordResult(ChangePasswordStatus.CurrentPasswordIncorrect);
var result = await _userManager.ChangePasswordAsync(user, currentPassword ?? "", confirmPassword ?? ""); // The current password is verified before the new one is evaluated, so a
return result.Succeeded; // caller without it learns nothing about the policy outcome.
if (!await _userManager.CheckPasswordAsync(user, currentPassword ?? ""))
return ChangePasswordResult(ChangePasswordStatus.CurrentPasswordIncorrect);
var result = await _userManager.ChangePasswordAsync(user, currentPassword ?? "", newPassword ?? "");
if (result.Succeeded)
return ChangePasswordResult(ChangePasswordStatus.Succeeded);
return ChangePasswordResult(IdentityPasswordPolicy.IsPolicyRejection(result)
? ChangePasswordStatus.PasswordRejected
: ChangePasswordStatus.Failed);
} }
private static ChangePasswordResultDTO ChangePasswordResult(ChangePasswordStatus status) =>
new() { Status = status };
public async Task<UserProfileDTO?> UpdateProfileAsync(string userId, UpdateProfileRequestDTO dto, CancellationToken cancellationToken) public async Task<UserProfileDTO?> UpdateProfileAsync(string userId, UpdateProfileRequestDTO dto, CancellationToken cancellationToken)
{ {
var exists = await _userDataService.UpdateProfileAsync( var exists = await _userDataService.UpdateProfileAsync(

View file

@ -5,7 +5,7 @@ namespace SeaHaven.Services.Interfaces
public interface IAuthenticationService public interface IAuthenticationService
{ {
Task<LoginResultDTO?> LoginAsync(string? username, string? password, CancellationToken cancellationToken); Task<LoginResultDTO?> LoginAsync(string? username, string? password, CancellationToken cancellationToken);
Task<bool> ChangePasswordAsync(string userId, string? currentPassword, string? confirmPassword, CancellationToken cancellationToken); Task<ChangePasswordResultDTO> ChangePasswordAsync(string userId, string? currentPassword, string? newPassword, CancellationToken cancellationToken);
Task<UserProfileDTO?> UpdateProfileAsync(string userId, UpdateProfileRequestDTO dto, CancellationToken cancellationToken); Task<UserProfileDTO?> UpdateProfileAsync(string userId, UpdateProfileRequestDTO dto, CancellationToken cancellationToken);
Task<bool> ForgetPasswordAsync(string email, CancellationToken cancellationToken); Task<bool> ForgetPasswordAsync(string email, CancellationToken cancellationToken);
Task<bool> VerifyCodeAsync(string code, CancellationToken cancellationToken); Task<bool> VerifyCodeAsync(string code, CancellationToken cancellationToken);

View file

@ -31,7 +31,8 @@ builder.Services.AddAuthentication(options =>
.AddIdentityCookies(); .AddIdentityCookies();
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found."); var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");
builder.Services.AddDbContext<ApplicationDbContext>(options => { builder.Services.AddDbContext<ApplicationDbContext>(options =>
{
options.UseSqlServer(connectionString); options.UseSqlServer(connectionString);
}, ServiceLifetime.Transient); }, ServiceLifetime.Transient);
builder.Services.AddDatabaseDeveloperPageExceptionFilter(); builder.Services.AddDatabaseDeveloperPageExceptionFilter();
@ -39,11 +40,7 @@ builder.Services.AddDatabaseDeveloperPageExceptionFilter();
builder.Services.AddIdentityCore<ApplicationUser>(options => builder.Services.AddIdentityCore<ApplicationUser>(options =>
{ {
options.SignIn.RequireConfirmedAccount = true; options.SignIn.RequireConfirmedAccount = true;
options.Password.RequireDigit = true; IdentityPasswordPolicy.Apply(options.Password);
options.Password.RequireLowercase = false;
options.Password.RequireUppercase = false;
options.Password.RequireNonAlphanumeric = true;
options.Password.RequiredLength = 8;
}).AddRoles<IdentityRole>().AddEntityFrameworkStores<ApplicationDbContext>().AddSignInManager() }).AddRoles<IdentityRole>().AddEntityFrameworkStores<ApplicationDbContext>().AddSignInManager()
.AddDefaultTokenProviders(); .AddDefaultTokenProviders();