Adds User Component
This commit is contained in:
parent
73a1f11e99
commit
69758735de
14 changed files with 222 additions and 65 deletions
|
|
@ -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
8
Core/ISecureTokenizer.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
namespace PlanTempus.Core
|
||||
{
|
||||
public interface ISecureTokenizer
|
||||
{
|
||||
string TokenizeText(string word);
|
||||
bool VerifyToken(string hash, string word);
|
||||
}
|
||||
}
|
||||
|
|
@ -10,10 +10,12 @@
|
|||
<PackageReference Include="Akka" Version="1.5.32" />
|
||||
<PackageReference Include="Autofac" Version="8.1.1" />
|
||||
<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.Providers.PostgreSQL" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.ApplicationInsights" 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="npgsql" Version="9.0.2" />
|
||||
<PackageReference Include="Seq.Api" Version="2024.3.0" />
|
||||
|
|
|
|||
48
Core/SecureTokenizer.cs
Normal file
48
Core/SecureTokenizer.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
namespace PlanTempus.Components
|
||||
{
|
||||
public class Class1
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -10,11 +10,15 @@
|
|||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Users\Create\" />
|
||||
<Folder Include="Users\Delete\" />
|
||||
<Folder Include="Users\Update\" />
|
||||
<Folder Include="Organizations\Delete\" />
|
||||
<Folder Include="Organizations\Update\" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentValidation" Version="11.11.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Core" Version="2.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
9
PlanTempus.Components/Users/Create/CreateUserCommand.cs
Normal file
9
PlanTempus.Components/Users/Create/CreateUserCommand.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
33
PlanTempus.Components/Users/Create/CreateUserController.cs
Normal file
33
PlanTempus.Components/Users/Create/CreateUserController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
64
PlanTempus.Components/Users/Create/CreateUserHandler.cs
Normal file
64
PlanTempus.Components/Users/Create/CreateUserHandler.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
11
PlanTempus.Components/Users/Create/CreateUserResponse.cs
Normal file
11
PlanTempus.Components/Users/Create/CreateUserResponse.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
23
PlanTempus.Components/Users/Create/CreateUserValidator.cs
Normal file
23
PlanTempus.Components/Users/Create/CreateUserValidator.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
8
PlanTempus.Components/Users/Create/UserCreationResult.cs
Normal file
8
PlanTempus.Components/Users/Create/UserCreationResult.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
namespace PlanTempus.Components.Users.Create
|
||||
{
|
||||
public class UserCreationResult
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue