Register -> Next step manage exceptions and tests

This commit is contained in:
Blyssco 2026-03-09 15:57:16 +01:00
parent b3223df0f9
commit 4c98577eed
19 changed files with 406 additions and 6 deletions

View File

@ -0,0 +1,22 @@
using chat.Application.Authentication.Commands.Register;
using chat.Application.Authentication.Dtos;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
namespace chat.API.AuthController;
[ApiController]
[Route("chat_api/auth")]
[Authorize]
public class AuthController(IMediator mediator) : ControllerBase
{
[HttpPost]
[AllowAnonymous]
public async Task<ActionResult<RegisterResponseDto>> Register([FromBody] RegisterCommand command)
{
RegisterResponseDto dto = await mediator.Send(command);
return Ok(dto);
}
}

View File

@ -0,0 +1,43 @@
using chat.API.Middlewares;
using Microsoft.OpenApi.Models;
namespace chat.API.Extensions;
public static class WebApplicationBuilderExtensions
{
public static void AddPresentation(this WebApplicationBuilder builder)
{
builder.Services.AddAuthentication();
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
// Définit le schéma de sécurité pour l'authentification Bearer (JWT)
c.AddSecurityDefinition("bearerAuth", new OpenApiSecurityScheme()
{
Type = SecuritySchemeType.Http,
Scheme = "bearer",
});
// Ajoute l'exigence de sécurité pour que Swagger utilise le schéma Bearer
c.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "bearerAuth"
}
},
[]
}
});
});
builder.Services.AddScoped<ErrorHandlingMiddleware>();
}
}

View File

@ -0,0 +1,28 @@
using chat.Domain.Exceptions;
namespace chat.API.Middlewares;
public class ErrorHandlingMiddleware(ILogger<ErrorHandlingMiddleware> logger) : IMiddleware
{
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
try
{
await next(context);
}
catch (HttpResponseException ex)
{
logger.LogWarning(ex, "HTTP {StatusCode}: {Message}", ex.StatusCode, ex.Message);
context.Response.StatusCode = ex.StatusCode;
context.Response.ContentType = "application/json";
await context.Response.WriteAsJsonAsync(new { error = ex.Message });
}
catch (Exception ex)
{
logger.LogError(ex, "Unexpected error occurred");
context.Response.StatusCode = 500;
context.Response.ContentType = "application/json";
await context.Response.WriteAsJsonAsync(new { error = ex.Message });
}
}
}

View File

@ -1,16 +1,27 @@
using chat.API.Extensions;
using chat.API.Middlewares;
using chat.Application.Extensions;
using chat.Infrastructure.Extensions;
var builder = WebApplication.CreateBuilder(args);
builder.AddPresentation();
builder.Services.AddApplication();
builder.Services.AddInfrastructure();
var app = builder.Build();
app.UseMiddleware<ErrorHandlingMiddleware>();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

View File

@ -5,5 +5,15 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
"AllowedHosts": "*",
"Logto": {
"Endpoint": "https://en7hny.logto.app/", // URL de base de ton tenant Logto
"Issuer": "https://en7hny.logto.app/oidc", // URL pour valider les JWT (utilisé par le middleware auth)
"Audience": "https://localhost:7169", // Identifiant de ton API (pour valider les tokens)
"ManagementApi": "https://en7hny.logto.app/api", // URL de l'API Management Logto (pour créer des users)
"M2M": {
"AppId": "", // Client ID de l'app M2M (pour obtenir un token Management) dans user-secrets
"AppSecret": "" // Client Secret de l'app M2M dans user-secrets
}
}
}

View File

@ -1,16 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>7224f703-f9b3-4ef2-a34d-b2ba0038d160</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\chat.Application\chat.Application.csproj" />
<ProjectReference Include="..\chat.Domain\chat.Domain.csproj" />
<ProjectReference Include="..\chat.Infrastructure\chat.Infrastructure.csproj" />
</ItemGroup>

View File

@ -0,0 +1,11 @@
using chat.Application.Authentication.Dtos;
using MediatR;
namespace chat.Application.Authentication.Commands.Register;
public class RegisterCommand : IRequest<RegisterResponseDto>
{
public string Username { get; set; } = default!;
public string Email { get; set; } = default!;
public string Password { get; set; } = default!;
}

