Add initial implementation of API, database, and user management components.
This commit is contained in:
40
MikrocopApi/Services/AuthService.cs
Normal file
40
MikrocopApi/Services/AuthService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
8
MikrocopApi/Services/Interfaces/IAuthService.cs
Normal file
8
MikrocopApi/Services/Interfaces/IAuthService.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
using MikrocopApi.Dtos;
|
||||
|
||||
namespace MikrocopApi.Services;
|
||||
|
||||
public interface IAuthService
|
||||
{
|
||||
Task<LoginResponseDto> LoginAsync(LoginRequestDto request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
8
MikrocopApi/Services/Interfaces/IJwtTokenService.cs
Normal file
8
MikrocopApi/Services/Interfaces/IJwtTokenService.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
using MikrocopDb.Entities;
|
||||
|
||||
namespace MikrocopApi.Services;
|
||||
|
||||
public interface IJwtTokenService
|
||||
{
|
||||
(string AccessToken, DateTime ExpiresAtUtc) Generate(UserEntity user);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
12
MikrocopApi/Services/Interfaces/IUserService.cs
Normal file
12
MikrocopApi/Services/Interfaces/IUserService.cs
Normal 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);
|
||||
}
|
||||
47
MikrocopApi/Services/JwtTokenService.cs
Normal file
47
MikrocopApi/Services/JwtTokenService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
37
MikrocopApi/Services/PasswordHashingService.cs
Normal file
37
MikrocopApi/Services/PasswordHashingService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
133
MikrocopApi/Services/UserService.cs
Normal file
133
MikrocopApi/Services/UserService.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user