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

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

View File

@@ -0,0 +1,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;
}

View 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);
}
}

View 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());
}
}

View 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;
}

View 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;
}

View 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; }
}

View 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; }
}

View 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; }
}

View 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;
}

View File

@@ -0,0 +1,6 @@
namespace MikrocopApi.Dtos;
public sealed class ValidatePasswordResponseDto
{
public bool IsValid { get; init; }
}

View 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; }
}

View 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";
}

View 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";
}

View 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";
}

View 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";
}

View 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;
}
}

View 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
};
}
}

View 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
};
}
}

View 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;
}
}

View 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
});
}
}
}

View 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>

View 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
View 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();

View 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"
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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
}
}

View 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": "*"
}