View File

@ -0,0 +1,28 @@
using AutoMapper;
using chat.Application.Authentication.Dtos;
using chat.Domain.Entities;
using chat.Domain.Interfaces;
using MediatR;
using Microsoft.Extensions.Logging;
namespace chat.Application.Authentication.Commands.Register;
public class RegisterCommandHandler(
ILogger<RegisterCommandHandler> logger,
IMapper mapper,
ILogtoAuthService authRepository
) : IRequestHandler<RegisterCommand, RegisterResponseDto>
{
public async Task<RegisterResponseDto> Handle(RegisterCommand request, CancellationToken cancellationToken)
{
logger.LogInformation("Creating a new user with the following credentials {Username} {Email}", request.Username, request.Email);
var user = mapper.Map<User>(request);
var userRegistered = await authRepository.Register(user , cancellationToken);
if (userRegistered == null)
{
throw new Exception("Registration failed");
}
var response = mapper.Map<RegisterResponseDto>(userRegistered);
return response;
}
}

View File

@ -0,0 +1,19 @@
using FluentValidation;
namespace chat.Application.Authentication.Commands.Register;
public class RegisterCommandValidator : AbstractValidator<RegisterCommand>
{
public RegisterCommandValidator()
{
RuleFor(x => x.Username)
.NotEmpty().WithMessage("Username is required.")
.MinimumLength(3).WithMessage("Username must be at least 3 characters long.");
RuleFor(x => x.Password)
.NotEmpty().WithMessage("Password is required.")
.MinimumLength(6).WithMessage("Password must be at least 6 characters long.");
RuleFor(x => x.Email)
.NotEmpty().WithMessage("Email is required.")
.EmailAddress().WithMessage("A valid email address is required.");
}
}

View File

@ -0,0 +1,24 @@
using AutoMapper;
using chat.Application.Authentication.Commands.Register;
using chat.Domain.Entities;
namespace chat.Application.Authentication.Dtos
{
public class AuthProfile : Profile
{
public AuthProfile()
{
CreateMap<RegisterCommand, User>()
.ForMember(dest => dest.Username, opt => opt.MapFrom(src => src.Username))
.ForMember(dest => dest.Email, opt => opt.MapFrom(src => src.Email))
.ForMember(dest => dest.Password, opt => opt.MapFrom(src => src.Password))
.ReverseMap();
CreateMap<RegisterResponseDto, User>()
.ForMember(dest => dest.Username, opt => opt.MapFrom(src => src.Username))
.ForMember(dest => dest.Email, opt => opt.MapFrom(src => src.Email))
.ForMember(dest => dest.Password, opt => opt.MapFrom(src => src.Password))
.ReverseMap();
}
}
}

View File

@ -0,0 +1,8 @@
namespace chat.Application.Authentication.Dtos;
public class RegisterResponseDto
{
public string Username { get; set; } = default!;
public string Email { get; set; } = default!;
public string Password { get; set; } = default!;
}

View File

@ -0,0 +1,20 @@
using FluentValidation;
using Microsoft.Extensions.DependencyInjection;
using SharpGrip.FluentValidation.AutoValidation.Mvc.Extensions;
namespace chat.Application.Extensions;
public static class ServiceCollectionExtensions
{
public static void AddApplication(this IServiceCollection services)
{
var applicationAssembly = typeof(ServiceCollectionExtensions).Assembly;
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(applicationAssembly));
services.AddAutoMapper(cfg => cfg.AddMaps(applicationAssembly));
services.AddValidatorsFromAssembly(applicationAssembly)
.AddFluentValidationAutoValidation();
}
}

View File

@ -6,4 +6,17 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AutoMapper" Version="16.1.0" />
<PackageReference Include="FluentValidation" Version="12.1.1" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
<PackageReference Include="MediatR" Version="14.1.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3" />
<PackageReference Include="SharpGrip.FluentValidation.AutoValidation.Mvc" Version="2.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\chat.Domain\chat.Domain.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,8 @@
namespace chat.Domain.Entities;
public class User
{
public string Username { get; set; } = default!;
public string Email { get; set; } = default!;
public string Password { get; set; } = default!;
}

View File

