Corrige un contournement : après réinitialisation de la base (même JWT_SECRET_KEY), un jeton restait cryptographiquement valide et donnait accès alors que le compte n'existait plus, permettant d'atteindre l'onboarding sans s'inscrire. - Backend : JwtBearer.OnTokenValidated vérifie désormais l'existence du compte référencé par le claim sub (lu sous 'sub' ou NameIdentifier selon le remapping) et rejette le jeton (401) si le compte est introuvable. - Backend : nouvel endpoint protégé GET /api/auth/moi (SessionController) renvoyant 200 si la session est réellement valide, 401 sinon. - Frontend : authGuard valide la session côté serveur (GET /auth/moi) au lieu de se fier au seul jeton local ; sur échec, redirection vers /login (jeton purgé par l'intercepteur sur 401). AuthService.validerSession() ajouté. Tests backend : 54/54 au vert.
260 lines
11 KiB
C#
260 lines
11 KiB
C#
using System.Text;
|
|
using FluentValidation;
|
|
using FluentValidation.AspNetCore;
|
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
|
using Microsoft.AspNetCore.Diagnostics;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
using SchotzIadKmTracker.Api.Data;
|
|
using SchotzIadKmTracker.Api.Models.Entities;
|
|
using SchotzIadKmTracker.Api.Validators;
|
|
|
|
using SchotzIadKmTracker.Api.Configuration;
|
|
using SchotzIadKmTracker.Api.Repositories;
|
|
using SchotzIadKmTracker.Api.Services;
|
|
|
|
// Licence QuestPDF (usage communautaire, gratuit) — requise avant toute génération de PDF.
|
|
QuestPDF.Settings.License = QuestPDF.Infrastructure.LicenseType.Community;
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
// --- Options de configuration liées depuis appsettings.json ---
|
|
builder.Services.Configure<BANOptions>(
|
|
builder.Configuration.GetSection(BANOptions.SectionName));
|
|
builder.Services.Configure<ORSOptions>(
|
|
builder.Configuration.GetSection(ORSOptions.SectionName));
|
|
builder.Services.Configure<JwtOptions>(
|
|
builder.Configuration.GetSection(JwtOptions.SectionName));
|
|
builder.Services.Configure<AuthOptions>(
|
|
builder.Configuration.GetSection(AuthOptions.SectionName));
|
|
builder.Services.Configure<SmtpOptions>(
|
|
builder.Configuration.GetSection(SmtpOptions.SectionName));
|
|
|
|
// --- Client typé BAN (Base Adresses Nationale) ---
|
|
// BaseAddress et Timeout proviennent de BANOptions (appsettings.json).
|
|
var banOptions = builder.Configuration
|
|
.GetSection(BANOptions.SectionName)
|
|
.Get<BANOptions>() ?? new BANOptions();
|
|
|
|
builder.Services.AddHttpClient<IBANClient, BANClient>(client =>
|
|
{
|
|
client.BaseAddress = new Uri(banOptions.BaseUrl);
|
|
client.Timeout = TimeSpan.FromSeconds(banOptions.TimeoutSeconds);
|
|
});
|
|
|
|
// --- Client typé ORS (OpenRouteService) ---
|
|
// BaseAddress, Timeout et en-tête de clé d'API proviennent de ORSOptions (appsettings.json).
|
|
var orsOptions = builder.Configuration
|
|
.GetSection(ORSOptions.SectionName)
|
|
.Get<ORSOptions>() ?? new ORSOptions();
|
|
|
|
builder.Services.AddHttpClient<IORSClient, ORSClient>(client =>
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(orsOptions.BaseUrl))
|
|
{
|
|
// Le slash final garantit la résolution correcte des chemins relatifs.
|
|
var baseUrl = orsOptions.BaseUrl.EndsWith('/')
|
|
? orsOptions.BaseUrl
|
|
: orsOptions.BaseUrl + "/";
|
|
client.BaseAddress = new Uri(baseUrl);
|
|
}
|
|
|
|
client.Timeout = TimeSpan.FromSeconds(orsOptions.TimeoutSeconds);
|
|
|
|
if (!string.IsNullOrWhiteSpace(orsOptions.ApiKey))
|
|
{
|
|
// ORS attend la clé brute dans l'en-tête Authorization (sans schéma « Bearer »).
|
|
// Les clés ORS (JWT) contiennent des caractères refusés par la validation stricte
|
|
// de DefaultRequestHeaders.Add ; on utilise TryAddWithoutValidation pour l'insérer
|
|
// telle quelle et éviter une FormatException à la création du client.
|
|
client.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", orsOptions.ApiKey);
|
|
}
|
|
});
|
|
|
|
// --- Calculateur de distance (orchestration BAN + ORS + fallback Haversine) ---
|
|
builder.Services.AddScoped<IDistanceCalculator, DistanceCalculatorService>();
|
|
|
|
// --- Cache des adresses géocodées ---
|
|
builder.Services.AddScoped<IAdresseCacheRepository, AdresseCacheRepository>();
|
|
builder.Services.AddScoped<IAdresseCacheService, AdresseCacheService>();
|
|
|
|
// --- Services de base ---
|
|
// Autorisation GLOBALE par défaut : toutes les routes MVC exigent un utilisateur authentifié.
|
|
// AuthController porte [AllowAnonymous] ; /health (endpoint minimal) n'est pas couvert par les filtres MVC.
|
|
builder.Services.AddControllers(options =>
|
|
{
|
|
var policy = new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder()
|
|
.RequireAuthenticatedUser()
|
|
.Build();
|
|
options.Filters.Add(new Microsoft.AspNetCore.Mvc.Authorization.AuthorizeFilter(policy));
|
|
});
|
|
|
|
// --- Health checks (endpoint GET /health, utilisé par Docker Compose) ---
|
|
builder.Services.AddHealthChecks();
|
|
|
|
// --- Validation des requêtes (FluentValidation) ---
|
|
// Enregistre tous les validateurs de l'assembly et active la validation automatique côté MVC.
|
|
builder.Services.AddValidatorsFromAssemblyContaining<CreateTrajetDtoValidator>();
|
|
builder.Services.AddFluentValidationAutoValidation();
|
|
|
|
// --- Base de données (EF Core + SQLite) ---
|
|
// La chaîne de connexion provient de la configuration ("ConnectionStrings:Default").
|
|
var connectionString = builder.Configuration.GetConnectionString("Default")
|
|
?? "Data Source=trajets.db";
|
|
builder.Services.AddDbContext<AppDbContext>(options =>
|
|
options.UseSqlite(connectionString));
|
|
|
|
// --- Hachage des mots de passe (ASP.NET Core Identity PasswordHasher) ---
|
|
builder.Services.AddSingleton<IPasswordHasher<Compte>, PasswordHasher<Compte>>();
|
|
|
|
// --- Repositories & services métier ---
|
|
builder.Services.AddScoped<IJwtService, JwtService>();
|
|
|
|
// --- Sélection de l'implémentation Email selon la présence de config SMTP ---
|
|
var smtpOptions = builder.Configuration.GetSection(SmtpOptions.SectionName).Get<SmtpOptions>() ?? new SmtpOptions();
|
|
if (string.IsNullOrWhiteSpace(smtpOptions.Host))
|
|
builder.Services.AddScoped<IEmailService, LoggingEmailService>(); // repli journalisation (Exigence 6.5)
|
|
else
|
|
builder.Services.AddScoped<IEmailService, SmtpEmailService>(); // MailKit (Exigence 6.4)
|
|
|
|
builder.Services.AddScoped<IAuthService, AuthService>();
|
|
|
|
// --- Authentification JWT Bearer ---
|
|
var jwtOptions = builder.Configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>() ?? new JwtOptions();
|
|
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
|
.AddJwtBearer(options =>
|
|
{
|
|
options.TokenValidationParameters = new TokenValidationParameters
|
|
{
|
|
ValidateIssuer = true,
|
|
ValidateAudience = true,
|
|
ValidateLifetime = true, // rejette les jetons expirés (Exigence 5.2)
|
|
ValidateIssuerSigningKey = true, // rejette les signatures invalides (Exigence 5.3)
|
|
ValidIssuer = jwtOptions.Issuer,
|
|
ValidAudience = jwtOptions.Audience,
|
|
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SecretKey)),
|
|
ClockSkew = TimeSpan.FromSeconds(30) // tolérance d'horloge réduite
|
|
};
|
|
|
|
// Un jeton peut être cryptographiquement valide (bonne signature, non expiré) alors
|
|
// que le compte n'existe plus (ex. base réinitialisée avec la même clé JWT). On vérifie
|
|
// donc l'EXISTENCE du compte référencé par le claim `sub` à chaque requête : sinon le
|
|
// jeton est rejeté (401), ce qui force la reconnexion / l'inscription côté frontend.
|
|
options.Events = new JwtBearerEvents
|
|
{
|
|
OnTokenValidated = async context =>
|
|
{
|
|
// ASP.NET Core remappe par défaut le claim « sub » vers ClaimTypes.NameIdentifier.
|
|
// On cherche donc l'identifiant sous les deux noms possibles.
|
|
var principal = context.Principal;
|
|
var sub = principal?.FindFirst(System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.Sub)?.Value
|
|
?? principal?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
|
|
|
if (!int.TryParse(sub, out var compteId))
|
|
{
|
|
context.Fail("Jeton invalide : identifiant de compte manquant.");
|
|
return;
|
|
}
|
|
|
|
var db = context.HttpContext.RequestServices.GetRequiredService<AppDbContext>();
|
|
var compteExiste = await db.Comptes.AsNoTracking()
|
|
.AnyAsync(c => c.Id == compteId, context.HttpContext.RequestAborted);
|
|
|
|
if (!compteExiste)
|
|
{
|
|
context.Fail("Compte introuvable : jeton non valide.");
|
|
}
|
|
}
|
|
};
|
|
});
|
|
|
|
builder.Services.AddScoped<SchotzIadKmTracker.Api.Repositories.IVehiculeRepository,
|
|
SchotzIadKmTracker.Api.Repositories.VehiculeRepository>();
|
|
builder.Services.AddScoped<IVehiculeService, VehiculeService>();
|
|
builder.Services.AddScoped<IProfilService, ProfilService>();
|
|
builder.Services.AddScoped<IAdresseNommeeService, AdresseNommeeService>();
|
|
builder.Services.AddScoped<IBaremeService, BaremeService>();
|
|
builder.Services.AddScoped<IMontantRegleService, MontantRegleService>();
|
|
builder.Services.AddScoped<IRemboursementService, RemboursementService>();
|
|
builder.Services.AddScoped<ITotauxService, TotauxService>();
|
|
builder.Services.AddScoped<ITrajetRepository, TrajetRepository>();
|
|
builder.Services.AddScoped<ITrajetService, TrajetService>();
|
|
builder.Services.AddScoped<IResumeService, ResumeService>();
|
|
builder.Services.AddScoped<IPdfService, PdfService>();
|
|
|
|
// Exploration des endpoints + Swagger / OpenAPI
|
|
builder.Services.AddEndpointsApiExplorer();
|
|
builder.Services.AddSwaggerGen(options =>
|
|
{
|
|
options.SwaggerDoc("v1", new Microsoft.OpenApi.Models.OpenApiInfo
|
|
{
|
|
Title = "SchotzIAD KM Tracker API",
|
|
Version = "v1",
|
|
Description = "API de saisie, calcul et consultation des trajets kilométriques."
|
|
});
|
|
});
|
|
|
|
// ProblemDetails : réponses d'erreur normalisées (RFC 7807)
|
|
builder.Services.AddProblemDetails();
|
|
|
|
// CORS pour le frontend Angular.
|
|
// Les origines autorisées sont configurables via appsettings ("Cors:AllowedOrigins").
|
|
const string CorsPolicyName = "AngularFrontend";
|
|
var allowedOrigins = builder.Configuration
|
|
.GetSection("Cors:AllowedOrigins")
|
|
.Get<string[]>() ?? new[] { "http://localhost:4200" };
|
|
|
|
builder.Services.AddCors(options =>
|
|
{
|
|
options.AddPolicy(CorsPolicyName, policy =>
|
|
{
|
|
policy.WithOrigins(allowedOrigins)
|
|
.AllowAnyHeader()
|
|
.AllowAnyMethod();
|
|
});
|
|
});
|
|
|
|
var app = builder.Build();
|
|
|
|
// --- Pipeline HTTP ---
|
|
|
|
// Gestion centralisée des exceptions -> ProblemDetails
|
|
app.UseExceptionHandler();
|
|
app.UseStatusCodePages();
|
|
|
|
if (app.Environment.IsDevelopment())
|
|
{
|
|
app.UseSwagger();
|
|
app.UseSwaggerUI();
|
|
}
|
|
|
|
// Pas de redirection HTTPS côté application : le conteneur écoute en HTTP et la
|
|
// terminaison TLS est assurée par le reverse-proxy (Caddy/nginx) placé devant.
|
|
// Activer UseHttpsRedirection ici provoquerait des redirections parasites derrière le proxy.
|
|
app.UseCors(CorsPolicyName);
|
|
|
|
app.UseAuthentication();
|
|
|
|
app.UseAuthorization();
|
|
|
|
app.MapControllers();
|
|
|
|
// Endpoint de santé — utilisé par le healthcheck Docker Compose (depends_on: service_healthy).
|
|
app.MapHealthChecks("/health");
|
|
|
|
// --- Application automatique des migrations EF Core au démarrage ---
|
|
// Au premier lancement, la base (ex. /data/trajets.db sur le volume monté) est créée ;
|
|
// aux lancements suivants, les migrations en attente sont appliquées sans intervention manuelle.
|
|
using (var scope = app.Services.CreateScope())
|
|
{
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
await dbContext.Database.MigrateAsync();
|
|
}
|
|
|
|
app.Run();
|
|
|
|
// Exposé pour les tests d'intégration (WebApplicationFactory<Program>).
|
|
public partial class Program { }
|