Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Test Recovery Code Endpoint #196

Merged
merged 18 commits into from
Oct 16, 2023
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion LeaderboardBackend.Test/Consts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,8 @@ internal static class Routes
public const string REGISTER = "/account/register";
public const string RESEND_CONFIRMATION = "/account/confirm";
public const string RECOVER_ACCOUNT = "/account/recover";
public static string ConfirmAccount(Guid id) => $"/account/confirm/{id.ToUrlSafeBase64String()}";
public static string ConfirmAccount(string id) => $"/account/confirm/{id}";
public static string ConfirmAccount(Guid id) => ConfirmAccount(id.ToUrlSafeBase64String());
public static string RecoverAccount(string id) => $"/account/recover/{id}";
public static string RecoverAccount(Guid id) => RecoverAccount(id.ToUrlSafeBase64String());
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
namespace LeaderboardBackend.Test.Features.Users;

[TestFixture]
public class AccountRecoveryTests : IntegrationTestsBase
public class SendRecoveryTests : IntegrationTestsBase
{
private IServiceScope _scope = null!;

Expand Down
160 changes: 160 additions & 0 deletions LeaderboardBackend.Test/Features/Users/TestRecoveryTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using LeaderboardBackend.Models.Entities;
using LeaderboardBackend.Test.Fixtures;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using NodaTime;
using NodaTime.Testing;
using NUnit.Framework;

namespace LeaderboardBackend.Test.Features.Users;

public class TestRecoveryTests : IntegrationTestsBase
{
private IServiceScope _scope = null!;
private readonly HttpClient _client;
private readonly FakeClock _clock = new(Instant.FromUnixTimeSeconds(1));

public TestRecoveryTests()
{
_client = _factory.WithWebHostBuilder(builder =>
Dalet marked this conversation as resolved.
Show resolved Hide resolved
builder.ConfigureTestServices(services =>
services.AddSingleton<IClock, FakeClock>(_ => _clock)
)
).CreateClient();
}

[SetUp]
public void Init()
{
_factory.ResetDatabase();
_scope = _factory.Services.CreateScope();
}

[TearDown]
public void TearDown() => _scope.Dispose();

[TestCase("not_a_guid")]
[TestCase("L8msfy9wd0qWbDJMZwwgQg")]
public async Task TestRecovery_BadRecoveryId(string id)
{
HttpResponseMessage res = await _client.GetAsync(Routes.RecoverAccount(id));
res.Should().HaveStatusCode(HttpStatusCode.NotFound);
}

[Test]
public async Task TestRecovery_Expired()
{
_clock.Reset(Instant.FromUnixTimeSeconds(1) + Duration.FromHours(2));
ApplicationContext context = _scope.ServiceProvider.GetRequiredService<ApplicationContext>();

AccountRecovery recovery = new()
{
CreatedAt = Instant.FromUnixTimeSeconds(0),
ExpiresAt = Instant.FromUnixTimeSeconds(0).Plus(Duration.FromHours(1)),
User = new()
{
Email = "[email protected]",
Password = "password",
Username = "username",
Role = UserRole.Confirmed
}
};

await context.AccountRecoveries.AddAsync(recovery);
await context.SaveChangesAsync();
HttpResponseMessage res = await _client.GetAsync(Routes.RecoverAccount(recovery.Id));
res.StatusCode.Should().Be(HttpStatusCode.NotFound);
}

[Test]
public async Task TestRecovery_Old()
{
_clock.Reset(Instant.FromUnixTimeSeconds(10));
ApplicationContext context = _scope.ServiceProvider.GetRequiredService<ApplicationContext>();

User user = new()
{
Email = "[email protected]",
Password = "password",
Username = "username",
Role = UserRole.Confirmed
};
await context.Users.AddAsync(user);

AccountRecovery recovery1 = new()
{
CreatedAt = Instant.FromUnixTimeSeconds(0),
ExpiresAt = Instant.FromUnixTimeSeconds(0).Plus(Duration.FromHours(1)),
User = user
};

AccountRecovery recovery2 = new()
{
CreatedAt = Instant.FromUnixTimeSeconds(5),
ExpiresAt = Instant.FromUnixTimeSeconds(5).Plus(Duration.FromHours(1)),
User = user
};

await context.AccountRecoveries.AddRangeAsync(recovery1, recovery2);
await context.SaveChangesAsync();
HttpResponseMessage res = await _client.GetAsync(Routes.RecoverAccount(recovery1.Id));
res.Should().HaveStatusCode(HttpStatusCode.NotFound);
}

[Test]
public async Task TestRecovery_Used()
{
_clock.Reset(Instant.FromUnixTimeSeconds(10));
ApplicationContext context = _scope.ServiceProvider.GetRequiredService<ApplicationContext>();

AccountRecovery recovery = new()
{
CreatedAt = Instant.FromUnixTimeSeconds(0),
ExpiresAt = Instant.FromUnixTimeSeconds(0).Plus(Duration.FromHours(1)),
UsedAt = Instant.FromUnixTimeSeconds(5),
User = new()
{
Email = "[email protected]",
Password = "password",
Username = "username",
Role = UserRole.Confirmed
}
};

await context.AccountRecoveries.AddAsync(recovery);
await context.SaveChangesAsync();
HttpResponseMessage res = await _client.GetAsync(Routes.RecoverAccount(recovery.Id));
res.Should().HaveStatusCode(HttpStatusCode.NotFound);
}

[TestCase(UserRole.Administrator, HttpStatusCode.OK)]
[TestCase(UserRole.Banned, HttpStatusCode.NotFound)]
[TestCase(UserRole.Confirmed, HttpStatusCode.OK)]
[TestCase(UserRole.Registered, HttpStatusCode.OK)]
public async Task TestRecovery_Roles(UserRole role, HttpStatusCode expected)
{
_clock.Reset(Instant.FromUnixTimeSeconds(1));
ApplicationContext context = _scope.ServiceProvider.GetRequiredService<ApplicationContext>();

AccountRecovery recovery = new()
{
CreatedAt = Instant.FromUnixTimeSeconds(0),
ExpiresAt = Instant.FromUnixTimeSeconds(0).Plus(Duration.FromHours(1)),
User = new()
{
Email = "[email protected]",
Password = "password",
Username = "username",
Role = role
}
};

await context.AccountRecoveries.AddAsync(recovery);
await context.SaveChangesAsync();
HttpResponseMessage res = await _client.GetAsync(Routes.RecoverAccount(recovery.Id));
res.Should().HaveStatusCode(expected);
}
}
25 changes: 25 additions & 0 deletions LeaderboardBackend/Controllers/AccountController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -222,4 +222,29 @@ public async Task<ActionResult> ConfirmAccount(Guid id, [FromServices] IAccountC
expired => NotFound()
);
}

