diff --git a/LeaderboardBackend.Test/Consts.cs b/LeaderboardBackend.Test/Consts.cs index c85c7e4f..d418e9e1 100644 --- a/LeaderboardBackend.Test/Consts.cs +++ b/LeaderboardBackend.Test/Consts.cs @@ -5,4 +5,5 @@ internal static class Routes public const string LOGIN = "/login"; public const string REGISTER = "/account/register"; public const string RESEND_CONFIRMATION = "/account/confirm"; + public const string RECOVER_ACCOUNT = "/account/recover"; } diff --git a/LeaderboardBackend.Test/Features/Users/AccountRecoveryTests.cs b/LeaderboardBackend.Test/Features/Users/AccountRecoveryTests.cs new file mode 100644 index 00000000..e62cb4c5 --- /dev/null +++ b/LeaderboardBackend.Test/Features/Users/AccountRecoveryTests.cs @@ -0,0 +1,263 @@ +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Threading.Tasks; +using LeaderboardBackend.Models.Entities; +using LeaderboardBackend.Models.Requests; +using LeaderboardBackend.Services; +using LeaderboardBackend.Test.Fixtures; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using NodaTime; +using NodaTime.Testing; +using NUnit.Framework; + +namespace LeaderboardBackend.Test.Features.Users; + +[TestFixture] +public class AccountRecoveryTests : IntegrationTestsBase +{ + private IServiceScope _scope = null!; + + [SetUp] + public void Init() + { + _scope = _factory.Services.CreateScope(); + } + + [TearDown] + public void TearDown() + { + _factory.ResetDatabase(); + _scope.Dispose(); + } + + public async Task SendRecoveryEmail_MalformedMissingUsername() + { + Mock emailSenderMock = new(); + + HttpClient client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.AddScoped(_ => emailSenderMock.Object); + }); + }).CreateClient(); + + ApplicationContext context = _scope.ServiceProvider.GetRequiredService(); + + User user = new() + { + Email = "test@email.com", + Username = "username", + Password = "password", + Role = UserRole.Confirmed + }; + + await context.Users.AddAsync(user); + await context.SaveChangesAsync(); + + HttpResponseMessage res = await Client.PostAsJsonAsync( + Routes.RECOVER_ACCOUNT, + new + { + Email = user.Email + } + ); + + res.Should().HaveStatusCode(HttpStatusCode.BadRequest); + context.ChangeTracker.Clear(); + + AccountRecovery? recovery = await context.AccountRecoveries.FirstOrDefaultAsync( + ar => ar.UserId == user.Id + ); + + recovery.Should().BeNull(); + emailSenderMock.Verify( + m => m.EnqueueEmailAsync(user.Email, It.IsAny(), It.IsAny()), + Times.Never() + ); + } + + public async Task SendRecoveryEmail_MalformedMissingEmail() + { + Mock emailSenderMock = new(); + + HttpClient client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.AddScoped(_ => emailSenderMock.Object); + }); + }).CreateClient(); + + ApplicationContext context = _scope.ServiceProvider.GetRequiredService(); + + User user = new() + { + Email = "test@email.com", + Username = "username", + Password = "password", + Role = UserRole.Confirmed + }; + + await context.Users.AddAsync(user); + await context.SaveChangesAsync(); + + HttpResponseMessage res = await Client.PostAsJsonAsync( + Routes.RECOVER_ACCOUNT, + new + { + Username = "username" + } + ); + + res.Should().HaveStatusCode(HttpStatusCode.BadRequest); + context.ChangeTracker.Clear(); + + AccountRecovery? recovery = await context.AccountRecoveries.FirstOrDefaultAsync( + ar => ar.UserId == user.Id + ); + + recovery.Should().BeNull(); + emailSenderMock.Verify( + m => m.EnqueueEmailAsync(user.Email, It.IsAny(), It.IsAny()), + Times.Never() + ); + } + + [TestCase(UserRole.Banned)] + [TestCase(UserRole.Registered)] + public async Task SendRecoveryEmail_BadRole(UserRole role) + { + Mock emailSenderMock = new(); + + HttpClient client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.AddScoped(_ => emailSenderMock.Object); + }); + }).CreateClient(); + + ApplicationContext context = _scope.ServiceProvider.GetRequiredService(); + + User user = new() + { + Email = "test@email.com", + Password = "password", + Username = "username", + Role = role + }; + + await context.Users.AddAsync(user); + await context.SaveChangesAsync(); + + HttpResponseMessage res = await Client.PostAsJsonAsync( + Routes.RECOVER_ACCOUNT, + new RecoverAccountRequest + { + Email = "test@email.com", + Username = "username" + } + ); + + res.Should().HaveStatusCode(HttpStatusCode.OK); + context.ChangeTracker.Clear(); + + AccountRecovery? recovery = await context.AccountRecoveries.SingleOrDefaultAsync( + ar => ar.UserId == user.Id + ); + + recovery.Should().BeNull(); + emailSenderMock.Verify( + m => m.EnqueueEmailAsync(user.Email, It.IsAny(), It.IsAny()), + Times.Never() + ); + } + + [Test] + public async Task SendRecoveryEmail_UserNotPresent() + { + Mock emailSenderMock = new(); + + HttpClient client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.AddScoped(_ => emailSenderMock.Object); + }); + }) + .CreateClient(); + + HttpResponseMessage res = await client.PostAsJsonAsync( + Routes.RECOVER_ACCOUNT, + new RecoverAccountRequest + { + Email = "test@email.com", + Username = "username" + } + ); + + res.Should().HaveStatusCode(HttpStatusCode.OK); + + emailSenderMock.Verify(m => m.EnqueueEmailAsync( + It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never() + ); + } + + [Test] + public async Task SendRecoveryEmail_Success() + { + Mock emailSenderMock = new(); + + HttpClient client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.AddScoped(_ => emailSenderMock.Object); + services.AddSingleton(_ => new(Instant.FromUnixTimeSeconds(0))); + }); + }).CreateClient(); + + ApplicationContext context = _scope.ServiceProvider.GetRequiredService(); + User user = new() + { + Email = "test@email.com", + Username = "username", + Password = "password", + Role = UserRole.Confirmed + }; + + await context.Users.AddAsync(user); + await context.SaveChangesAsync(); + + HttpResponseMessage res = await client.PostAsJsonAsync( + Routes.RECOVER_ACCOUNT, + new RecoverAccountRequest + { + Email = "test@email.com", + Username = "username" + } + ); + + res.Should().HaveStatusCode(HttpStatusCode.OK); + context.ChangeTracker.Clear(); + + AccountRecovery? recovery = await context.AccountRecoveries.FirstOrDefaultAsync( + ar => ar.UserId == user.Id + ); + + recovery.Should().NotBeNull(); + recovery!.CreatedAt.Should().Be(Instant.FromUnixTimeSeconds(0)); + recovery!.ExpiresAt.Should().Be(Instant.FromUnixTimeSeconds(0) + Duration.FromHours(1)); + + emailSenderMock.Verify( + m => m.EnqueueEmailAsync(user.Email, It.IsAny(), It.IsAny()), + Times.Once() + ); + } +} diff --git a/LeaderboardBackend/Controllers/AccountController.cs b/LeaderboardBackend/Controllers/AccountController.cs index 06b426e5..e99a3300 100644 --- a/LeaderboardBackend/Controllers/AccountController.cs +++ b/LeaderboardBackend/Controllers/AccountController.cs @@ -2,6 +2,7 @@ using LeaderboardBackend.Models.Entities; using LeaderboardBackend.Models.Requests; using LeaderboardBackend.Models.ViewModels; +using LeaderboardBackend.Result; using LeaderboardBackend.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -169,4 +170,30 @@ [FromServices] IAccountConfirmationService confirmationService notFound => Unauthorized() ); } + + /// + /// Sends an account recovery email. + /// + /// IAccountRecoveryService dependency. + /// The account recovery request. + /// This endpoint returns 200 OK regardless of whether the email was sent successfully or not. + /// The request object was malformed. + [AllowAnonymous] + [HttpPost("recover")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task RecoverAccount( + [FromServices] IAccountRecoveryService recoveryService, + [FromBody] RecoverAccountRequest request + ) + { + User? user = await _userService.GetUserByNameAndEmail(request.Username, request.Email); + + if (user is not null) + { + await recoveryService.CreateRecoveryAndSendEmail(user); + } + + return Ok(); + } } diff --git a/LeaderboardBackend/Controllers/ApiController.cs b/LeaderboardBackend/Controllers/ApiController.cs index d1d56990..e4b33411 100644 --- a/LeaderboardBackend/Controllers/ApiController.cs +++ b/LeaderboardBackend/Controllers/ApiController.cs @@ -3,6 +3,7 @@ namespace LeaderboardBackend.Controllers; [ApiController] +[Consumes("application/json")] [Produces("application/json")] [Route("api/[controller]")] public abstract class ApiController : ControllerBase { } diff --git a/LeaderboardBackend/Models/Entities/AccountRecovery.cs b/LeaderboardBackend/Models/Entities/AccountRecovery.cs index f4166ecd..f55e554a 100644 --- a/LeaderboardBackend/Models/Entities/AccountRecovery.cs +++ b/LeaderboardBackend/Models/Entities/AccountRecovery.cs @@ -24,7 +24,6 @@ public class AccountRecovery /// /// The `User` relationship model. /// - [Required] public User User { get; set; } = null!; /// diff --git a/LeaderboardBackend/Models/Requests/UserRequests.cs b/LeaderboardBackend/Models/Requests/UserRequests.cs index 4c1c0662..6af42861 100644 --- a/LeaderboardBackend/Models/Requests/UserRequests.cs +++ b/LeaderboardBackend/Models/Requests/UserRequests.cs @@ -100,6 +100,22 @@ public record RegisterRequest public required string Password { get; set; } } +public record RecoverAccountRequest +{ + /// + /// The user's name. + /// + [Required] + public required string Username { get; set; } + + /// + /// The user's email address. + /// + [EmailAddress] + [Required] + public required string Email { get; set; } +} + public class LoginRequestValidator : AbstractValidator { public LoginRequestValidator() diff --git a/LeaderboardBackend/Program.cs b/LeaderboardBackend/Program.cs index ffe06887..dfc5199a 100644 --- a/LeaderboardBackend/Program.cs +++ b/LeaderboardBackend/Program.cs @@ -102,6 +102,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(_ => new SmtpClient() { Timeout = 3000 }); diff --git a/LeaderboardBackend/Results.cs b/LeaderboardBackend/Results.cs new file mode 100644 index 00000000..945fe185 --- /dev/null +++ b/LeaderboardBackend/Results.cs @@ -0,0 +1,7 @@ +namespace LeaderboardBackend.Result; + +public readonly record struct BadCredentials(); +public readonly record struct BadRole(); +public readonly record struct EmailFailed(); +public readonly record struct UserNotFound(); +public readonly record struct UserBanned(); diff --git a/LeaderboardBackend/Services/IAccountConfirmationService.cs b/LeaderboardBackend/Services/IAccountConfirmationService.cs index fa4fa485..ca972572 100644 --- a/LeaderboardBackend/Services/IAccountConfirmationService.cs +++ b/LeaderboardBackend/Services/IAccountConfirmationService.cs @@ -1,4 +1,5 @@ using LeaderboardBackend.Models.Entities; +using LeaderboardBackend.Result; using OneOf; namespace LeaderboardBackend.Services; @@ -11,6 +12,3 @@ public interface IAccountConfirmationService [GenerateOneOf] public partial class CreateConfirmationResult : OneOfBase { }; - -public readonly record struct BadRole(); -public readonly record struct EmailFailed(); diff --git a/LeaderboardBackend/Services/IAccountRecoveryService.cs b/LeaderboardBackend/Services/IAccountRecoveryService.cs new file mode 100644 index 00000000..4a29d889 --- /dev/null +++ b/LeaderboardBackend/Services/IAccountRecoveryService.cs @@ -0,0 +1,13 @@ +using LeaderboardBackend.Models.Entities; +using LeaderboardBackend.Result; +using OneOf; + +namespace LeaderboardBackend.Services; + +public interface IAccountRecoveryService +{ + Task CreateRecoveryAndSendEmail(User user); +} + +[GenerateOneOf] +public partial class CreateRecoveryResult : OneOfBase { }; diff --git a/LeaderboardBackend/Services/IUserService.cs b/LeaderboardBackend/Services/IUserService.cs index df53cc64..8caebf36 100644 --- a/LeaderboardBackend/Services/IUserService.cs +++ b/LeaderboardBackend/Services/IUserService.cs @@ -1,6 +1,7 @@ using System.Security.Claims; using LeaderboardBackend.Models.Entities; using LeaderboardBackend.Models.Requests; +using LeaderboardBackend.Result; using OneOf; namespace LeaderboardBackend.Services; @@ -15,6 +16,7 @@ public interface IUserService Task LoginByEmailAndPassword(string email, string password); // TODO: Convert return sig to Task Task GetUserByName(string name); + Task GetUserByNameAndEmail(string name, string email); Task CreateUser(RegisterRequest request); } @@ -27,9 +29,5 @@ public partial class CreateUserResult : OneOfBase { } [GenerateOneOf] public partial class LoginResult : OneOfBase { } -public readonly record struct UserNotFound(); -public readonly record struct UserBanned(); -public readonly record struct BadCredentials(); - [GenerateOneOf] public partial class GetUserResult : OneOfBase { } diff --git a/LeaderboardBackend/Services/Impl/AccountConfirmationService.cs b/LeaderboardBackend/Services/Impl/AccountConfirmationService.cs index 3cc22e65..4b21b251 100644 --- a/LeaderboardBackend/Services/Impl/AccountConfirmationService.cs +++ b/LeaderboardBackend/Services/Impl/AccountConfirmationService.cs @@ -1,4 +1,5 @@ using LeaderboardBackend.Models.Entities; +using LeaderboardBackend.Result; using Microsoft.Extensions.Options; using NodaTime; diff --git a/LeaderboardBackend/Services/Impl/AccountRecoveryService.cs b/LeaderboardBackend/Services/Impl/AccountRecoveryService.cs new file mode 100644 index 00000000..79d19a4a --- /dev/null +++ b/LeaderboardBackend/Services/Impl/AccountRecoveryService.cs @@ -0,0 +1,73 @@ +using LeaderboardBackend.Models.Entities; +using LeaderboardBackend.Result; +using Microsoft.Extensions.Options; +using NodaTime; + +namespace LeaderboardBackend.Services; + +public class AccountRecoveryService : IAccountRecoveryService +{ + private readonly ApplicationContext _applicationContext; + private readonly IEmailSender _emailSender; + private readonly IClock _clock; + private readonly AppConfig _appConfig; + + public AccountRecoveryService( + ApplicationContext applicationContext, + IEmailSender emailSender, + IClock clock, + IOptions appConfig + ) + { + _applicationContext = applicationContext; + _emailSender = emailSender; + _clock = clock; + _appConfig = appConfig.Value; + } + + public async Task CreateRecoveryAndSendEmail(User user) + { + if (user.Role is not UserRole.Confirmed && user.Role is not UserRole.Administrator) + { + return new BadRole(); + } + + Instant now = _clock.GetCurrentInstant(); + + AccountRecovery recovery = new() + { + CreatedAt = now, + ExpiresAt = now + Duration.FromHours(1), + User = user + }; + + await _applicationContext.AccountRecoveries.AddAsync(recovery); + await _applicationContext.SaveChangesAsync(); + + try + { + await _emailSender.EnqueueEmailAsync( + user.Email, + "Recover Your Account", + GenerateAccountRecoveryEmailBody(user, recovery) + ); + } + catch + { + return new EmailFailed(); + } + + return recovery; + } + + private string GenerateAccountRecoveryEmailBody(User user, AccountRecovery recovery) + { + UriBuilder builder = new(_appConfig.WebsiteUrl) + { + Path = "reset-password", + Query = $"code={recovery.Id.ToUrlSafeBase64String()}" + }; + + return $@"Hi {user.Username},

