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="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
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 Insight.Database;
|
||||||
|
using PlanTempus.Core;
|
||||||
using PlanTempus.Core.Entities.Users;
|
using PlanTempus.Core.Entities.Users;
|
||||||
using System.Data;
|
using System.Data;
|
||||||
|
|
||||||
namespace PlanTempus.Database.Core
|
namespace PlanTempus.Database.Core
|
||||||
{
|
{
|
||||||
public class UserService
|
public class UserService
|
||||||
{
|
{
|
||||||
public record UserCreateCommand(string CorrelationId, string Email, string Password);
|
public record UserCreateCommand(string CorrelationId, string Email, string Password);
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
namespace PlanTempus.Components
|
|
||||||
{
|
|
||||||
public class Class1
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
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 System.Diagnostics;
|
||||||
using Sodium;
|
using Sodium;
|
||||||
using PlanTempus.Core.Entities.Users;
|
using PlanTempus.Core;
|
||||||
|
|
||||||
namespace PlanTempus.Tests
|
namespace PlanTempus.Tests
|
||||||
{
|
{
|
||||||
[TestClass]
|
[TestClass]
|
||||||
public class PasswordHasherTests : TestFixture
|
public class PasswordHasherTests : TestFixture
|
||||||
{
|
{
|
||||||
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue