diff --git a/src/Api/Controllers/AuthController.cs b/src/Api/Controllers/AuthController.cs index 0190f016..2f8f4e4f 100644 --- a/src/Api/Controllers/AuthController.cs +++ b/src/Api/Controllers/AuthController.cs @@ -115,8 +115,7 @@ public async Task Logout() return Ok(); } - - [HttpPost("reset-password")] + public async Task ResetPassword([FromBody] ResetPasswordRequest request) { if (string.IsNullOrEmpty(request.NewPassword)) diff --git a/src/Api/appsettings.Development.json b/src/Api/appsettings.Development.json index ef1296b0..c0380e8b 100644 --- a/src/Api/appsettings.Development.json +++ b/src/Api/appsettings.Development.json @@ -9,6 +9,10 @@ "TokenLifetime": "00:20:00", "RefreshTokenLifetimeInDays": 3 }, + "SecuritySettings": { + "Pepper": "1f952d7238f35083abc3d6bf28410702c65f54afc0be29af7f1c89f5859d1d53" + }, + "MailSettings": { "ClientUrl": "https://send.api.mailtrap.io/api/send", "Token": "745f040659edff0ce87b545567da72d2", diff --git a/src/Api/appsettings.Testing.json b/src/Api/appsettings.Testing.json index 5dbdc362..aafebb14 100644 --- a/src/Api/appsettings.Testing.json +++ b/src/Api/appsettings.Testing.json @@ -5,6 +5,9 @@ "TokenLifetime": "00:20:00", "RefreshTokenLifetimeInDays": 3 }, + "SecuritySettings": { + "Pepper": "1f952d7238f35083abc3d6bf28410702c65f54afc0be29af7f1c89f5859d1d53" + }, "Seed": true, "Serilog" : { "MinimumLevel" : { diff --git a/src/Application/Common/Interfaces/ISecurityService.cs b/src/Application/Common/Interfaces/ISecurityService.cs new file mode 100644 index 00000000..5cbd6f96 --- /dev/null +++ b/src/Application/Common/Interfaces/ISecurityService.cs @@ -0,0 +1,6 @@ +namespace Application.Common.Interfaces; + +public interface ISecurityService +{ + string Hash(string input, string salt); +} \ No newline at end of file diff --git a/src/Application/Helpers/SecurityUtil.cs b/src/Application/Helpers/SecurityUtil.cs index 1ea689af..9936b212 100644 --- a/src/Application/Helpers/SecurityUtil.cs +++ b/src/Application/Helpers/SecurityUtil.cs @@ -19,4 +19,11 @@ public static string Hash(string input) return stringBuilder.ToString(); } + + public static string HashPasswordWith(this string input, string salt, string pepper) + { + pepper = Convert.ToBase64String(Encoding.UTF8.GetBytes(pepper)); + salt = Convert.ToBase64String(Encoding.UTF8.GetBytes(salt)); + return Hash(salt + input + pepper); + } } \ No newline at end of file diff --git a/src/Application/Helpers/StringUtil.cs b/src/Application/Helpers/StringUtil.cs index 09f68c24..ec3c3b74 100644 --- a/src/Application/Helpers/StringUtil.cs +++ b/src/Application/Helpers/StringUtil.cs @@ -16,4 +16,8 @@ public static string RandomString(int n) return stringBuilder.ToString(); } + + public static string RandomPassword() => RandomString(8); + + public static string RandomSalt() => RandomString(24); } \ No newline at end of file diff --git a/src/Application/Users/Commands/AddUser.cs b/src/Application/Users/Commands/AddUser.cs index c077511c..2ccef831 100644 --- a/src/Application/Users/Commands/AddUser.cs +++ b/src/Application/Users/Commands/AddUser.cs @@ -66,11 +66,13 @@ public class AddUserCommandHandler : IRequestHandler { private readonly IApplicationDbContext _context; private readonly IMapper _mapper; + private readonly ISecurityService _securityService; - public AddUserCommandHandler(IApplicationDbContext context, IMapper mapper) + public AddUserCommandHandler(IApplicationDbContext context, IMapper mapper, ISecurityService securityService) { _context = context; _mapper = mapper; + _securityService = securityService; } public async Task Handle(Command request, CancellationToken cancellationToken) @@ -91,11 +93,14 @@ public async Task Handle(Command request, CancellationToken cancellatio throw new KeyNotFoundException("Department does not exist."); } - var password = StringUtil.RandomString(8); + var password = StringUtil.RandomPassword(); + var salt = StringUtil.RandomSalt(); + var entity = new User { Username = request.Username, - PasswordHash = SecurityUtil.Hash(password), + PasswordHash = _securityService.Hash(password, salt), + PasswordSalt = salt, Email = request.Email, FirstName = request.FirstName?.Trim(), LastName = request.LastName?.Trim(), diff --git a/src/Domain/Entities/User.cs b/src/Domain/Entities/User.cs index 527300bc..014d974e 100644 --- a/src/Domain/Entities/User.cs +++ b/src/Domain/Entities/User.cs @@ -9,6 +9,7 @@ public class User : BaseAuditableEntity public string Username { get; set; } = null!; public string Email { get; set; } = null!; public string PasswordHash { get; set; } = null!; + public string PasswordSalt { get; set; } = null!; public string? FirstName { get; set; } public string? LastName { get; set; } public Department? Department { get; set; } diff --git a/src/Infrastructure/ConfigureServices.cs b/src/Infrastructure/ConfigureServices.cs index 3c6c8af2..3d4ef3bb 100644 --- a/src/Infrastructure/ConfigureServices.cs +++ b/src/Infrastructure/ConfigureServices.cs @@ -25,6 +25,7 @@ public static IServiceCollection AddInfrastructureServices(this IServiceCollecti services.AddJweAuthentication(configuration); services.AddAuthorization(); + services.AddSecurityService(configuration); return services; } @@ -109,4 +110,18 @@ private static IServiceCollection AddMailService(this IServiceCollection service return services; } + + private static IServiceCollection AddSecurityService(this IServiceCollection services, IConfiguration configuration) + { + var securitySettings = configuration.GetSection(nameof(SecuritySettings)).Get(); + + services.Configure(option => + { + option.Pepper = securitySettings!.Pepper; + }); + + services.AddTransient(); + + return services; + } } \ No newline at end of file diff --git a/src/Infrastructure/Identity/IdentityService.cs b/src/Infrastructure/Identity/IdentityService.cs index 8e913ca5..ef335a22 100644 --- a/src/Infrastructure/Identity/IdentityService.cs +++ b/src/Infrastructure/Identity/IdentityService.cs @@ -29,6 +29,7 @@ public class IdentityService : IIdentityService private readonly RSA _encryptionKey; private readonly ECDsa _signingKey; private readonly IMapper _mapper; + private readonly SecuritySettings _securitySettings; public IdentityService( TokenValidationParameters tokenValidationParameters, @@ -37,7 +38,8 @@ public IdentityService( IAuthDbContext authDbContext, RSA encryptionKey, ECDsa signingKey, - IMapper mapper) + IMapper mapper, + IOptions securitySettingsOptions) { _tokenValidationParameters = tokenValidationParameters; _jweSettings = jweSettingsOptions.Value; @@ -46,6 +48,7 @@ public IdentityService( _encryptionKey = encryptionKey; _signingKey = signingKey; _mapper = mapper; + _securitySettings = securitySettingsOptions.Value; } public async Task Validate(string token, string refreshToken) @@ -196,7 +199,7 @@ public async Task RefreshTokenAsync(string token, string r .Include(x => x.Department) .FirstOrDefault(x => x.Email!.Equals(email)); - if (user is null || !user.PasswordHash.Equals(SecurityUtil.Hash(password))) + if (user is null || !user.PasswordHash.Equals(password.HashPasswordWith(user.PasswordSalt, _securitySettings.Pepper))) { throw new AuthenticationException("Username or password is invalid."); } @@ -255,8 +258,9 @@ public async Task ResetPassword(string token, string newPassword) { user.IsActivated = true; } - - user.PasswordHash = SecurityUtil.Hash(newPassword); + var salt = StringUtil.RandomSalt(); + user.PasswordSalt = salt; + user.PasswordHash = newPassword.HashPasswordWith(salt, newPassword); resetPasswordToken.IsInvalidated = true; await _applicationDbContext.SaveChangesAsync(CancellationToken.None); await _authDbContext.SaveChangesAsync(CancellationToken.None); diff --git a/src/Infrastructure/Persistence/ApplicationDbContextSeed.cs b/src/Infrastructure/Persistence/ApplicationDbContextSeed.cs index a6593d43..37a37324 100644 --- a/src/Infrastructure/Persistence/ApplicationDbContextSeed.cs +++ b/src/Infrastructure/Persistence/ApplicationDbContextSeed.cs @@ -14,10 +14,11 @@ public class ApplicationDbContextSeed public static async Task Seed(ApplicationDbContext context, IConfiguration configuration, ILogger logger) { if (!configuration.GetValue("Seed")) return; - + + var securitySettings = configuration.GetSection(nameof(SecuritySettings)).Get(); try { - await TrySeedAsync(context); + await TrySeedAsync(context, securitySettings!.Pepper); } catch (Exception ex) { @@ -26,19 +27,22 @@ public static async Task Seed(ApplicationDbContext context, IConfiguration confi } } - private static async Task TrySeedAsync(ApplicationDbContext context) + private static async Task TrySeedAsync(ApplicationDbContext context, string pepper) { var department = new Department() { Name = "Admin" }; + + var salt = StringUtil.RandomSalt(); // Default users var admin = new User { Username = "admin", Email = "admin@profile.dev", - PasswordHash = SecurityUtil.Hash("admin"), + PasswordHash = "admin".HashPasswordWith(salt, pepper), + PasswordSalt = salt, IsActive = true, IsActivated = true, Created = LocalDateTime.FromDateTime(DateTime.UtcNow), diff --git a/src/Infrastructure/Persistence/Configurations/UserConfiguration.cs b/src/Infrastructure/Persistence/Configurations/UserConfiguration.cs index b3e55685..3240464d 100644 --- a/src/Infrastructure/Persistence/Configurations/UserConfiguration.cs +++ b/src/Infrastructure/Persistence/Configurations/UserConfiguration.cs @@ -24,6 +24,10 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.PasswordHash) .HasMaxLength(64) .IsRequired(); + + builder.Property(x => x.PasswordSalt) + .HasMaxLength(32) + .IsRequired(); builder.Property(x => x.FirstName) .HasMaxLength(50) diff --git a/src/Infrastructure/Persistence/Migrations/00000000000013_AddPasswordSaltFieldToUsers.Designer.cs b/src/Infrastructure/Persistence/Migrations/00000000000013_AddPasswordSaltFieldToUsers.Designer.cs new file mode 100644 index 00000000..d702cff5 --- /dev/null +++ b/src/Infrastructure/Persistence/Migrations/00000000000013_AddPasswordSaltFieldToUsers.Designer.cs @@ -0,0 +1,491 @@ +// +using System; +using Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NodaTime; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20230608044119_AddPasswordSaltFieldToUsers")] + partial class AddPasswordSaltFieldToUsers + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Domain.Entities.Department", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasAlternateKey("Name"); + + b.ToTable("Departments"); + }); + + modelBuilder.Entity("Domain.Entities.Physical.Borrow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActualReturnTime") + .HasColumnType("timestamp without time zone"); + + b.Property("BorrowTime") + .HasColumnType("timestamp without time zone"); + + b.Property("BorrowerId") + .HasColumnType("uuid"); + + b.Property("DocumentId") + .HasColumnType("uuid"); + + b.Property("DueTime") + .HasColumnType("timestamp without time zone"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BorrowerId"); + + b.HasIndex("DocumentId"); + + b.ToTable("Borrows"); + }); + + modelBuilder.Entity("Domain.Entities.Physical.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DepartmentId") + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DocumentType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("FolderId") + .HasColumnType("uuid"); + + b.Property("ImporterId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("FolderId"); + + b.HasIndex("ImporterId"); + + b.ToTable("Documents"); + }); + + modelBuilder.Entity("Domain.Entities.Physical.Folder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Capacity") + .HasColumnType("integer"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsAvailable") + .HasColumnType("boolean"); + + b.Property("LockerId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NumberOfDocuments") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("LockerId"); + + b.ToTable("Folders"); + }); + + modelBuilder.Entity("Domain.Entities.Physical.Locker", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Capacity") + .HasColumnType("integer"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsAvailable") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NumberOfFolders") + .HasColumnType("integer"); + + b.Property("RoomId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoomId"); + + b.ToTable("Lockers"); + }); + + modelBuilder.Entity("Domain.Entities.Physical.Room", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Capacity") + .HasColumnType("integer"); + + b.Property("DepartmentId") + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsAvailable") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NumberOfLockers") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasAlternateKey("Name"); + + b.HasIndex("DepartmentId") + .IsUnique(); + + b.ToTable("Rooms"); + }); + + modelBuilder.Entity("Domain.Entities.Physical.Staff", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("UserId"); + + b.Property("RoomId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoomId") + .IsUnique(); + + b.ToTable("Staffs"); + }); + + modelBuilder.Entity("Domain.Entities.RefreshToken", b => + { + b.Property("Token") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("ExpiryDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("IsInvalidated") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsUsed") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("JwtId") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Token"); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp without time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("DepartmentId") + .HasColumnType("uuid"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)"); + + b.Property("FirstName") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsActivated") + .HasColumnType("boolean"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastModified") + .HasColumnType("timestamp without time zone"); + + b.Property("LastModifiedBy") + .HasColumnType("uuid"); + + b.Property("LastName") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PasswordSalt") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Position") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("DepartmentId"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Domain.Entities.Physical.Borrow", b => + { + b.HasOne("Domain.Entities.User", "Borrower") + .WithMany() + .HasForeignKey("BorrowerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Domain.Entities.Physical.Document", "Document") + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Borrower"); + + b.Navigation("Document"); + }); + + modelBuilder.Entity("Domain.Entities.Physical.Document", b => + { + b.HasOne("Domain.Entities.Department", "Department") + .WithMany() + .HasForeignKey("DepartmentId"); + + b.HasOne("Domain.Entities.Physical.Folder", "Folder") + .WithMany("Documents") + .HasForeignKey("FolderId"); + + b.HasOne("Domain.Entities.User", "Importer") + .WithMany() + .HasForeignKey("ImporterId"); + + b.Navigation("Department"); + + b.Navigation("Folder"); + + b.Navigation("Importer"); + }); + + modelBuilder.Entity("Domain.Entities.Physical.Folder", b => + { + b.HasOne("Domain.Entities.Physical.Locker", "Locker") + .WithMany("Folders") + .HasForeignKey("LockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Locker"); + }); + + modelBuilder.Entity("Domain.Entities.Physical.Locker", b => + { + b.HasOne("Domain.Entities.Physical.Room", "Room") + .WithMany("Lockers") + .HasForeignKey("RoomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Room"); + }); + + modelBuilder.Entity("Domain.Entities.Physical.Room", b => + { + b.HasOne("Domain.Entities.Department", "Department") + .WithOne("Room") + .HasForeignKey("Domain.Entities.Physical.Room", "DepartmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Department"); + }); + + modelBuilder.Entity("Domain.Entities.Physical.Staff", b => + { + b.HasOne("Domain.Entities.User", "User") + .WithOne() + .HasForeignKey("Domain.Entities.Physical.Staff", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Domain.Entities.Physical.Room", "Room") + .WithOne("Staff") + .HasForeignKey("Domain.Entities.Physical.Staff", "RoomId"); + + b.Navigation("Room"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.RefreshToken", b => + { + b.HasOne("Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.HasOne("Domain.Entities.Department", "Department") + .WithMany() + .HasForeignKey("DepartmentId"); + + b.Navigation("Department"); + }); + + modelBuilder.Entity("Domain.Entities.Department", b => + { + b.Navigation("Room"); + }); + + modelBuilder.Entity("Domain.Entities.Physical.Folder", b => + { + b.Navigation("Documents"); + }); + + modelBuilder.Entity("Domain.Entities.Physical.Locker", b => + { + b.Navigation("Folders"); + }); + + modelBuilder.Entity("Domain.Entities.Physical.Room", b => + { + b.Navigation("Lockers"); + + b.Navigation("Staff"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/Persistence/Migrations/00000000000013_AddPasswordSaltFieldToUsers.cs b/src/Infrastructure/Persistence/Migrations/00000000000013_AddPasswordSaltFieldToUsers.cs new file mode 100644 index 00000000..6b4e0f69 --- /dev/null +++ b/src/Infrastructure/Persistence/Migrations/00000000000013_AddPasswordSaltFieldToUsers.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Infrastructure.Persistence.Migrations +{ + /// + public partial class AddPasswordSaltFieldToUsers : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PasswordSalt", + table: "Users", + type: "character varying(32)", + maxLength: 32, + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PasswordSalt", + table: "Users"); + } + } +} diff --git a/src/Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs index d7177ccb..9fab7c8c 100644 --- a/src/Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs @@ -417,6 +417,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(64) .HasColumnType("character varying(64)"); + b.Property("PasswordSalt") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + b.Property("Position") .HasMaxLength(64) .HasColumnType("character varying(64)"); diff --git a/src/Infrastructure/Services/SecurityService.cs b/src/Infrastructure/Services/SecurityService.cs new file mode 100644 index 00000000..9e38f89e --- /dev/null +++ b/src/Infrastructure/Services/SecurityService.cs @@ -0,0 +1,21 @@ +using Application.Common.Interfaces; +using Application.Helpers; +using Infrastructure.Shared; +using Microsoft.Extensions.Options; + +namespace Infrastructure.Services; + +public class SecurityService : ISecurityService +{ + private readonly SecuritySettings _securitySettings; + + public SecurityService(IOptions securitySettingsOptions) + { + _securitySettings = securitySettingsOptions.Value; + } + + public string Hash(string input, string salt) + { + return input.HashPasswordWith(salt, _securitySettings.Pepper); + } +} \ No newline at end of file diff --git a/src/Infrastructure/Shared/SecuritySettings.cs b/src/Infrastructure/Shared/SecuritySettings.cs new file mode 100644 index 00000000..beba3ec4 --- /dev/null +++ b/src/Infrastructure/Shared/SecuritySettings.cs @@ -0,0 +1,6 @@ +namespace Infrastructure.Shared; + +public class SecuritySettings +{ + public string Pepper { get; set; } +} \ No newline at end of file diff --git a/tests/Application.Tests.Integration/BaseClassFixture.cs b/tests/Application.Tests.Integration/BaseClassFixture.cs index 0339e595..eaa0afa5 100644 --- a/tests/Application.Tests.Integration/BaseClassFixture.cs +++ b/tests/Application.Tests.Integration/BaseClassFixture.cs @@ -174,6 +174,8 @@ protected static Department CreateDepartment() protected static User CreateUser(string role, string password) { + var salt = StringUtil.RandomSalt(); + return new User() { Id = Guid.NewGuid(), @@ -186,7 +188,8 @@ protected static User CreateUser(string role, string password) IsActivated = true, IsActive = true, Created = LocalDateTime.FromDateTime(DateTime.Now), - PasswordHash = SecurityUtil.Hash(password) + PasswordHash = password.HashPasswordWith(salt, "random pepper"), + PasswordSalt = salt }; } diff --git a/tests/Application.Tests.Unit/Helpers/SecurityUtilTests.cs b/tests/Application.Tests.Unit/Helpers/SecurityUtilTests.cs new file mode 100644 index 00000000..259638f7 --- /dev/null +++ b/tests/Application.Tests.Unit/Helpers/SecurityUtilTests.cs @@ -0,0 +1,27 @@ +using Application.Helpers; +using FluentAssertions; +using Xunit; + +namespace Application.Tests.Unit.Helpers; + +public class SecurityUtilTests +{ + public SecurityUtilTests() + { + } + + [Fact] + public void ShouldReturnHash_WhenHashPasswordWithSaltAndPepper() + { + // Arrange + string salt = "dwnqjkdqwW4q"; + string input = "ThizIsAveRyl0000GandS3cur4dP@ssWord"; + string pepper = "Some secret here"; + string expectedHash = "27745bdd5e09aae12213a1ea7a3f9056b7de257050370d6cfa1c2d1b954de335"; + // Act + string password = input.HashPasswordWith(salt, pepper); + + // Assert + password.Should().Be(expectedHash); + } +} \ No newline at end of file