Add initial implementation of API, database, and user management components.
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
bin/
|
||||
obj/
|
||||
/packages/
|
||||
riderModule.iml
|
||||
/_ReSharper.Caches/
|
||||
15
.idea/.idea.MikrocopTest/.idea/.gitignore
generated
vendored
Normal file
15
.idea/.idea.MikrocopTest/.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Rider ignored files
|
||||
/contentModel.xml
|
||||
/modules.xml
|
||||
/projectSettingsUpdater.xml
|
||||
/.idea.MikrocopTest.iml
|
||||
# Ignored default folder with query files
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
4
.idea/.idea.MikrocopTest/.idea/encodings.xml
generated
Normal file
4
.idea/.idea.MikrocopTest/.idea/encodings.xml
generated
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
|
||||
</project>
|
||||
8
.idea/.idea.MikrocopTest/.idea/indexLayout.xml
generated
Normal file
8
.idea/.idea.MikrocopTest/.idea/indexLayout.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="UserContentModel">
|
||||
<attachedFolders />
|
||||
<explicitIncludes />
|
||||
<explicitExcludes />
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/.idea.MikrocopTest/.idea/vcs.xml
generated
Normal file
6
.idea/.idea.MikrocopTest/.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
9
MikrocopApi/Configuration/JwtOptions.cs
Normal file
9
MikrocopApi/Configuration/JwtOptions.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace MikrocopApi.Configuration;
|
||||
|
||||
public sealed class JwtOptions
|
||||
{
|
||||
public string Issuer { get; set; } = string.Empty;
|
||||
public string Audience { get; set; } = string.Empty;
|
||||
public string SigningKey { get; set; } = string.Empty;
|
||||
public int ExpirationMinutes { get; set; } = 60;
|
||||
}
|
||||
42
MikrocopApi/Controllers/AuthController.cs
Normal file
42
MikrocopApi/Controllers/AuthController.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MikrocopApi.Dtos;
|
||||
using MikrocopApi.Services;
|
||||
|
||||
namespace MikrocopApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/auth")]
|
||||
public sealed class AuthController : ControllerBase
|
||||
{
|
||||
private readonly IAuthService _authService;
|
||||
private readonly IUserService _userService;
|
||||
|
||||
public AuthController(IAuthService authService, IUserService userService)
|
||||
{
|
||||
_authService = authService;
|
||||
_userService = userService;
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("register")]
|
||||
[ProducesResponseType<UserDto>(StatusCodes.Status201Created)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> Register([FromBody] CreateUserDto request, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await _userService.CreateAsync(request, cancellationToken);
|
||||
return Created($"/api/users/{user.Id}", user);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("login")]
|
||||
[ProducesResponseType<LoginResponseDto>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> Login([FromBody] LoginRequestDto request, CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await _authService.LoginAsync(request, cancellationToken);
|
||||
return Ok(response);
|
||||
}
|
||||
}
|
||||
69
MikrocopApi/Controllers/UsersController.cs
Normal file
69
MikrocopApi/Controllers/UsersController.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using MikrocopApi.Dtos;
|
||||
using MikrocopApi.Mappers;
|
||||
using MikrocopApi.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace MikrocopApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/users")]
|
||||
public sealed class UsersController : ControllerBase
|
||||
{
|
||||
private readonly IUserService _userService;
|
||||
|
||||
public UsersController(IUserService userService)
|
||||
{
|
||||
_userService = userService;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType<UserDto>(StatusCodes.Status201Created)]
|
||||
[ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> Create([FromBody] CreateUserDto request, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await _userService.CreateAsync(request, cancellationToken);
|
||||
return CreatedAtAction(nameof(GetById), new { id = user.Id }, user);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[ProducesResponseType<UserDto>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetById([FromRoute] Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await _userService.GetByIdAsync(id, cancellationToken);
|
||||
return Ok(user);
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> Update([FromRoute] Guid id, [FromBody] UpdateUserDto request, CancellationToken cancellationToken)
|
||||
{
|
||||
await _userService.UpdateAsync(id, request, cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Delete([FromRoute] Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
await _userService.DeleteAsync(id, cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/validate-password")]
|
||||
[ProducesResponseType<ValidatePasswordResponseDto>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> ValidatePassword([FromRoute] Guid id, [FromBody] ValidatePasswordRequestDto request, CancellationToken cancellationToken)
|
||||
{
|
||||
var isValid = await _userService.ValidatePasswordAsync(id, request.Password, cancellationToken);
|
||||
return Ok(isValid.ToDto());
|
||||
}
|
||||
}
|
||||
38
MikrocopApi/Dtos/CreateUserDto.cs
Normal file
38
MikrocopApi/Dtos/CreateUserDto.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace MikrocopApi.Dtos;
|
||||
|
||||
public sealed class CreateUserDto
|
||||
{
|
||||
[Required]
|
||||
[StringLength(100, MinimumLength = 3)]
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[StringLength(200, MinimumLength = 2)]
|
||||
public string FullName { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[EmailAddress]
|
||||
[StringLength(200)]
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[StringLength(30, MinimumLength = 7)]
|
||||
[RegularExpression(@"^\+?[0-9\- ]+$", ErrorMessage = "Mobile number contains invalid characters.")]
|
||||
public string MobileNumber { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[StringLength(10, MinimumLength = 2)]
|
||||
[RegularExpression(@"^[a-zA-Z]{2,10}$", ErrorMessage = "Language must contain only letters.")]
|
||||
public string Language { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[StringLength(20, MinimumLength = 2)]
|
||||
[RegularExpression(@"^[a-zA-Z]{2,10}(-[a-zA-Z]{2,10})?$", ErrorMessage = "Culture format is invalid.")]
|
||||
public string Culture { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[StringLength(128, MinimumLength = 8)]
|
||||
public string Password { get; set; } = string.Empty;
|
||||
}
|
||||
14
MikrocopApi/Dtos/LoginRequestDto.cs
Normal file
14
MikrocopApi/Dtos/LoginRequestDto.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace MikrocopApi.Dtos;
|
||||
|
||||
public sealed class LoginRequestDto
|
||||
{
|
||||
[Required]
|
||||
[StringLength(100, MinimumLength = 3)]
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[StringLength(128, MinimumLength = 8)]
|
||||
public string Password { get; set; } = string.Empty;
|
||||
}
|
||||
8
MikrocopApi/Dtos/LoginResponseDto.cs
Normal file
8
MikrocopApi/Dtos/LoginResponseDto.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace MikrocopApi.Dtos;
|
||||
|
||||
public sealed class LoginResponseDto
|
||||
{
|
||||
public required string AccessToken { get; init; }
|
||||
public required DateTime ExpiresAtUtc { get; init; }
|
||||
public required string TokenType { get; init; }
|
||||
}
|
||||
37
MikrocopApi/Dtos/UpdateUserDto.cs
Normal file
37
MikrocopApi/Dtos/UpdateUserDto.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace MikrocopApi.Dtos;
|
||||
|
||||
public sealed class UpdateUserDto
|
||||
{
|
||||
[Required]
|
||||
[StringLength(100, MinimumLength = 3)]
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[StringLength(200, MinimumLength = 2)]
|
||||
public string FullName { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[EmailAddress]
|
||||
[StringLength(200)]
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[StringLength(30, MinimumLength = 7)]
|
||||
[RegularExpression(@"^\+?[0-9\- ]+$", ErrorMessage = "Mobile number contains invalid characters.")]
|
||||
public string MobileNumber { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[StringLength(10, MinimumLength = 2)]
|
||||
[RegularExpression(@"^[a-zA-Z]{2,10}$", ErrorMessage = "Language must contain only letters.")]
|
||||
public string Language { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[StringLength(20, MinimumLength = 2)]
|
||||
[RegularExpression(@"^[a-zA-Z]{2,10}(-[a-zA-Z]{2,10})?$", ErrorMessage = "Culture format is invalid.")]
|
||||
public string Culture { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(128, MinimumLength = 8)]
|
||||
public string? Password { get; set; }
|
||||
}
|
||||
12
MikrocopApi/Dtos/UserDto.cs
Normal file
12
MikrocopApi/Dtos/UserDto.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace MikrocopApi.Dtos;
|
||||
|
||||
public sealed class UserDto
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public required string UserName { get; init; }
|
||||
public required string FullName { get; init; }
|
||||
public required string Email { get; init; }
|
||||
public required string MobileNumber { get; init; }
|
||||
public required string Language { get; init; }
|
||||
public required string Culture { get; init; }
|
||||
}
|
||||
10
MikrocopApi/Dtos/ValidatePasswordRequestDto.cs
Normal file
10
MikrocopApi/Dtos/ValidatePasswordRequestDto.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace MikrocopApi.Dtos;
|
||||
|
||||
public sealed class ValidatePasswordRequestDto
|
||||
{
|
||||
[Required]
|
||||
[StringLength(128, MinimumLength = 8)]
|
||||
public string Password { get; set; } = string.Empty;
|
||||
}
|
||||
6
MikrocopApi/Dtos/ValidatePasswordResponseDto.cs
Normal file
6
MikrocopApi/Dtos/ValidatePasswordResponseDto.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace MikrocopApi.Dtos;
|
||||
|
||||
public sealed class ValidatePasswordResponseDto
|
||||
{
|
||||
public bool IsValid { get; init; }
|
||||
}
|
||||
11
MikrocopApi/Exceptions/AppException.cs
Normal file
11
MikrocopApi/Exceptions/AppException.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace MikrocopApi.Exceptions;
|
||||
|
||||
public abstract class AppException : Exception
|
||||
{
|
||||
protected AppException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public abstract int StatusCode { get; }
|
||||
public abstract string Title { get; }
|
||||
}
|
||||
11
MikrocopApi/Exceptions/BadRequestException.cs
Normal file
11
MikrocopApi/Exceptions/BadRequestException.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace MikrocopApi.Exceptions;
|
||||
|
||||
public sealed class BadRequestException : AppException
|
||||
{
|
||||
public BadRequestException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public override int StatusCode => StatusCodes.Status400BadRequest;
|
||||
public override string Title => "Bad Request";
|
||||
}
|
||||
11
MikrocopApi/Exceptions/ConflictException.cs
Normal file
11
MikrocopApi/Exceptions/ConflictException.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace MikrocopApi.Exceptions;
|
||||
|
||||
public sealed class ConflictException : AppException
|
||||
{
|
||||
public ConflictException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public override int StatusCode => StatusCodes.Status409Conflict;
|
||||
public override string Title => "Conflict";
|
||||
}
|
||||
11
MikrocopApi/Exceptions/NotFoundException.cs
Normal file
11
MikrocopApi/Exceptions/NotFoundException.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace MikrocopApi.Exceptions;
|
||||
|
||||
public sealed class NotFoundException : AppException
|
||||
{
|
||||
public NotFoundException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public override int StatusCode => StatusCodes.Status404NotFound;
|
||||
public override string Title => "Not Found";
|
||||
}
|
||||
11
MikrocopApi/Exceptions/UnauthorizedException.cs
Normal file
11
MikrocopApi/Exceptions/UnauthorizedException.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace MikrocopApi.Exceptions;
|
||||
|
||||
public sealed class UnauthorizedException : AppException
|
||||
{
|
||||
public UnauthorizedException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public override int StatusCode => StatusCodes.Status401Unauthorized;
|
||||
public override string Title => "Unauthorized";
|
||||
}
|
||||
65
MikrocopApi/Extensions/SwaggerExtensions.cs
Normal file
65
MikrocopApi/Extensions/SwaggerExtensions.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.OpenApi;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace MikrocopApi.Extensions;
|
||||
|
||||
public static class SwaggerExtensions
|
||||
{
|
||||
public static IServiceCollection AddSwaggerWithJwtAuth(this IServiceCollection services)
|
||||
{
|
||||
services.AddEndpointsApiExplorer();
|
||||
services.AddSwaggerGen(options =>
|
||||
{
|
||||
const string schemeName = "Bearer";
|
||||
options.SwaggerDoc("v1", new OpenApiInfo
|
||||
{
|
||||
Title = "Mikrocop API",
|
||||
Version = "v1"
|
||||
});
|
||||
|
||||
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
|
||||
{
|
||||
Name = "Authorization",
|
||||
Type = SecuritySchemeType.Http,
|
||||
Scheme = "bearer",
|
||||
BearerFormat = "JWT",
|
||||
In = ParameterLocation.Header,
|
||||
Description = "Enter 'Bearer' followed by a space and your token, e.g., 'Bearer abc123'"
|
||||
});
|
||||
|
||||
|
||||
options.AddSecurityRequirement(document => new OpenApiSecurityRequirement
|
||||
{
|
||||
[new OpenApiSecuritySchemeReference("bearer", document)] = []
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IApplicationBuilder UseSwaggerWithJwtAuth(this IApplicationBuilder app)
|
||||
{
|
||||
app.UseSwagger(options =>
|
||||
{
|
||||
options.PreSerializeFilters.Add((swaggerDoc, _) =>
|
||||
{
|
||||
swaggerDoc.Security =
|
||||
[
|
||||
new OpenApiSecurityRequirement
|
||||
{
|
||||
[new OpenApiSecuritySchemeReference("Bearer", swaggerDoc, null)] = new List<string>()
|
||||
}
|
||||
];
|
||||
});
|
||||
});
|
||||
app.UseSwaggerUI(options =>
|
||||
{
|
||||
options.SwaggerEndpoint("/swagger/v1/swagger.json", "Mikrocop API v1");
|
||||
options.EnablePersistAuthorization();
|
||||
});
|
||||
return app;
|
||||
}
|
||||
}
|
||||
24
MikrocopApi/Mappers/AuthMappingExtensions.cs
Normal file
24
MikrocopApi/Mappers/AuthMappingExtensions.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using MikrocopApi.Dtos;
|
||||
|
||||
namespace MikrocopApi.Mappers;
|
||||
|
||||
public static class AuthMappingExtensions
|
||||
{
|
||||
public static LoginResponseDto ToDto(this (string AccessToken, DateTime ExpiresAtUtc) token)
|
||||
{
|
||||
return new LoginResponseDto
|
||||
{
|
||||
AccessToken = token.AccessToken,
|
||||
ExpiresAtUtc = token.ExpiresAtUtc,
|
||||
TokenType = "Bearer"
|
||||
};
|
||||
}
|
||||
|
||||
public static ValidatePasswordResponseDto ToDto(this bool isValid)
|
||||
{
|
||||
return new ValidatePasswordResponseDto
|
||||
{
|
||||
IsValid = isValid
|
||||
};
|
||||
}
|
||||
}
|
||||
47
MikrocopApi/Mappers/UserMappingExtensions.cs
Normal file
47
MikrocopApi/Mappers/UserMappingExtensions.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
using MikrocopApi.Dtos;
|
||||
using MikrocopDb.Entities;
|
||||
|
||||
namespace MikrocopApi.Mappers;
|
||||
|
||||
public static class UserMappingExtensions
|
||||
{
|
||||
public static UserEntity ToEntity(this CreateUserDto dto)
|
||||
{
|
||||
return new UserEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserName = dto.UserName.Trim(),
|
||||
FullName = dto.FullName.Trim(),
|
||||
Email = dto.Email.Trim(),
|
||||
MobileNumber = dto.MobileNumber.Trim(),
|
||||
Language = dto.Language.Trim(),
|
||||
Culture = dto.Culture.Trim(),
|
||||
PasswordHash = string.Empty,
|
||||
PasswordSalt = string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
public static void ApplyFromDto(this UserEntity entity, UpdateUserDto dto)
|
||||
{
|
||||
entity.UserName = dto.UserName.Trim();
|
||||
entity.FullName = dto.FullName.Trim();
|
||||
entity.Email = dto.Email.Trim();
|
||||
entity.MobileNumber = dto.MobileNumber.Trim();
|
||||
entity.Language = dto.Language.Trim();
|
||||
entity.Culture = dto.Culture.Trim();
|
||||
}
|
||||
|
||||
public static UserDto ToDto(this UserEntity entity)
|
||||
{
|
||||
return new UserDto
|
||||
{
|
||||
Id = entity.Id,
|
||||
UserName = entity.UserName,
|
||||
FullName = entity.FullName,
|
||||
Email = entity.Email,
|
||||
MobileNumber = entity.MobileNumber,
|
||||
Language = entity.Language,
|
||||
Culture = entity.Culture
|
||||
};
|
||||
}
|
||||
}
|
||||
188
MikrocopApi/Middleware/ApiRequestLoggingMiddleware.cs
Normal file
188
MikrocopApi/Middleware/ApiRequestLoggingMiddleware.cs
Normal file
@@ -0,0 +1,188 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using MikrocopApi.Exceptions;
|
||||
|
||||
namespace MikrocopApi.Middleware;
|
||||
|
||||
public sealed class ApiRequestLoggingMiddleware
|
||||
{
|
||||
private static readonly HashSet<string> SensitiveFields = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"password",
|
||||
"passwordhash",
|
||||
"apikey",
|
||||
"x-api-key"
|
||||
};
|
||||
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<ApiRequestLoggingMiddleware> _logger;
|
||||
|
||||
public ApiRequestLoggingMiddleware(RequestDelegate next, ILogger<ApiRequestLoggingMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
var requestBody = await ExtractBodyAsync(context.Request);
|
||||
|
||||
try
|
||||
{
|
||||
await _next(context);
|
||||
var requestParameters = BuildRequestParameters(context, requestBody);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Time={Time}; ClientIp={ClientIp}; ClientName={ClientName}; Host={Host}; ApiMethod={ApiMethod}; RequestParameters={RequestParameters}; Message={Message}",
|
||||
DateTimeOffset.UtcNow,
|
||||
GetClientIp(context),
|
||||
GetClientName(context),
|
||||
Environment.MachineName,
|
||||
GetApiMethod(context),
|
||||
requestParameters,
|
||||
"Request completed.");
|
||||
}
|
||||
catch (AppException ex)
|
||||
{
|
||||
var requestParameters = BuildRequestParameters(context, requestBody);
|
||||
_logger.LogInformation(
|
||||
ex,
|
||||
"Time={Time}; ClientIp={ClientIp}; ClientName={ClientName}; Host={Host}; ApiMethod={ApiMethod}; RequestParameters={RequestParameters}; Message={Message}",
|
||||
DateTimeOffset.UtcNow,
|
||||
GetClientIp(context),
|
||||
GetClientName(context),
|
||||
Environment.MachineName,
|
||||
GetApiMethod(context),
|
||||
requestParameters,
|
||||
"Request handled with business exception.");
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var requestParameters = BuildRequestParameters(context, requestBody);
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Time={Time}; ClientIp={ClientIp}; ClientName={ClientName}; Host={Host}; ApiMethod={ApiMethod}; RequestParameters={RequestParameters}; Message={Message}",
|
||||
DateTimeOffset.UtcNow,
|
||||
GetClientIp(context),
|
||||
GetClientName(context),
|
||||
Environment.MachineName,
|
||||
GetApiMethod(context),
|
||||
requestParameters,
|
||||
"Request failed.");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetClientIp(HttpContext context)
|
||||
{
|
||||
return context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
}
|
||||
|
||||
private static string GetClientName(HttpContext context)
|
||||
{
|
||||
if (context.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
return context.User.Identity.Name ?? "authenticated-user";
|
||||
}
|
||||
|
||||
return "anonymous";
|
||||
}
|
||||
|
||||
private static string GetApiMethod(HttpContext context)
|
||||
{
|
||||
return context.GetEndpoint()?.DisplayName ?? $"{context.Request.Method} {context.Request.Path}";
|
||||
}
|
||||
|
||||
private static string BuildRequestParameters(HttpContext context, string? body)
|
||||
{
|
||||
var query = context.Request.Query.ToDictionary(kvp => kvp.Key, kvp => SanitizeValue(kvp.Key, kvp.Value.ToString()));
|
||||
var route = context.Request.RouteValues.ToDictionary(kvp => kvp.Key, kvp => SanitizeValue(kvp.Key, kvp.Value?.ToString() ?? string.Empty));
|
||||
|
||||
var payload = new
|
||||
{
|
||||
query,
|
||||
route,
|
||||
body
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(payload);
|
||||
}
|
||||
|
||||
private static async Task<string?> ExtractBodyAsync(HttpRequest request)
|
||||
{
|
||||
if (request.ContentLength is not > 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
request.EnableBuffering();
|
||||
request.Body.Position = 0;
|
||||
using var reader = new StreamReader(request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true);
|
||||
var rawBody = await reader.ReadToEndAsync();
|
||||
request.Body.Position = 0;
|
||||
|
||||
return SanitizeBody(rawBody);
|
||||
}
|
||||
|
||||
private static string SanitizeBody(string body)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
{
|
||||
return body;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var node = JsonNode.Parse(body);
|
||||
if (node is null)
|
||||
{
|
||||
return body;
|
||||
}
|
||||
|
||||
SanitizeNode(node);
|
||||
return node.ToJsonString();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
||||
private static void SanitizeNode(JsonNode node)
|
||||
{
|
||||
if (node is JsonObject jsonObject)
|
||||
{
|
||||
foreach (var kvp in jsonObject.ToList())
|
||||
{
|
||||
if (SensitiveFields.Contains(kvp.Key))
|
||||
{
|
||||
jsonObject[kvp.Key] = "***";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (kvp.Value is not null)
|
||||
{
|
||||
SanitizeNode(kvp.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (node is JsonArray jsonArray)
|
||||
{
|
||||
foreach (var item in jsonArray)
|
||||
{
|
||||
if (item is not null)
|
||||
{
|
||||
SanitizeNode(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string SanitizeValue(string key, string value)
|
||||
{
|
||||
return SensitiveFields.Contains(key) ? "***" : value;
|
||||
}
|
||||
}
|
||||
42
MikrocopApi/Middleware/ExceptionHandlingMiddleware.cs
Normal file
42
MikrocopApi/Middleware/ExceptionHandlingMiddleware.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MikrocopApi.Exceptions;
|
||||
|
||||
namespace MikrocopApi.Middleware;
|
||||
|
||||
public sealed class ExceptionHandlingMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
|
||||
public ExceptionHandlingMiddleware(RequestDelegate next)
|
||||
{
|
||||
_next = next;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _next(context);
|
||||
}
|
||||
catch (AppException ex)
|
||||
{
|
||||
context.Response.StatusCode = ex.StatusCode;
|
||||
await context.Response.WriteAsJsonAsync(new ProblemDetails
|
||||
{
|
||||
Title = ex.Title,
|
||||
Detail = ex.Message,
|
||||
Status = ex.StatusCode
|
||||
});
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
||||
await context.Response.WriteAsJsonAsync(new ProblemDetails
|
||||
{
|
||||
Title = "Internal Server Error",
|
||||
Detail = "An unexpected error occurred.",
|
||||
Status = StatusCodes.Status500InternalServerError
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
23
MikrocopApi/MikrocopApi.csproj
Normal file
23
MikrocopApi/MikrocopApi.csproj
Normal file
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.3"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.OpenApi" Version="2.3.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.0.1" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MikrocopDb\MikrocopDb.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
75
MikrocopApi/MikrocopApi.http
Normal file
75
MikrocopApi/MikrocopApi.http
Normal file
@@ -0,0 +1,75 @@
|
||||
@MikrocopApi_HostAddress = http://localhost:5018
|
||||
@AccessToken = paste-jwt-here
|
||||
@UserId = 00000000-0000-0000-0000-000000000000
|
||||
|
||||
POST {{MikrocopApi_HostAddress}}/api/auth/register
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"userName": "john.doe",
|
||||
"fullName": "John Doe",
|
||||
"email": "john.doe@example.com",
|
||||
"mobileNumber": "+15551234567",
|
||||
"language": "en",
|
||||
"culture": "en-US",
|
||||
"password": "StrongPass123"
|
||||
}
|
||||
|
||||
###
|
||||
POST {{MikrocopApi_HostAddress}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"userName": "john.doe",
|
||||
"password": "StrongPass123"
|
||||
}
|
||||
|
||||
###
|
||||
POST {{MikrocopApi_HostAddress}}/api/users
|
||||
Authorization: Bearer {{AccessToken}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"userName": "jane.doe",
|
||||
"fullName": "Jane Doe",
|
||||
"email": "jane.doe@example.com",
|
||||
"mobileNumber": "+15550000001",
|
||||
"language": "en",
|
||||
"culture": "en-US",
|
||||
"password": "StrongPass123"
|
||||
}
|
||||
|
||||
###
|
||||
GET {{MikrocopApi_HostAddress}}/api/users/{{UserId}}
|
||||
Authorization: Bearer {{AccessToken}}
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
PUT {{MikrocopApi_HostAddress}}/api/users/{{UserId}}
|
||||
Authorization: Bearer {{AccessToken}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"userName": "john.doe",
|
||||
"fullName": "Johnathan Doe",
|
||||
"email": "john.doe@example.com",
|
||||
"mobileNumber": "+15550000002",
|
||||
"language": "en",
|
||||
"culture": "en-US",
|
||||
"password": "AnotherStrongPass123"
|
||||
}
|
||||
|
||||
###
|
||||
POST {{MikrocopApi_HostAddress}}/api/users/{{UserId}}/validate-password
|
||||
Authorization: Bearer {{AccessToken}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"password": "AnotherStrongPass123"
|
||||
}
|
||||
|
||||
###
|
||||
DELETE {{MikrocopApi_HostAddress}}/api/users/{{UserId}}
|
||||
Authorization: Bearer {{AccessToken}}
|
||||
|
||||
###
|
||||
108
MikrocopApi/Program.cs
Normal file
108
MikrocopApi/Program.cs
Normal file
@@ -0,0 +1,108 @@
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using MikrocopApi.Configuration;
|
||||
using MikrocopApi.Extensions;
|
||||
using MikrocopApi.Middleware;
|
||||
using MikrocopApi.Services;
|
||||
using MikrocopDb;
|
||||
using MikrocopDb.Repositories;
|
||||
using Serilog;
|
||||
using Serilog.Formatting.Json;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
var loggerConfiguration = new LoggerConfiguration()
|
||||
.ReadFrom.Configuration(builder.Configuration)
|
||||
.WriteTo.File(
|
||||
formatter: new JsonFormatter(renderMessage: true),
|
||||
path: "Logs/log-.json",
|
||||
rollingInterval: RollingInterval.Day,
|
||||
shared: true);
|
||||
|
||||
if (builder.Environment.IsDevelopment())
|
||||
{
|
||||
loggerConfiguration.WriteTo.Console();
|
||||
}
|
||||
else
|
||||
{
|
||||
loggerConfiguration.WriteTo.Console(new JsonFormatter(renderMessage: true));
|
||||
}
|
||||
|
||||
Log.Logger = loggerConfiguration.CreateLogger();
|
||||
|
||||
builder.Host.UseSerilog();
|
||||
|
||||
builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection("Jwt"));
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddSwaggerWithJwtAuth();
|
||||
|
||||
var jwtOptions = builder.Configuration.GetSection("Jwt").Get<JwtOptions>() ?? new JwtOptions();
|
||||
if (string.IsNullOrWhiteSpace(jwtOptions.SigningKey) || jwtOptions.SigningKey.Length < 32)
|
||||
{
|
||||
throw new InvalidOperationException("Jwt:SigningKey must be configured and at least 32 characters long.");
|
||||
}
|
||||
|
||||
builder.Services
|
||||
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidIssuer = jwtOptions.Issuer,
|
||||
ValidAudience = jwtOptions.Audience,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SigningKey)),
|
||||
ClockSkew = TimeSpan.Zero
|
||||
};
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
builder.Services.AddDbContext<AppDbContext>(options =>
|
||||
options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection")));
|
||||
|
||||
builder.Services.AddScoped<IUserRepository, UserRepository>();
|
||||
builder.Services.AddScoped<IUserService, UserService>();
|
||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||
builder.Services.AddScoped<IJwtTokenService, JwtTokenService>();
|
||||
builder.Services.AddSingleton<IPasswordHashingService, PasswordHashingService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
await dbContext.Database.MigrateAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
await dbContext.Database.EnsureCreatedAsync();
|
||||
}
|
||||
}
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwaggerWithJwtAuth();
|
||||
}
|
||||
|
||||
app.UseMiddleware<ExceptionHandlingMiddleware>();
|
||||
app.UseMiddleware<ApiRequestLoggingMiddleware>();
|
||||
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseHttpsRedirection();
|
||||
}
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
await app.RunAsync();
|
||||
25
MikrocopApi/Properties/launchSettings.json
Normal file
25
MikrocopApi/Properties/launchSettings.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:5018",
|
||||
"launchUrl": "swagger",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"applicationUrl": "https://localhost:7007;http://localhost:5018",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
11
MikrocopApi/appsettings.Development.json
Normal file
11
MikrocopApi/appsettings.Development.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Data Source=mikrocop.dev.db"
|
||||
},
|
||||
"Jwt": {
|
||||
"Issuer": "MikrocopApi",
|
||||
"Audience": "MikrocopApiClients",
|
||||
"SigningKey": "dev-only-very-long-secret-signing-key-1234567890",
|
||||
"ExpirationMinutes": 60
|
||||
}
|
||||
}
|
||||
22
MikrocopApi/appsettings.json
Normal file
22
MikrocopApi/appsettings.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Data Source=mikrocop.db"
|
||||
},
|
||||
"Jwt": {
|
||||
"Issuer": "MikrocopApi",
|
||||
"Audience": "MikrocopApiClients",
|
||||
"SigningKey": "replace-with-a-long-random-secret-key-min-32chars",
|
||||
"ExpirationMinutes": 60
|
||||
},
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
"Override": {
|
||||
"Microsoft": "Warning",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"Enrich": [ "FromLogContext" ]
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
32
MikrocopDb/AppDbContext.cs
Normal file
32
MikrocopDb/AppDbContext.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MikrocopDb.Entities;
|
||||
|
||||
namespace MikrocopDb;
|
||||
|
||||
public sealed class AppDbContext : DbContext
|
||||
{
|
||||
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public DbSet<UserEntity> Users => Set<UserEntity>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<UserEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.UserName).HasMaxLength(100).IsRequired();
|
||||
entity.Property(x => x.FullName).HasMaxLength(200).IsRequired();
|
||||
entity.Property(x => x.Email).HasMaxLength(200).IsRequired();
|
||||
entity.Property(x => x.MobileNumber).HasMaxLength(30).IsRequired();
|
||||
entity.Property(x => x.Language).HasMaxLength(20).IsRequired();
|
||||
entity.Property(x => x.Culture).HasMaxLength(20).IsRequired();
|
||||
entity.Property(x => x.PasswordHash).IsRequired();
|
||||
entity.Property(x => x.PasswordSalt).HasMaxLength(128).IsRequired();
|
||||
|
||||
entity.HasIndex(x => x.UserName).IsUnique();
|
||||
entity.HasIndex(x => x.Email).IsUnique();
|
||||
});
|
||||
}
|
||||
}
|
||||
15
MikrocopDb/AppDbContextFactory.cs
Normal file
15
MikrocopDb/AppDbContextFactory.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace MikrocopDb;
|
||||
|
||||
public sealed class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
|
||||
{
|
||||
public AppDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var optionsBuilder = new DbContextOptionsBuilder<AppDbContext>();
|
||||
optionsBuilder.UseSqlite("Data Source=mikrocop.db");
|
||||
|
||||
return new AppDbContext(optionsBuilder.Options);
|
||||
}
|
||||
}
|
||||
14
MikrocopDb/Entities/UserEntity.cs
Normal file
14
MikrocopDb/Entities/UserEntity.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace MikrocopDb.Entities;
|
||||
|
||||
public sealed class UserEntity
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public required string UserName { get; set; }
|
||||
public required string FullName { get; set; }
|
||||
public required string Email { get; set; }
|
||||
public required string MobileNumber { get; set; }
|
||||
public required string Language { get; set; }
|
||||
public required string Culture { get; set; }
|
||||
public required string PasswordHash { get; set; }
|
||||
public required string PasswordSalt { get; set; }
|
||||
}
|
||||
81
MikrocopDb/Migrations/20260315212014_Initial.Designer.cs
generated
Normal file
81
MikrocopDb/Migrations/20260315212014_Initial.Designer.cs
generated
Normal file
@@ -0,0 +1,81 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using MikrocopDb;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MikrocopDb.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260315212014_Initial")]
|
||||
partial class Initial
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
|
||||
|
||||
modelBuilder.Entity("MikrocopDb.Entities.UserEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Culture")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("FullName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("MobileNumber")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordSalt")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
53
MikrocopDb/Migrations/20260315212014_Initial.cs
Normal file
53
MikrocopDb/Migrations/20260315212014_Initial.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MikrocopDb.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Initial : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Users",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
UserName = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
|
||||
FullName = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||
Email = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||
MobileNumber = table.Column<string>(type: "TEXT", maxLength: 30, nullable: false),
|
||||
Language = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false),
|
||||
Culture = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false),
|
||||
PasswordHash = table.Column<string>(type: "TEXT", nullable: false),
|
||||
PasswordSalt = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Users", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Users_Email",
|
||||
table: "Users",
|
||||
column: "Email",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Users_UserName",
|
||||
table: "Users",
|
||||
column: "UserName",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Users");
|
||||
}
|
||||
}
|
||||
}
|
||||
78
MikrocopDb/Migrations/AppDbContextModelSnapshot.cs
Normal file
78
MikrocopDb/Migrations/AppDbContextModelSnapshot.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using MikrocopDb;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MikrocopDb.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
partial class AppDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
|
||||
|
||||
modelBuilder.Entity("MikrocopDb.Entities.UserEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Culture")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("FullName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("MobileNumber")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordSalt")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
18
MikrocopDb/MikrocopDb.csproj
Normal file
18
MikrocopDb/MikrocopDb.csproj
Normal file
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.5">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
13
MikrocopDb/Repositories/IUserRepository.cs
Normal file
13
MikrocopDb/Repositories/IUserRepository.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using MikrocopDb.Entities;
|
||||
|
||||
namespace MikrocopDb.Repositories;
|
||||
|
||||
public interface IUserRepository
|
||||
{
|
||||
Task<UserEntity?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<UserEntity?> GetByUserNameAsync(string userName, CancellationToken cancellationToken = default);
|
||||
Task<UserEntity?> GetByEmailAsync(string email, CancellationToken cancellationToken = default);
|
||||
Task AddAsync(UserEntity user, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(UserEntity user, CancellationToken cancellationToken = default);
|
||||
Task DeleteAsync(UserEntity user, CancellationToken cancellationToken = default);
|
||||
}
|
||||
47
MikrocopDb/Repositories/UserRepository.cs
Normal file
47
MikrocopDb/Repositories/UserRepository.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MikrocopDb.Entities;
|
||||
|
||||
namespace MikrocopDb.Repositories;
|
||||
|
||||
public sealed class UserRepository : IUserRepository
|
||||
{
|
||||
private readonly AppDbContext _dbContext;
|
||||
|
||||
public UserRepository(AppDbContext dbContext)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
}
|
||||
|
||||
public Task<UserEntity?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _dbContext.Users.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<UserEntity?> GetByUserNameAsync(string userName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _dbContext.Users.FirstOrDefaultAsync(x => x.UserName == userName, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<UserEntity?> GetByEmailAsync(string email, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _dbContext.Users.FirstOrDefaultAsync(x => x.Email == email, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task AddAsync(UserEntity user, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _dbContext.Users.AddAsync(user, cancellationToken);
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(UserEntity user, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_dbContext.Users.Update(user);
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(UserEntity user, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_dbContext.Users.Remove(user);
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
28
MikrocopTest.sln
Normal file
28
MikrocopTest.sln
Normal file
@@ -0,0 +1,28 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MikrocopTests", "MikrocopTests\MikrocopTests.csproj", "{97E68DAE-AE49-45FB-93D9-F80A8644409E}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MikrocopApi", "MikrocopApi\MikrocopApi.csproj", "{9B06D5D5-434B-49B6-A941-C46B8B5D841B}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MikrocopDb", "MikrocopDb\MikrocopDb.csproj", "{3612D5AB-3F51-4785-A7C5-A8BFF449AF92}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{97E68DAE-AE49-45FB-93D9-F80A8644409E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{97E68DAE-AE49-45FB-93D9-F80A8644409E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{97E68DAE-AE49-45FB-93D9-F80A8644409E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{97E68DAE-AE49-45FB-93D9-F80A8644409E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{9B06D5D5-434B-49B6-A941-C46B8B5D841B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{9B06D5D5-434B-49B6-A941-C46B8B5D841B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{9B06D5D5-434B-49B6-A941-C46B8B5D841B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{9B06D5D5-434B-49B6-A941-C46B8B5D841B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{3612D5AB-3F51-4785-A7C5-A8BFF449AF92}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{3612D5AB-3F51-4785-A7C5-A8BFF449AF92}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{3612D5AB-3F51-4785-A7C5-A8BFF449AF92}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{3612D5AB-3F51-4785-A7C5-A8BFF449AF92}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
104
MikrocopTests/AuthServiceTests.cs
Normal file
104
MikrocopTests/AuthServiceTests.cs
Normal file
@@ -0,0 +1,104 @@
|
||||
using MikrocopApi.Dtos;
|
||||
using MikrocopApi.Exceptions;
|
||||
using MikrocopApi.Services;
|
||||
using MikrocopDb.Entities;
|
||||
using MikrocopDb.Repositories;
|
||||
using Moq;
|
||||
|
||||
namespace MikrocopTests;
|
||||
|
||||
[TestFixture]
|
||||
public sealed class AuthServiceTests
|
||||
{
|
||||
[Test]
|
||||
public async Task LoginAsync_ReturnsToken_WhenCredentialsAreValid()
|
||||
{
|
||||
var user = CreateUser();
|
||||
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
|
||||
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
|
||||
var jwtTokenServiceMock = new Mock<IJwtTokenService>(MockBehavior.Strict);
|
||||
var request = new LoginRequestDto { UserName = user.UserName, Password = "ValidPassword!123" };
|
||||
var expectedExpiry = DateTime.UtcNow.AddHours(1);
|
||||
|
||||
repositoryMock
|
||||
.Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(user);
|
||||
hashingServiceMock
|
||||
.Setup(x => x.VerifyPassword(request.Password, user.PasswordHash, user.PasswordSalt))
|
||||
.Returns(true);
|
||||
jwtTokenServiceMock
|
||||
.Setup(x => x.Generate(user))
|
||||
.Returns(("token-value", expectedExpiry));
|
||||
|
||||
var sut = new AuthService(repositoryMock.Object, hashingServiceMock.Object, jwtTokenServiceMock.Object);
|
||||
|
||||
var result = await sut.LoginAsync(request);
|
||||
|
||||
Assert.That(result.AccessToken, Is.EqualTo("token-value"));
|
||||
Assert.That(result.ExpiresAtUtc, Is.EqualTo(expectedExpiry));
|
||||
Assert.That(result.TokenType, Is.EqualTo("Bearer"));
|
||||
repositoryMock.VerifyAll();
|
||||
hashingServiceMock.VerifyAll();
|
||||
jwtTokenServiceMock.VerifyAll();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void LoginAsync_ThrowsUnauthorized_WhenUserDoesNotExist()
|
||||
{
|
||||
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
|
||||
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
|
||||
var jwtTokenServiceMock = new Mock<IJwtTokenService>(MockBehavior.Strict);
|
||||
var request = new LoginRequestDto { UserName = "missing", Password = "ValidPassword!123" };
|
||||
|
||||
repositoryMock
|
||||
.Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((UserEntity?)null);
|
||||
|
||||
var sut = new AuthService(repositoryMock.Object, hashingServiceMock.Object, jwtTokenServiceMock.Object);
|
||||
|
||||
Assert.ThrowsAsync<UnauthorizedException>(() => sut.LoginAsync(request));
|
||||
repositoryMock.VerifyAll();
|
||||
hashingServiceMock.VerifyNoOtherCalls();
|
||||
jwtTokenServiceMock.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void LoginAsync_ThrowsUnauthorized_WhenPasswordIsInvalid()
|
||||
{
|
||||
var user = CreateUser();
|
||||
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
|
||||
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
|
||||
var jwtTokenServiceMock = new Mock<IJwtTokenService>(MockBehavior.Strict);
|
||||
var request = new LoginRequestDto { UserName = user.UserName, Password = "WrongPassword!123" };
|
||||
|
||||
repositoryMock
|
||||
.Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(user);
|
||||
hashingServiceMock
|
||||
.Setup(x => x.VerifyPassword(request.Password, user.PasswordHash, user.PasswordSalt))
|
||||
.Returns(false);
|
||||
|
||||
var sut = new AuthService(repositoryMock.Object, hashingServiceMock.Object, jwtTokenServiceMock.Object);
|
||||
|
||||
Assert.ThrowsAsync<UnauthorizedException>(() => sut.LoginAsync(request));
|
||||
repositoryMock.VerifyAll();
|
||||
hashingServiceMock.VerifyAll();
|
||||
jwtTokenServiceMock.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
private static UserEntity CreateUser()
|
||||
{
|
||||
return new UserEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserName = "test-user",
|
||||
FullName = "Test User",
|
||||
Email = "test@example.com",
|
||||
MobileNumber = "+38640111222",
|
||||
Language = "en",
|
||||
Culture = "en-US",
|
||||
PasswordHash = "hash",
|
||||
PasswordSalt = "salt"
|
||||
};
|
||||
}
|
||||
}
|
||||
29
MikrocopTests/MikrocopTests.csproj
Normal file
29
MikrocopTests/MikrocopTests.csproj
Normal file
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0"/>
|
||||
<PackageReference Include="Moq" Version="4.20.72"/>
|
||||
<PackageReference Include="NUnit" Version="4.3.2"/>
|
||||
<PackageReference Include="NUnit.Analyzers" Version="4.7.0"/>
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="5.0.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="NUnit.Framework"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MikrocopApi\MikrocopApi.csproj" />
|
||||
<ProjectReference Include="..\MikrocopDb\MikrocopDb.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
401
MikrocopTests/UserServiceTests.cs
Normal file
401
MikrocopTests/UserServiceTests.cs
Normal file
@@ -0,0 +1,401 @@
|
||||
using MikrocopApi.Dtos;
|
||||
using MikrocopApi.Exceptions;
|
||||
using MikrocopApi.Services;
|
||||
using MikrocopDb.Entities;
|
||||
using MikrocopDb.Repositories;
|
||||
using Moq;
|
||||
|
||||
namespace MikrocopTests;
|
||||
|
||||
[TestFixture]
|
||||
public sealed class UserServiceTests
|
||||
{
|
||||
[Test]
|
||||
public async Task CreateAsync_ReturnsCreatedUser_WhenRequestIsValid()
|
||||
{
|
||||
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
|
||||
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
|
||||
var request = CreateRequest();
|
||||
|
||||
repositoryMock.Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((UserEntity?)null);
|
||||
repositoryMock.Setup(x => x.GetByEmailAsync(request.Email, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((UserEntity?)null);
|
||||
hashingServiceMock.Setup(x => x.HashPassword(request.Password))
|
||||
.Returns(("hash-value", "salt-value"));
|
||||
repositoryMock.Setup(x => x.AddAsync(It.IsAny<UserEntity>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object);
|
||||
|
||||
var result = await sut.CreateAsync(request);
|
||||
|
||||
Assert.That(result.UserName, Is.EqualTo("new-user"));
|
||||
Assert.That(result.Email, Is.EqualTo("new.user@example.com"));
|
||||
repositoryMock.Verify(x => x.AddAsync(It.Is<UserEntity>(u =>
|
||||
u.UserName == "new-user" &&
|
||||
u.FullName == "New User" &&
|
||||
u.Email == "new.user@example.com" &&
|
||||
u.PasswordHash == "hash-value" &&
|
||||
u.PasswordSalt == "salt-value"),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
repositoryMock.VerifyAll();
|
||||
hashingServiceMock.VerifyAll();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateAsync_ThrowsConflict_WhenUserNameExists()
|
||||
{
|
||||
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
|
||||
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
|
||||
var request = CreateRequest();
|
||||
|
||||
repositoryMock.Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(CreateEntity());
|
||||
|
||||
var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object);
|
||||
|
||||
Assert.ThrowsAsync<ConflictException>(() => sut.CreateAsync(request));
|
||||
repositoryMock.VerifyAll();
|
||||
hashingServiceMock.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateAsync_ThrowsConflict_WhenEmailExists()
|
||||
{
|
||||
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
|
||||
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
|
||||
var request = CreateRequest();
|
||||
|
||||
repositoryMock.Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((UserEntity?)null);
|
||||
repositoryMock.Setup(x => x.GetByEmailAsync(request.Email, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(CreateEntity());
|
||||
|
||||
var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object);
|
||||
|
||||
Assert.ThrowsAsync<ConflictException>(() => sut.CreateAsync(request));
|
||||
repositoryMock.VerifyAll();
|
||||
hashingServiceMock.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CreateAsync_ThrowsBadRequest_WhenPasswordIsNotComplexEnough()
|
||||
{
|
||||
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
|
||||
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
|
||||
var request = CreateRequest();
|
||||
request.Password = "weak";
|
||||
|
||||
repositoryMock.Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((UserEntity?)null);
|
||||
repositoryMock.Setup(x => x.GetByEmailAsync(request.Email, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((UserEntity?)null);
|
||||
|
||||
var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object);
|
||||
|
||||
Assert.ThrowsAsync<BadRequestException>(() => sut.CreateAsync(request));
|
||||
repositoryMock.VerifyAll();
|
||||
hashingServiceMock.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task UpdateAsync_UpdatesUserWithoutPassword_WhenPasswordIsNotProvided()
|
||||
{
|
||||
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
|
||||
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
|
||||
var id = Guid.NewGuid();
|
||||
var existing = CreateEntity(id);
|
||||
var request = CreateUpdateRequest(password: null);
|
||||
|
||||
repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(existing);
|
||||
repositoryMock.Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((UserEntity?)null);
|
||||
repositoryMock.Setup(x => x.GetByEmailAsync(request.Email, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((UserEntity?)null);
|
||||
repositoryMock.Setup(x => x.UpdateAsync(existing, It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object);
|
||||
|
||||
await sut.UpdateAsync(id, request);
|
||||
|
||||
Assert.That(existing.UserName, Is.EqualTo(request.UserName));
|
||||
Assert.That(existing.PasswordHash, Is.EqualTo("hash"));
|
||||
repositoryMock.VerifyAll();
|
||||
hashingServiceMock.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task UpdateAsync_HashesPassword_WhenPasswordIsProvided()
|
||||
{
|
||||
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
|
||||
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
|
||||
var id = Guid.NewGuid();
|
||||
var existing = CreateEntity(id);
|
||||
var request = CreateUpdateRequest(password: "UpdatedPassword!123");
|
||||
|
||||
repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(existing);
|
||||
repositoryMock.Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((UserEntity?)null);
|
||||
repositoryMock.Setup(x => x.GetByEmailAsync(request.Email, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((UserEntity?)null);
|
||||
hashingServiceMock.Setup(x => x.HashPassword(request.Password!))
|
||||
.Returns(("new-hash", "new-salt"));
|
||||
repositoryMock.Setup(x => x.UpdateAsync(existing, It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object);
|
||||
|
||||
await sut.UpdateAsync(id, request);
|
||||
|
||||
Assert.That(existing.PasswordHash, Is.EqualTo("new-hash"));
|
||||
Assert.That(existing.PasswordSalt, Is.EqualTo("new-salt"));
|
||||
repositoryMock.VerifyAll();
|
||||
hashingServiceMock.VerifyAll();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void UpdateAsync_ThrowsNotFound_WhenUserDoesNotExist()
|
||||
{
|
||||
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
|
||||
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
|
||||
var id = Guid.NewGuid();
|
||||
var request = CreateUpdateRequest(password: null);
|
||||
|
||||
repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((UserEntity?)null);
|
||||
|
||||
var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object);
|
||||
|
||||
Assert.ThrowsAsync<NotFoundException>(() => sut.UpdateAsync(id, request));
|
||||
repositoryMock.VerifyAll();
|
||||
hashingServiceMock.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void UpdateAsync_ThrowsConflict_WhenUserNameBelongsToAnotherUser()
|
||||
{
|
||||
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
|
||||
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
|
||||
var id = Guid.NewGuid();
|
||||
var request = CreateUpdateRequest(password: null);
|
||||
|
||||
repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(CreateEntity(id));
|
||||
repositoryMock.Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(CreateEntity(Guid.NewGuid()));
|
||||
|
||||
var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object);
|
||||
|
||||
Assert.ThrowsAsync<ConflictException>(() => sut.UpdateAsync(id, request));
|
||||
repositoryMock.VerifyAll();
|
||||
hashingServiceMock.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void UpdateAsync_ThrowsConflict_WhenEmailBelongsToAnotherUser()
|
||||
{
|
||||
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
|
||||
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
|
||||
var id = Guid.NewGuid();
|
||||
var request = CreateUpdateRequest(password: null);
|
||||
|
||||
repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(CreateEntity(id));
|
||||
repositoryMock.Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((UserEntity?)null);
|
||||
repositoryMock.Setup(x => x.GetByEmailAsync(request.Email, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(CreateEntity(Guid.NewGuid()));
|
||||
|
||||
var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object);
|
||||
|
||||
Assert.ThrowsAsync<ConflictException>(() => sut.UpdateAsync(id, request));
|
||||
repositoryMock.VerifyAll();
|
||||
hashingServiceMock.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void UpdateAsync_ThrowsBadRequest_WhenProvidedPasswordIsWeak()
|
||||
{
|
||||
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
|
||||
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
|
||||
var id = Guid.NewGuid();
|
||||
var request = CreateUpdateRequest(password: "weak");
|
||||
|
||||
repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(CreateEntity(id));
|
||||
repositoryMock.Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((UserEntity?)null);
|
||||
repositoryMock.Setup(x => x.GetByEmailAsync(request.Email, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((UserEntity?)null);
|
||||
|
||||
var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object);
|
||||
|
||||
Assert.ThrowsAsync<BadRequestException>(() => sut.UpdateAsync(id, request));
|
||||
repositoryMock.VerifyAll();
|
||||
hashingServiceMock.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetByIdAsync_ReturnsUser_WhenUserExists()
|
||||
{
|
||||
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
|
||||
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
|
||||
var id = Guid.NewGuid();
|
||||
var user = CreateEntity(id);
|
||||
|
||||
repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(user);
|
||||
|
||||
var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object);
|
||||
|
||||
var result = await sut.GetByIdAsync(id);
|
||||
|
||||
Assert.That(result.Id, Is.EqualTo(id));
|
||||
Assert.That(result.UserName, Is.EqualTo(user.UserName));
|
||||
repositoryMock.VerifyAll();
|
||||
hashingServiceMock.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetByIdAsync_ThrowsNotFound_WhenUserDoesNotExist()
|
||||
{
|
||||
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
|
||||
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
|
||||
var id = Guid.NewGuid();
|
||||
|
||||
repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((UserEntity?)null);
|
||||
|
||||
var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object);
|
||||
|
||||
Assert.ThrowsAsync<NotFoundException>(() => sut.GetByIdAsync(id));
|
||||
repositoryMock.VerifyAll();
|
||||
hashingServiceMock.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DeleteAsync_DeletesUser_WhenUserExists()
|
||||
{
|
||||
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
|
||||
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
|
||||
var id = Guid.NewGuid();
|
||||
var user = CreateEntity(id);
|
||||
|
||||
repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(user);
|
||||
repositoryMock.Setup(x => x.DeleteAsync(user, It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object);
|
||||
|
||||
await sut.DeleteAsync(id);
|
||||
|
||||
repositoryMock.VerifyAll();
|
||||
hashingServiceMock.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DeleteAsync_ThrowsNotFound_WhenUserDoesNotExist()
|
||||
{
|
||||
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
|
||||
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
|
||||
var id = Guid.NewGuid();
|
||||
|
||||
repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((UserEntity?)null);
|
||||
|
||||
var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object);
|
||||
|
||||
Assert.ThrowsAsync<NotFoundException>(() => sut.DeleteAsync(id));
|
||||
repositoryMock.VerifyAll();
|
||||
hashingServiceMock.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ValidatePasswordAsync_ReturnsResult_WhenUserExists()
|
||||
{
|
||||
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
|
||||
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
|
||||
var id = Guid.NewGuid();
|
||||
var user = CreateEntity(id);
|
||||
const string password = "AnyPassword!123";
|
||||
|
||||
repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(user);
|
||||
hashingServiceMock.Setup(x => x.VerifyPassword(password, user.PasswordHash, user.PasswordSalt))
|
||||
.Returns(true);
|
||||
|
||||
var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object);
|
||||
|
||||
var result = await sut.ValidatePasswordAsync(id, password);
|
||||
|
||||
Assert.That(result, Is.True);
|
||||
repositoryMock.VerifyAll();
|
||||
hashingServiceMock.VerifyAll();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ValidatePasswordAsync_ThrowsNotFound_WhenUserDoesNotExist()
|
||||
{
|
||||
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
|
||||
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
|
||||
var id = Guid.NewGuid();
|
||||
|
||||
repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((UserEntity?)null);
|
||||
|
||||
var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object);
|
||||
|
||||
Assert.ThrowsAsync<NotFoundException>(() => sut.ValidatePasswordAsync(id, "AnyPassword!123"));
|
||||
repositoryMock.VerifyAll();
|
||||
hashingServiceMock.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
private static CreateUserDto CreateRequest()
|
||||
{
|
||||
return new CreateUserDto
|
||||
{
|
||||
UserName = " new-user ",
|
||||
FullName = " New User ",
|
||||
Email = " new.user@example.com ",
|
||||
MobileNumber = " +38640123456 ",
|
||||
Language = " en ",
|
||||
Culture = " en-US ",
|
||||
Password = "ValidPassword!123"
|
||||
};
|
||||
}
|
||||
|
||||
private static UpdateUserDto CreateUpdateRequest(string? password)
|
||||
{
|
||||
return new UpdateUserDto
|
||||
{
|
||||
UserName = "updated-user",
|
||||
FullName = "Updated User",
|
||||
Email = "updated.user@example.com",
|
||||
MobileNumber = "+38640999888",
|
||||
Language = "en",
|
||||
Culture = "en-US",
|
||||
Password = password
|
||||
};
|
||||
}
|
||||
|
||||
private static UserEntity CreateEntity(Guid? id = null)
|
||||
{
|
||||
return new UserEntity
|
||||
{
|
||||
Id = id ?? Guid.NewGuid(),
|
||||
UserName = "existing-user",
|
||||
FullName = "Existing User",
|
||||
Email = "existing@example.com",
|
||||
MobileNumber = "+38640111222",
|
||||
Language = "en",
|
||||
Culture = "en-US",
|
||||
PasswordHash = "hash",
|
||||
PasswordSalt = "salt"
|
||||
};
|
||||
}
|
||||
}
|
||||
68
README.md
Normal file
68
README.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# MikrocopTest
|
||||
|
||||
This repository contains a .NET 10 solution with:
|
||||
- `MikrocopApi`: ASP.NET Core Web API
|
||||
- `MikrocopDb`: Entity Framework Core data access layer (SQLite)
|
||||
- `MikrocopTests`: NUnit test project
|
||||
|
||||
## Technologies Used
|
||||
|
||||
- .NET SDK 10 (`net10.0`)
|
||||
- ASP.NET Core Web API
|
||||
- Entity Framework Core 10
|
||||
- SQLite (`Microsoft.EntityFrameworkCore.Sqlite`)
|
||||
- JWT authentication (`Microsoft.AspNetCore.Authentication.JwtBearer`)
|
||||
- Swagger / OpenAPI (`Swashbuckle.AspNetCore`)
|
||||
- Serilog (console + rolling file logs)
|
||||
- NUnit + Moq + Microsoft.NET.Test.Sdk
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- .NET 10 SDK installed
|
||||
|
||||
Check your installed version:
|
||||
|
||||
```bash
|
||||
dotnet --version
|
||||
```
|
||||
|
||||
## How to Run the Program
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
dotnet restore
|
||||
dotnet run --project MikrocopApi
|
||||
```
|
||||
|
||||
Notes:
|
||||
- In `Development`, EF Core migrations are applied automatically on startup.
|
||||
- Swagger UI is available at `http://localhost:5018/swagger` by default.
|
||||
- Development DB connection string is in `MikrocopApi/appsettings.Development.json` (`mikrocop.dev.db`).
|
||||
|
||||
## How to Use the API (JWT Flow)
|
||||
|
||||
To use protected endpoints, follow this order:
|
||||
1. Register a user with `POST /api/auth/register`
|
||||
2. Log in with `POST /api/auth/login`
|
||||
3. Copy the returned JWT token and send it as:
|
||||
`Authorization: Bearer <token>`
|
||||
|
||||
Notes:
|
||||
- `api/auth/register` and `api/auth/login` are anonymous endpoints.
|
||||
- `api/users/*` endpoints require authentication (`[Authorize]`).
|
||||
- In Swagger, use the **Authorize** button and paste the token as `Bearer <token>`.
|
||||
|
||||
## How to Run the Tests
|
||||
|
||||
Run all tests in the solution:
|
||||
|
||||
```bash
|
||||
dotnet test MikrocopTest.sln
|
||||
```
|
||||
|
||||
Or run only the test project:
|
||||
|
||||
```bash
|
||||
dotnet test MikrocopTests/MikrocopTests.csproj
|
||||
```
|
||||
Reference in New Issue
Block a user