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,40 @@
using MikrocopApi.Dtos;
using MikrocopApi.Exceptions;
using MikrocopApi.Mappers;
using MikrocopDb.Repositories;
namespace MikrocopApi.Services;
public sealed class AuthService : IAuthService
{
private readonly IUserRepository _userRepository;
private readonly IPasswordHashingService _passwordHashingService;
private readonly IJwtTokenService _jwtTokenService;
public AuthService(
IUserRepository userRepository,
IPasswordHashingService passwordHashingService,
IJwtTokenService jwtTokenService)
{
_userRepository = userRepository;
_passwordHashingService = passwordHashingService;
_jwtTokenService = jwtTokenService;
}
public async Task<LoginResponseDto> LoginAsync(LoginRequestDto request, CancellationToken cancellationToken = default)
{
var user = await _userRepository.GetByUserNameAsync(request.UserName, cancellationToken);
if (user is null)
{
throw new UnauthorizedException("Invalid username or password.");
}
var isValid = _passwordHashingService.VerifyPassword(request.Password, user.PasswordHash, user.PasswordSalt);
if (!isValid)
{
throw new UnauthorizedException("Invalid username or password.");
}
return _jwtTokenService.Generate(user).ToDto();
}
}

View File

@@ -0,0 +1,8 @@
using MikrocopApi.Dtos;
namespace MikrocopApi.Services;
public interface IAuthService
{
Task<LoginResponseDto> LoginAsync(LoginRequestDto request, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,8 @@
using MikrocopDb.Entities;
namespace MikrocopApi.Services;
public interface IJwtTokenService
{
(string AccessToken, DateTime ExpiresAtUtc) Generate(UserEntity user);
}

View File

@@ -0,0 +1,7 @@
namespace MikrocopApi.Services;
public interface IPasswordHashingService
{
(string Hash, string Salt) HashPassword(string password);
bool VerifyPassword(string password, string hashBase64, string saltBase64);
}

View File

@@ -0,0 +1,12 @@
using MikrocopApi.Dtos;
namespace MikrocopApi.Services;
public interface IUserService
{
Task<UserDto> CreateAsync(CreateUserDto request, CancellationToken cancellationToken = default);
Task UpdateAsync(Guid id, UpdateUserDto request, CancellationToken cancellationToken = default);
Task<UserDto> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task DeleteAsync(Guid id, CancellationToken cancellationToken = default);
Task<bool> ValidatePasswordAsync(Guid id, string password, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,47 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using MikrocopApi.Configuration;
using MikrocopDb.Entities;
namespace MikrocopApi.Services;
public sealed class JwtTokenService : IJwtTokenService
{
private readonly JwtOptions _options;
public JwtTokenService(IOptions<JwtOptions> options)
{
_options = options.Value;
}
public (string AccessToken, DateTime ExpiresAtUtc) Generate(UserEntity user)
{
var now = DateTime.UtcNow;
var expires = now.AddMinutes(_options.ExpirationMinutes);
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new(JwtRegisteredClaimNames.UniqueName, user.UserName),
new(ClaimTypes.Name, user.UserName),
new(ClaimTypes.NameIdentifier, user.Id.ToString())
};
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SigningKey));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: _options.Issuer,
audience: _options.Audience,
claims: claims,
notBefore: now,
expires: expires,
signingCredentials: creds);
var serialized = new JwtSecurityTokenHandler().WriteToken(token);
return (serialized, expires);
}
}

View File

@@ -0,0 +1,37 @@
using System.Security.Cryptography;
namespace MikrocopApi.Services;
public sealed class PasswordHashingService : IPasswordHashingService
{
private const int SaltSizeBytes = 16;
private const int HashSizeBytes = 32;
private const int Iterations = 100_000;
public (string Hash, string Salt) HashPassword(string password)
{
var salt = RandomNumberGenerator.GetBytes(SaltSizeBytes);
var hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations, HashAlgorithmName.SHA256, HashSizeBytes);
return (Convert.ToBase64String(hash), Convert.ToBase64String(salt));
}
public bool VerifyPassword(string password, string hashBase64, string saltBase64)
{
byte[] expectedHash;
byte[] salt;
try
{
expectedHash = Convert.FromBase64String(hashBase64);
salt = Convert.FromBase64String(saltBase64);
}
catch (FormatException)
{
return false;
}
var actualHash = Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations, HashAlgorithmName.SHA256, expectedHash.Length);
return CryptographicOperations.FixedTimeEquals(actualHash, expectedHash);
}
}

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.");
}
}
}