From 36cc2d97a086be71d20b7585166d5b25741cc879 Mon Sep 17 00:00:00 2001 From: "Tizian.Breuch" Date: Thu, 25 Sep 2025 15:17:36 +0200 Subject: [PATCH] auth --- .../Controllers/Auth/AuthController.cs | 84 ++++----- .../Services/Auth/AuthService.cs | 172 +++++++++--------- .../Services/Auth/IAuthService.cs | 4 +- 3 files changed, 126 insertions(+), 134 deletions(-) diff --git a/Webshop.Api/Controllers/Auth/AuthController.cs b/Webshop.Api/Controllers/Auth/AuthController.cs index 673cb1d..cbb34b6 100644 --- a/Webshop.Api/Controllers/Auth/AuthController.cs +++ b/Webshop.Api/Controllers/Auth/AuthController.cs @@ -23,44 +23,45 @@ namespace Webshop.Api.Controllers.Auth [HttpPost("register")] [AllowAnonymous] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] public async Task Register([FromBody] RegisterRequestDto request) { if (!ModelState.IsValid) return BadRequest(ModelState); var result = await _authService.RegisterUserAsync(request); - if (result.Type == ServiceResultType.Success) + return result.Type switch { - return Ok(new { Message = "Registrierung erfolgreich. Bitte bestätigen Sie Ihre E-Mail-Adresse." }); - } - - return BadRequest(new { Message = result.ErrorMessage }); + ServiceResultType.Success => Ok(new { Message = "Registrierung erfolgreich. Bitte bestätigen Sie Ihre E-Mail-Adresse." }), + ServiceResultType.InvalidInput => BadRequest(new { Message = result.ErrorMessage }), + ServiceResultType.Failure => BadRequest(new { Message = result.ErrorMessage }), + _ => StatusCode(StatusCodes.Status500InternalServerError, new { Message = result.ErrorMessage ?? "Ein unerwarteter Fehler ist aufgetreten." }) + }; } [HttpPost("login/customer")] [AllowAnonymous] [ProducesResponseType(typeof(AuthResponseDto), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] public async Task LoginCustomer([FromBody] LoginRequestDto request) { if (!ModelState.IsValid) return BadRequest(ModelState); var result = await _authService.LoginUserAsync(request); - if (result.Type == ServiceResultType.Success) + return result.Type switch { - return Ok(result.Value); - } - - return Unauthorized(new { Message = result.ErrorMessage }); + ServiceResultType.Success => Ok(result.Value), + ServiceResultType.Unauthorized => Unauthorized(new { Message = result.ErrorMessage }), + _ => Unauthorized(new { Message = result.ErrorMessage ?? "Ungültige Anmeldeinformationen." }) + }; } [HttpPost("login/admin")] [AllowAnonymous] [ProducesResponseType(typeof(AuthResponseDto), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] public async Task LoginAdmin([FromBody] LoginRequestDto request) { @@ -72,80 +73,73 @@ namespace Webshop.Api.Controllers.Auth { ServiceResultType.Success => Ok(result.Value), ServiceResultType.Forbidden => Forbid(), - _ => Unauthorized(new { Message = result.ErrorMessage }) + ServiceResultType.Unauthorized => Unauthorized(new { Message = result.ErrorMessage }), + _ => Unauthorized(new { Message = result.ErrorMessage ?? "Ungültige Anmeldeinformationen." }) }; } [HttpGet("confirm-email")] [AllowAnonymous] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] public async Task ConfirmEmail([FromQuery] string userId, [FromQuery] string token) { var result = await _authService.ConfirmEmailAsync(userId, token); - if (result.Type == ServiceResultType.Success) + return result.Type switch { - return Ok(new { Message = "E-Mail-Adresse erfolgreich bestätigt. Sie können sich jetzt anmelden." }); - } - - return BadRequest(new { Message = result.ErrorMessage }); + ServiceResultType.Success => Ok(new { Message = "E-Mail-Adresse erfolgreich bestätigt. Sie können sich jetzt anmelden." }), + _ => BadRequest(new { Message = result.ErrorMessage ?? "Der Bestätigungslink ist ungültig oder abgelaufen." }) + }; } [HttpPost("resend-email-confirmation")] [AllowAnonymous] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] public async Task ResendEmailConfirmation([FromBody] ResendEmailConfirmationRequestDto request) { if (!ModelState.IsValid) return BadRequest(ModelState); var result = await _authService.ResendEmailConfirmationAsync(request.Email); - if (result.Type == ServiceResultType.Success) + return result.Type switch { - // << KORREKTUR: Feste Erfolgsmeldung senden >> - return Ok(new { Message = "Wenn ein Konto mit dieser E-Mail-Adresse existiert und noch nicht bestätigt wurde, wurde eine neue E-Mail gesendet." }); - } - - // Wenn die E-Mail bereits bestätigt war, ist dies ein 'BadRequest' aus Sicht des Workflows. - return BadRequest(new { Message = result.ErrorMessage }); + // Aus Sicherheitsgründen wird immer eine Erfolgsmeldung zurückgegeben, es sei denn, die E-Mail wurde bereits bestätigt. + ServiceResultType.Success => Ok(new { Message = "Wenn ein Konto mit dieser E-Mail-Adresse existiert und noch nicht bestätigt wurde, wurde eine neue E-Mail gesendet." }), + ServiceResultType.InvalidInput => BadRequest(new { Message = result.ErrorMessage }), + _ => Ok(new { Message = "Wenn ein Konto mit dieser E-Mail-Adresse existiert und noch nicht bestätigt wurde, wurde eine neue E-Mail gesendet." }) + }; } - // << NEUER ENDPUNKT FÜR PASSWORT VERGESSEN >> [HttpPost("forgot-password")] [AllowAnonymous] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] public async Task ForgotPassword([FromBody] ForgotPasswordRequestDto request) { if (!ModelState.IsValid) return BadRequest(ModelState); + // Diese Methode gibt bewusst kein Fehlerdetail zurück, um das Vorhandensein von E-Mail-Adressen nicht preiszugeben. await _authService.ForgotPasswordAsync(request); return Ok(new { Message = "Wenn ein Konto mit dieser E-Mail-Adresse existiert, wurde eine E-Mail zum Zurücksetzen des Passworts gesendet." }); } - // << NEUER ENDPUNKT FÜR PASSWORT ZURÜCKSETZEN >> [HttpPost("reset-password")] [AllowAnonymous] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] public async Task ResetPassword([FromBody] ResetPasswordDto request) { if (!ModelState.IsValid) return BadRequest(ModelState); var result = await _authService.ResetPasswordAsync(request); - if (result.Type == ServiceResultType.Success) + return result.Type switch { - return Ok(new { Message = "Ihr Passwort wurde erfolgreich zurückgesetzt." }); - } - - return BadRequest(new { Message = result.ErrorMessage }); + ServiceResultType.Success => Ok(new { Message = "Ihr Passwort wurde erfolgreich zurückgesetzt." }), + _ => BadRequest(new { Message = result.ErrorMessage ?? "Das Passwort konnte nicht zurückgesetzt werden. Der Link ist möglicherweise ungültig oder abgelaufen." }) + }; } - - // HINWEIS: Der 'change-email-confirm'-Endpunkt gehört logisch eher zum CustomerController, - // da er eine Aktion eines eingeloggten Benutzers ist. } } \ No newline at end of file diff --git a/Webshop.Application/Services/Auth/AuthService.cs b/Webshop.Application/Services/Auth/AuthService.cs index f4bf50f..a38f7c7 100644 --- a/Webshop.Application/Services/Auth/AuthService.cs +++ b/Webshop.Application/Services/Auth/AuthService.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.IdentityModel.Tokens; using Resend; using System; +using System.IO; using System.IdentityModel.Tokens.Jwt; using System.Linq; using System.Security.Claims; @@ -14,6 +15,7 @@ using Webshop.Application.DTOs.Auth; using Webshop.Domain.Identity; using Webshop.Infrastructure.Data; using Webshop.Application; +using System.Collections.Generic; namespace Webshop.Application.Services.Auth { @@ -68,38 +70,86 @@ namespace Webshop.Application.Services.Auth return ServiceResult.Ok(); } + public async Task> LoginUserAsync(LoginRequestDto request) + { + var user = await _userManager.FindByEmailAsync(request.Email); + if (user == null || !await _userManager.CheckPasswordAsync(user, request.Password)) + { + return ServiceResult.Fail(ServiceResultType.Unauthorized, "Ungültige Anmeldeinformationen."); + } + + if (!await _userManager.IsEmailConfirmedAsync(user)) + { + return ServiceResult.Fail(ServiceResultType.Unauthorized, "E-Mail-Adresse wurde noch nicht bestätigt."); + } + + var roles = await _userManager.GetRolesAsync(user); + var token = GenerateJwtToken(user, roles); + + var response = new AuthResponseDto + { + IsAuthSuccessful = true, + Token = token, + UserId = user.Id, + Email = user.Email, + Roles = roles.ToList() + }; + return ServiceResult.Ok(response); + } + + public async Task> LoginAdminAsync(LoginRequestDto request) + { + var loginResult = await LoginUserAsync(request); + if (loginResult.Type != ServiceResultType.Success) + { + // Propagate the specific login failure (e.g., Unauthorized) + return ServiceResult.Fail(loginResult.Type, loginResult.ErrorMessage!); + } + + var user = await _userManager.FindByEmailAsync(request.Email); + // This check is belt-and-suspenders, but good practice + if (user == null || !await _userManager.IsInRoleAsync(user, "Admin")) + { + return ServiceResult.Fail(ServiceResultType.Forbidden, "Keine Berechtigung für den Admin-Zugang."); + } + + return loginResult; // Return the successful login result + } + public async Task ConfirmEmailAsync(string userId, string token) { + if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(token)) + { + return ServiceResult.Fail(ServiceResultType.InvalidInput, "Benutzer-ID und Token sind erforderlich."); + } + var user = await _userManager.FindByIdAsync(userId); if (user == null) { - return ServiceResult.Fail(ServiceResultType.NotFound, "Benutzer nicht gefunden."); + // Do not reveal that the user does not exist + return ServiceResult.Fail(ServiceResultType.NotFound, "Ungültiger Bestätigungsversuch."); } - var result = await _userManager.ConfirmEmailAsync(user, HttpUtility.UrlDecode(token)); - return result.Succeeded ? ServiceResult.Ok() : ServiceResult.Fail(ServiceResultType.Failure, "E-Mail-Bestätigung fehlgeschlagen."); + var result = await _userManager.ConfirmEmailAsync(user, token); // The token from the URL is already decoded by ASP.NET Core + return result.Succeeded + ? ServiceResult.Ok() + : ServiceResult.Fail(ServiceResultType.Failure, "E-Mail-Bestätigung fehlgeschlagen."); } public async Task ResendEmailConfirmationAsync(string email) { var user = await _userManager.FindByEmailAsync(email); - // Fall 1: Benutzer existiert nicht. Aus Sicherheitsgründen trotzdem OK zurückgeben. if (user == null) { - return ServiceResult.Ok(); + return ServiceResult.Ok(); // Do not reveal user existence } - // Fall 2: Die E-Mail des Benutzers ist bereits bestätigt. - // Wir prüfen die Eigenschaft direkt auf dem frisch geladenen User-Objekt. if (user.EmailConfirmed) { - // In diesem Fall wollen wir einen Fehler an das Frontend geben, - // damit der Benutzer weiß, dass alles in Ordnung ist. return ServiceResult.Fail(ServiceResultType.InvalidInput, "Diese E-Mail-Adresse ist bereits bestätigt."); } - // Fall 3: Benutzer existiert, ist aber nicht bestätigt -> E-Mail senden. await SendEmailConfirmationEmail(user); return ServiceResult.Ok(); } @@ -109,19 +159,19 @@ namespace Webshop.Application.Services.Auth var user = await _userManager.FindByEmailAsync(request.Email); if (user == null || !(await _userManager.IsEmailConfirmedAsync(user))) { - return ServiceResult.Ok(); + return ServiceResult.Ok(); // Do not reveal user existence or status } var token = await _userManager.GeneratePasswordResetTokenAsync(user); - var encodedToken = HttpUtility.UrlEncode(token); var clientUrl = _configuration["App:ClientUrl"] ?? "http://localhost:3000"; - var resetLink = $"{clientUrl}/reset-password?email={HttpUtility.UrlEncode(request.Email)}&token={encodedToken}"; + // Important: URL-encode components separately to avoid encoding the whole URL structure + var resetLink = $"{clientUrl}/reset-password?email={HttpUtility.UrlEncode(request.Email)}&token={HttpUtility.UrlEncode(token)}"; var emailHtmlBody = await LoadAndFormatEmailTemplate( - titel: "Setzen Sie Ihr Passwort zurück", - haupttext: "Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gesendet. Klicken Sie auf den Button unten, um ein neues Passwort festzulegen.", - callToActionText: "Passwort zurücksetzen", - callToActionLink: resetLink + "Setzen Sie Ihr Passwort zurück", + "Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gesendet. Klicken Sie auf den Button unten, um ein neues Passwort festzulegen.", + "Passwort zurücksetzen", + resetLink ); var message = new EmailMessage(); @@ -139,18 +189,17 @@ namespace Webshop.Application.Services.Auth var user = await _userManager.FindByEmailAsync(request.Email); if (user == null) { + // Don't reveal user non-existence, but the error message will be generic return ServiceResult.Fail(ServiceResultType.InvalidInput, "Fehler beim Zurücksetzen des Passworts."); } - var result = await _userManager.ResetPasswordAsync(user, HttpUtility.UrlDecode(request.Token), request.NewPassword); + var result = await _userManager.ResetPasswordAsync(user, request.Token, request.NewPassword); return result.Succeeded ? ServiceResult.Ok() : ServiceResult.Fail(ServiceResultType.InvalidInput, string.Join(" ", result.Errors.Select(e => e.Description))); } - // --- Private Helper-Methoden --- - private async Task SendEmailConfirmationEmail(ApplicationUser user) { var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); @@ -159,42 +208,20 @@ namespace Webshop.Application.Services.Auth var confirmationLink = $"{clientUrl}/confirm-email?userId={user.Id}&token={encodedToken}"; var emailHtmlBody = await LoadAndFormatEmailTemplate( - titel: "Bestätigen Sie Ihre E-Mail-Adresse", - haupttext: "Vielen Dank für Ihre Registrierung! Bitte klicken Sie auf den Button unten, um Ihr Konto zu aktivieren.", - callToActionText: "Konto aktivieren", - callToActionLink: confirmationLink + "Bestätigen Sie Ihre E-Mail-Adresse", + "Vielen Dank für Ihre Registrierung! Bitte klicken Sie auf den Button unten, um Ihr Konto zu aktivieren.", + "Konto aktivieren", + confirmationLink ); var message = new EmailMessage(); - message.To.Add(user.Email); + message.To.Add(user.Email!); message.From = _configuration["Resend:FromEmail"]!; message.Subject = "Willkommen! Bitte bestätigen Sie Ihre E-Mail-Adresse"; message.HtmlBody = emailHtmlBody; await _resend.EmailSendAsync(message); } - private async Task LoadAndFormatEmailTemplate(string titel, string haupttext, string callToActionText, string callToActionLink) - { - var templatePath = Path.Combine(AppContext.BaseDirectory, "Templates", "_EmailTemplate.html"); - - if (!File.Exists(templatePath)) - { - // Fallback, falls die Vorlagendatei nicht gefunden wird - return $"

{titel}

{haupttext}

{callToActionText}"; - } - - var template = await File.ReadAllTextAsync(templatePath); - - template = template.Replace("{{ShopName}}", _configuration["ShopInfo:Name"] ?? "Ihr Webshop"); - template = template.Replace("{{Titel}}", titel); - template = template.Replace("{{Haupttext}}", haupttext); - template = template.Replace("{{CallToActionText}}", callToActionText); - template = template.Replace("{{CallToActionLink}}", callToActionLink); - template = template.Replace("{{Jahr}}", DateTime.UtcNow.Year.ToString()); - - return template; - } - private string GenerateJwtToken(ApplicationUser user, IList roles) { var claims = new List @@ -225,48 +252,21 @@ namespace Webshop.Application.Services.Auth return new JwtSecurityTokenHandler().WriteToken(token); } - public async Task> LoginAdminAsync(LoginRequestDto request) + private async Task LoadAndFormatEmailTemplate(string titel, string haupttext, string callToActionText, string callToActionLink) { - var loginResult = await LoginUserAsync(request); - if (loginResult.Type != ServiceResultType.Success) + var templatePath = Path.Combine(AppContext.BaseDirectory, "Templates", "_EmailTemplate.html"); + if (!File.Exists(templatePath)) { - return loginResult; + return $"

{titel}

{haupttext}

{callToActionText}"; } - - var user = await _userManager.FindByEmailAsync(request.Email); - if (user == null || !await _userManager.IsInRoleAsync(user, "Admin")) - { - return ServiceResult.Fail(ServiceResultType.Forbidden, "Keine Berechtigung für den Admin-Zugang."); - } - - return loginResult; - } - - public async Task> LoginUserAsync(LoginRequestDto request) - { - var user = await _userManager.FindByEmailAsync(request.Email); - if (user == null || !await _userManager.CheckPasswordAsync(user, request.Password)) - { - return ServiceResult.Fail(ServiceResultType.Unauthorized, "Ungültige Anmeldeinformationen."); - } - - if (!await _userManager.IsEmailConfirmedAsync(user)) - { - return ServiceResult.Fail(ServiceResultType.Unauthorized, "E-Mail-Adresse wurde noch nicht bestätigt."); - } - - var roles = await _userManager.GetRolesAsync(user); - var token = GenerateJwtToken(user, roles); - - var response = new AuthResponseDto - { - IsAuthSuccessful = true, - Token = token, - UserId = user.Id, - Email = user.Email, - Roles = roles.ToList() - }; - return ServiceResult.Ok(response); + var template = await File.ReadAllTextAsync(templatePath); + template = template.Replace("{{ShopName}}", _configuration["ShopInfo:Name"] ?? "Ihr Webshop"); + template = template.Replace("{{Titel}}", titel); + template = template.Replace("{{Haupttext}}", haupttext); + template = template.Replace("{{CallToActionText}}", callToActionText); + template = template.Replace("{{CallToActionLink}}", callToActionLink); + template = template.Replace("{{Jahr}}", DateTime.UtcNow.Year.ToString()); + return template; } } } \ No newline at end of file diff --git a/Webshop.Application/Services/Auth/IAuthService.cs b/Webshop.Application/Services/Auth/IAuthService.cs index 3fae1a5..239f0c4 100644 --- a/Webshop.Application/Services/Auth/IAuthService.cs +++ b/Webshop.Application/Services/Auth/IAuthService.cs @@ -7,13 +7,11 @@ namespace Webshop.Application.Services.Auth { public interface IAuthService { - Task RegisterUserAsync(RegisterRequestDto request); // << Gibt kein DTO mehr zurück >> + Task RegisterUserAsync(RegisterRequestDto request); Task> LoginUserAsync(LoginRequestDto request); Task> LoginAdminAsync(LoginRequestDto request); Task ConfirmEmailAsync(string userId, string token); Task ResendEmailConfirmationAsync(string email); - - // << NEUE METHODEN >> Task ForgotPasswordAsync(ForgotPasswordRequestDto request); Task ResetPasswordAsync(ResetPasswordDto request); }