LOT B — une seule migration EF (LotB_FreezeSchema) : - SectionMap.MapResourceId → IconResourceId. L'écart (g) de la bascule tombe avec. Il fallait renommer aussi la propriété de navigation MapResource : la convention EF l'appariait au FK, la laisser aurait fabriqué un FK fantôme. Elle n'était utilisée nulle part ailleurs. - SectionEvent.ParcoursIds supprimé (champ, DTO, SectionFactory, et une initialisation dans un montage de test). - Instance.IsImageWatermark remplace le `instanceId == "633ee379…"` en dur de ResourceController. EF a généré un RenameColumn, pas un drop+add : les icônes déjà configurées survivent. L'avertissement de perte de données ne porte que sur le DropColumn de ParcoursIds, ce qui est l'intention. Non fait, et c'était une erreur de doc : « supprimer SectionEvent.IconResourceId ». Ce champ n'existe pas — la ligne visée appartient à la classe imbriquée MapAnnotation, partagée par SectionEvent, SectionAgenda et SectionMap, lue par cinq contrôleurs et par GetReferencedResourceIds. La supprimer aurait cassé les icônes d'annotation des trois types et la collecte offline. SÉCURITÉ (lot A, même repo) : - AuthenticationController.Authenticate : un bloc #if DEBUG écrasait l'email et le mot de passe reçus par un compte de test, donc toute compilation en Debug authentifiait n'importe quelle saisie. Retiré. - EnableSensitiveDataLogging (qui écrit les valeurs des paramètres dans les logs) passe sous #if DEBUG, l'idiome déjà employé dans Startup.cs pour le CORS et Hangfire. Le Dockerfile publiant en -c Release, c'est un verrou réel. LOT C1 : - Calculateur StoragePath/SizeBytes extrait dans Helpers/ResourceStorage.cs, avec 13 tests fixant l'invariant des types URL. Il ferme le lien L5 : le backfill (C2) et l'écart (e) de la migration appelleront le même code. - L'extraction a révélé la divergence qu'elle devait empêcher : des deux chemins de création de ResourceController, le chemin multipart écrivait SizeBytes mais laissait StoragePath nul. dotnet build Debug et Release verts, dotnet test 143/143 (130 + 13). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
203 lines
8.9 KiB
C#
203 lines
8.9 KiB
C#
using Manager.Services;
|
|
using ManagerService.Data;
|
|
using ManagerService.DTOs;
|
|
using ManagerService.Helpers;
|
|
using ManagerService.Service.Services;
|
|
using ManagerService.Services;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.Logging;
|
|
using Mqtt.Client.AspNetCore.Services;
|
|
using NSwag.Annotations;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Net;
|
|
using System.Linq;
|
|
using System.Threading.Tasks;
|
|
|
|
namespace ManagerService.Service.Controllers
|
|
{
|
|
/// <summary>
|
|
/// Authentication controller
|
|
/// </summary>
|
|
[ApiController, Route("api/[controller]")]
|
|
[Authorize]
|
|
[OpenApiTag("Authentication", Description = "Authentication management")]
|
|
public class AuthenticationController : ControllerBase
|
|
{
|
|
private readonly ILogger<AuthenticationController> _logger;
|
|
private readonly TokensService _tokensService;
|
|
/*private readonly UserDatabaseService _UserDatabaseService;
|
|
private readonly DeviceDatabaseService _DeviceDatabaseService;
|
|
private readonly ConfigurationDatabaseService _ConfigurationDatabaseService;*/
|
|
private readonly MyInfoMateDbContext _myInfoMateDbContext;
|
|
private readonly ProfileLogic _profileLogic;
|
|
private readonly IEmailService _emailService;
|
|
private readonly IConfiguration _configuration;
|
|
|
|
|
|
public AuthenticationController(ILogger<AuthenticationController> logger, TokensService tokensService, MyInfoMateDbContext myInfoMateDbContext, ProfileLogic profileLogic, IEmailService emailService, IConfiguration configuration/*UserDatabaseService UserDatabaseService, DeviceDatabaseService DeviceDatabaseService, ConfigurationDatabaseService ConfigurationDatabaseService*/)
|
|
{
|
|
_logger = logger;
|
|
_tokensService = tokensService;
|
|
_myInfoMateDbContext = myInfoMateDbContext;
|
|
_profileLogic = profileLogic;
|
|
_emailService = emailService;
|
|
_configuration = configuration;
|
|
//_UserDatabaseService = UserDatabaseService;
|
|
//_DeviceDatabaseService = DeviceDatabaseService;
|
|
//_ConfigurationDatabaseService = ConfigurationDatabaseService;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Authenticate (business)
|
|
/// </summary>
|
|
/// <param name="email">user email</param>
|
|
/// <param name="password">user password</param>
|
|
/// <returns>Token descriptor</returns>
|
|
private ObjectResult Authenticate(string email, string password)
|
|
{
|
|
try
|
|
{
|
|
// Retiré le 2026-08-11 : un bloc `#if DEBUG` écrasait ici l'email et le
|
|
// mot de passe reçus par un compte de test, donc toute compilation en
|
|
// Debug authentifiait n'importe qui en tant que test@email.be.
|
|
// Set user token ?
|
|
var user = _myInfoMateDbContext.Users.FirstOrDefault(u => u.Email.ToLower() == email.ToLower());
|
|
//var user = _UserDatabaseService.GetByEmail(email.ToLower());
|
|
|
|
if (user == null)
|
|
throw new KeyNotFoundException("User not found");
|
|
|
|
var token = _tokensService.Authenticate(user, password);
|
|
|
|
MqttClientService.SetServices(_myInfoMateDbContext);//_DeviceDatabaseService, _ConfigurationDatabaseService);
|
|
|
|
return new OkObjectResult(token);
|
|
}
|
|
catch (UnauthorizedAccessException ex)
|
|
{
|
|
_logger?.LogError(ex, $"Authentication error for user '{email}': unauthorized access");
|
|
return Unauthorized(ex);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger?.LogError(ex, $"Authenticate error for user '{email}'");
|
|
return Problem($"Authenticate error for user '{email}': {ex.Message}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Authenticate with form parameters (used by Swagger test client)
|
|
/// </summary>
|
|
/// <param name="tokenRequest">Swagger token request</param>
|
|
/// <returns>Token descriptor</returns>
|
|
[AllowAnonymous]
|
|
[HttpPost("Token")]
|
|
[Consumes("application/x-www-form-urlencoded")]
|
|
[ProducesResponseType(typeof(TokenDTO), (int) HttpStatusCode.OK)]
|
|
[ProducesResponseType(typeof(string), (int) HttpStatusCode.Unauthorized)]
|
|
[ProducesResponseType(typeof(string), (int) HttpStatusCode.InternalServerError)]
|
|
public ObjectResult AuthenticateWithForm([FromForm] SwaggerTokenRequest tokenRequest)
|
|
{
|
|
return Authenticate(tokenRequest.username, tokenRequest.password);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Authenticate with Json parameters (used by most clients)
|
|
/// </summary>
|
|
/// <param name="login">Login DTO</param>
|
|
/// <returns>Token descriptor</returns>
|
|
[AllowAnonymous]
|
|
[HttpPost("Authenticate")]
|
|
[Consumes("application/json")]
|
|
[ProducesResponseType(typeof(TokenDTO), (int)HttpStatusCode.OK)]
|
|
[ProducesResponseType(typeof(string), (int)HttpStatusCode.Unauthorized)]
|
|
[ProducesResponseType(typeof(string), (int)HttpStatusCode.InternalServerError)]
|
|
public ObjectResult AuthenticateWithJson([FromBody] LoginDTO login)
|
|
{
|
|
return Authenticate(login.email.ToLower(), login.password);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Request a password reset link by email. Always returns 200 (does not reveal
|
|
/// whether the email exists) to avoid leaking which addresses have an account.
|
|
/// </summary>
|
|
/// <param name="dto">Email of the account</param>
|
|
[AllowAnonymous]
|
|
[HttpPost("forgot-password")]
|
|
[Consumes("application/json")]
|
|
[ProducesResponseType(typeof(string), (int)HttpStatusCode.OK)]
|
|
public async Task<ObjectResult> ForgotPassword([FromBody] ForgotPasswordDTO dto)
|
|
{
|
|
try
|
|
{
|
|
var email = dto?.email?.Trim().ToLowerInvariant();
|
|
var user = string.IsNullOrEmpty(email)
|
|
? null
|
|
: _myInfoMateDbContext.Users.FirstOrDefault(u => u.Email.ToLower() == email);
|
|
|
|
if (user != null)
|
|
{
|
|
var rawToken = PasswordTokenHelper.GenerateRawToken();
|
|
user.PasswordTokenHash = PasswordTokenHelper.Hash(rawToken);
|
|
user.PasswordTokenExpiresAt = PasswordTokenHelper.DefaultExpiry();
|
|
_myInfoMateDbContext.SaveChanges();
|
|
|
|
var managerAppUrl = _configuration["AppUrls:ManagerApp"];
|
|
var resetUrl = $"{managerAppUrl}/set-password?token={rawToken}";
|
|
await _emailService.SendPasswordResetEmailAsync(user.Email, user.FirstName, resetUrl);
|
|
}
|
|
|
|
return new OkObjectResult("If this email is registered, a reset link has been sent.");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger?.LogError(ex, "Forgot-password error");
|
|
return new OkObjectResult("If this email is registered, a reset link has been sent.");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Consume a set-password/reset/invite token and set a new password
|
|
/// </summary>
|
|
/// <param name="dto">Token and new password</param>
|
|
[AllowAnonymous]
|
|
[HttpPost("set-password")]
|
|
[Consumes("application/json")]
|
|
[ProducesResponseType(typeof(string), (int)HttpStatusCode.OK)]
|
|
[ProducesResponseType(typeof(string), (int)HttpStatusCode.BadRequest)]
|
|
public ObjectResult SetPassword([FromBody] SetPasswordDTO dto)
|
|
{
|
|
try
|
|
{
|
|
if (string.IsNullOrEmpty(dto?.token) || string.IsNullOrEmpty(dto?.newPassword) || dto.newPassword.Length < 8)
|
|
throw new ArgumentException("Token and a password of at least 8 characters are required");
|
|
|
|
var tokenHash = PasswordTokenHelper.Hash(dto.token);
|
|
var user = _myInfoMateDbContext.Users.FirstOrDefault(u => u.PasswordTokenHash == tokenHash);
|
|
|
|
if (user == null || user.PasswordTokenExpiresAt == null || user.PasswordTokenExpiresAt < DateTime.UtcNow)
|
|
throw new ArgumentException("This link is invalid or has expired");
|
|
|
|
user.Password = _profileLogic.HashPassword(dto.newPassword);
|
|
user.PasswordTokenHash = null;
|
|
user.PasswordTokenExpiresAt = null;
|
|
_myInfoMateDbContext.SaveChanges();
|
|
|
|
return new OkObjectResult("Password updated");
|
|
}
|
|
catch (ArgumentException ex)
|
|
{
|
|
return new BadRequestObjectResult(ex.Message) { };
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger?.LogError(ex, "Set-password error");
|
|
return new ObjectResult(ex.Message) { StatusCode = 500 };
|
|
}
|
|
}
|
|
}
|
|
}
|