add reset password mail

This commit is contained in:
beo3000 2026-01-02 13:58:28 +01:00
parent 7ba1307bac
commit f5c8e07730
4 changed files with 102 additions and 9 deletions

View File

@ -49,4 +49,14 @@ public interface IEmailService
/// <returns>true if sent successfully sent, false if not.</returns> /// <returns>true if sent successfully sent, false if not.</returns>
Task<bool> SendTestMailAsync(string toEmail, Guid clubId, Task<bool> SendTestMailAsync(string toEmail, Guid clubId,
CancellationToken ct = default); CancellationToken ct = default);
/// <summary>
/// Sends a password reset email with the reset link.
/// </summary>
/// <param name="toEmail">User's email address</param>
/// <param name="resetUrl">The full password reset URL with token</param>
/// <param name="ct">Cancellation token</param>
/// <returns>true if sent successfully, false otherwise.</returns>
Task<bool> SendPasswordResetEmailAsync(string toEmail, string resetUrl,
CancellationToken ct = default);
} }

View File

@ -238,7 +238,7 @@ public class SmtpEmailService : IEmailService
{ {
await using var context = await _contextFactory.CreateDbContextAsync(ct); await using var context = await _contextFactory.CreateDbContextAsync(ct);
var club = await context.Clubs.FirstOrDefaultAsync(c => c.Id == clubId, ct); var club = await context.Clubs.FirstOrDefaultAsync(c => c.Id == clubId, ct);
if (club is null) if (club is null)
return false; return false;
var senderEmail = GetSenderEmail(club); var senderEmail = GetSenderEmail(club);
@ -265,6 +265,44 @@ public class SmtpEmailService : IEmailService
} }
} }
/// <inheritdoc />
public async Task<bool> 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 = $@"
<html>
<body>
<h2>Passwort zurücksetzen</h2>
<p>Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gestellt.</p>
<p>Klicken Sie auf den folgenden Link, um Ihr Passwort zurückzusetzen:</p>
<p><a href=""{resetUrl}"">{resetUrl}</a></p>
<p>Falls Sie diese Anfrage nicht gestellt haben, können Sie diese E-Mail ignorieren.</p>
<p>Der Link ist aus Sicherheitsgründen nur für eine begrenzte Zeit gültig.</p>
<p>Viele Grüße,<br/>Koogle</p>
</body>
</html>";
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) private string GetSenderEmail(Club club)
{ {
var parts = _settings.DefaultSenderEmail.Split('@'); var parts = _settings.DefaultSenderEmail.Split('@');

View File

@ -1,6 +1,7 @@
using Fluxor; using Fluxor;
using Koogle.Application.DTOs; using Koogle.Application.DTOs;
using Koogle.Application.Interfaces; using Koogle.Application.Interfaces;
using Koogle.Domain.Interfaces;
using Koogle.Infrastructure.Identity; using Koogle.Infrastructure.Identity;
using Koogle.Web.Store.AuthState; using Koogle.Web.Store.AuthState;
using Microsoft.AspNetCore.Components; 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. /// Aus dem Grund werden Login/Logout Aktionen hier im MVC Controller mit einem "normalen" Post-Request abgewickelt.
/// </remarks> /// </remarks>
[Microsoft.AspNetCore.Mvc.Route("auth")] [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 IDispatcher _dispatcher = dispatcher;
private readonly IUserService _userService = userService; private readonly IUserService _userService = userService;
private readonly IEmailService _emailService = emailService;
[HttpPost("login")] [HttpPost("login")]
@ -90,10 +92,9 @@ namespace Koogle.Web.Controllers
if (token != null) if (token != null)
{ {
// TODO: Send email with reset link var baseUrl = $"{Request.Scheme}://{Request.Host}";
// For now, log the token for development purposes var resetUrl = $"{baseUrl}/account/reset-password?email={Uri.EscapeDataString(email)}&token={Uri.EscapeDataString(token)}";
var resetUrl = $"/account/reset-password?email={Uri.EscapeDataString(email)}&token={Uri.EscapeDataString(token)}"; await _emailService.SendPasswordResetEmailAsync(email, resetUrl);
Console.WriteLine($"[DEV] Password reset link: {resetUrl}");
} }
return LocalRedirect("/account/forgot-password?success=true"); return LocalRedirect("/account/forgot-password?success=true");

View File

@ -1,6 +1,7 @@
using Fluxor; using Fluxor;
using Koogle.Application.DTOs; using Koogle.Application.DTOs;
using Koogle.Application.Interfaces; using Koogle.Application.Interfaces;
using Koogle.Domain.Interfaces;
using Koogle.Web.Controllers; using Koogle.Web.Controllers;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
@ -16,18 +17,23 @@ public class AuthControllerTests
{ {
private readonly Mock<IDispatcher> _dispatcherMock; private readonly Mock<IDispatcher> _dispatcherMock;
private readonly Mock<IUserService> _userServiceMock; private readonly Mock<IUserService> _userServiceMock;
private readonly Mock<IEmailService> _emailServiceMock;
private readonly AuthController _sut; private readonly AuthController _sut;
public AuthControllerTests() public AuthControllerTests()
{ {
_dispatcherMock = new Mock<IDispatcher>(); _dispatcherMock = new Mock<IDispatcher>();
_userServiceMock = new Mock<IUserService>(); _userServiceMock = new Mock<IUserService>();
_sut = new AuthController(_dispatcherMock.Object, _userServiceMock.Object); _emailServiceMock = new Mock<IEmailService>();
_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 _sut.ControllerContext = new ControllerContext
{ {
HttpContext = new DefaultHttpContext() HttpContext = httpContext
}; };
} }
@ -241,6 +247,8 @@ public class AuthControllerTests
// Arrange // Arrange
_userServiceMock.Setup(s => s.RequestPasswordResetAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())) _userServiceMock.Setup(s => s.RequestPasswordResetAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync("reset-token"); .ReturnsAsync("reset-token");
_emailServiceMock.Setup(s => s.SendPasswordResetEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
// Act // Act
await _sut.ForgotPassword("test@example.com"); await _sut.ForgotPassword("test@example.com");
@ -249,6 +257,42 @@ public class AuthControllerTests
_userServiceMock.Verify(s => s.RequestPasswordResetAsync("test@example.com", It.IsAny<CancellationToken>()), Times.Once); _userServiceMock.Verify(s => s.RequestPasswordResetAsync("test@example.com", It.IsAny<CancellationToken>()), Times.Once);
} }
[Fact]
public async Task ForgotPassword_SendsEmail_WhenTokenGenerated()
{
// Arrange
_userServiceMock.Setup(s => s.RequestPasswordResetAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync("reset-token");
_emailServiceMock.Setup(s => s.SendPasswordResetEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
// Act
await _sut.ForgotPassword("test@example.com");
// Assert
_emailServiceMock.Verify(s => s.SendPasswordResetEmailAsync(
"test@example.com",
It.Is<string>(url => url.Contains("reset-password") && url.Contains("token=")),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task ForgotPassword_DoesNotSendEmail_WhenNoToken()
{
// Arrange - No user found, no token generated
_userServiceMock.Setup(s => s.RequestPasswordResetAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.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<string>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()), Times.Never);
}
#endregion #endregion
#region ResetPassword Tests #region ResetPassword Tests