Adds User Component

This commit is contained in:
Janus Knudsen 2025-03-04 17:13:02 +01:00
parent 73a1f11e99
commit 69758735de
14 changed files with 222 additions and 65 deletions

View file

@ -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);
}
}
}
}

8
Core/ISecureTokenizer.cs Normal file
View file

@ -0,0 +1,8 @@
namespace PlanTempus.Core
{
public interface ISecureTokenizer
{
string TokenizeText(string word);
bool VerifyToken(string hash, string word);
}
}

View file

@ -10,10 +10,12 @@
<PackageReference Include="Akka" Version="1.5.32" /> <PackageReference Include="Akka" Version="1.5.32" />
<PackageReference Include="Autofac" Version="8.1.1" /> <PackageReference Include="Autofac" Version="8.1.1" />
<PackageReference Include="Autofac.Extensions.DependencyInjection" Version="10.0.0" /> <PackageReference Include="Autofac.Extensions.DependencyInjection" Version="10.0.0" />
<PackageReference Include="FluentValidation" Version="11.11.0" />
<PackageReference Include="Insight.Database" Version="8.0.1" /> <PackageReference Include="Insight.Database" Version="8.0.1" />
<PackageReference Include="Insight.Database.Providers.PostgreSQL" Version="8.0.1" /> <PackageReference Include="Insight.Database.Providers.PostgreSQL" Version="8.0.1" />
<PackageReference Include="Microsoft.ApplicationInsights" Version="2.22.0" /> <PackageReference Include="Microsoft.ApplicationInsights" Version="2.22.0" />
<PackageReference Include="Microsoft.ApplicationInsights.WindowsServer.TelemetryChannel" Version="2.22.0" /> <PackageReference Include="Microsoft.ApplicationInsights.WindowsServer.TelemetryChannel" Version="2.22.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.3.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.1" /> <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.1" />
<PackageReference Include="npgsql" Version="9.0.2" /> <PackageReference Include="npgsql" Version="9.0.2" />
<PackageReference Include="Seq.Api" Version="2024.3.0" /> <PackageReference Include="Seq.Api" Version="2024.3.0" />

48
Core/SecureTokenizer.cs Normal file
View file

@ -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);
}
}
}
}

View file

@ -1,4 +1,5 @@
using Insight.Database; using Insight.Database;
using PlanTempus.Core;
using PlanTempus.Core.Entities.Users; using PlanTempus.Core.Entities.Users;
using System.Data; using System.Data;
@ -21,7 +22,7 @@ namespace PlanTempus.Database.Core
var user = new User var user = new User
{ {
Email = command.Email, Email = command.Email,
PasswordHash = PasswordHasher.HashPassword(command.Password), PasswordHash = new SecureTokenizer().TokenizeText(command.Password),
SecurityStamp = Guid.NewGuid().ToString(), SecurityStamp = Guid.NewGuid().ToString(),
EmailConfirmed = false, EmailConfirmed = false,
CreatedDate = DateTime.UtcNow CreatedDate = DateTime.UtcNow

View file

@ -1,7 +0,0 @@
namespace PlanTempus.Components
{
public class Class1
{
}
}

View file

@ -10,11 +10,15 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Folder Include="Users\Create\" />
<Folder Include="Users\Delete\" /> <Folder Include="Users\Delete\" />
<Folder Include="Users\Update\" /> <Folder Include="Users\Update\" />
<Folder Include="Organizations\Delete\" /> <Folder Include="Organizations\Delete\" />
<Folder Include="Organizations\Update\" /> <Folder Include="Organizations\Update\" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<PackageReference Include="FluentValidation" Version="11.11.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Core" Version="2.3.0" />
</ItemGroup>
</Project> </Project>

View file

@ -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;
}
}

View file

@ -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<ActionResult<CreateUserResponse>> 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);
}
}
}
}

View file

@ -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<CreateUserResponse> 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<UserCreationResult>(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; }
}
}
}

View file

@ -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; }
}
}

View file

@ -0,0 +1,23 @@
using FluentValidation;
namespace PlanTempus.Components.Users.Create
{
public class CreateUserValidator : AbstractValidator<CreateUserCommand>
{
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.");
}
}
}

View file

@ -0,0 +1,8 @@
namespace PlanTempus.Components.Users.Create
{
public class UserCreationResult
{
public long Id { get; set; }
public DateTime CreatedAt { get; set; }
}
}

View file

@ -1,6 +1,6 @@
using System.Diagnostics; using System.Diagnostics;
using Sodium; using Sodium;
using PlanTempus.Core.Entities.Users; using PlanTempus.Core;
namespace PlanTempus.Tests namespace PlanTempus.Tests
{ {
@ -35,7 +35,7 @@ namespace PlanTempus.Tests
string password = "TestPassword123"; string password = "TestPassword123";
// Act // Act
string hashedPassword = PasswordHasher.HashPassword(password); string hashedPassword = new SecureTokenizer().TokenizeText(password);
string[] parts = hashedPassword.Split('.'); string[] parts = hashedPassword.Split('.');
// Assert // Assert
@ -48,10 +48,10 @@ namespace PlanTempus.Tests
{ {
// Arrange // Arrange
string password = "TestPassword123"; string password = "TestPassword123";
string hashedPassword = PasswordHasher.HashPassword(password); string hashedPassword = new SecureTokenizer().TokenizeText(password);
// Act // Act
bool result = PasswordHasher.VerifyPassword(hashedPassword, password); bool result = new SecureTokenizer().VerifyToken(hashedPassword, password);
// Assert // Assert
Assert.IsTrue(result); Assert.IsTrue(result);
@ -63,10 +63,10 @@ namespace PlanTempus.Tests
// Arrange // Arrange
string correctPassword = "TestPassword123"; string correctPassword = "TestPassword123";
string wrongPassword = "WrongPassword123"; string wrongPassword = "WrongPassword123";
string hashedPassword = PasswordHasher.HashPassword(correctPassword); string hashedPassword = new SecureTokenizer().TokenizeText(correctPassword);
// Act // Act
bool result = PasswordHasher.VerifyPassword(hashedPassword, wrongPassword); bool result = new SecureTokenizer().VerifyToken(hashedPassword, wrongPassword);
// Assert // Assert
Assert.IsFalse(result); Assert.IsFalse(result);
@ -80,7 +80,7 @@ namespace PlanTempus.Tests
string invalidHash = "InvalidHash"; string invalidHash = "InvalidHash";
// Act // Act
bool result = PasswordHasher.VerifyPassword(invalidHash, password); bool result = new SecureTokenizer().VerifyToken(invalidHash, password);
// Assert // Assert
Assert.IsFalse(result); Assert.IsFalse(result);