diff --git a/src/Koogle.Domain/Interfaces/IEmailService.cs b/src/Koogle.Domain/Interfaces/IEmailService.cs index 4478325..c72922f 100644 --- a/src/Koogle.Domain/Interfaces/IEmailService.cs +++ b/src/Koogle.Domain/Interfaces/IEmailService.cs @@ -49,4 +49,14 @@ public interface IEmailService /// true if sent successfully sent, false if not. Task SendTestMailAsync(string toEmail, Guid clubId, CancellationToken ct = default); + + /// + /// Sends a password reset email with the reset link. + /// + /// User's email address + /// The full password reset URL with token + /// Cancellation token + /// true if sent successfully, false otherwise. + Task SendPasswordResetEmailAsync(string toEmail, string resetUrl, + CancellationToken ct = default); } diff --git a/src/Koogle.Infrastructure/Services/SmtpEmailService.cs b/src/Koogle.Infrastructure/Services/SmtpEmailService.cs index 846cd47..fb0ff14 100644 --- a/src/Koogle.Infrastructure/Services/SmtpEmailService.cs +++ b/src/Koogle.Infrastructure/Services/SmtpEmailService.cs @@ -238,7 +238,7 @@ public class SmtpEmailService : IEmailService { await using var context = await _contextFactory.CreateDbContextAsync(ct); var club = await context.Clubs.FirstOrDefaultAsync(c => c.Id == clubId, ct); - if (club is null) + if (club is null) return false; var senderEmail = GetSenderEmail(club); @@ -265,6 +265,44 @@ public class SmtpEmailService : IEmailService } } + /// + public async Task SendPasswordResetEmailAsync(string toEmail, string resetUrl, CancellationToken ct = default) + { + if (!_settings.Enabled) + { + _logger.LogInformation("[EMAIL DISABLED] Password reset - To: {Email}, URL: {Url}", toEmail, resetUrl); + return false; + } + + try + { + var senderEmail = _settings.DefaultSenderEmail; + var senderName = "Koogle"; + + var subject = "Passwort zurücksetzen - Koogle"; + var body = $@" + + +

Passwort zurücksetzen

+

Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gestellt.

+

Klicken Sie auf den folgenden Link, um Ihr Passwort zurückzusetzen:

+

{resetUrl}

+

Falls Sie diese Anfrage nicht gestellt haben, können Sie diese E-Mail ignorieren.

+

Der Link ist aus Sicherheitsgründen nur für eine begrenzte Zeit gültig.

+

Viele Grüße,
Koogle