/// <summary>
/// Tests an account recovery token for validity.
/// </summary>
/// <param name="id">The recovery token.</param>
/// <param name="recoveryService">IAccountRecoveryService dependency.</param>
/// <response code="200">The token provided is valid.</response>
/// <response code="404">The token provided is invalid or expired, or the user is banned.</response>
[AllowAnonymous]
[HttpGet("recover/{id}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> TestRecovery(Guid id, [FromServices] IAccountRecoveryService recoveryService)
{
RecoverAccountResult result = await recoveryService.TestRecovery(id);

return result.Match<ActionResult>(
alreadyUsed => NotFound(),
badRole => NotFound(),
expired => NotFound(),
notFound => NotFound(),
success => Ok()
);
}
}
1 change: 0 additions & 1 deletion LeaderboardBackend/Models/Entities/AccountRecovery.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using NodaTime;

namespace LeaderboardBackend.Models.Entities;
Expand Down
5 changes: 5 additions & 0 deletions LeaderboardBackend/Services/IAccountRecoveryService.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
using LeaderboardBackend.Models.Entities;
using LeaderboardBackend.Result;
using OneOf;
using OneOf.Types;

namespace LeaderboardBackend.Services;

public interface IAccountRecoveryService
{
Task<CreateRecoveryResult> CreateRecoveryAndSendEmail(User user);
Task<RecoverAccountResult> TestRecovery(Guid id);
}

[GenerateOneOf]
public partial class CreateRecoveryResult : OneOfBase<AccountRecovery, BadRole, EmailFailed> { };

[GenerateOneOf]
public partial class RecoverAccountResult : OneOfBase<AlreadyUsed, BadRole, Expired, NotFound, Success> { };
44 changes: 44 additions & 0 deletions LeaderboardBackend/Services/Impl/AccountRecoveryService.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
using LeaderboardBackend.Models.Entities;
using LeaderboardBackend.Result;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using NodaTime;
using OneOf.Types;

namespace LeaderboardBackend.Services;

Expand Down Expand Up @@ -70,4 +72,46 @@ private string GenerateAccountRecoveryEmailBody(User user, AccountRecovery recov

return $@"Hi {user.Username},<br/><br/>Click <a href=""{builder.Uri}"">here</a> to reset your password.";
}

public async Task<RecoverAccountResult> TestRecovery(Guid id)
{
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();
Dalet marked this conversation as resolved.
Show resolved Hide resolved
}

if (recovery.UsedAt is not null)
{
return new AlreadyUsed();
}

Instant now = _clock.GetCurrentInstant();

if (recovery.ExpiresAt <= now)
{
return new Expired();
}

IQueryable<Guid> latest =
from rec in _applicationContext.AccountRecoveries
where rec.UserId == recovery.UserId
orderby rec.CreatedAt descending
Dalet marked this conversation as resolved.
Show resolved Hide resolved
select rec.Id;

Guid latestId = await latest.FirstAsync();

if (latestId != id)
{
return new Expired();
}

return new Success();
}
}
45 changes: 45 additions & 0 deletions LeaderboardBackend/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,51 @@
}
}
},
"/Account/recover/{id}": {
"get": {
"tags": [
"Account"
],
"summary": "Tests an account recovery token for validity.",
"parameters": [
{
"name": "id",
"in": "path",
"description": "The recovery token.",
"required": true,
"schema": {
"pattern": "^[a-zA-Z0-9-_]{22}$",
"type": "string"
}
}
],
"responses": {
"200": {
"description": "The token provided is valid."
},
"400": {
"description": "Bad Request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"404": {
"description": "The token provided is invalid or expired, or the user is banned.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/api/Categories/{id}": {
"get": {
"tags": [
Expand Down
Loading