shoc-backend/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs

366 lines
20 KiB
C#

using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using Moq;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class AuthenticationServiceTests
{
private static AuthenticationService NewService(
Mock<IUserDataService> userData,
Mock<IForgetPasswordDataService> forget,
Mock<IPasswordResetEmailQueue> email,
out Mock<IUserRoleStore<ApplicationUser>> store,
out Mock<IPasswordHasher<ApplicationUser>> hasher)
{
var (manager, s, h) = IdentityTestHelpers.CreateUserManager();
store = s;
hasher = h;
return new AuthenticationService(manager, Microsoft.Extensions.Options.Options.Create(JwtOptions), userData.Object, forget.Object, email.Object, new InMemoryPasswordResetThrottle(TimeProvider.System), TimeProvider.System);
}
private static JwtOptions JwtOptions => new()
{
Secret = new string('x', 64),
ValidIssuer = "issuer",
ValidAudience = "audience"
};
[Fact]
public async Task Login_UnknownUser_ReturnsNull()
{
var service = NewService(new Mock<IUserDataService>(), new Mock<IForgetPasswordDataService>(), new Mock<IPasswordResetEmailQueue>(), out var store, out _);
store.Setup(s => s.FindByNameAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync((ApplicationUser?)null);
var result = await service.LoginAsync("nobody", "pw", CancellationToken.None);
result.Should().BeNull();
}
[Fact]
public async Task Login_DeletedUser_RejectedBeforePasswordCheck()
{
var deletedUser = IdentityTestHelpers.User(isDeleted: true);
var service = NewService(new Mock<IUserDataService>(), new Mock<IForgetPasswordDataService>(), new Mock<IPasswordResetEmailQueue>(), out var store, out var hasher);
store.Setup(s => s.FindByNameAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(deletedUser);
store.Setup(s => s.GetRolesAsync(deletedUser, It.IsAny<CancellationToken>())).ReturnsAsync(new List<string>());
var result = await service.LoginAsync("alice", "pw", CancellationToken.None);
result.Should().BeNull();
hasher.Verify(h => h.VerifyHashedPassword(It.IsAny<ApplicationUser>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task Login_ValidUser_ReturnsTokenFirstRoleAndIdentity()
{
var user = IdentityTestHelpers.User();
var service = NewService(new Mock<IUserDataService>(), new Mock<IForgetPasswordDataService>(), new Mock<IPasswordResetEmailQueue>(), out var store, out var hasher);
store.Setup(s => s.FindByNameAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(user);
store.Setup(s => s.GetRolesAsync(user, It.IsAny<CancellationToken>())).ReturnsAsync(new List<string> { "Admin", "Manager" });
store.As<Microsoft.AspNetCore.Identity.IUserPasswordStore<ApplicationUser>>()
.Setup(s => s.GetPasswordHashAsync(user, It.IsAny<CancellationToken>())).ReturnsAsync("hash");
hasher.Setup(h => h.VerifyHashedPassword(user, "hash", "pw")).Returns(Microsoft.AspNetCore.Identity.PasswordVerificationResult.Success);
var result = await service.LoginAsync("alice", "pw", CancellationToken.None);
result.Should().NotBeNull();
result!.Id.Should().Be(user.Id);
result.Email.Should().Be(user.Email);
result.PhoneNumber.Should().Be(user.PhoneNumber);
result.Fullname.Should().Be("Alice Q");
result.UserRole.Should().Be("Admin");
result.Token.Should().NotBeNullOrEmpty();
result.Expiration.Should().BeAfter(DateTime.Now.AddDays(9));
}
[Fact]
public async Task Login_BadPassword_ReturnsNull()
{
var user = IdentityTestHelpers.User();
var service = NewService(new Mock<IUserDataService>(), new Mock<IForgetPasswordDataService>(), new Mock<IPasswordResetEmailQueue>(), out var store, out var hasher);
store.Setup(s => s.FindByNameAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(user);
store.As<Microsoft.AspNetCore.Identity.IUserPasswordStore<ApplicationUser>>()
.Setup(s => s.GetPasswordHashAsync(user, It.IsAny<CancellationToken>())).ReturnsAsync("hash");
hasher.Setup(h => h.VerifyHashedPassword(user, "hash", "wrong")).Returns(Microsoft.AspNetCore.Identity.PasswordVerificationResult.Failed);
var result = await service.LoginAsync("alice", "wrong", CancellationToken.None);
result.Should().BeNull();
}
private static byte[] ResetKey => PasswordResetCodeSecrets.DeriveKey(JwtOptions.Secret);
[Fact]
public async Task ForgetPassword_RegisteredEmail_StoresOnlyAKeyedHashAndQueuesTheCode()
{
var user = IdentityTestHelpers.User();
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetByEmailNormalizedAsync("alice@example.com", It.IsAny<CancellationToken>())).ReturnsAsync(user);
var forget = new Mock<IForgetPasswordDataService>();
var email = new Mock<IPasswordResetEmailQueue>();
string? stored = null, salt = null, body = null;
DateTime expires = default;
forget.Setup(f => f.ReplaceCodeAsync(user.Email!, user.Id, It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
.Callback<string, string, string, string, DateTime, DateTime, CancellationToken>((_, _, h, s, e, _, _) => { stored = h; salt = s; expires = e; })
.Returns(Task.CompletedTask);
email.Setup(e => e.TryEnqueue(user.Email!, "Forget Password Request.", It.IsAny<string>()))
.Callback<string, string, string>((_, _, b) => body = b)
.Returns(true);
var service = NewService(userData, forget, email, out _, out _);
var before = DateTime.UtcNow;
await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None);
var code = System.Text.RegularExpressions.Regex.Match(body!, @"Your Password Reset Code is: (\d{6})").Groups[1].Value;
code.Should().HaveLength(6);
stored.Should().NotBe(code).And.MatchRegex("^[0-9a-f]{64}$");
PasswordResetCodeSecrets.Matches(ResetKey, salt!, code, stored!).Should().BeTrue();
expires.Should().BeCloseTo(before.AddMinutes(15), TimeSpan.FromSeconds(5));
}
[Fact]
public async Task ForgetPassword_UnknownEmail_MakesTheSameDataCallsAndQueuesNothing()
{
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetByEmailNormalizedAsync("alice@example.com", It.IsAny<CancellationToken>())).ReturnsAsync(IdentityTestHelpers.User());
var registered = new Mock<IForgetPasswordDataService>();
var unregistered = new Mock<IForgetPasswordDataService>();
var email = new Mock<IPasswordResetEmailQueue>();
email.Setup(e => e.TryEnqueue(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>())).Returns(true);
await NewService(userData, registered, email, out _, out _).ForgetPasswordAsync("alice@example.com", CancellationToken.None);
await NewService(userData, unregistered, email, out _, out _).ForgetPasswordAsync("nope@example.com", CancellationToken.None);
registered.Invocations.Select(call => call.Method.Name)
.Should().Equal(unregistered.Invocations.Select(call => call.Method.Name))
.And.Equal(nameof(IForgetPasswordDataService.ReplaceCodeAsync));
unregistered.Verify(f => f.ReplaceCodeAsync("nope@example.com", string.Empty, It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()), Times.Once);
email.Verify(e => e.TryEnqueue(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Once);
}
[Fact]
public async Task ForgetPassword_EmailThatCannotBeQueued_DropsTheCodeAndDoesNotCountTheRequest()
{
var user = IdentityTestHelpers.User();
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(user);
var forget = new Mock<IForgetPasswordDataService>();
var stored = new List<(string Hash, string Salt)>();
forget.Setup(f => f.ReplaceCodeAsync(user.Email!, user.Id, It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
.Callback<string, string, string, string, DateTime, DateTime, CancellationToken>((_, _, h, s, _, _, _) => stored.Add((h, s)))
.Returns(Task.CompletedTask);
var email = new Mock<IPasswordResetEmailQueue>();
var bodies = new List<string>();
// The queue is full for the first three requests.
email.Setup(e => e.TryEnqueue(user.Email!, It.IsAny<string>(), It.IsAny<string>()))
.Returns<string, string, string>((_, _, b) =>
{
bodies.Add(b);
return bodies.Count > 3;
});
var service = NewService(userData, forget, email, out _, out _);
for (var request = 0; request < 4; request++)
await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None);
// The three undelivered requests did not use up the hourly limit of three.
email.Verify(e => e.TryEnqueue(user.Email!, It.IsAny<string>(), It.IsAny<string>()), Times.Exactly(4));
stored.Should().HaveCount(4);
for (var request = 0; request < 3; request++)
{
var code = System.Text.RegularExpressions.Regex.Match(bodies[request], @"Your Password Reset Code is: (\d{6})").Groups[1].Value;
PasswordResetCodeSecrets.Matches(ResetKey, stored[request].Salt, code, stored[request].Hash).Should().BeFalse();
}
var delivered = System.Text.RegularExpressions.Regex.Match(bodies[3], @"Your Password Reset Code is: (\d{6})").Groups[1].Value;
PasswordResetCodeSecrets.Matches(ResetKey, stored[3].Salt, delivered, stored[3].Hash).Should().BeTrue();
forget.Verify(f => f.RemoveByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task ForgetPassword_EmailThatCannotBeQueued_MakesTheSameDataCallsAsAnUnknownEmail()
{
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetByEmailNormalizedAsync("alice@example.com", It.IsAny<CancellationToken>())).ReturnsAsync(IdentityTestHelpers.User());
var registered = new Mock<IForgetPasswordDataService>();
var unregistered = new Mock<IForgetPasswordDataService>();
var email = new Mock<IPasswordResetEmailQueue>();
email.Setup(e => e.TryEnqueue(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>())).Returns(false);
await NewService(userData, registered, email, out _, out _).ForgetPasswordAsync("alice@example.com", CancellationToken.None);
await NewService(userData, unregistered, email, out _, out _).ForgetPasswordAsync("nope@example.com", CancellationToken.None);
registered.Invocations.Select(call => call.Method.Name)
.Should().Equal(unregistered.Invocations.Select(call => call.Method.Name))
.And.Equal(nameof(IForgetPasswordDataService.ReplaceCodeAsync));
}
[Fact]
public async Task ForgetPassword_DeletedAccount_IsTreatedAsUnregistered()
{
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(IdentityTestHelpers.User(isDeleted: true));
var forget = new Mock<IForgetPasswordDataService>();
var email = new Mock<IPasswordResetEmailQueue>();
var service = NewService(userData, forget, email, out _, out _);
await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None);
email.Verify(e => e.TryEnqueue(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Theory]
[InlineData(null, "123456")]
[InlineData("", "123456")]
[InlineData(" ", "123456")]
[InlineData("a@b.com", null)]
[InlineData("a@b.com", "")]
public async Task VerifyCode_WithoutEmailOrCode_FailsWithoutTouchingStoredCodes(string? emailAddress, string? code)
{
var forget = new Mock<IForgetPasswordDataService>(MockBehavior.Strict);
var service = NewService(new Mock<IUserDataService>(), forget, new Mock<IPasswordResetEmailQueue>(), out _, out _);
var result = await service.VerifyCodeAsync(emailAddress, code, CancellationToken.None);
result.Should().BeFalse();
}
[Fact]
public async Task ResetPassword_NoPendingCodeForEmail_ReturnsFalseWithoutReset()
{
var forget = new Mock<IForgetPasswordDataService>();
forget.Setup(f => f.GetByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync((ForgetPasswordCode?)null);
var service = NewService(new Mock<IUserDataService>(), forget, new Mock<IPasswordResetEmailQueue>(), out var store, out _);
var result = await service.ResetPasswordAsync("a@b.com", "999999", "new", CancellationToken.None);
result.Should().BeFalse();
store.Verify(s => s.FindByIdAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
forget.Verify(f => f.TryConsumeAttemptAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task ResetPassword_AttemptBudgetSpent_DeletesTheCodeAndFails()
{
var pending = new ForgetPasswordCode { Id = 7, Email = "a@b.com", UserId = "u1", CodeSalt = "s", CodeHash = PasswordResetCodeSecrets.Hash(ResetKey, "s", "123456"), FailedAttempts = 5 };
var forget = new Mock<IForgetPasswordDataService>();
forget.Setup(f => f.GetByEmailAsync("a@b.com", It.IsAny<CancellationToken>())).ReturnsAsync(pending);
forget.Setup(f => f.TryConsumeAttemptAsync(7, AuthenticationService.MaxCodeAttempts, It.IsAny<DateTime>(), It.IsAny<CancellationToken>())).ReturnsAsync(false);
var service = NewService(new Mock<IUserDataService>(), forget, new Mock<IPasswordResetEmailQueue>(), out var store, out _);
var result = await service.ResetPasswordAsync("a@b.com", "123456", "New@67890", CancellationToken.None);
result.Should().BeFalse();
forget.Verify(f => f.RemoveIssuedThroughAsync("a@b.com", 7, It.IsAny<CancellationToken>()), Times.Once);
store.Verify(s => s.FindByIdAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task VerifyCode_FailedOrCancelledLookups_LeaveTheAccountCheckBudgetUntouched(bool cancelled)
{
var pending = new ForgetPasswordCode { Id = 7, Email = "a@b.com", UserId = "u1", CodeSalt = "s", CodeHash = PasswordResetCodeSecrets.Hash(ResetKey, "s", "123456") };
var forget = new Mock<IForgetPasswordDataService>();
var lookups = 0;
forget.Setup(f => f.GetByEmailAsync("a@b.com", It.IsAny<CancellationToken>()))
.Returns(() => ++lookups <= InMemoryPasswordResetThrottle.FailedChecksPerDay
? Task.FromException<ForgetPasswordCode?>(cancelled ? new OperationCanceledException() : new InvalidOperationException("database unavailable"))
: Task.FromResult<ForgetPasswordCode?>(pending));
forget.Setup(f => f.TryConsumeAttemptAsync(7, AuthenticationService.MaxCodeAttempts, It.IsAny<DateTime>(), It.IsAny<CancellationToken>())).ReturnsAsync(true);
var service = NewService(new Mock<IUserDataService>(), forget, new Mock<IPasswordResetEmailQueue>(), out _, out _);
for (var check = 0; check < InMemoryPasswordResetThrottle.FailedChecksPerDay; check++)
{
var act = () => service.VerifyCodeAsync("a@b.com", "123456", CancellationToken.None);
await act.Should().ThrowAsync<Exception>();
}
(await service.VerifyCodeAsync("a@b.com", "123456", CancellationToken.None)).Should().BeTrue();
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task VerifyCode_FailedOrCancelledAttemptConsumes_LeaveTheAccountCheckBudgetUntouched(bool cancelled)
{
var pending = new ForgetPasswordCode { Id = 7, Email = "a@b.com", UserId = "u1", CodeSalt = "s", CodeHash = PasswordResetCodeSecrets.Hash(ResetKey, "s", "123456") };
var forget = new Mock<IForgetPasswordDataService>();
forget.Setup(f => f.GetByEmailAsync("a@b.com", It.IsAny<CancellationToken>())).ReturnsAsync(pending);
var consumes = 0;
forget.Setup(f => f.TryConsumeAttemptAsync(7, AuthenticationService.MaxCodeAttempts, It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
.Returns(() => ++consumes <= InMemoryPasswordResetThrottle.FailedChecksPerDay
? Task.FromException<bool>(cancelled ? new OperationCanceledException() : new InvalidOperationException("database unavailable"))
: Task.FromResult(true));
var service = NewService(new Mock<IUserDataService>(), forget, new Mock<IPasswordResetEmailQueue>(), out _, out _);
for (var check = 0; check < InMemoryPasswordResetThrottle.FailedChecksPerDay; check++)
{
var act = () => service.VerifyCodeAsync("a@b.com", "123456", CancellationToken.None);
await act.Should().ThrowAsync<Exception>();
}
(await service.VerifyCodeAsync("a@b.com", "123456", CancellationToken.None)).Should().BeTrue();
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task ForgetPassword_FailedOrCancelledWrites_DoNotUseUpTheEmailRequestLimit(bool cancelled)
{
var user = IdentityTestHelpers.User();
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(user);
var forget = new Mock<IForgetPasswordDataService>();
var writes = 0;
forget.Setup(f => f.ReplaceCodeAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
.Returns(() => ++writes <= InMemoryPasswordResetThrottle.CodeRequestsPerHour
? Task.FromException(cancelled ? new OperationCanceledException() : new InvalidOperationException("database unavailable"))
: Task.CompletedTask);
var email = new Mock<IPasswordResetEmailQueue>();
email.Setup(e => e.TryEnqueue(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>())).Returns(true);
var service = NewService(userData, forget, email, out _, out _);
for (var request = 0; request < InMemoryPasswordResetThrottle.CodeRequestsPerHour; request++)
{
var act = () => service.ForgetPasswordAsync("alice@example.com", CancellationToken.None);
await act.Should().ThrowAsync<Exception>();
}
await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None);
writes.Should().Be(InMemoryPasswordResetThrottle.CodeRequestsPerHour + 1);
forget.Verify(f => f.PurgeExpiredAsync(It.IsAny<DateTime>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Theory]
[InlineData("123456", "123456", true)]
[InlineData("123456", " 123456 ", true)]
[InlineData("123456", "123457", false)]
public void ResetCodeHash_IsSaltedAndComparedByValue(string issued, string candidate, bool expected)
{
var salt = PasswordResetCodeSecrets.NewSalt();
var hash = PasswordResetCodeSecrets.Hash(ResetKey, salt, issued);
PasswordResetCodeSecrets.Matches(ResetKey, salt, candidate, hash).Should().Be(expected);
PasswordResetCodeSecrets.Hash(ResetKey, PasswordResetCodeSecrets.NewSalt(), issued).Should().NotBe(hash);
PasswordResetCodeSecrets.Matches(ResetKey, "", issued, hash).Should().BeFalse();
PasswordResetCodeSecrets.Matches(ResetKey, salt, issued, "").Should().BeFalse();
}
}