@ -0,0 +1,11 @@
namespace chat.Domain.Exceptions;
public class HttpResponseException : Exception
{
public int StatusCode { get; }
public HttpResponseException(int statusCode, string message) : base(message)
{
StatusCode = statusCode;
}
}

View File

@ -0,0 +1,9 @@
using chat.Domain.Entities;
namespace chat.Domain.Interfaces
{
public interface ILogtoAuthService
{
Task<User?> Register(User request, CancellationToken cancellationToken);
}
}

View File

@ -6,4 +6,8 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.3" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,13 @@
using chat.Domain.Interfaces;
using Microsoft.Extensions.DependencyInjection;
namespace chat.Infrastructure.Extensions;
public static class ServiceCollectionExtensions
{
public static void AddInfrastructure(this IServiceCollection services)
{
services.AddHttpClient<LogtoAuthService>();
services.AddScoped<ILogtoAuthService, LogtoAuthService>();
}
}

View File

@ -0,0 +1,115 @@
using chat.Domain.Entities;
using chat.Domain.Interfaces;
using chat.Domain.Exceptions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
namespace chat.Infrastructure;
public class LogtoAuthService(
ILogger<LogtoAuthService> _logger,
HttpClient _httpClient,
IConfiguration _configuration) : ILogtoAuthService
{
public async Task<User?> Register(User request, CancellationToken cancellationToken)
{
var token = await RetrieveManagementToken(cancellationToken);
var managementApiUrl = _configuration["Logto:ManagementApi"]!;
var createUserEndpoint = $"{managementApiUrl}/users";
var createUserRequest = new
{
username = request.Username,
primaryEmail = request.Email,
password = request.Password,
name = request.Username
};
var requestMessage = new HttpRequestMessage(HttpMethod.Post, createUserEndpoint)
{
Content = JsonContent.Create(createUserRequest),
Headers =
{
{ "Authorization", $"Bearer {token}" }
}
};
var response = await _httpClient.SendAsync(requestMessage, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync(cancellationToken);
throw new HttpResponseException((int)response.StatusCode, error);
}
var logtoUser = await response.Content.ReadFromJsonAsync<LogtoUserResponse>(cancellationToken);
if (logtoUser == null)
{
throw new InvalidOperationException("Failed to deserialize Logto user response");
}
_logger.LogInformation("User created successfully in Logto. UserId: {UserId}, Username: {Username}",
logtoUser.Id, logtoUser.Username);
return new User
{
Username = logtoUser.Username ?? request.Username,
Email = logtoUser.PrimaryEmail ?? request.Email,
Password = "********"
};
}
private async Task<string> RetrieveManagementToken(CancellationToken cancellationToken)
{
var tokenEndpoint = $"{_configuration["Logto:Issuer"]}/token";
var managementApiUrl = _configuration["Logto:ManagementApi"]!;
var tokenRequest = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["client_id"] = _configuration["Logto:M2M:AppId"]!,
["client_secret"] = _configuration["Logto:M2M:AppSecret"]!,
["resource"] = managementApiUrl,
["scope"] = "all"
});
var response = await _httpClient.PostAsync(tokenEndpoint, tokenRequest, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync(cancellationToken);
throw new HttpResponseException((int)response.StatusCode, $"{error}");
}
var tokenResponse = await response.Content.ReadFromJsonAsync<TokenResponse>(cancellationToken);
if (tokenResponse?.AccessToken == null)
{
throw new InvalidOperationException("M2M token response is invalid");
}
_logger.LogInformation("M2M token obtained successfully. Type: {TokenType}, Expires in: {ExpiresIn}s, scope: {Scope}",
tokenResponse.TokenType,
tokenResponse.ExpiresIn,
tokenResponse.Scope);
return tokenResponse.AccessToken;
}
private record TokenResponse(
[property: JsonPropertyName("access_token")] string AccessToken,
[property: JsonPropertyName("token_type")] string TokenType,
[property: JsonPropertyName("expires_in")] int ExpiresIn,
[property: JsonPropertyName("scope")] string Scope
);
private record LogtoUserResponse(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("username")] string? Username,
[property: JsonPropertyName("primaryEmail")] string? PrimaryEmail,
[property: JsonPropertyName("name")] string? Name
);
}