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>
Task<bool> SendTestMailAsync(string toEmail, Guid clubId,
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);
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
}
}
/// <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)
{
var parts = _settings.DefaultSenderEmail.Split('@');

View File

@ -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.
/// </remarks>
[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");

View File

@ -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<IDispatcher> _dispatcherMock;
private readonly Mock<IUserService> _userServiceMock;
private readonly Mock<IEmailService> _emailServiceMock;
private readonly AuthController _sut;
public AuthControllerTests()
{
_dispatcherMock = new Mock<IDispatcher>();
_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
{
HttpContext = new DefaultHttpContext()
HttpContext = httpContext
};
}
@ -241,6 +247,8 @@ public class AuthControllerTests
// 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");
@ -249,6 +257,42 @@ public class AuthControllerTests
_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
#region ResetPassword Tests