diff --git a/src/chat.API/AuthController/AuthController.cs b/src/chat.API/AuthController/AuthController.cs new file mode 100644 index 0000000..adf1ce2 --- /dev/null +++ b/src/chat.API/AuthController/AuthController.cs @@ -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> Register([FromBody] RegisterCommand command) + { + RegisterResponseDto dto = await mediator.Send(command); + return Ok(dto); + } +} diff --git a/src/chat.API/Extensions/WebApplicationBuilderExtensions.cs b/src/chat.API/Extensions/WebApplicationBuilderExtensions.cs new file mode 100644 index 0000000..a9887a9 --- /dev/null +++ b/src/chat.API/Extensions/WebApplicationBuilderExtensions.cs @@ -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(); + } +} diff --git a/src/chat.API/Middlewares/ErrorHandlingMiddleware.cs b/src/chat.API/Middlewares/ErrorHandlingMiddleware.cs new file mode 100644 index 0000000..5e1b99a --- /dev/null +++ b/src/chat.API/Middlewares/ErrorHandlingMiddleware.cs @@ -0,0 +1,28 @@ +using chat.Domain.Exceptions; + +namespace chat.API.Middlewares; + +public class ErrorHandlingMiddleware(ILogger 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 }); + } + } +} diff --git a/src/chat.API/Program.cs b/src/chat.API/Program.cs index 0db052d..e71f79a 100644 --- a/src/chat.API/Program.cs +++ b/src/chat.API/Program.cs @@ -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(); + // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { - app.MapOpenApi(); + app.UseSwagger(); + app.UseSwaggerUI(); } app.UseHttpsRedirection(); -app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); diff --git a/src/chat.API/appsettings.json b/src/chat.API/appsettings.json index 10f68b8..9f0eae0 100644 --- a/src/chat.API/appsettings.json +++ b/src/chat.API/appsettings.json @@ -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 + } + } +} \ No newline at end of file diff --git a/src/chat.API/chat.API.csproj b/src/chat.API/chat.API.csproj index 7149008..5b21662 100644 --- a/src/chat.API/chat.API.csproj +++ b/src/chat.API/chat.API.csproj @@ -1,16 +1,19 @@ - + net10.0 enable enable + 7224f703-f9b3-4ef2-a34d-b2ba0038d160 - + + + diff --git a/src/chat.Application/Authentication/Commands/Register/RegisterCommand.cs b/src/chat.Application/Authentication/Commands/Register/RegisterCommand.cs new file mode 100644 index 0000000..d69bcfa --- /dev/null +++ b/src/chat.Application/Authentication/Commands/Register/RegisterCommand.cs @@ -0,0 +1,11 @@ +using chat.Application.Authentication.Dtos; +using MediatR; + +namespace chat.Application.Authentication.Commands.Register; + +public class RegisterCommand : IRequest +{ + public string Username { get; set; } = default!; + public string Email { get; set; } = default!; + public string Password { get; set; } = default!; +} diff --git a/src/chat.Application/Authentication/Commands/Register/RegisterCommandHandler.cs b/src/chat.Application/Authentication/Commands/Register/RegisterCommandHandler.cs new file mode 100644 index 0000000..307426c --- /dev/null +++ b/src/chat.Application/Authentication/Commands/Register/RegisterCommandHandler.cs @@ -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 logger, + IMapper mapper, + ILogtoAuthService authRepository + ) : IRequestHandler +{ + public async Task 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(request); + var userRegistered = await authRepository.Register(user , cancellationToken); + if (userRegistered == null) + { + throw new Exception("Registration failed"); + } + var response = mapper.Map(userRegistered); + return response; + } +} diff --git a/src/chat.Application/Authentication/Commands/Register/RegisterCommandValidator.cs b/src/chat.Application/Authentication/Commands/Register/RegisterCommandValidator.cs new file mode 100644 index 0000000..3fec1ad --- /dev/null +++ b/src/chat.Application/Authentication/Commands/Register/RegisterCommandValidator.cs @@ -0,0 +1,19 @@ +using FluentValidation; + +namespace chat.Application.Authentication.Commands.Register; + +public class RegisterCommandValidator : AbstractValidator +{ + 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."); + } +} diff --git a/src/chat.Application/Authentication/Dtos/AuthProfile.cs b/src/chat.Application/Authentication/Dtos/AuthProfile.cs new file mode 100644 index 0000000..a02f3eb --- /dev/null +++ b/src/chat.Application/Authentication/Dtos/AuthProfile.cs @@ -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() + .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() + .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(); + } + } +} diff --git a/src/chat.Application/Authentication/Dtos/RegisterResponseDto.cs b/src/chat.Application/Authentication/Dtos/RegisterResponseDto.cs new file mode 100644 index 0000000..4121992 --- /dev/null +++ b/src/chat.Application/Authentication/Dtos/RegisterResponseDto.cs @@ -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!; +} diff --git a/src/chat.Application/Extensions/ServiceCollectionExtensions.cs b/src/chat.Application/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..06f96ef --- /dev/null +++ b/src/chat.Application/Extensions/ServiceCollectionExtensions.cs @@ -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(); + } +} diff --git a/src/chat.Application/chat.Application.csproj b/src/chat.Application/chat.Application.csproj index b760144..d53cc43 100644 --- a/src/chat.Application/chat.Application.csproj +++ b/src/chat.Application/chat.Application.csproj @@ -6,4 +6,17 @@ enable + + + + + + + + + + + + + diff --git a/src/chat.Domain/Entities/User.cs b/src/chat.Domain/Entities/User.cs new file mode 100644 index 0000000..f79775c --- /dev/null +++ b/src/chat.Domain/Entities/User.cs @@ -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!; +} diff --git a/src/chat.Domain/Exceptions/HttpResponseException.cs b/src/chat.Domain/Exceptions/HttpResponseException.cs new file mode 100644 index 0000000..b40a38b --- /dev/null +++ b/src/chat.Domain/Exceptions/HttpResponseException.cs @@ -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; + } +} \ No newline at end of file diff --git a/src/chat.Domain/Interfaces/ILogtoAuthService.cs b/src/chat.Domain/Interfaces/ILogtoAuthService.cs new file mode 100644 index 0000000..cecc0d1 --- /dev/null +++ b/src/chat.Domain/Interfaces/ILogtoAuthService.cs @@ -0,0 +1,9 @@ +using chat.Domain.Entities; + +namespace chat.Domain.Interfaces +{ + public interface ILogtoAuthService + { + Task Register(User request, CancellationToken cancellationToken); + } +} diff --git a/src/chat.Domain/chat.Domain.csproj b/src/chat.Domain/chat.Domain.csproj index b760144..5831e53 100644 --- a/src/chat.Domain/chat.Domain.csproj +++ b/src/chat.Domain/chat.Domain.csproj @@ -6,4 +6,8 @@ enable + + + + diff --git a/src/chat.Infrastructure/Extensions/ServiceCollectionExtensions.cs b/src/chat.Infrastructure/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..fd69337 --- /dev/null +++ b/src/chat.Infrastructure/Extensions/ServiceCollectionExtensions.cs @@ -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(); + services.AddScoped(); + } +} diff --git a/src/chat.Infrastructure/LogtoAuthService.cs b/src/chat.Infrastructure/LogtoAuthService.cs new file mode 100644 index 0000000..e2cc6ed --- /dev/null +++ b/src/chat.Infrastructure/LogtoAuthService.cs @@ -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 _logger, + HttpClient _httpClient, + IConfiguration _configuration) : ILogtoAuthService +{ + public async Task 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(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 RetrieveManagementToken(CancellationToken cancellationToken) + { + var tokenEndpoint = $"{_configuration["Logto:Issuer"]}/token"; + var managementApiUrl = _configuration["Logto:ManagementApi"]!; + + var tokenRequest = new FormUrlEncodedContent(new Dictionary + { + ["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(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 + ); +}