diff --git a/LeaderboardBackend.Test/Features/Users/ResetPasswordTests.cs b/LeaderboardBackend.Test/Features/Users/ResetPasswordTests.cs new file mode 100644 index 00000000..79646479 --- /dev/null +++ b/LeaderboardBackend.Test/Features/Users/ResetPasswordTests.cs @@ -0,0 +1,331 @@ +using System.Collections.Generic; +using System.Net.Http; +using System.Net.Http.Json; +using System.Threading.Tasks; +using LeaderboardBackend.Models.Entities; +using LeaderboardBackend.Models.Requests; +using LeaderboardBackend.Test.Fixtures; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using NodaTime; +using NodaTime.Testing; +using NUnit.Framework; +using BCryptNet = BCrypt.Net.BCrypt; + +namespace LeaderboardBackend.Test.Features.Users; + +[TestFixture] +public class ResetPasswordTests : IntegrationTestsBase +{ + private IServiceScope _scope = null!; + private FakeClock _clock = null!; + private HttpClient _client = null!; + private int _userNumber; + + [OneTimeSetUp] + public void OneTimeSetUp() + { + _userNumber = 0; + _clock = new(Instant.FromUnixTimeSeconds(10) + Duration.FromHours(1)); + + _client = _factory.WithWebHostBuilder( + builder => builder.ConfigureTestServices( + services => services.AddSingleton(_ => _clock) + ) + ).CreateClient(); + } + + [SetUp] + public void Init() + { + _scope = _factory.Services.CreateScope(); + } + + [TearDown] + public void TearDown() + { + _scope.Dispose(); + } + + [OneTimeTearDown] + public void OneTimeTearDown() + { + _client.Dispose(); + } + + [TestCase("not_an_id")] + [TestCase("4BZgqaqRPEC7CKykWc0b2g")] + public async Task ResetPassword_BadId(string id) + { + HttpResponseMessage res = await _client.PostAsJsonAsync(Routes.RecoverAccount(id), new ChangePasswordRequest + { + Password = "AValidP4ssword" + }); + + res.Should().HaveStatusCode(System.Net.HttpStatusCode.NotFound); + } + + [Test] + public async Task ResetPassword_Expired() + { + ApplicationContext context = _scope.ServiceProvider.GetRequiredService(); + int userNumber = _userNumber++; + + AccountRecovery recovery = new() + { + CreatedAt = Instant.FromUnixTimeSeconds(0), + ExpiresAt = Instant.FromUnixTimeSeconds(0) + Duration.FromHours(1), + User = new() + { + Email = $"pwdresettestuser{userNumber}@email.com", + Password = BCryptNet.EnhancedHashPassword("P4ssword"), + Username = $"pwdresettestuser{userNumber}", + Role = UserRole.Confirmed + } + }; + + context.AccountRecoveries.Add(recovery); + await context.SaveChangesAsync(); + + HttpResponseMessage res = await _client.PostAsJsonAsync(Routes.RecoverAccount(recovery.Id), new ChangePasswordRequest + { + Password = "AValidP4ssword" + }); + + res.Should().HaveStatusCode(System.Net.HttpStatusCode.NotFound); + context.ChangeTracker.Clear(); + recovery = await context.AccountRecoveries.Include(ar => ar.User).SingleAsync(ar => ar.Id == recovery.Id); + recovery.UsedAt.Should().BeNull(); + BCryptNet.EnhancedVerify("P4ssword", recovery.User.Password).Should().BeTrue(); + } + + [Test] + public async Task ResetPassword_NotMostRecent() + { + ApplicationContext context = _scope.ServiceProvider.GetRequiredService(); + int userNumber = _userNumber++; + + User user = new() + { + Email = $"pwdresettestuser{userNumber}@email.com", + Password = BCryptNet.EnhancedHashPassword("P4ssword"), + Username = $"pwdresettestuser{userNumber}", + Role = UserRole.Confirmed + }; + + AccountRecovery recovery1 = new() + { + CreatedAt = Instant.FromUnixTimeSeconds(20), + ExpiresAt = Instant.FromUnixTimeSeconds(20) + Duration.FromHours(1), + User = user + }; + + AccountRecovery recovery2 = new() + { + CreatedAt = Instant.FromUnixTimeSeconds(30), + ExpiresAt = Instant.FromUnixTimeSeconds(30) + Duration.FromHours(1), + User = user + }; + + context.AccountRecoveries.AddRange(recovery1, recovery2); + await context.SaveChangesAsync(); + + HttpResponseMessage res = await _client.PostAsJsonAsync(Routes.RecoverAccount(recovery1.Id), new ChangePasswordRequest + { + Password = "AValidP4ssword" + }); + + res.Should().HaveStatusCode(System.Net.HttpStatusCode.NotFound); + context.ChangeTracker.Clear(); + recovery1 = await context.AccountRecoveries.Include(ar => ar.User).SingleAsync(ar => ar.Id == recovery1.Id); + recovery1.UsedAt.Should().BeNull(); + BCryptNet.EnhancedVerify("P4ssword", recovery1.User.Password).Should().BeTrue(); + } + + [Test] + public async Task ResetPassword_AlreadyUsed() + { + ApplicationContext context = _scope.ServiceProvider.GetRequiredService(); + int userNumber = _userNumber++; + + AccountRecovery recovery = new() + { + CreatedAt = Instant.FromUnixTimeSeconds(20), + ExpiresAt = Instant.FromUnixTimeSeconds(20) + Duration.FromHours(1), + UsedAt = Instant.FromUnixTimeSeconds(30), + User = new() + { + Email = $"pwdresettestuser{userNumber}@email.com", + Password = BCryptNet.EnhancedHashPassword("P4ssword"), + Username = $"pwdresettestuser{userNumber}", + Role = UserRole.Confirmed + } + }; + + context.AccountRecoveries.Add(recovery); + await context.SaveChangesAsync(); + + HttpResponseMessage res = await _client.PostAsJsonAsync(Routes.RecoverAccount(recovery.Id), new ChangePasswordRequest + { + Password = "AValidP4ssword" + }); + + res.Should().HaveStatusCode(System.Net.HttpStatusCode.NotFound); + context.ChangeTracker.Clear(); + recovery = await context.AccountRecoveries.Include(ar => ar.User).SingleAsync(ar => ar.Id == recovery.Id); + recovery.UsedAt.Should().Be(Instant.FromUnixTimeSeconds(30)); + BCryptNet.EnhancedVerify("P4ssword", recovery.User.Password).Should().BeTrue(); + } + + [Test] + public async Task ResetPassword_Banned() + { + ApplicationContext context = _scope.ServiceProvider.GetRequiredService(); + int userNumber = _userNumber++; + + AccountRecovery recovery = new() + { + CreatedAt = Instant.FromUnixTimeSeconds(20), + ExpiresAt = Instant.FromUnixTimeSeconds(20) + Duration.FromHours(1), + User = new() + { + Email = $"pwdresettestuser{userNumber}@email.com", + Password = BCryptNet.EnhancedHashPassword("P4ssword"), + Username = $"pwdresettestuser{userNumber}", + Role = UserRole.Banned + } + }; + + context.AccountRecoveries.Add(recovery); + await context.SaveChangesAsync(); + + HttpResponseMessage res = await _client.PostAsJsonAsync(Routes.RecoverAccount(recovery.Id), new ChangePasswordRequest + { + Password = "AValidP4ssword" + }); + + res.Should().HaveStatusCode(System.Net.HttpStatusCode.Forbidden); + context.ChangeTracker.Clear(); + recovery = await context.AccountRecoveries.Include(ar => ar.User).SingleAsync(ar => ar.Id == recovery.Id); + recovery.UsedAt.Should().BeNull(); + BCryptNet.EnhancedVerify("P4ssword", recovery.User.Password).Should().BeTrue(); + } + + [TestCase("OuxNzURtWdXWd", Description = "No number")] + [TestCase("DZWVZVV5ED8QE", Description = "No lowercase letter")] + [TestCase("y267pmi50skcc", Description = "No uppercase letter")] + [TestCase("zmgoyGS", Description = "7 characters")] + [TestCase("qutOboNSzYplEKCDlCEbGPIEtMEnJImHwnluHvksTZbhuHSwFLpvUZQQxIdHctldJkdEVMRyiWcyuIeBe", + Description = "81 characters")] + public async Task ResetPassword_BadPassword(string pwd) + { + ApplicationContext context = _scope.ServiceProvider.GetRequiredService(); + int userNumber = _userNumber++; + + AccountRecovery recovery = new() + { + CreatedAt = Instant.FromUnixTimeSeconds(20), + ExpiresAt = Instant.FromUnixTimeSeconds(20) + Duration.FromHours(1), + User = new() + { + Email = $"pwdresettestuser{userNumber}@email.com", + Password = BCryptNet.EnhancedHashPassword("P4ssword"), + Username = $"pwdresettestuser{userNumber}", + Role = UserRole.Confirmed + } + }; + + context.AccountRecoveries.Add(recovery); + await context.SaveChangesAsync(); + + HttpResponseMessage res = await _client.PostAsJsonAsync(Routes.RecoverAccount(recovery.Id), new ChangePasswordRequest + { + Password = pwd + }); + + res.Should().HaveStatusCode(System.Net.HttpStatusCode.UnprocessableEntity); + ValidationProblemDetails? content = await res.Content.ReadFromJsonAsync(); + content.Should().NotBeNull(); + + content!.Errors.Should().BeEquivalentTo(new Dictionary + { + { nameof(RegisterRequest.Password), new[] { "PasswordFormat" } } + }); + + context.ChangeTracker.Clear(); + recovery = await context.AccountRecoveries.Include(ar => ar.User).SingleAsync(ar => ar.Id == recovery.Id); + recovery.UsedAt.Should().BeNull(); + BCryptNet.EnhancedVerify("P4ssword", recovery.User.Password).Should().BeTrue(); + } + + [Test] + public async Task ResetPassword_SamePassword() + { + ApplicationContext context = _scope.ServiceProvider.GetRequiredService(); + int userNumber = _userNumber++; + + AccountRecovery recovery = new() + { + CreatedAt = Instant.FromUnixTimeSeconds(20), + ExpiresAt = Instant.FromUnixTimeSeconds(20) + Duration.FromHours(1), + User = new() + { + Email = $"pwdresettestuser{userNumber}@email.com", + Password = BCryptNet.EnhancedHashPassword("P4ssword"), + Username = $"pwdresettestuser{userNumber}", + Role = UserRole.Confirmed + } + }; + + context.AccountRecoveries.Add(recovery); + await context.SaveChangesAsync(); + + HttpResponseMessage res = await _client.PostAsJsonAsync(Routes.RecoverAccount(recovery.Id), new ChangePasswordRequest + { + Password = "P4ssword" + }); + + res.Should().HaveStatusCode(System.Net.HttpStatusCode.Conflict); + context.ChangeTracker.Clear(); + recovery = await context.AccountRecoveries.Include(ar => ar.User).SingleAsync(ar => ar.Id == recovery.Id); + recovery.UsedAt.Should().BeNull(); + } + + [TestCase(UserRole.Administrator)] + [TestCase(UserRole.Confirmed)] + [TestCase(UserRole.Registered)] + public async Task ResetPassword_Success(UserRole role) + { + ApplicationContext context = _scope.ServiceProvider.GetRequiredService(); + int userNumber = _userNumber++; + + AccountRecovery recovery = new() + { + CreatedAt = Instant.FromUnixTimeSeconds(20), + ExpiresAt = Instant.FromUnixTimeSeconds(20) + Duration.FromHours(1), + User = new() + { + Email = $"pwdresettestuser{userNumber}@email.com", + Password = BCryptNet.EnhancedHashPassword("P4ssword"), + Username = $"pwdresettestuser{userNumber}", + Role = role + } + }; + + context.AccountRecoveries.Add(recovery); + await context.SaveChangesAsync(); + + HttpResponseMessage res = await _client.PostAsJsonAsync(Routes.RecoverAccount(recovery.Id), new ChangePasswordRequest + { + Password = "AValidP4ssword" + }); + + res.Should().HaveStatusCode(System.Net.HttpStatusCode.OK); + context.ChangeTracker.Clear(); + recovery = await context.AccountRecoveries.Include(ar => ar.User).SingleAsync(ar => ar.Id == recovery.Id); + recovery.UsedAt.Should().Be(Instant.FromUnixTimeSeconds(10) + Duration.FromHours(1)); + BCryptNet.EnhancedVerify("AValidP4ssword", recovery.User.Password).Should().BeTrue(); + } +} diff --git a/LeaderboardBackend/Controllers/AccountController.cs b/LeaderboardBackend/Controllers/AccountController.cs index 7e2989d7..def0e517 100644 --- a/LeaderboardBackend/Controllers/AccountController.cs +++ b/LeaderboardBackend/Controllers/AccountController.cs @@ -237,7 +237,7 @@ public async Task ConfirmAccount(Guid id, [FromServices] IAccountC [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task TestRecovery(Guid id, [FromServices] IAccountRecoveryService recoveryService) { - RecoverAccountResult result = await recoveryService.TestRecovery(id); + TestRecoveryResult result = await recoveryService.TestRecovery(id); return result.Match( alreadyUsed => NotFound(), @@ -247,4 +247,44 @@ public async Task TestRecovery(Guid id, [FromServices] IAccountRec success => Ok() ); } + + /// + /// Recover the user's account by resetting their password to a new value. + /// + /// The recovery token. + /// The password recovery request object. + /// IAccountRecoveryService dependency + /// The user's password was reset successfully. + /// The user is banned. + /// The token provided is invalid or expired. + /// The new password is the same as the user's existing password. + /// + /// The request body contains errors.
+ /// A **PasswordFormat** Validation error on the Password field indicates that the password format is invalid. + ///
+ [AllowAnonymous] + [HttpPost("recover/{id}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity, Type = typeof(ValidationProblemDetails))] + public async Task ResetPassword( + Guid id, + [FromBody] ChangePasswordRequest request, + [FromServices] IAccountRecoveryService recoveryService + ) + { + ResetPasswordResult result = await recoveryService.ResetPassword(id, request.Password); + + return result.Match( + alreadyUsed => NotFound(), + badRole => Forbid(), + expired => NotFound(), + notFound => NotFound(), + samePassword => Conflict(), + success => Ok() + ); + } } diff --git a/LeaderboardBackend/Models/Requests/UserRequests.cs b/LeaderboardBackend/Models/Requests/UserRequests.cs index 6af42861..181c04ee 100644 --- a/LeaderboardBackend/Models/Requests/UserRequests.cs +++ b/LeaderboardBackend/Models/Requests/UserRequests.cs @@ -116,6 +116,11 @@ public record RecoverAccountRequest public required string Email { get; set; } } +public record ChangePasswordRequest +{ + public required string Password { get; set; } +} + public class LoginRequestValidator : AbstractValidator { public LoginRequestValidator() @@ -135,3 +140,8 @@ public RegisterRequestValidator() RuleFor(x => x.Password).UserPassword(); } } + +public class ChangePasswordValidator : AbstractValidator +{ + public ChangePasswordValidator() => RuleFor(x => x.Password).UserPassword(); +} diff --git a/LeaderboardBackend/Services/IAccountRecoveryService.cs b/LeaderboardBackend/Services/IAccountRecoveryService.cs index d8b6cde9..9e53b018 100644 --- a/LeaderboardBackend/Services/IAccountRecoveryService.cs +++ b/LeaderboardBackend/Services/IAccountRecoveryService.cs @@ -8,11 +8,17 @@ namespace LeaderboardBackend.Services; public interface IAccountRecoveryService { Task CreateRecoveryAndSendEmail(User user); - Task TestRecovery(Guid id); + Task TestRecovery(Guid id); + Task ResetPassword(Guid id, string password); } +public readonly record struct SamePassword { } + [GenerateOneOf] public partial class CreateRecoveryResult : OneOfBase { }; [GenerateOneOf] -public partial class RecoverAccountResult : OneOfBase { }; +public partial class TestRecoveryResult : OneOfBase { }; + +[GenerateOneOf] +public partial class ResetPasswordResult : OneOfBase { } diff --git a/LeaderboardBackend/Services/Impl/AccountRecoveryService.cs b/LeaderboardBackend/Services/Impl/AccountRecoveryService.cs index 01217760..b19830ef 100644 --- a/LeaderboardBackend/Services/Impl/AccountRecoveryService.cs +++ b/LeaderboardBackend/Services/Impl/AccountRecoveryService.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Options; using NodaTime; using OneOf.Types; +using BCryptNet = BCrypt.Net.BCrypt; namespace LeaderboardBackend.Services; @@ -73,7 +74,7 @@ private string GenerateAccountRecoveryEmailBody(User user, AccountRecovery recov return $@"Hi {user.Username},

Click here to reset your password."; } - public async Task TestRecovery(Guid id) + public async Task TestRecovery(Guid id) { AccountRecovery? recovery = await _applicationContext.AccountRecoveries.Include(ar => ar.User).SingleOrDefaultAsync(ar => ar.Id == id); @@ -114,4 +115,55 @@ orderby rec.CreatedAt descending return new Success(); } + + public async Task ResetPassword(Guid id, string password) + { + AccountRecovery? recovery = await _applicationContext.AccountRecoveries.Include(ar => ar.User).SingleOrDefaultAsync(ar => ar.Id == id); + + if (recovery is null) + { + return new NotFound(); + } + + if (recovery.User.Role is UserRole.Banned) + { + return new BadRole(); + } + + if (recovery.UsedAt is not null) + { + return new AlreadyUsed(); + } + + Instant now = _clock.GetCurrentInstant(); + + if (recovery.ExpiresAt <= now) + { + return new Expired(); + } + + IQueryable latest = + from rec in _applicationContext.AccountRecoveries + where rec.UserId == recovery.UserId + orderby rec.CreatedAt descending + select rec.Id; + + Guid latestId = await latest.FirstAsync(); + + if (latestId != id) + { + return new Expired(); + } + + if (BCryptNet.EnhancedVerify(password, recovery.User.Password)) + { + return new SamePassword(); + } + + recovery.User.Password = BCryptNet.EnhancedHashPassword(password); + recovery.UsedAt = now; + await _applicationContext.SaveChangesAsync(); + + return new Success(); + } } diff --git a/LeaderboardBackend/openapi.json b/LeaderboardBackend/openapi.json index d51c22c9..fa279b85 100644 --- a/LeaderboardBackend/openapi.json +++ b/LeaderboardBackend/openapi.json @@ -304,6 +304,89 @@ } } } + }, + "post": { + "tags": [ + "Account" + ], + "summary": "Recover the user's account by resetting their password to a new value.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The recovery token.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z0-9-_]{22}$", + "type": "string" + } + } + ], + "requestBody": { + "description": "The password recovery request object.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChangePasswordRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The user's password was reset successfully." + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "The user is banned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "The token provided is invalid or expired.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "The new password is the same as the user's existing password.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "The request body contains errors.
\r\nA **PasswordFormat** Validation error on the Password field indicates that the password format is invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } + } + } } }, "/api/Categories/{id}": { @@ -1048,6 +1131,18 @@ "additionalProperties": false, "description": "Represents a `Category` tied to a `Leaderboard`." }, + "ChangePasswordRequest": { + "required": [ + "password" + ], + "type": "object", + "properties": { + "password": { + "type": "string" + } + }, + "additionalProperties": false + }, "CreateCategoryRequest": { "required": [ "leaderboardId",