From 69758735de73bb1d5fbac492f685c2109904e292 Mon Sep 17 00:00:00 2001 From: Janus Knudsen Date: Tue, 4 Mar 2025 17:13:02 +0100 Subject: [PATCH] Adds User Component --- Core/Entities/Users/PasswordHasher.cs | 47 -------------- Core/ISecureTokenizer.cs | 8 +++ Core/PlanTempus.Core.csproj | 2 + Core/SecureTokenizer.cs | 48 ++++++++++++++ Database/Core/UserService.cs | 5 +- PlanTempus.Components/Class1.cs | 7 -- .../PlanTempus.Components.csproj | 6 +- .../Users/Create/CreateUserCommand.cs | 9 +++ .../Users/Create/CreateUserController.cs | 33 ++++++++++ .../Users/Create/CreateUserHandler.cs | 64 +++++++++++++++++++ .../Users/Create/CreateUserResponse.cs | 11 ++++ .../Users/Create/CreateUserValidator.cs | 23 +++++++ .../Users/Create/UserCreationResult.cs | 8 +++ Tests/PasswordHasherTest.cs | 16 ++--- 14 files changed, 222 insertions(+), 65 deletions(-) delete mode 100644 Core/Entities/Users/PasswordHasher.cs create mode 100644 Core/ISecureTokenizer.cs create mode 100644 Core/SecureTokenizer.cs delete mode 100644 PlanTempus.Components/Class1.cs create mode 100644 PlanTempus.Components/Users/Create/CreateUserCommand.cs create mode 100644 PlanTempus.Components/Users/Create/CreateUserController.cs create mode 100644 PlanTempus.Components/Users/Create/CreateUserHandler.cs create mode 100644 PlanTempus.Components/Users/Create/CreateUserResponse.cs create mode 100644 PlanTempus.Components/Users/Create/CreateUserValidator.cs create mode 100644 PlanTempus.Components/Users/Create/UserCreationResult.cs diff --git a/Core/Entities/Users/PasswordHasher.cs b/Core/Entities/Users/PasswordHasher.cs deleted file mode 100644 index 6d1e8aa..0000000 --- a/Core/Entities/Users/PasswordHasher.cs +++ /dev/null @@ -1,47 +0,0 @@ -namespace PlanTempus.Core.Entities.Users -{ - public static class PasswordHasher - { - private const int _saltSize = 16; // 128 bit - private const int _keySize = 32; // 256 bit - private const int _iterations = 100000; - - public static string HashPassword(string password) - { - using (var algorithm = new System.Security.Cryptography.Rfc2898DeriveBytes( - password, - _saltSize, - _iterations, - System.Security.Cryptography.HashAlgorithmName.SHA256)) - { - var key = Convert.ToBase64String(algorithm.GetBytes(_keySize)); - var salt = Convert.ToBase64String(algorithm.Salt); - - return $"{_iterations}.{salt}.{key}"; - } - } - - public static bool VerifyPassword(string hash, string password) - { - var parts = hash.Split('.', 3); - if (parts.Length != 3) - { - return false; - } - - var iterations = Convert.ToInt32(parts[0]); - var salt = Convert.FromBase64String(parts[1]); - var key = Convert.FromBase64String(parts[2]); - - using (var algorithm = new System.Security.Cryptography.Rfc2898DeriveBytes( - password, - salt, - iterations, - System.Security.Cryptography.HashAlgorithmName.SHA256)) - { - var keyToCheck = algorithm.GetBytes(_keySize); - return keyToCheck.SequenceEqual(key); - } - } - } -} diff --git a/Core/ISecureTokenizer.cs b/Core/ISecureTokenizer.cs new file mode 100644 index 0000000..c29a610 --- /dev/null +++ b/Core/ISecureTokenizer.cs @@ -0,0 +1,8 @@ +namespace PlanTempus.Core +{ + public interface ISecureTokenizer + { + string TokenizeText(string word); + bool VerifyToken(string hash, string word); + } +} \ No newline at end of file diff --git a/Core/PlanTempus.Core.csproj b/Core/PlanTempus.Core.csproj index 96a7344..7fc76f2 100644 --- a/Core/PlanTempus.Core.csproj +++ b/Core/PlanTempus.Core.csproj @@ -10,10 +10,12 @@ + + diff --git a/Core/SecureTokenizer.cs b/Core/SecureTokenizer.cs new file mode 100644 index 0000000..bffe593 --- /dev/null +++ b/Core/SecureTokenizer.cs @@ -0,0 +1,48 @@ +namespace PlanTempus.Core +{ + + public class SecureTokenizer : ISecureTokenizer + { + private const int _saltSize = 16; // 128 bit + private const int _keySize = 32; // 256 bit + private const int _iterations = 100000; + + public string TokenizeText(string word) + { + using (var algorithm = new System.Security.Cryptography.Rfc2898DeriveBytes( + word, + _saltSize, + _iterations, + System.Security.Cryptography.HashAlgorithmName.SHA256)) + { + var key = Convert.ToBase64String(algorithm.GetBytes(_keySize)); + var salt = Convert.ToBase64String(algorithm.Salt); + + return $"{_iterations}.{salt}.{key}"; + } + } + + public bool VerifyToken(string hash, string word) + { + var parts = hash.Split('.', 3); + if (parts.Length != 3) + { + return false; + } + + var iterations = Convert.ToInt32(parts[0]); + var salt = Convert.FromBase64String(parts[1]); + var key = Convert.FromBase64String(parts[2]); + + using (var algorithm = new System.Security.Cryptography.Rfc2898DeriveBytes( + word, + salt, + iterations, + System.Security.Cryptography.HashAlgorithmName.SHA256)) + { + var keyToCheck = algorithm.GetBytes(_keySize); + return keyToCheck.SequenceEqual(key); + } + } + } +} diff --git a/Database/Core/UserService.cs b/Database/Core/UserService.cs index 0cbdcb4..da8e721 100644 --- a/Database/Core/UserService.cs +++ b/Database/Core/UserService.cs @@ -1,10 +1,11 @@ using Insight.Database; +using PlanTempus.Core; using PlanTempus.Core.Entities.Users; using System.Data; namespace PlanTempus.Database.Core { - public class UserService + public class UserService { public record UserCreateCommand(string CorrelationId, string Email, string Password); @@ -21,7 +22,7 @@ namespace PlanTempus.Database.Core var user = new User { Email = command.Email, - PasswordHash = PasswordHasher.HashPassword(command.Password), + PasswordHash = new SecureTokenizer().TokenizeText(command.Password), SecurityStamp = Guid.NewGuid().ToString(), EmailConfirmed = false, CreatedDate = DateTime.UtcNow diff --git a/PlanTempus.Components/Class1.cs b/PlanTempus.Components/Class1.cs deleted file mode 100644 index d850929..0000000 --- a/PlanTempus.Components/Class1.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace PlanTempus.Components -{ - public class Class1 - { - - } -} diff --git a/PlanTempus.Components/PlanTempus.Components.csproj b/PlanTempus.Components/PlanTempus.Components.csproj index d0bccb2..d925cd4 100644 --- a/PlanTempus.Components/PlanTempus.Components.csproj +++ b/PlanTempus.Components/PlanTempus.Components.csproj @@ -10,11 +10,15 @@ - + + + + + diff --git a/PlanTempus.Components/Users/Create/CreateUserCommand.cs b/PlanTempus.Components/Users/Create/CreateUserCommand.cs new file mode 100644 index 0000000..f07a6d5 --- /dev/null +++ b/PlanTempus.Components/Users/Create/CreateUserCommand.cs @@ -0,0 +1,9 @@ +namespace PlanTempus.Components.Users.Create +{ + public class CreateUserCommand + { + public string Email { get; set; } + public string Password { get; set; } + public bool IsActive { get; set; } = true; + } +} \ No newline at end of file diff --git a/PlanTempus.Components/Users/Create/CreateUserController.cs b/PlanTempus.Components/Users/Create/CreateUserController.cs new file mode 100644 index 0000000..c4cd2f5 --- /dev/null +++ b/PlanTempus.Components/Users/Create/CreateUserController.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Mvc; + +namespace PlanTempus.Components.Users.Create +{ + [ApiController] + [Route("api/users")] + public class CreateUserController(CreateUserHandler handler, CreateUserValidator validator) : ControllerBase + { + [HttpPost] + public async Task> Create([FromBody] CreateUserCommand command) + { + try + { + var validationResult = await validator.ValidateAsync(command); + if (!validationResult.IsValid) + { + return BadRequest(validationResult.Errors); + } + + var result = await handler.Handle(command); + return CreatedAtAction( + nameof(CreateUserCommand), + "GetUser", + new { id = result.Id }, + result); + } + catch (Exception ex) + { + return BadRequest(ex.Message); + } + } + } +} \ No newline at end of file diff --git a/PlanTempus.Components/Users/Create/CreateUserHandler.cs b/PlanTempus.Components/Users/Create/CreateUserHandler.cs new file mode 100644 index 0000000..259053d --- /dev/null +++ b/PlanTempus.Components/Users/Create/CreateUserHandler.cs @@ -0,0 +1,64 @@ +using Insight.Database; +using PlanTempus.Core; +using PlanTempus.Core.Sql; + +namespace PlanTempus.Components.Users.Create +{ + public class CreateUserHandler(IDatabaseOperations databaseOperations, ISecureTokenizer secureTokenizer) + { + private readonly ISecureTokenizer _secureTokenizer; + + public async Task Handle(CreateUserCommand command) + { + using var db = databaseOperations.CreateScope(nameof(CreateUserHandler)); + try + { + var sql = @" + INSERT INTO system.users(email, password_hash, security_stamp, email_confirmed, + access_failed_count, lockout_enabled, lockout_end, + is_active, created_at, last_login_at) + VALUES(@Email, @PasswordHash, @SecurityStamp, @EmailConfirmed, + @AccessFailedCount, @LockoutEnabled, @LockoutEnd, + @IsActive, @CreatedAt, @LastLoginAt) + RETURNING id, created_at"; + + var result = await db.Connection.QuerySqlAsync(sql, new + { + Email = command.Email, + PasswordHash = secureTokenizer.TokenizeText(command.Password), + SecurityStamp = Guid.NewGuid().ToString("N"), + EmailConfirmed = false, + AccessFailedCount = 0, + LockoutEnabled = true, + LockoutEnd = (DateTime?)null, + IsActive = command.IsActive, + CreatedAt = DateTime.UtcNow, + LastLoginAt = (DateTime?)null + }); + + var createdUser = result.First(); + + db.Success(); + + return new CreateUserResponse + { + Id = createdUser.Id, + Email = command.Email, + IsActive = command.IsActive, + CreatedAt = createdUser.CreatedAt + }; + } + catch (Exception ex) + { + db.Error(ex); + throw; + } + } + + private class UserCreationResult + { + public long Id { get; set; } + public DateTime CreatedAt { get; set; } + } + } +} \ No newline at end of file diff --git a/PlanTempus.Components/Users/Create/CreateUserResponse.cs b/PlanTempus.Components/Users/Create/CreateUserResponse.cs new file mode 100644 index 0000000..defdbd4 --- /dev/null +++ b/PlanTempus.Components/Users/Create/CreateUserResponse.cs @@ -0,0 +1,11 @@ + +namespace PlanTempus.Components.Users.Create +{ + public class CreateUserResponse + { + public long Id { get; set; } + public string Email { get; set; } + public bool IsActive { get; set; } + public DateTime CreatedAt { get; set; } + } +} \ No newline at end of file diff --git a/PlanTempus.Components/Users/Create/CreateUserValidator.cs b/PlanTempus.Components/Users/Create/CreateUserValidator.cs new file mode 100644 index 0000000..6007115 --- /dev/null +++ b/PlanTempus.Components/Users/Create/CreateUserValidator.cs @@ -0,0 +1,23 @@ +using FluentValidation; + +namespace PlanTempus.Components.Users.Create +{ + public class CreateUserValidator : AbstractValidator + { + public CreateUserValidator() + { + RuleFor(x => x.Email) + .NotEmpty().WithMessage("Email skal angives.") + .EmailAddress().WithMessage("Ugyldig emailadresse.") + .MaximumLength(256).WithMessage("Email må højst være 256 tegn."); + + RuleFor(x => x.Password) + .NotEmpty().WithMessage("Password skal angives.") + .MinimumLength(8).WithMessage("Password skal være mindst 8 tegn.") + .Matches("[A-Z]").WithMessage("Password skal indeholde mindst ét stort bogstav.") + .Matches("[a-z]").WithMessage("Password skal indeholde mindst ét lille bogstav.") + .Matches("[0-9]").WithMessage("Password skal indeholde mindst ét tal.") + .Matches("[^a-zA-Z0-9]").WithMessage("Password skal indeholde mindst ét specialtegn."); + } + } +} \ No newline at end of file diff --git a/PlanTempus.Components/Users/Create/UserCreationResult.cs b/PlanTempus.Components/Users/Create/UserCreationResult.cs new file mode 100644 index 0000000..35f4aa6 --- /dev/null +++ b/PlanTempus.Components/Users/Create/UserCreationResult.cs @@ -0,0 +1,8 @@ +namespace PlanTempus.Components.Users.Create +{ + public class UserCreationResult + { + public long Id { get; set; } + public DateTime CreatedAt { get; set; } + } +} \ No newline at end of file diff --git a/Tests/PasswordHasherTest.cs b/Tests/PasswordHasherTest.cs index e8129f5..60521f6 100644 --- a/Tests/PasswordHasherTest.cs +++ b/Tests/PasswordHasherTest.cs @@ -1,10 +1,10 @@ using System.Diagnostics; using Sodium; -using PlanTempus.Core.Entities.Users; +using PlanTempus.Core; namespace PlanTempus.Tests { - [TestClass] + [TestClass] public class PasswordHasherTests : TestFixture { @@ -35,7 +35,7 @@ namespace PlanTempus.Tests string password = "TestPassword123"; // Act - string hashedPassword = PasswordHasher.HashPassword(password); + string hashedPassword = new SecureTokenizer().TokenizeText(password); string[] parts = hashedPassword.Split('.'); // Assert @@ -48,10 +48,10 @@ namespace PlanTempus.Tests { // Arrange string password = "TestPassword123"; - string hashedPassword = PasswordHasher.HashPassword(password); + string hashedPassword = new SecureTokenizer().TokenizeText(password); // Act - bool result = PasswordHasher.VerifyPassword(hashedPassword, password); + bool result = new SecureTokenizer().VerifyToken(hashedPassword, password); // Assert Assert.IsTrue(result); @@ -63,10 +63,10 @@ namespace PlanTempus.Tests // Arrange string correctPassword = "TestPassword123"; string wrongPassword = "WrongPassword123"; - string hashedPassword = PasswordHasher.HashPassword(correctPassword); + string hashedPassword = new SecureTokenizer().TokenizeText(correctPassword); // Act - bool result = PasswordHasher.VerifyPassword(hashedPassword, wrongPassword); + bool result = new SecureTokenizer().VerifyToken(hashedPassword, wrongPassword); // Assert Assert.IsFalse(result); @@ -80,7 +80,7 @@ namespace PlanTempus.Tests string invalidHash = "InvalidHash"; // Act - bool result = PasswordHasher.VerifyPassword(invalidHash, password); + bool result = new SecureTokenizer().VerifyToken(invalidHash, password); // Assert Assert.IsFalse(result);