+ +"; + + await SendEmailAsync(senderEmail, senderName, toEmail, subject, body, ct); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send password reset email to {Email}", toEmail); + return false; + } + } + private string GetSenderEmail(Club club) { var parts = _settings.DefaultSenderEmail.Split('@'); diff --git a/src/Koogle.Web/Controllers/AuthController.cs b/src/Koogle.Web/Controllers/AuthController.cs index 087f8d9..f8d670a 100644 --- a/src/Koogle.Web/Controllers/AuthController.cs +++ b/src/Koogle.Web/Controllers/AuthController.cs @@ -1,6 +1,7 @@ using Fluxor; using Koogle.Application.DTOs; using Koogle.Application.Interfaces; +using Koogle.Domain.Interfaces; using Koogle.Infrastructure.Identity; using Koogle.Web.Store.AuthState; using Microsoft.AspNetCore.Components; @@ -17,10 +18,11 @@ namespace Koogle.Web.Controllers /// Aus dem Grund werden Login/Logout Aktionen hier im MVC Controller mit einem "normalen" Post-Request abgewickelt. /// [Microsoft.AspNetCore.Mvc.Route("auth")] - public class AuthController(IDispatcher dispatcher, IUserService userService) : Controller + public class AuthController(IDispatcher dispatcher, IUserService userService, IEmailService emailService) : Controller { private readonly IDispatcher _dispatcher = dispatcher; private readonly IUserService _userService = userService; + private readonly IEmailService _emailService = emailService; [HttpPost("login")] @@ -90,10 +92,9 @@ namespace Koogle.Web.Controllers if (token != null) { - // TODO: Send email with reset link - // For now, log the token for development purposes - var resetUrl = $"/account/reset-password?email={Uri.EscapeDataString(email)}&token={Uri.EscapeDataString(token)}"; - Console.WriteLine($"[DEV] Password reset link: {resetUrl}"); + var baseUrl = $"{Request.Scheme}://{Request.Host}"; + var resetUrl = $"{baseUrl}/account/reset-password?email={Uri.EscapeDataString(email)}&token={Uri.EscapeDataString(token)}"; + await _emailService.SendPasswordResetEmailAsync(email, resetUrl); } return LocalRedirect("/account/forgot-password?success=true"); diff --git a/test/Koogle.Tests/Integration/AuthControllerTests.cs b/test/Koogle.Tests/Integration/AuthControllerTests.cs index 9f7199f..918438c 100644 --- a/test/Koogle.Tests/Integration/AuthControllerTests.cs +++ b/test/Koogle.Tests/Integration/AuthControllerTests.cs @@ -1,6 +1,7 @@ using Fluxor; using Koogle.Application.DTOs; using Koogle.Application.Interfaces; +using Koogle.Domain.Interfaces; using Koogle.Web.Controllers; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; @@ -16,18 +17,23 @@ public class AuthControllerTests { private readonly Mock _dispatcherMock; private readonly Mock _userServiceMock; + private readonly Mock _emailServiceMock; private readonly AuthController _sut; public AuthControllerTests() { _dispatcherMock = new Mock(); _userServiceMock = new Mock(); - _sut = new AuthController(_dispatcherMock.Object, _userServiceMock.Object); + _emailServiceMock = new Mock(); + _sut = new AuthController(_dispatcherMock.Object, _userServiceMock.Object, _emailServiceMock.Object); - // Setup controller context + // Setup controller context with request info for password reset URL building + var httpContext = new DefaultHttpContext(); + httpContext.Request.Scheme = "https"; + httpContext.Request.Host = new HostString("localhost"); _sut.ControllerContext = new ControllerContext { - HttpContext = new DefaultHttpContext() + HttpContext = httpContext }; } @@ -241,6 +247,8 @@ public class AuthControllerTests // Arrange _userServiceMock.Setup(s => s.RequestPasswordResetAsync(It.IsAny(), It.IsAny())) .ReturnsAsync("reset-token"); + _emailServiceMock.Setup(s => s.SendPasswordResetEmailAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(true); // Act await _sut.ForgotPassword("test@example.com"); @@ -249,6 +257,42 @@ public class AuthControllerTests _userServiceMock.Verify(s => s.RequestPasswordResetAsync("test@example.com", It.IsAny()), Times.Once); } + [Fact] + public async Task ForgotPassword_SendsEmail_WhenTokenGenerated() + { + // Arrange + _userServiceMock.Setup(s => s.RequestPasswordResetAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync("reset-token"); + _emailServiceMock.Setup(s => s.SendPasswordResetEmailAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + // Act + await _sut.ForgotPassword("test@example.com"); + + // Assert + _emailServiceMock.Verify(s => s.SendPasswordResetEmailAsync( + "test@example.com", + It.Is(url => url.Contains("reset-password") && url.Contains("token=")), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task ForgotPassword_DoesNotSendEmail_WhenNoToken() + { + // Arrange - No user found, no token generated + _userServiceMock.Setup(s => s.RequestPasswordResetAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((string?)null); + + // Act + await _sut.ForgotPassword("nonexistent@example.com"); + + // Assert - Email should not be sent for non-existent users + _emailServiceMock.Verify(s => s.SendPasswordResetEmailAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + #endregion #region ResetPassword Tests