From d9d24d105aa8cb54b86c4b3a54f160037fa075b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rasmus=20H=C3=B8llund=20Kristensen?= Date: Thu, 11 Dec 2025 02:46:36 +0100 Subject: [PATCH] Added email verification --- .../Register/RegisterCommandHandlerTests.cs | 15 +- ...endVerificationEmailCommandHandlerTests.cs | 193 ++++++++ ...dVerificationEmailCommandValidatorTests.cs | 36 ++ .../VerifyEmailCommandHandlerTests.cs | 155 +++++++ .../VerifyEmailCommandValidatorTests.cs | 91 ++++ ...mailVerificationStatusQueryHandlerTests.cs | 119 +++++ .../DTOs/Users/UserResponse.cs | 5 +- .../Register/RegisterCommandHandler.cs | 103 ++++- .../ResendVerificationEmailCommand.cs | 5 + .../ResendVerificationEmailCommandHandler.cs | 149 ++++++ ...ResendVerificationEmailCommandValidator.cs | 12 + .../VerifyEmail/VerifyEmailCommand.cs | 5 + .../VerifyEmail/VerifyEmailCommandHandler.cs | 93 ++++ .../VerifyEmailCommandValidator.cs | 17 + .../GetEmailVerificationStatusQuery.cs | 12 + .../GetEmailVerificationStatusQueryHandler.cs | 37 ++ .../EditUser/EditUserCommandHandler.cs | 2 +- .../UploadAvatarCommandHandler.cs | 2 +- .../Queries/GetUser/GetUserQueryHandler.cs | 3 +- AutoStack.Domain/AutoStack.Domain.csproj | 4 - AutoStack.Domain/Entities/User.cs | 64 ++- .../Handlers/EmailVerifiedHandlerTests.cs | 152 ++++++ .../DependencyInjection.cs | 13 +- ...210214501_AddEmailVerification.Designer.cs | 436 ++++++++++++++++++ .../20251210214501_AddEmailVerification.cs | 61 +++ .../ApplicationDbContextModelSnapshot.cs | 15 + .../Configurations/UserConfiguration.cs | 15 +- .../Security/Handlers/EmailVerifiedHandler.cs | 43 ++ .../Requirements/EmailVerifiedRequirement.cs | 10 + .../AutoStack.Presentation.Tests.csproj | 33 -- .../EmailVerificationEndpoints.cs | 144 ++++++ .../Endpoints/Stack/StackEndpoints.cs | 16 +- .../Endpoints/TwoFactor/TwoFactorEndpoints.cs | 8 +- .../Endpoints/User/UserEndpoints.cs | 8 +- .../Extensions/RateLimitingExtensions.cs | 18 + AutoStack.Presentation/Program.cs | 2 + AutoStack.Presentation/appsettings.json | 3 + AutoStack.slnx | 1 - 38 files changed, 2028 insertions(+), 72 deletions(-) create mode 100644 AutoStack.Application.Tests/Features/Auth/Commands/ResendVerificationEmail/ResendVerificationEmailCommandHandlerTests.cs create mode 100644 AutoStack.Application.Tests/Features/Auth/Commands/ResendVerificationEmail/ResendVerificationEmailCommandValidatorTests.cs create mode 100644 AutoStack.Application.Tests/Features/Auth/Commands/VerifyEmail/VerifyEmailCommandHandlerTests.cs create mode 100644 AutoStack.Application.Tests/Features/Auth/Commands/VerifyEmail/VerifyEmailCommandValidatorTests.cs create mode 100644 AutoStack.Application.Tests/Features/Auth/Queries/EmailVerificationStatus/GetEmailVerificationStatusQueryHandlerTests.cs create mode 100644 AutoStack.Application/Features/Auth/Commands/ResendVerificationEmail/ResendVerificationEmailCommand.cs create mode 100644 AutoStack.Application/Features/Auth/Commands/ResendVerificationEmail/ResendVerificationEmailCommandHandler.cs create mode 100644 AutoStack.Application/Features/Auth/Commands/ResendVerificationEmail/ResendVerificationEmailCommandValidator.cs create mode 100644 AutoStack.Application/Features/Auth/Commands/VerifyEmail/VerifyEmailCommand.cs create mode 100644 AutoStack.Application/Features/Auth/Commands/VerifyEmail/VerifyEmailCommandHandler.cs create mode 100644 AutoStack.Application/Features/Auth/Commands/VerifyEmail/VerifyEmailCommandValidator.cs create mode 100644 AutoStack.Application/Features/Auth/Queries/EmailVerificationStatus/GetEmailVerificationStatusQuery.cs create mode 100644 AutoStack.Application/Features/Auth/Queries/EmailVerificationStatus/GetEmailVerificationStatusQueryHandler.cs create mode 100644 AutoStack.Infrastructure.Tests/Security/Handlers/EmailVerifiedHandlerTests.cs create mode 100644 AutoStack.Infrastructure/Migrations/20251210214501_AddEmailVerification.Designer.cs create mode 100644 AutoStack.Infrastructure/Migrations/20251210214501_AddEmailVerification.cs create mode 100644 AutoStack.Infrastructure/Security/Handlers/EmailVerifiedHandler.cs create mode 100644 AutoStack.Infrastructure/Security/Requirements/EmailVerifiedRequirement.cs delete mode 100644 AutoStack.Presentation.Tests/AutoStack.Presentation.Tests.csproj create mode 100644 AutoStack.Presentation/Endpoints/EmailVerification/EmailVerificationEndpoints.cs diff --git a/AutoStack.Application.Tests/Features/Auth/Commands/Register/RegisterCommandHandlerTests.cs b/AutoStack.Application.Tests/Features/Auth/Commands/Register/RegisterCommandHandlerTests.cs index 2606867..bd356e8 100644 --- a/AutoStack.Application.Tests/Features/Auth/Commands/Register/RegisterCommandHandlerTests.cs +++ b/AutoStack.Application.Tests/Features/Auth/Commands/Register/RegisterCommandHandlerTests.cs @@ -1,8 +1,10 @@ +using AutoStack.Application.Common.Interfaces; using AutoStack.Application.Common.Interfaces.Auth; using AutoStack.Application.Features.Auth.Commands.Register; using AutoStack.Application.Tests.Common; using AutoStack.Domain.Entities; using FluentAssertions; +using Microsoft.Extensions.Configuration; using Moq; using Xunit; @@ -11,17 +13,26 @@ namespace AutoStack.Application.Tests.Features.Auth.Commands.Register; public class RegisterCommandHandlerTests : CommandHandlerTestBase { private readonly Mock _mockPasswordHasher; + private readonly Mock _mockEmailService; + private readonly Mock _mockConfiguration; private readonly RegisterCommandHandler _handler; public RegisterCommandHandlerTests() { _mockPasswordHasher = new Mock(); + _mockEmailService = new Mock(); + _mockConfiguration = new Mock(); + + _mockConfiguration.Setup(c => c.GetSection("EmailVerification:ExpiryMinutes").Value) + .Returns("15"); _handler = new RegisterCommandHandler( MockUserRepository.Object, _mockPasswordHasher.Object, MockUnitOfWork.Object, - MockAuditLog.Object + MockAuditLog.Object, + _mockEmailService.Object, + _mockConfiguration.Object ); } @@ -359,7 +370,7 @@ public async Task Handle_ShouldSaveChanges() await _handler.Handle(command, CancellationToken.None); - MockUnitOfWork.Verify(u => u.SaveChangesAsync(It.IsAny()), Times.Once); + MockUnitOfWork.Verify(u => u.SaveChangesAsync(It.IsAny()), Times.Exactly(2)); } [Fact] diff --git a/AutoStack.Application.Tests/Features/Auth/Commands/ResendVerificationEmail/ResendVerificationEmailCommandHandlerTests.cs b/AutoStack.Application.Tests/Features/Auth/Commands/ResendVerificationEmail/ResendVerificationEmailCommandHandlerTests.cs new file mode 100644 index 0000000..6a0db99 --- /dev/null +++ b/AutoStack.Application.Tests/Features/Auth/Commands/ResendVerificationEmail/ResendVerificationEmailCommandHandlerTests.cs @@ -0,0 +1,193 @@ +using AutoStack.Application.Common.Interfaces; +using AutoStack.Application.Features.Auth.Commands.ResendVerificationEmail; +using AutoStack.Application.Tests.Common; +using AutoStack.Domain.Entities; +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Moq; +using Xunit; + +namespace AutoStack.Application.Tests.Features.Auth.Commands.ResendVerificationEmail; + +public class ResendVerificationEmailCommandHandlerTests : CommandHandlerTestBase +{ + private readonly Mock _mockEmailService; + private readonly Mock _mockConfiguration; + private readonly ResendVerificationEmailCommandHandler _handler; + + public ResendVerificationEmailCommandHandlerTests() + { + _mockEmailService = new Mock(); + _mockConfiguration = new Mock(); + + _mockConfiguration.Setup(c => c.GetSection("EmailVerification:ExpiryMinutes").Value) + .Returns("15"); + + _handler = new ResendVerificationEmailCommandHandler( + MockUserRepository.Object, + MockUnitOfWork.Object, + _mockEmailService.Object, + MockAuditLog.Object, + _mockConfiguration.Object + ); + } + + [Fact] + public async Task Handle_WithValidUser_ShouldGenerateAndSendCode() + { + var userId = Guid.NewGuid(); + var user = User.CreateUser("test@example.com", "testuser"); + + MockUserRepository.Setup(r => r.GetByIdAsync(userId, It.IsAny())) + .ReturnsAsync(user); + + MockUserRepository.Setup(r => r.UpdateAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + MockUnitOfWork.Setup(u => u.SaveChangesAsync(It.IsAny())) + .ReturnsAsync(1); + + _mockEmailService.Setup(e => e.SendEmailAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + var command = new ResendVerificationEmailCommand(userId); + var result = await _handler.Handle(command, CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + user.EmailVerificationCode.Should().NotBeNullOrEmpty(); + user.EmailVerificationCode.Should().HaveLength(6); + user.EmailVerificationCodeExpiry.Should().BeAfter(DateTime.UtcNow); + } + + [Fact] + public async Task Handle_WithNonExistentUser_ShouldReturnFailure() + { + var userId = Guid.NewGuid(); + + MockUserRepository.Setup(r => r.GetByIdAsync(userId, It.IsAny())) + .ReturnsAsync((User?)null); + + var command = new ResendVerificationEmailCommand(userId); + var result = await _handler.Handle(command, CancellationToken.None); + + result.IsSuccess.Should().BeFalse(); + result.Message.Should().Be("User not found"); + } + + [Fact] + public async Task Handle_WithAlreadyVerifiedEmail_ShouldReturnFailure() + { + var userId = Guid.NewGuid(); + var user = User.CreateUser("test@example.com", "testuser"); + user.SetEmailVerificationCode("123456", DateTime.UtcNow.AddMinutes(15)); + user.VerifyEmail(); + + MockUserRepository.Setup(r => r.GetByIdAsync(userId, It.IsAny())) + .ReturnsAsync(user); + + var command = new ResendVerificationEmailCommand(userId); + var result = await _handler.Handle(command, CancellationToken.None); + + result.IsSuccess.Should().BeFalse(); + result.Message.Should().Be("Email is already verified"); + } + + [Fact] + public async Task Handle_WithValidUser_ShouldSendEmail() + { + var userId = Guid.NewGuid(); + var user = User.CreateUser("test@example.com", "testuser"); + + MockUserRepository.Setup(r => r.GetByIdAsync(userId, It.IsAny())) + .ReturnsAsync(user); + + MockUserRepository.Setup(r => r.UpdateAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + MockUnitOfWork.Setup(u => u.SaveChangesAsync(It.IsAny())) + .ReturnsAsync(1); + + _mockEmailService.Setup(e => e.SendEmailAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + var command = new ResendVerificationEmailCommand(userId); + await _handler.Handle(command, CancellationToken.None); + + _mockEmailService.Verify(e => e.SendEmailAsync( + "test@example.com", + "Verify Your Email - AutoStack", + It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WithValidUser_ShouldSaveChanges() + { + var userId = Guid.NewGuid(); + var user = User.CreateUser("test@example.com", "testuser"); + + MockUserRepository.Setup(r => r.GetByIdAsync(userId, It.IsAny())) + .ReturnsAsync(user); + + MockUserRepository.Setup(r => r.UpdateAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + MockUnitOfWork.Setup(u => u.SaveChangesAsync(It.IsAny())) + .ReturnsAsync(1); + + _mockEmailService.Setup(e => e.SendEmailAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + var command = new ResendVerificationEmailCommand(userId); + await _handler.Handle(command, CancellationToken.None); + + MockUserRepository.Verify(r => r.UpdateAsync(user, It.IsAny()), Times.Once); + MockUnitOfWork.Verify(u => u.SaveChangesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WithValidUser_ShouldUseConfiguredExpiry() + { + var userId = Guid.NewGuid(); + var user = User.CreateUser("test@example.com", "testuser"); + + _mockConfiguration.Setup(c => c.GetSection("EmailVerification:ExpiryMinutes").Value) + .Returns("30"); + + var handler = new ResendVerificationEmailCommandHandler( + MockUserRepository.Object, + MockUnitOfWork.Object, + _mockEmailService.Object, + MockAuditLog.Object, + _mockConfiguration.Object + ); + + MockUserRepository.Setup(r => r.GetByIdAsync(userId, It.IsAny())) + .ReturnsAsync(user); + + MockUserRepository.Setup(r => r.UpdateAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + MockUnitOfWork.Setup(u => u.SaveChangesAsync(It.IsAny())) + .ReturnsAsync(1); + + _mockEmailService.Setup(e => e.SendEmailAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + var command = new ResendVerificationEmailCommand(userId); + await handler.Handle(command, CancellationToken.None); + + user.EmailVerificationCodeExpiry.Should().BeCloseTo(DateTime.UtcNow.AddMinutes(30), TimeSpan.FromSeconds(5)); + } +} diff --git a/AutoStack.Application.Tests/Features/Auth/Commands/ResendVerificationEmail/ResendVerificationEmailCommandValidatorTests.cs b/AutoStack.Application.Tests/Features/Auth/Commands/ResendVerificationEmail/ResendVerificationEmailCommandValidatorTests.cs new file mode 100644 index 0000000..936034e --- /dev/null +++ b/AutoStack.Application.Tests/Features/Auth/Commands/ResendVerificationEmail/ResendVerificationEmailCommandValidatorTests.cs @@ -0,0 +1,36 @@ +using AutoStack.Application.Features.Auth.Commands.ResendVerificationEmail; +using FluentAssertions; +using Xunit; + +namespace AutoStack.Application.Tests.Features.Auth.Commands.ResendVerificationEmail; + +public class ResendVerificationEmailCommandValidatorTests +{ + private readonly ResendVerificationEmailCommandValidator _validator; + + public ResendVerificationEmailCommandValidatorTests() + { + _validator = new ResendVerificationEmailCommandValidator(); + } + + [Fact] + public void Validate_WithValidCommand_ShouldNotHaveErrors() + { + var command = new ResendVerificationEmailCommand(Guid.NewGuid()); + + var result = _validator.Validate(command); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void Validate_WithEmptyUserId_ShouldHaveError() + { + var command = new ResendVerificationEmailCommand(Guid.Empty); + + var result = _validator.Validate(command); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "UserId" && e.ErrorMessage == "User ID is required"); + } +} diff --git a/AutoStack.Application.Tests/Features/Auth/Commands/VerifyEmail/VerifyEmailCommandHandlerTests.cs b/AutoStack.Application.Tests/Features/Auth/Commands/VerifyEmail/VerifyEmailCommandHandlerTests.cs new file mode 100644 index 0000000..7ff50ed --- /dev/null +++ b/AutoStack.Application.Tests/Features/Auth/Commands/VerifyEmail/VerifyEmailCommandHandlerTests.cs @@ -0,0 +1,155 @@ +using AutoStack.Application.Features.Auth.Commands.VerifyEmail; +using AutoStack.Application.Tests.Common; +using AutoStack.Domain.Entities; +using FluentAssertions; +using Moq; +using Xunit; + +namespace AutoStack.Application.Tests.Features.Auth.Commands.VerifyEmail; + +public class VerifyEmailCommandHandlerTests : CommandHandlerTestBase +{ + private readonly VerifyEmailCommandHandler _handler; + + public VerifyEmailCommandHandlerTests() + { + _handler = new VerifyEmailCommandHandler( + MockUserRepository.Object, + MockUnitOfWork.Object, + MockAuditLog.Object + ); + } + + [Fact] + public async Task Handle_WithValidCode_ShouldVerifyEmail() + { + var userId = Guid.NewGuid(); + var code = "123456"; + var user = User.CreateUser("test@example.com", "testuser"); + user.SetEmailVerificationCode(code, DateTime.UtcNow.AddMinutes(15)); + + MockUserRepository.Setup(r => r.GetByIdAsync(userId, It.IsAny())) + .ReturnsAsync(user); + + MockUserRepository.Setup(r => r.UpdateAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + MockUnitOfWork.Setup(u => u.SaveChangesAsync(It.IsAny())) + .ReturnsAsync(1); + + var command = new VerifyEmailCommand(userId, code); + var result = await _handler.Handle(command, CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + user.EmailVerified.Should().BeTrue(); + user.EmailVerificationCode.Should().BeNull(); + user.EmailVerifiedAt.Should().NotBeNull(); + } + + [Fact] + public async Task Handle_WithNonExistentUser_ShouldReturnFailure() + { + var userId = Guid.NewGuid(); + + MockUserRepository.Setup(r => r.GetByIdAsync(userId, It.IsAny())) + .ReturnsAsync((User?)null); + + var command = new VerifyEmailCommand(userId, "123456"); + var result = await _handler.Handle(command, CancellationToken.None); + + result.IsSuccess.Should().BeFalse(); + result.Message.Should().Be("User not found"); + } + + [Fact] + public async Task Handle_WithAlreadyVerifiedEmail_ShouldReturnFailure() + { + var userId = Guid.NewGuid(); + var user = User.CreateUser("test@example.com", "testuser"); + user.SetEmailVerificationCode("123456", DateTime.UtcNow.AddMinutes(15)); + user.VerifyEmail(); + + MockUserRepository.Setup(r => r.GetByIdAsync(userId, It.IsAny())) + .ReturnsAsync(user); + + var command = new VerifyEmailCommand(userId, "123456"); + var result = await _handler.Handle(command, CancellationToken.None); + + result.IsSuccess.Should().BeFalse(); + result.Message.Should().Be("Email is already verified"); + } + + [Fact] + public async Task Handle_WithNoVerificationCode_ShouldReturnFailure() + { + var userId = Guid.NewGuid(); + var user = User.CreateUser("test@example.com", "testuser"); + + MockUserRepository.Setup(r => r.GetByIdAsync(userId, It.IsAny())) + .ReturnsAsync(user); + + var command = new VerifyEmailCommand(userId, "123456"); + var result = await _handler.Handle(command, CancellationToken.None); + + result.IsSuccess.Should().BeFalse(); + result.Message.Should().Be("No verification code found. Please request a new code."); + } + + [Fact] + public async Task Handle_WithExpiredCode_ShouldReturnFailure() + { + var userId = Guid.NewGuid(); + var user = User.CreateUser("test@example.com", "testuser"); + user.SetEmailVerificationCode("123456", DateTime.UtcNow.AddMinutes(-1)); // Expired + + MockUserRepository.Setup(r => r.GetByIdAsync(userId, It.IsAny())) + .ReturnsAsync(user); + + var command = new VerifyEmailCommand(userId, "123456"); + var result = await _handler.Handle(command, CancellationToken.None); + + result.IsSuccess.Should().BeFalse(); + result.Message.Should().Be("Verification code has expired. Please request a new code."); + } + + [Fact] + public async Task Handle_WithInvalidCode_ShouldReturnFailure() + { + var userId = Guid.NewGuid(); + var user = User.CreateUser("test@example.com", "testuser"); + user.SetEmailVerificationCode("123456", DateTime.UtcNow.AddMinutes(15)); + + MockUserRepository.Setup(r => r.GetByIdAsync(userId, It.IsAny())) + .ReturnsAsync(user); + + var command = new VerifyEmailCommand(userId, "654321"); // Wrong code + var result = await _handler.Handle(command, CancellationToken.None); + + result.IsSuccess.Should().BeFalse(); + result.Message.Should().Be("Invalid verification code"); + } + + [Fact] + public async Task Handle_WithValidCode_ShouldSaveChanges() + { + var userId = Guid.NewGuid(); + var code = "123456"; + var user = User.CreateUser("test@example.com", "testuser"); + user.SetEmailVerificationCode(code, DateTime.UtcNow.AddMinutes(15)); + + MockUserRepository.Setup(r => r.GetByIdAsync(userId, It.IsAny())) + .ReturnsAsync(user); + + MockUserRepository.Setup(r => r.UpdateAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + MockUnitOfWork.Setup(u => u.SaveChangesAsync(It.IsAny())) + .ReturnsAsync(1); + + var command = new VerifyEmailCommand(userId, code); + await _handler.Handle(command, CancellationToken.None); + + MockUserRepository.Verify(r => r.UpdateAsync(user, It.IsAny()), Times.Once); + MockUnitOfWork.Verify(u => u.SaveChangesAsync(It.IsAny()), Times.Once); + } +} diff --git a/AutoStack.Application.Tests/Features/Auth/Commands/VerifyEmail/VerifyEmailCommandValidatorTests.cs b/AutoStack.Application.Tests/Features/Auth/Commands/VerifyEmail/VerifyEmailCommandValidatorTests.cs new file mode 100644 index 0000000..f751483 --- /dev/null +++ b/AutoStack.Application.Tests/Features/Auth/Commands/VerifyEmail/VerifyEmailCommandValidatorTests.cs @@ -0,0 +1,91 @@ +using AutoStack.Application.Features.Auth.Commands.VerifyEmail; +using FluentAssertions; +using Xunit; + +namespace AutoStack.Application.Tests.Features.Auth.Commands.VerifyEmail; + +public class VerifyEmailCommandValidatorTests +{ + private readonly VerifyEmailCommandValidator _validator; + + public VerifyEmailCommandValidatorTests() + { + _validator = new VerifyEmailCommandValidator(); + } + + [Fact] + public void Validate_WithValidCommand_ShouldNotHaveErrors() + { + var command = new VerifyEmailCommand(Guid.NewGuid(), "123456"); + + var result = _validator.Validate(command); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void Validate_WithEmptyUserId_ShouldHaveError() + { + var command = new VerifyEmailCommand(Guid.Empty, "123456"); + + var result = _validator.Validate(command); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "UserId"); + } + + [Fact] + public void Validate_WithEmptyCode_ShouldHaveError() + { + var command = new VerifyEmailCommand(Guid.NewGuid(), string.Empty); + + var result = _validator.Validate(command); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "Code" && e.ErrorMessage == "Verification code is required"); + } + + [Fact] + public void Validate_WithCodeShorterThan6Digits_ShouldHaveError() + { + var command = new VerifyEmailCommand(Guid.NewGuid(), "12345"); + + var result = _validator.Validate(command); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "Code" && e.ErrorMessage == "Verification code must be 6 digits"); + } + + [Fact] + public void Validate_WithCodeLongerThan6Digits_ShouldHaveError() + { + var command = new VerifyEmailCommand(Guid.NewGuid(), "1234567"); + + var result = _validator.Validate(command); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "Code" && e.ErrorMessage == "Verification code must be 6 digits"); + } + + [Fact] + public void Validate_WithCodeContainingLetters_ShouldHaveError() + { + var command = new VerifyEmailCommand(Guid.NewGuid(), "12A456"); + + var result = _validator.Validate(command); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "Code" && e.ErrorMessage == "Verification code must contain only digits"); + } + + [Fact] + public void Validate_WithCodeContainingSpecialCharacters_ShouldHaveError() + { + var command = new VerifyEmailCommand(Guid.NewGuid(), "123-56"); + + var result = _validator.Validate(command); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "Code"); + } +} diff --git a/AutoStack.Application.Tests/Features/Auth/Queries/EmailVerificationStatus/GetEmailVerificationStatusQueryHandlerTests.cs b/AutoStack.Application.Tests/Features/Auth/Queries/EmailVerificationStatus/GetEmailVerificationStatusQueryHandlerTests.cs new file mode 100644 index 0000000..abfc82a --- /dev/null +++ b/AutoStack.Application.Tests/Features/Auth/Queries/EmailVerificationStatus/GetEmailVerificationStatusQueryHandlerTests.cs @@ -0,0 +1,119 @@ +using AutoStack.Application.Features.Auth.Queries.EmailVerificationStatus; +using AutoStack.Application.Tests.Common; +using AutoStack.Domain.Entities; +using FluentAssertions; +using Moq; +using Xunit; + +namespace AutoStack.Application.Tests.Features.Auth.Queries.EmailVerificationStatus; + +public class GetEmailVerificationStatusQueryHandlerTests : QueryHandlerTestBase +{ + private readonly GetEmailVerificationStatusQueryHandler _handler; + + public GetEmailVerificationStatusQueryHandlerTests() + { + _handler = new GetEmailVerificationStatusQueryHandler(MockUserRepository.Object); + } + + [Fact] + public async Task Handle_WithVerifiedUser_ShouldReturnVerifiedStatus() + { + var userId = Guid.NewGuid(); + var user = User.CreateUser("test@example.com", "testuser"); + user.SetEmailVerificationCode("123456", DateTime.UtcNow.AddMinutes(15)); + user.VerifyEmail(); + + MockUserRepository.Setup(r => r.GetByIdAsync(userId, It.IsAny())) + .ReturnsAsync(user); + + var query = new GetEmailVerificationStatusQuery(userId); + var result = await _handler.Handle(query, CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBeNull(); + result.Value.IsVerified.Should().BeTrue(); + result.Value.VerifiedAt.Should().NotBeNull(); + result.Value.HasPendingCode.Should().BeFalse(); + result.Value.CodeExpiresAt.Should().BeNull(); + } + + [Fact] + public async Task Handle_WithUnverifiedUserWithValidCode_ShouldReturnCorrectStatus() + { + var userId = Guid.NewGuid(); + var user = User.CreateUser("test@example.com", "testuser"); + var expiryTime = DateTime.UtcNow.AddMinutes(15); + user.SetEmailVerificationCode("123456", expiryTime); + + MockUserRepository.Setup(r => r.GetByIdAsync(userId, It.IsAny())) + .ReturnsAsync(user); + + var query = new GetEmailVerificationStatusQuery(userId); + var result = await _handler.Handle(query, CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBeNull(); + result.Value.IsVerified.Should().BeFalse(); + result.Value.VerifiedAt.Should().BeNull(); + result.Value.HasPendingCode.Should().BeTrue(); + result.Value.CodeExpiresAt.Should().BeCloseTo(expiryTime, TimeSpan.FromSeconds(1)); + } + + [Fact] + public async Task Handle_WithUnverifiedUserWithExpiredCode_ShouldReturnCorrectStatus() + { + var userId = Guid.NewGuid(); + var user = User.CreateUser("test@example.com", "testuser"); + var expiryTime = DateTime.UtcNow.AddMinutes(-5); // Expired + user.SetEmailVerificationCode("123456", expiryTime); + + MockUserRepository.Setup(r => r.GetByIdAsync(userId, It.IsAny())) + .ReturnsAsync(user); + + var query = new GetEmailVerificationStatusQuery(userId); + var result = await _handler.Handle(query, CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBeNull(); + result.Value.IsVerified.Should().BeFalse(); + result.Value.VerifiedAt.Should().BeNull(); + result.Value.HasPendingCode.Should().BeFalse(); + result.Value.CodeExpiresAt.Should().BeCloseTo(expiryTime, TimeSpan.FromSeconds(1)); + } + + [Fact] + public async Task Handle_WithUnverifiedUserWithNoCode_ShouldReturnCorrectStatus() + { + var userId = Guid.NewGuid(); + var user = User.CreateUser("test@example.com", "testuser"); + + MockUserRepository.Setup(r => r.GetByIdAsync(userId, It.IsAny())) + .ReturnsAsync(user); + + var query = new GetEmailVerificationStatusQuery(userId); + var result = await _handler.Handle(query, CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBeNull(); + result.Value.IsVerified.Should().BeFalse(); + result.Value.VerifiedAt.Should().BeNull(); + result.Value.HasPendingCode.Should().BeFalse(); + result.Value.CodeExpiresAt.Should().BeNull(); + } + + [Fact] + public async Task Handle_WithNonExistentUser_ShouldReturnFailure() + { + var userId = Guid.NewGuid(); + + MockUserRepository.Setup(r => r.GetByIdAsync(userId, It.IsAny())) + .ReturnsAsync((User?)null); + + var query = new GetEmailVerificationStatusQuery(userId); + var result = await _handler.Handle(query, CancellationToken.None); + + result.IsSuccess.Should().BeFalse(); + result.Message.Should().Be("User not found"); + } +} diff --git a/AutoStack.Application/DTOs/Users/UserResponse.cs b/AutoStack.Application/DTOs/Users/UserResponse.cs index c0c9d3c..520fffb 100644 --- a/AutoStack.Application/DTOs/Users/UserResponse.cs +++ b/AutoStack.Application/DTOs/Users/UserResponse.cs @@ -6,9 +6,12 @@ namespace AutoStack.Application.DTOs.Users; /// The unique identifier of the user /// The email address of the user /// The username of the user +/// The avatar URL of the user +/// Whether the user's email is verified public record UserResponse( Guid Id, string Email, string Username, - string AvatarUrl + string AvatarUrl, + bool EmailVerified ); diff --git a/AutoStack.Application/Features/Auth/Commands/Register/RegisterCommandHandler.cs b/AutoStack.Application/Features/Auth/Commands/Register/RegisterCommandHandler.cs index 4accaea..84b17e4 100644 --- a/AutoStack.Application/Features/Auth/Commands/Register/RegisterCommandHandler.cs +++ b/AutoStack.Application/Features/Auth/Commands/Register/RegisterCommandHandler.cs @@ -1,10 +1,12 @@ -using AutoStack.Application.Common.Interfaces; +using System.Security.Cryptography; +using AutoStack.Application.Common.Interfaces; using AutoStack.Application.Common.Interfaces.Auth; using AutoStack.Application.Common.Interfaces.Commands; using AutoStack.Application.Common.Models; using AutoStack.Domain.Entities; using AutoStack.Domain.Enums; using AutoStack.Domain.Repositories; +using Microsoft.Extensions.Configuration; namespace AutoStack.Application.Features.Auth.Commands.Register; @@ -17,13 +19,23 @@ public class RegisterCommandHandler : ICommandHandler private readonly IPasswordHasher _passwordHasher; private readonly IUnitOfWork _unitOfWork; private readonly IAuditLogService _auditLogService; - - public RegisterCommandHandler(IUserRepository userRepository, IPasswordHasher passwordHasher, IUnitOfWork unitOfWork, IAuditLogService auditLogService) + private readonly IEmailService _emailService; + private readonly IConfiguration _configuration; + + public RegisterCommandHandler( + IUserRepository userRepository, + IPasswordHasher passwordHasher, + IUnitOfWork unitOfWork, + IAuditLogService auditLogService, + IEmailService emailService, + IConfiguration configuration) { _userRepository = userRepository; _passwordHasher = passwordHasher; _unitOfWork = unitOfWork; _auditLogService = auditLogService; + _emailService = emailService; + _configuration = configuration; } /// @@ -36,7 +48,6 @@ public async Task> Handle(RegisterCommand request, CancellationToke { if (await _userRepository.EmailExists(request.Email.ToLower(), cancellationToken)) { - // Log failed registration attempt - email exists try { await _auditLogService.LogAsync(new AuditLogRequest @@ -63,7 +74,6 @@ await _auditLogService.LogAsync(new AuditLogRequest if (await _userRepository.UsernameExists(request.Username.ToLower(), cancellationToken)) { - // Log failed registration attempt - username exists try { await _auditLogService.LogAsync(new AuditLogRequest @@ -101,7 +111,16 @@ await _auditLogService.LogAsync(new AuditLogRequest await _userRepository.AddAsync(user, cancellationToken); await _unitOfWork.SaveChangesAsync(cancellationToken); - // Log successful registration + // Generate and send verification email + var verificationCode = RandomNumberGenerator.GetInt32(100000, 1000000).ToString(); + var expiryMinutes = _configuration.GetValue("EmailVerification:ExpiryMinutes", 15); + + user.SetEmailVerificationCode(verificationCode, DateTime.UtcNow.AddMinutes(expiryMinutes)); + await _userRepository.UpdateAsync(user, cancellationToken); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + await SendVerificationEmail(user.Email, user.Username, verificationCode, expiryMinutes); + try { await _auditLogService.LogAsync(new AuditLogRequest @@ -127,6 +146,78 @@ await _auditLogService.LogAsync(new AuditLogRequest return Result.Success(true); } + private async Task SendVerificationEmail(string email, string username, string code, int expiryMinutes) + { + await _emailService.SendEmailAsync( + email, + "Verify Your Email - AutoStack", + GenerateEmailHtml(username, code, expiryMinutes) + ); + } + + private static string GenerateEmailHtml(string username, string code, int expiryMinutes) + { + return $""" + + + + + + + + + + + +
+ + + + + + + + + + +
+

Verify Your Email

+
+

+ Hi {username}, +

+

+ Thank you for creating an account with AutoStack! Please use the following verification code to verify your email address: +

+ + + + + +
+
+ {code} +
+
+ +

+ This code will expire in {expiryMinutes} minutes. +

+ +

+ If you didn't create an account with AutoStack, you can safely ignore this email. +

+
+

+ This email was sent by AutoStack. If you have questions, please contact support. +

+
+
+ + + """; + } + private static string MaskEmail(string email) { if (string.IsNullOrWhiteSpace(email)) return "null"; diff --git a/AutoStack.Application/Features/Auth/Commands/ResendVerificationEmail/ResendVerificationEmailCommand.cs b/AutoStack.Application/Features/Auth/Commands/ResendVerificationEmail/ResendVerificationEmailCommand.cs new file mode 100644 index 0000000..5e20891 --- /dev/null +++ b/AutoStack.Application/Features/Auth/Commands/ResendVerificationEmail/ResendVerificationEmailCommand.cs @@ -0,0 +1,5 @@ +using AutoStack.Application.Common.Interfaces.Commands; + +namespace AutoStack.Application.Features.Auth.Commands.ResendVerificationEmail; + +public record ResendVerificationEmailCommand(Guid UserId) : ICommand; diff --git a/AutoStack.Application/Features/Auth/Commands/ResendVerificationEmail/ResendVerificationEmailCommandHandler.cs b/AutoStack.Application/Features/Auth/Commands/ResendVerificationEmail/ResendVerificationEmailCommandHandler.cs new file mode 100644 index 0000000..b042bdc --- /dev/null +++ b/AutoStack.Application/Features/Auth/Commands/ResendVerificationEmail/ResendVerificationEmailCommandHandler.cs @@ -0,0 +1,149 @@ +using System.Security.Cryptography; +using AutoStack.Application.Common.Interfaces; +using AutoStack.Application.Common.Interfaces.Commands; +using AutoStack.Application.Common.Models; +using AutoStack.Domain.Enums; +using AutoStack.Domain.Repositories; +using Microsoft.Extensions.Configuration; + +namespace AutoStack.Application.Features.Auth.Commands.ResendVerificationEmail; + +public class ResendVerificationEmailCommandHandler : ICommandHandler +{ + private readonly IUserRepository _userRepository; + private readonly IUnitOfWork _unitOfWork; + private readonly IEmailService _emailService; + private readonly IAuditLogService _auditLogService; + private readonly IConfiguration _configuration; + + public ResendVerificationEmailCommandHandler( + IUserRepository userRepository, + IUnitOfWork unitOfWork, + IEmailService emailService, + IAuditLogService auditLogService, + IConfiguration configuration) + { + _userRepository = userRepository; + _unitOfWork = unitOfWork; + _emailService = emailService; + _auditLogService = auditLogService; + _configuration = configuration; + } + + public async Task> Handle(ResendVerificationEmailCommand request, CancellationToken cancellationToken) + { + var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken); + + if (user == null) + { + return Result.Failure("User not found"); + } + + if (user.EmailVerified) + { + return Result.Failure("Email is already verified"); + } + + // Generate 6-digit code + var code = RandomNumberGenerator.GetInt32(100000, 1000000).ToString(); + var expiryMinutes = _configuration.GetValue("EmailVerification:ExpiryMinutes", 15); + + user.SetEmailVerificationCode(code, DateTime.UtcNow.AddMinutes(expiryMinutes)); + await _userRepository.UpdateAsync(user, cancellationToken); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + // Send email + await SendVerificationEmail(user.Email, user.Username, code, expiryMinutes); + + // Log + try + { + await _auditLogService.LogAsync(new AuditLogRequest + { + Level = LogLevel.Information, + Category = LogCategory.Authentication, + Message = "Verification email resent", + UserIdOverride = user.Id, + UsernameOverride = user.Username + }, cancellationToken); + } + catch + { + // Ignore logging failures + } + + return Result.Success(true); + } + + private async Task SendVerificationEmail(string email, string username, string code, int expiryMinutes) + { + await _emailService.SendEmailAsync( + email, + "Verify Your Email - AutoStack", + GenerateEmailHtml(username, code, expiryMinutes) + ); + } + + private static string GenerateEmailHtml(string username, string code, int expiryMinutes) + { + return $""" + + + + + + + + + + + +
+ + + + + + + + + + +
+

Verify Your Email

+
+

+ Hi {username}, +

+

+ Please use the following verification code to verify your email address: +

+ + + + + +
+
+ {code} +
+
+ +

+ This code will expire in {expiryMinutes} minutes. +

+ +

+ If you didn't create an account with AutoStack, you can safely ignore this email. +

+
+

+ This email was sent by AutoStack. If you have questions, please contact support. +

+
+
+ + + """; + } +} diff --git a/AutoStack.Application/Features/Auth/Commands/ResendVerificationEmail/ResendVerificationEmailCommandValidator.cs b/AutoStack.Application/Features/Auth/Commands/ResendVerificationEmail/ResendVerificationEmailCommandValidator.cs new file mode 100644 index 0000000..8be0c2f --- /dev/null +++ b/AutoStack.Application/Features/Auth/Commands/ResendVerificationEmail/ResendVerificationEmailCommandValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace AutoStack.Application.Features.Auth.Commands.ResendVerificationEmail; + +public class ResendVerificationEmailCommandValidator : AbstractValidator +{ + public ResendVerificationEmailCommandValidator() + { + RuleFor(x => x.UserId) + .NotEmpty().WithMessage("User ID is required"); + } +} diff --git a/AutoStack.Application/Features/Auth/Commands/VerifyEmail/VerifyEmailCommand.cs b/AutoStack.Application/Features/Auth/Commands/VerifyEmail/VerifyEmailCommand.cs new file mode 100644 index 0000000..cb2bf47 --- /dev/null +++ b/AutoStack.Application/Features/Auth/Commands/VerifyEmail/VerifyEmailCommand.cs @@ -0,0 +1,5 @@ +using AutoStack.Application.Common.Interfaces.Commands; + +namespace AutoStack.Application.Features.Auth.Commands.VerifyEmail; + +public record VerifyEmailCommand(Guid UserId, string Code) : ICommand; diff --git a/AutoStack.Application/Features/Auth/Commands/VerifyEmail/VerifyEmailCommandHandler.cs b/AutoStack.Application/Features/Auth/Commands/VerifyEmail/VerifyEmailCommandHandler.cs new file mode 100644 index 0000000..a4d525d --- /dev/null +++ b/AutoStack.Application/Features/Auth/Commands/VerifyEmail/VerifyEmailCommandHandler.cs @@ -0,0 +1,93 @@ +using AutoStack.Application.Common.Interfaces; +using AutoStack.Application.Common.Interfaces.Commands; +using AutoStack.Application.Common.Models; +using AutoStack.Domain.Enums; +using AutoStack.Domain.Repositories; + +namespace AutoStack.Application.Features.Auth.Commands.VerifyEmail; + +public class VerifyEmailCommandHandler : ICommandHandler +{ + private readonly IUserRepository _userRepository; + private readonly IUnitOfWork _unitOfWork; + private readonly IAuditLogService _auditLogService; + + public VerifyEmailCommandHandler( + IUserRepository userRepository, + IUnitOfWork unitOfWork, + IAuditLogService auditLogService) + { + _userRepository = userRepository; + _unitOfWork = unitOfWork; + _auditLogService = auditLogService; + } + + public async Task> Handle(VerifyEmailCommand request, CancellationToken cancellationToken) + { + var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken); + + if (user == null) + { + return Result.Failure("User not found"); + } + + if (user.EmailVerified) + { + return Result.Failure("Email is already verified"); + } + + if (string.IsNullOrEmpty(user.EmailVerificationCode)) + { + return Result.Failure("No verification code found. Please request a new code."); + } + + if (user.EmailVerificationCodeExpiry < DateTime.UtcNow) + { + return Result.Failure("Verification code has expired. Please request a new code."); + } + + if (user.EmailVerificationCode != request.Code) + { + try + { + await _auditLogService.LogAsync(new AuditLogRequest + { + Level = LogLevel.Warning, + Category = LogCategory.Security, + Message = "Invalid email verification code attempt", + UserIdOverride = user.Id, + UsernameOverride = user.Username + }, cancellationToken); + } + catch + { + // Ignore logging failures + } + + return Result.Failure("Invalid verification code"); + } + + user.VerifyEmail(); + await _userRepository.UpdateAsync(user, cancellationToken); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + // Log successful verification + try + { + await _auditLogService.LogAsync(new AuditLogRequest + { + Level = LogLevel.Information, + Category = LogCategory.Authentication, + Message = "Email verified successfully", + UserIdOverride = user.Id, + UsernameOverride = user.Username + }, cancellationToken); + } + catch + { + // Ignore logging failures + } + + return Result.Success(true); + } +} diff --git a/AutoStack.Application/Features/Auth/Commands/VerifyEmail/VerifyEmailCommandValidator.cs b/AutoStack.Application/Features/Auth/Commands/VerifyEmail/VerifyEmailCommandValidator.cs new file mode 100644 index 0000000..9c6a982 --- /dev/null +++ b/AutoStack.Application/Features/Auth/Commands/VerifyEmail/VerifyEmailCommandValidator.cs @@ -0,0 +1,17 @@ +using FluentValidation; + +namespace AutoStack.Application.Features.Auth.Commands.VerifyEmail; + +public class VerifyEmailCommandValidator : AbstractValidator +{ + public VerifyEmailCommandValidator() + { + RuleFor(x => x.UserId) + .NotEmpty().WithMessage("User ID is required"); + + RuleFor(x => x.Code) + .NotEmpty().WithMessage("Verification code is required") + .Length(6).WithMessage("Verification code must be 6 digits") + .Matches("^[0-9]+$").WithMessage("Verification code must contain only digits"); + } +} diff --git a/AutoStack.Application/Features/Auth/Queries/EmailVerificationStatus/GetEmailVerificationStatusQuery.cs b/AutoStack.Application/Features/Auth/Queries/EmailVerificationStatus/GetEmailVerificationStatusQuery.cs new file mode 100644 index 0000000..4843a2e --- /dev/null +++ b/AutoStack.Application/Features/Auth/Queries/EmailVerificationStatus/GetEmailVerificationStatusQuery.cs @@ -0,0 +1,12 @@ +using AutoStack.Application.Common.Interfaces.Queries; + +namespace AutoStack.Application.Features.Auth.Queries.EmailVerificationStatus; + +public record GetEmailVerificationStatusQuery(Guid UserId) : IQuery; + +public record EmailVerificationStatusResponse( + bool IsVerified, + DateTime? VerifiedAt, + bool HasPendingCode, + DateTime? CodeExpiresAt +); diff --git a/AutoStack.Application/Features/Auth/Queries/EmailVerificationStatus/GetEmailVerificationStatusQueryHandler.cs b/AutoStack.Application/Features/Auth/Queries/EmailVerificationStatus/GetEmailVerificationStatusQueryHandler.cs new file mode 100644 index 0000000..4e56119 --- /dev/null +++ b/AutoStack.Application/Features/Auth/Queries/EmailVerificationStatus/GetEmailVerificationStatusQueryHandler.cs @@ -0,0 +1,37 @@ +using AutoStack.Application.Common.Interfaces.Queries; +using AutoStack.Application.Common.Models; +using AutoStack.Domain.Repositories; + +namespace AutoStack.Application.Features.Auth.Queries.EmailVerificationStatus; + +public class GetEmailVerificationStatusQueryHandler : IQueryHandler +{ + private readonly IUserRepository _userRepository; + + public GetEmailVerificationStatusQueryHandler(IUserRepository userRepository) + { + _userRepository = userRepository; + } + + public async Task> Handle( + GetEmailVerificationStatusQuery request, + CancellationToken cancellationToken) + { + var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken); + + if (user == null) + { + return Result.Failure("User not found"); + } + + var response = new EmailVerificationStatusResponse( + IsVerified: user.EmailVerified, + VerifiedAt: user.EmailVerifiedAt, + HasPendingCode: !string.IsNullOrEmpty(user.EmailVerificationCode) && + user.EmailVerificationCodeExpiry > DateTime.UtcNow, + CodeExpiresAt: user.EmailVerificationCodeExpiry + ); + + return Result.Success(response); + } +} diff --git a/AutoStack.Application/Features/Users/Commands/EditUser/EditUserCommandHandler.cs b/AutoStack.Application/Features/Users/Commands/EditUser/EditUserCommandHandler.cs index d9b84b7..a540cf2 100644 --- a/AutoStack.Application/Features/Users/Commands/EditUser/EditUserCommandHandler.cs +++ b/AutoStack.Application/Features/Users/Commands/EditUser/EditUserCommandHandler.cs @@ -123,7 +123,7 @@ await _auditLogService.LogAsync(new AuditLogRequest } } - var userResponse = new UserResponse(user.Id, user.Email, user.Username, user.AvatarUrl); + var userResponse = new UserResponse(user.Id, user.Email, user.Username, user.AvatarUrl, user.EmailVerified); return Result.Success(userResponse); } diff --git a/AutoStack.Application/Features/Users/Commands/UploadAvatar/UploadAvatarCommandHandler.cs b/AutoStack.Application/Features/Users/Commands/UploadAvatar/UploadAvatarCommandHandler.cs index a8e299f..eeb1b9d 100644 --- a/AutoStack.Application/Features/Users/Commands/UploadAvatar/UploadAvatarCommandHandler.cs +++ b/AutoStack.Application/Features/Users/Commands/UploadAvatar/UploadAvatarCommandHandler.cs @@ -91,7 +91,7 @@ await _auditLogService.LogAsync(new AuditLogRequest // Ignore logging failures } - var userResponse = new UserResponse(user.Id, user.Email, user.Username, user.AvatarUrl); + var userResponse = new UserResponse(user.Id, user.Email, user.Username, user.AvatarUrl, user.EmailVerified); return Result.Success(userResponse); } diff --git a/AutoStack.Application/Features/Users/Queries/GetUser/GetUserQueryHandler.cs b/AutoStack.Application/Features/Users/Queries/GetUser/GetUserQueryHandler.cs index 1d1c86b..b117a2d 100644 --- a/AutoStack.Application/Features/Users/Queries/GetUser/GetUserQueryHandler.cs +++ b/AutoStack.Application/Features/Users/Queries/GetUser/GetUserQueryHandler.cs @@ -35,7 +35,8 @@ public async Task> Handle(GetUserQuery request, Cancellatio user.Id, user.Email, user.Username, - user.AvatarUrl + user.AvatarUrl, + user.EmailVerified ); return Result.Success(response); diff --git a/AutoStack.Domain/AutoStack.Domain.csproj b/AutoStack.Domain/AutoStack.Domain.csproj index 04ecf2c..237d661 100644 --- a/AutoStack.Domain/AutoStack.Domain.csproj +++ b/AutoStack.Domain/AutoStack.Domain.csproj @@ -6,8 +6,4 @@ enable - - - - diff --git a/AutoStack.Domain/Entities/User.cs b/AutoStack.Domain/Entities/User.cs index 6f4a346..10b0cf6 100644 --- a/AutoStack.Domain/Entities/User.cs +++ b/AutoStack.Domain/Entities/User.cs @@ -44,9 +44,29 @@ public partial class User : Entity public DateTime? TwoFactorEnabledAt { get; private set; } public string? PasswordResetToken { get; private set; } - + public DateTime? PasswordResetTokenExpiry { get; private set; } - + + /// + /// Gets whether the user's email has been verified + /// + public bool EmailVerified { get; private set; } = false; + + /// + /// Gets the email verification code + /// + public string? EmailVerificationCode { get; private set; } + + /// + /// Gets when the email verification code expires + /// + public DateTime? EmailVerificationCodeExpiry { get; private set; } + + /// + /// Gets when the email was verified + /// + public DateTime? EmailVerifiedAt { get; private set; } + public User() {} @@ -214,4 +234,44 @@ public void ClearPasswordResetToken() PasswordResetToken = null; PasswordResetTokenExpiry = null; } + + /// + /// Sets the email verification code and expiry + /// + /// The 6-digit verification code + /// When the code expires + /// Thrown when code is null or empty + public void SetEmailVerificationCode(string code, DateTime expiry) + { + if (string.IsNullOrWhiteSpace(code)) + { + throw new ArgumentException("Verification code cannot be null or empty", nameof(code)); + } + + EmailVerificationCode = code; + EmailVerificationCodeExpiry = expiry; + UpdatedAt = DateTime.UtcNow; + } + + /// + /// Marks the email as verified and clears the verification code + /// + public void VerifyEmail() + { + EmailVerified = true; + EmailVerificationCode = null; + EmailVerificationCodeExpiry = null; + EmailVerifiedAt = DateTime.UtcNow; + UpdatedAt = DateTime.UtcNow; + } + + /// + /// Clears the email verification code without marking as verified + /// + public void ClearEmailVerificationCode() + { + EmailVerificationCode = null; + EmailVerificationCodeExpiry = null; + UpdatedAt = DateTime.UtcNow; + } } \ No newline at end of file diff --git a/AutoStack.Infrastructure.Tests/Security/Handlers/EmailVerifiedHandlerTests.cs b/AutoStack.Infrastructure.Tests/Security/Handlers/EmailVerifiedHandlerTests.cs new file mode 100644 index 0000000..4aa0b5b --- /dev/null +++ b/AutoStack.Infrastructure.Tests/Security/Handlers/EmailVerifiedHandlerTests.cs @@ -0,0 +1,152 @@ +using System.Security.Claims; +using AutoStack.Domain.Entities; +using AutoStack.Domain.Repositories; +using AutoStack.Infrastructure.Security.Handlers; +using AutoStack.Infrastructure.Security.Requirements; +using FluentAssertions; +using Microsoft.AspNetCore.Authorization; +using Moq; +using Xunit; + +namespace AutoStack.Infrastructure.Tests.Security.Handlers; + +public class EmailVerifiedHandlerTests +{ + private readonly Mock _mockUserRepository; + private readonly EmailVerifiedHandler _handler; + + public EmailVerifiedHandlerTests() + { + _mockUserRepository = new Mock(); + _handler = new EmailVerifiedHandler(_mockUserRepository.Object); + } + + [Fact] + public async Task HandleRequirementAsync_WithVerifiedUser_ShouldSucceed() + { + var userId = Guid.NewGuid(); + var user = User.CreateUser("test@example.com", "testuser"); + user.SetEmailVerificationCode("123456", DateTime.UtcNow.AddMinutes(15)); + user.VerifyEmail(); + + _mockUserRepository.Setup(r => r.GetByIdAsync(userId, default)) + .ReturnsAsync(user); + + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, userId.ToString()) + }; + + var identity = new ClaimsIdentity(claims); + var claimsPrincipal = new ClaimsPrincipal(identity); + + var requirement = new EmailVerifiedRequirement(); + var context = new AuthorizationHandlerContext( + new[] { requirement }, + claimsPrincipal, + null); + + await _handler.HandleAsync(context); + + context.HasSucceeded.Should().BeTrue(); + } + + [Fact] + public async Task HandleRequirementAsync_WithUnverifiedUser_ShouldFail() + { + var userId = Guid.NewGuid(); + var user = User.CreateUser("test@example.com", "testuser"); + + _mockUserRepository.Setup(r => r.GetByIdAsync(userId, default)) + .ReturnsAsync(user); + + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, userId.ToString()) + }; + + var identity = new ClaimsIdentity(claims); + var claimsPrincipal = new ClaimsPrincipal(identity); + + var requirement = new EmailVerifiedRequirement(); + var context = new AuthorizationHandlerContext( + new[] { requirement }, + claimsPrincipal, + null); + + await _handler.HandleAsync(context); + + context.HasSucceeded.Should().BeFalse(); + context.HasFailed.Should().BeTrue(); + } + + [Fact] + public async Task HandleRequirementAsync_WithNonExistentUser_ShouldFail() + { + var userId = Guid.NewGuid(); + + _mockUserRepository.Setup(r => r.GetByIdAsync(userId, default)) + .ReturnsAsync((User?)null); + + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, userId.ToString()) + }; + + var identity = new ClaimsIdentity(claims); + var claimsPrincipal = new ClaimsPrincipal(identity); + + var requirement = new EmailVerifiedRequirement(); + var context = new AuthorizationHandlerContext( + new[] { requirement }, + claimsPrincipal, + null); + + await _handler.HandleAsync(context); + + context.HasSucceeded.Should().BeFalse(); + context.HasFailed.Should().BeTrue(); + } + + [Fact] + public async Task HandleRequirementAsync_WithNoUserIdClaim_ShouldFail() + { + var claims = Array.Empty(); + var identity = new ClaimsIdentity(claims); + var claimsPrincipal = new ClaimsPrincipal(identity); + + var requirement = new EmailVerifiedRequirement(); + var context = new AuthorizationHandlerContext( + new[] { requirement }, + claimsPrincipal, + null); + + await _handler.HandleAsync(context); + + context.HasSucceeded.Should().BeFalse(); + context.HasFailed.Should().BeTrue(); + } + + [Fact] + public async Task HandleRequirementAsync_WithInvalidUserIdClaim_ShouldFail() + { + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, "not-a-guid") + }; + + var identity = new ClaimsIdentity(claims); + var claimsPrincipal = new ClaimsPrincipal(identity); + + var requirement = new EmailVerifiedRequirement(); + var context = new AuthorizationHandlerContext( + new[] { requirement }, + claimsPrincipal, + null); + + await _handler.HandleAsync(context); + + context.HasSucceeded.Should().BeFalse(); + context.HasFailed.Should().BeTrue(); + } +} diff --git a/AutoStack.Infrastructure/DependencyInjection.cs b/AutoStack.Infrastructure/DependencyInjection.cs index 8545b62..51ca8ce 100644 --- a/AutoStack.Infrastructure/DependencyInjection.cs +++ b/AutoStack.Infrastructure/DependencyInjection.cs @@ -7,9 +7,12 @@ using AutoStack.Infrastructure.Persistence; using AutoStack.Infrastructure.Repositories; using AutoStack.Infrastructure.Security; +using AutoStack.Infrastructure.Security.Handlers; using AutoStack.Infrastructure.Security.Models; +using AutoStack.Infrastructure.Security.Requirements; using AutoStack.Infrastructure.Services; using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.DependencyInjection; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; @@ -117,7 +120,11 @@ public static IServiceCollection AddAuthorizationService(this IServiceCollection if (jwtSettings == null) throw new InvalidOperationException("JwtSettings configuration is missing"); - services.AddAuthorization() + services.AddAuthorization(options => + { + options.AddPolicy("EmailVerified", policy => + policy.Requirements.Add(new EmailVerifiedRequirement())); + }) .AddAuthentication(x => { x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; @@ -139,7 +146,9 @@ public static IServiceCollection AddAuthorizationService(this IServiceCollection ClockSkew = TimeSpan.Zero }; }); - + + services.AddScoped(); + return services; } } \ No newline at end of file diff --git a/AutoStack.Infrastructure/Migrations/20251210214501_AddEmailVerification.Designer.cs b/AutoStack.Infrastructure/Migrations/20251210214501_AddEmailVerification.Designer.cs new file mode 100644 index 0000000..40722ce --- /dev/null +++ b/AutoStack.Infrastructure/Migrations/20251210214501_AddEmailVerification.Designer.cs @@ -0,0 +1,436 @@ +// +using System; +using AutoStack.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace AutoStack.Infrastructure.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20251210214501_AddEmailVerification")] + partial class AddEmailVerification + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AutoStack.Domain.Entities.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdditionalData") + .HasColumnType("jsonb"); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DurationMs") + .HasColumnType("bigint"); + + b.Property("Endpoint") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Exception") + .HasColumnType("text"); + + b.Property("HttpMethod") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("SanitizedRequestBody") + .HasColumnType("text"); + + b.Property("StatusCode") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Category"); + + b.HasIndex("CreatedAt") + .IsDescending(); + + b.HasIndex("UserId") + .HasFilter("\"UserId\" IS NOT NULL"); + + b.HasIndex("Level", "Category"); + + b.HasIndex("UserId", "CreatedAt") + .IsDescending(false, true) + .HasFilter("\"UserId\" IS NOT NULL"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("AutoStack.Domain.Entities.CliVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.HasKey("Id"); + + b.ToTable("CliVersions"); + }); + + modelBuilder.Entity("AutoStack.Domain.Entities.Package", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsVerified") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Link") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("IsVerified"); + + b.HasIndex("Link") + .IsUnique(); + + b.HasIndex("Name"); + + b.ToTable("Packages"); + }); + + modelBuilder.Entity("AutoStack.Domain.Entities.RecoveryCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CodeHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsUsed") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "IsUsed") + .HasFilter("\"IsUsed\" = false"); + + b.ToTable("RecoveryCodes"); + }); + + modelBuilder.Entity("AutoStack.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("int") + .HasComment("Epoch time that the refresh token expires at."); + + b.Property("Token") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("RefreshToken"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("AutoStack.Domain.Entities.Stack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Downloads") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.HasIndex("UserId"); + + b.ToTable("Stacks"); + }); + + modelBuilder.Entity("AutoStack.Domain.Entities.StackInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PackageId") + .HasColumnType("uuid"); + + b.Property("StackId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PackageId"); + + b.HasIndex("StackId"); + + b.HasIndex("StackId", "PackageId") + .IsUnique(); + + b.ToTable("StackInfos"); + }); + + modelBuilder.Entity("AutoStack.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarUrl") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EmailVerificationCode") + .HasMaxLength(6) + .HasColumnType("character varying(6)"); + + b.Property("EmailVerificationCodeExpiry") + .HasColumnType("timestamp with time zone"); + + b.Property("EmailVerified") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("EmailVerifiedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("PasswordResetToken") + .HasColumnType("text"); + + b.Property("PasswordResetTokenExpiry") + .HasColumnType("timestamp with time zone"); + + b.Property("TwoFactorEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("TwoFactorEnabledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TwoFactorSecretKey") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("AutoStack.Domain.Entities.Stack", b => + { + b.HasOne("AutoStack.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AutoStack.Domain.Entities.StackInfo", b => + { + b.HasOne("AutoStack.Domain.Entities.Package", "Package") + .WithMany("StackInfos") + .HasForeignKey("PackageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("AutoStack.Domain.Entities.Stack", "Stack") + .WithMany("Packages") + .HasForeignKey("StackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Package"); + + b.Navigation("Stack"); + }); + + modelBuilder.Entity("AutoStack.Domain.Entities.Package", b => + { + b.Navigation("StackInfos"); + }); + + modelBuilder.Entity("AutoStack.Domain.Entities.Stack", b => + { + b.Navigation("Packages"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/AutoStack.Infrastructure/Migrations/20251210214501_AddEmailVerification.cs b/AutoStack.Infrastructure/Migrations/20251210214501_AddEmailVerification.cs new file mode 100644 index 0000000..9af152b --- /dev/null +++ b/AutoStack.Infrastructure/Migrations/20251210214501_AddEmailVerification.cs @@ -0,0 +1,61 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace AutoStack.Infrastructure.Migrations +{ + /// + public partial class AddEmailVerification : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "EmailVerificationCode", + table: "Users", + type: "character varying(6)", + maxLength: 6, + nullable: true); + + migrationBuilder.AddColumn( + name: "EmailVerificationCodeExpiry", + table: "Users", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "EmailVerified", + table: "Users", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "EmailVerifiedAt", + table: "Users", + type: "timestamp with time zone", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "EmailVerificationCode", + table: "Users"); + + migrationBuilder.DropColumn( + name: "EmailVerificationCodeExpiry", + table: "Users"); + + migrationBuilder.DropColumn( + name: "EmailVerified", + table: "Users"); + + migrationBuilder.DropColumn( + name: "EmailVerifiedAt", + table: "Users"); + } + } +} diff --git a/AutoStack.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs b/AutoStack.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs index 8bd869a..caae152 100644 --- a/AutoStack.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/AutoStack.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs @@ -331,6 +331,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(100) .HasColumnType("character varying(100)"); + b.Property("EmailVerificationCode") + .HasMaxLength(6) + .HasColumnType("character varying(6)"); + + b.Property("EmailVerificationCodeExpiry") + .HasColumnType("timestamp with time zone"); + + b.Property("EmailVerified") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("EmailVerifiedAt") + .HasColumnType("timestamp with time zone"); + b.Property("PasswordHash") .IsRequired() .HasMaxLength(255) diff --git a/AutoStack.Infrastructure/Persistence/Configurations/UserConfiguration.cs b/AutoStack.Infrastructure/Persistence/Configurations/UserConfiguration.cs index c255c38..6a44844 100644 --- a/AutoStack.Infrastructure/Persistence/Configurations/UserConfiguration.cs +++ b/AutoStack.Infrastructure/Persistence/Configurations/UserConfiguration.cs @@ -26,9 +26,20 @@ public void Configure(EntityTypeBuilder builder) .HasMaxLength(512); builder.Property(x => x.TwoFactorEnabledAt); - + + builder.Property(x => x.EmailVerified) + .IsRequired() + .HasDefaultValue(false); + + builder.Property(x => x.EmailVerificationCode) + .HasMaxLength(6); + + builder.Property(x => x.EmailVerificationCodeExpiry); + + builder.Property(x => x.EmailVerifiedAt); + builder.HasIndex(x => x.Email).IsUnique(); builder.HasIndex(x => x.Username).IsUnique(); - + } } \ No newline at end of file diff --git a/AutoStack.Infrastructure/Security/Handlers/EmailVerifiedHandler.cs b/AutoStack.Infrastructure/Security/Handlers/EmailVerifiedHandler.cs new file mode 100644 index 0000000..11f0902 --- /dev/null +++ b/AutoStack.Infrastructure/Security/Handlers/EmailVerifiedHandler.cs @@ -0,0 +1,43 @@ +using System.Security.Claims; +using AutoStack.Domain.Repositories; +using AutoStack.Infrastructure.Security.Requirements; +using Microsoft.AspNetCore.Authorization; + +namespace AutoStack.Infrastructure.Security.Handlers; + +/// +/// Handles authorization for the EmailVerifiedRequirement +/// +public class EmailVerifiedHandler : AuthorizationHandler +{ + private readonly IUserRepository _userRepository; + + public EmailVerifiedHandler(IUserRepository userRepository) + { + _userRepository = userRepository; + } + + protected override async Task HandleRequirementAsync( + AuthorizationHandlerContext context, + EmailVerifiedRequirement requirement) + { + var userIdClaim = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + + if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) + { + context.Fail(); + return; + } + + var user = await _userRepository.GetByIdAsync(userId); + + if (user != null && user.EmailVerified) + { + context.Succeed(requirement); + } + else + { + context.Fail(); + } + } +} diff --git a/AutoStack.Infrastructure/Security/Requirements/EmailVerifiedRequirement.cs b/AutoStack.Infrastructure/Security/Requirements/EmailVerifiedRequirement.cs new file mode 100644 index 0000000..cd46618 --- /dev/null +++ b/AutoStack.Infrastructure/Security/Requirements/EmailVerifiedRequirement.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Authorization; + +namespace AutoStack.Infrastructure.Security.Requirements; + +/// +/// Authorization requirement that checks if the user's email is verified +/// +public class EmailVerifiedRequirement : IAuthorizationRequirement +{ +} diff --git a/AutoStack.Presentation.Tests/AutoStack.Presentation.Tests.csproj b/AutoStack.Presentation.Tests/AutoStack.Presentation.Tests.csproj deleted file mode 100644 index 6f8e3e7..0000000 --- a/AutoStack.Presentation.Tests/AutoStack.Presentation.Tests.csproj +++ /dev/null @@ -1,33 +0,0 @@ - - - - net10.0 - enable - enable - false - true - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - - - diff --git a/AutoStack.Presentation/Endpoints/EmailVerification/EmailVerificationEndpoints.cs b/AutoStack.Presentation/Endpoints/EmailVerification/EmailVerificationEndpoints.cs new file mode 100644 index 0000000..dd69308 --- /dev/null +++ b/AutoStack.Presentation/Endpoints/EmailVerification/EmailVerificationEndpoints.cs @@ -0,0 +1,144 @@ +using System.Security.Claims; +using AutoStack.Application.Features.Auth.Commands.ResendVerificationEmail; +using AutoStack.Application.Features.Auth.Commands.VerifyEmail; +using AutoStack.Application.Features.Auth.Queries.EmailVerificationStatus; +using MediatR; + +namespace AutoStack.Presentation.Endpoints.EmailVerification; + +public static class EmailVerificationEndpoints +{ + public static void MapEmailVerificationEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/email-verification") + .WithTags("Email Verification"); + + group.MapPost("/verify", VerifyEmail) + .WithName("VerifyEmail") + .WithSummary("Verify email with 6-digit code") + .RequireAuthorization() + .RequireRateLimiting("email-verify") + .Produces(200) + .Produces(400) + .Produces(401) + .Produces(429); + + group.MapPost("/resend", ResendVerificationEmail) + .WithName("ResendVerificationEmail") + .WithSummary("Resend verification email") + .RequireAuthorization() + .RequireRateLimiting("email-resend") + .Produces(200) + .Produces(400) + .Produces(401) + .Produces(429); + + group.MapGet("/status", GetVerificationStatus) + .WithName("GetEmailVerificationStatus") + .WithSummary("Get email verification status") + .RequireAuthorization() + .Produces(200) + .Produces(401); + } + + private static async Task VerifyEmail( + VerifyEmailRequest request, + HttpContext context, + IMediator mediator, + CancellationToken cancellationToken) + { + var userId = GetUserIdFromClaims(context); + if (userId == null) + { + return Results.Unauthorized(); + } + + var command = new VerifyEmailCommand(userId.Value, request.Code); + var result = await mediator.Send(command, cancellationToken); + + if (!result.IsSuccess) + { + return Results.BadRequest(new + { + success = false, + message = result.Message, + errors = result.ValidationErrors + }); + } + + return Results.Ok(new + { + success = true, + message = "Email verified successfully" + }); + } + + private static async Task ResendVerificationEmail( + HttpContext context, + IMediator mediator, + CancellationToken cancellationToken) + { + var userId = GetUserIdFromClaims(context); + if (userId == null) + { + return Results.Unauthorized(); + } + + var command = new ResendVerificationEmailCommand(userId.Value); + var result = await mediator.Send(command, cancellationToken); + + if (!result.IsSuccess) + { + return Results.BadRequest(new + { + success = false, + message = result.Message, + errors = result.ValidationErrors + }); + } + + return Results.Ok(new + { + success = true, + message = "Verification email sent" + }); + } + + private static async Task GetVerificationStatus( + HttpContext context, + IMediator mediator, + CancellationToken cancellationToken) + { + var userId = GetUserIdFromClaims(context); + if (userId == null) + { + return Results.Unauthorized(); + } + + var query = new GetEmailVerificationStatusQuery(userId.Value); + var result = await mediator.Send(query, cancellationToken); + + if (!result.IsSuccess) + { + return Results.BadRequest(new + { + success = false, + message = result.Message + }); + } + + return Results.Ok(new + { + success = true, + data = result.Value + }); + } + + private static Guid? GetUserIdFromClaims(HttpContext context) + { + var userIdClaim = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + return Guid.TryParse(userIdClaim, out var userId) ? userId : null; + } +} + +public record VerifyEmailRequest(string Code); diff --git a/AutoStack.Presentation/Endpoints/Stack/StackEndpoints.cs b/AutoStack.Presentation/Endpoints/Stack/StackEndpoints.cs index 1f82efa..fa13a5d 100644 --- a/AutoStack.Presentation/Endpoints/Stack/StackEndpoints.cs +++ b/AutoStack.Presentation/Endpoints/Stack/StackEndpoints.cs @@ -20,30 +20,30 @@ public static void MapStackEndpoints(this IEndpointRouteBuilder app) group.MapPost("/create", CreateStack) .WithName("CreateStack") .WithSummary("Create a new Stack") - .RequireAuthorization(); + .RequireAuthorization("EmailVerified"); group.MapDelete("/deletestack", DeleteStack) .WithName("DeleteStack") .WithSummary("Delete a stack") - .RequireAuthorization(); - + .RequireAuthorization("EmailVerified"); + group.MapGet("/getstacks", GetStacks) .WithName("GetStacks") .WithSummary("Get paginated stacks"); - + group.MapGet("/getstack", GetStack) .WithName("GetStack") .WithSummary("Get specific stack"); - + group.MapGet("/verifiedpackages", GetVerifiedPackages) .WithName("Packages") .WithSummary("Get packages") - .RequireAuthorization(); - + .RequireAuthorization("EmailVerified"); + group.MapGet("/mystacks", MyStacks) .WithName("MyStacks") .WithSummary("Get my stacks") - .RequireAuthorization(); + .RequireAuthorization("EmailVerified"); } private static async Task CreateStack( diff --git a/AutoStack.Presentation/Endpoints/TwoFactor/TwoFactorEndpoints.cs b/AutoStack.Presentation/Endpoints/TwoFactor/TwoFactorEndpoints.cs index 54f4838..3cde046 100644 --- a/AutoStack.Presentation/Endpoints/TwoFactor/TwoFactorEndpoints.cs +++ b/AutoStack.Presentation/Endpoints/TwoFactor/TwoFactorEndpoints.cs @@ -25,7 +25,7 @@ public static void MapTwoFactorEndpoints(this IEndpointRouteBuilder app) group.MapPost("/setup/begin", BeginSetup) .WithName("BeginTwoFactorSetup") .WithSummary("Begin 2FA setup - generates secret and QR code") - .RequireAuthorization() + .RequireAuthorization("EmailVerified") .Produces(200) .Produces(400) .Produces(401); @@ -33,7 +33,7 @@ public static void MapTwoFactorEndpoints(this IEndpointRouteBuilder app) group.MapPost("/setup/confirm", ConfirmSetup) .WithName("ConfirmTwoFactorSetup") .WithSummary("Confirm 2FA setup with TOTP code") - .RequireAuthorization() + .RequireAuthorization("EmailVerified") .Produces(200) .Produces(400) .Produces(401); @@ -41,7 +41,7 @@ public static void MapTwoFactorEndpoints(this IEndpointRouteBuilder app) group.MapPost("/disable", Disable) .WithName("DisableTwoFactor") .WithSummary("Disable 2FA") - .RequireAuthorization() + .RequireAuthorization("EmailVerified") .RequireRateLimiting("2fa-sensitive") .Produces(200) .Produces(400) @@ -51,7 +51,7 @@ public static void MapTwoFactorEndpoints(this IEndpointRouteBuilder app) group.MapPost("/recovery-codes/regenerate", RegenerateRecoveryCodes) .WithName("RegenerateRecoveryCodes") .WithSummary("Generate new recovery codes") - .RequireAuthorization() + .RequireAuthorization("EmailVerified") .RequireRateLimiting("2fa-sensitive") .Produces(200) .Produces(400) diff --git a/AutoStack.Presentation/Endpoints/User/UserEndpoints.cs b/AutoStack.Presentation/Endpoints/User/UserEndpoints.cs index c0a8548..fb87cc6 100644 --- a/AutoStack.Presentation/Endpoints/User/UserEndpoints.cs +++ b/AutoStack.Presentation/Endpoints/User/UserEndpoints.cs @@ -29,19 +29,19 @@ public static void MapUserEndpoints(this IEndpointRouteBuilder app) group.MapPatch("/edit", EditUser) .WithName("EditUser") .WithSummary("Edit User") - .RequireAuthorization(); + .RequireAuthorization("EmailVerified"); group.MapPost("/avatar", UploadAvatar) .WithName("UploadAvatar") .WithSummary("Upload user avatar") - .RequireAuthorization() + .RequireAuthorization("EmailVerified") .DisableAntiforgery() .Accepts("multipart/form-data"); - + group.MapGet("/deleteaccount", DeleteAccount) .WithName("DeleteAccount") .WithSummary("Delete account") - .RequireAuthorization(); + .RequireAuthorization("EmailVerified"); } private static async Task GetCurrentUser( diff --git a/AutoStack.Presentation/Extensions/RateLimitingExtensions.cs b/AutoStack.Presentation/Extensions/RateLimitingExtensions.cs index 225602b..c0905de 100644 --- a/AutoStack.Presentation/Extensions/RateLimitingExtensions.cs +++ b/AutoStack.Presentation/Extensions/RateLimitingExtensions.cs @@ -69,6 +69,24 @@ public static IServiceCollection AddRateLimiting(this IServiceCollection service options.QueueLimit = 0; }); + // Rate limiter for email verification attempts + options.AddFixedWindowLimiter("email-verify", options => + { + options.PermitLimit = 5; + options.Window = TimeSpan.FromMinutes(15); + options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; + options.QueueLimit = 0; + }); + + // Rate limiter for resending verification emails + options.AddFixedWindowLimiter("email-resend", options => + { + options.PermitLimit = 3; + options.Window = TimeSpan.FromHours(1); + options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; + options.QueueLimit = 0; + }); + // Rate limiter for sensitive 2FA operations (disable, regenerate codes) options.AddPolicy("2fa-sensitive", httpContext => RateLimitPartition.GetFixedWindowLimiter( diff --git a/AutoStack.Presentation/Program.cs b/AutoStack.Presentation/Program.cs index b049663..725df4a 100644 --- a/AutoStack.Presentation/Program.cs +++ b/AutoStack.Presentation/Program.cs @@ -3,6 +3,7 @@ using AutoStack.Infrastructure; using AutoStack.Presentation; using AutoStack.Presentation.Endpoints.Cli; +using AutoStack.Presentation.Endpoints.EmailVerification; using AutoStack.Presentation.Endpoints.Login; using AutoStack.Presentation.Endpoints.Stack; using AutoStack.Presentation.Endpoints.TwoFactor; @@ -125,5 +126,6 @@ app.MapTwoFactorEndpoints(); app.MapStackEndpoints(); app.MapCliEndpoints(); +app.MapEmailVerificationEndpoints(); await app.RunAsync(); \ No newline at end of file diff --git a/AutoStack.Presentation/appsettings.json b/AutoStack.Presentation/appsettings.json index 85c1a2d..ee00cd7 100644 --- a/AutoStack.Presentation/appsettings.json +++ b/AutoStack.Presentation/appsettings.json @@ -11,5 +11,8 @@ "KeepAliveTimeout": "00:02:10", "RequestHeadersTimeout": "00:00:30" } + }, + "EmailVerification": { + "ExpiryMinutes": 15 } } diff --git a/AutoStack.slnx b/AutoStack.slnx index 1c6345f..96bd750 100644 --- a/AutoStack.slnx +++ b/AutoStack.slnx @@ -6,5 +6,4 @@ -