Add initial implementation of API, database, and user management components.

This commit is contained in:
2026-03-15 23:17:51 +01:00
commit 0543120f3b
53 changed files with 2241 additions and 0 deletions

View File

@@ -0,0 +1,133 @@
using MikrocopApi.Dtos;
using MikrocopApi.Exceptions;
using MikrocopApi.Mappers;
using MikrocopDb.Repositories;
namespace MikrocopApi.Services;
public sealed class UserService : IUserService
{
private readonly IUserRepository _userRepository;
private readonly IPasswordHashingService _passwordHashingService;
public UserService(IUserRepository userRepository, IPasswordHashingService passwordHashingService)
{
_userRepository = userRepository;
_passwordHashingService = passwordHashingService;
}
public async Task<UserDto> CreateAsync(CreateUserDto request, CancellationToken cancellationToken = default)
{
if (await _userRepository.GetByUserNameAsync(request.UserName, cancellationToken) is not null)
{
throw new ConflictException("UserName already exists.");
}
if (await _userRepository.GetByEmailAsync(request.Email, cancellationToken) is not null)
{
throw new ConflictException("Email already exists.");
}
ValidatePasswordComplexity(request.Password);
var entity = request.ToEntity();
var (hash, salt) = _passwordHashingService.HashPassword(request.Password);
entity.PasswordHash = hash;
entity.PasswordSalt = salt;
await _userRepository.AddAsync(entity, cancellationToken);
return entity.ToDto();
}
public async Task UpdateAsync(Guid id, UpdateUserDto request, CancellationToken cancellationToken = default)
{
var existing = await _userRepository.GetByIdAsync(id, cancellationToken)
?? throw new NotFoundException($"User with id '{id}' was not found.");
var byUserName = await _userRepository.GetByUserNameAsync(request.UserName, cancellationToken);
if (byUserName is not null && byUserName.Id != id)
{
throw new ConflictException("UserName already exists.");
}
var byEmail = await _userRepository.GetByEmailAsync(request.Email, cancellationToken);
if (byEmail is not null && byEmail.Id != id)
{
throw new ConflictException("Email already exists.");
}
existing.ApplyFromDto(request);
if (!string.IsNullOrWhiteSpace(request.Password))
{
ValidatePasswordComplexity(request.Password);
var (hash, salt) = _passwordHashingService.HashPassword(request.Password);
existing.PasswordHash = hash;
existing.PasswordSalt = salt;
}
await _userRepository.UpdateAsync(existing, cancellationToken);
}
public async Task<UserDto> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
var user = await _userRepository.GetByIdAsync(id, cancellationToken)
?? throw new NotFoundException($"User with id '{id}' was not found.");
return user.ToDto();
}
public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default)
{
var user = await _userRepository.GetByIdAsync(id, cancellationToken)
?? throw new NotFoundException($"User with id '{id}' was not found.");
await _userRepository.DeleteAsync(user, cancellationToken);
}
public async Task<bool> ValidatePasswordAsync(Guid id, string password, CancellationToken cancellationToken = default)
{
var user = await _userRepository.GetByIdAsync(id, cancellationToken)
?? throw new NotFoundException($"User with id '{id}' was not found.");
return _passwordHashingService.VerifyPassword(password, user.PasswordHash, user.PasswordSalt);
}
private static void ValidatePasswordComplexity(string password)
{
if (string.IsNullOrWhiteSpace(password))
{
throw new BadRequestException("Password is required.");
}
if (password.Length < 12)
{
throw new BadRequestException("Password must be at least 12 characters long.");
}
if (password.Any(char.IsWhiteSpace))
{
throw new BadRequestException("Password must not contain whitespace.");
}
if (!password.Any(char.IsUpper))
{
throw new BadRequestException("Password must contain at least one uppercase letter.");
}
if (!password.Any(char.IsLower))
{
throw new BadRequestException("Password must contain at least one lowercase letter.");
}
if (!password.Any(char.IsDigit))
{
throw new BadRequestException("Password must contain at least one digit.");
}
if (!password.Any(ch => !char.IsLetterOrDigit(ch)))
{
throw new BadRequestException("Password must contain at least one special character.");
}
}
}