Click here to reset your password."; + } +} diff --git a/LeaderboardBackend/Services/Impl/UserService.cs b/LeaderboardBackend/Services/Impl/UserService.cs index 0f865297..3f159947 100644 --- a/LeaderboardBackend/Services/Impl/UserService.cs +++ b/LeaderboardBackend/Services/Impl/UserService.cs @@ -1,6 +1,7 @@ using System.Security.Claims; using LeaderboardBackend.Models.Entities; using LeaderboardBackend.Models.Requests; +using LeaderboardBackend.Result; using Microsoft.EntityFrameworkCore; using Npgsql; using BCryptNet = BCrypt.Net.BCrypt; @@ -77,6 +78,13 @@ public async Task LoginByEmailAndPassword(string email, string pass return await _applicationContext.Users.SingleOrDefaultAsync(user => user.Username == name); } + public async Task GetUserByNameAndEmail(string name, string email) + { + return await _applicationContext.Users.SingleOrDefaultAsync( + user => user.Username == name && user.Email == email + ); + } + public async Task CreateUser(RegisterRequest request) { User newUser = diff --git a/LeaderboardBackend/openapi.json b/LeaderboardBackend/openapi.json index cfc590ca..a3a963fe 100644 --- a/LeaderboardBackend/openapi.json +++ b/LeaderboardBackend/openapi.json @@ -18,16 +18,6 @@ "schema": { "$ref": "#/components/schemas/RegisterRequest" } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/RegisterRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/RegisterRequest" - } } } }, @@ -74,16 +64,6 @@ "schema": { "$ref": "#/components/schemas/LoginRequest" } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/LoginRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/LoginRequest" - } } } }, @@ -193,6 +173,39 @@ } } }, + "/Account/recover": { + "post": { + "tags": [ + "Account" + ], + "summary": "Sends an account recovery email.", + "requestBody": { + "description": "The account recovery request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecoverAccountRequest" + } + } + } + }, + "responses": { + "200": { + "description": "This endpoint returns 200 OK regardless of whether the email was sent successfully or not." + }, + "400": { + "description": "The request object was malformed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, "/api/Categories/{id}": { "get": { "tags": [ @@ -278,16 +291,6 @@ "schema": { "$ref": "#/components/schemas/CreateCategoryRequest" } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/CreateCategoryRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/CreateCategoryRequest" - } } } }, @@ -506,16 +509,6 @@ "schema": { "$ref": "#/components/schemas/CreateLeaderboardRequest" } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/CreateLeaderboardRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/CreateLeaderboardRequest" - } } } }, @@ -668,16 +661,6 @@ "schema": { "$ref": "#/components/schemas/CreateRunRequest" } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/CreateRunRequest" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/CreateRunRequest" - } } } }, @@ -1229,6 +1212,27 @@ }, "additionalProperties": { } }, + "RecoverAccountRequest": { + "required": [ + "email", + "username" + ], + "type": "object", + "properties": { + "username": { + "minLength": 1, + "type": "string", + "description": "The user's name." + }, + "email": { + "minLength": 1, + "type": "string", + "description": "The user's email address.", + "format": "email" + } + }, + "additionalProperties": false + }, "RegisterRequest": { "required": [ "email",