mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 06:03:12 +00:00
Forgot Password answers every address the same way and emails a code only to an active account. Codes are stored as salted SHA-256 hashes, expire 15 minutes after issue, are replaced by a newer request, and are checked only against the email they were issued to. Five failed checks delete the code; attempts are reserved with one conditional UPDATE so concurrent guesses cannot exceed the budget. VerificationCode requires the email, and email and code are accepted in the JSON body so they stay out of URLs. The three anonymous endpoints are rate limited to 10 requests per 15 minutes per client IP. Forwarded headers are trusted only through loopback and private hops, since the API sits behind the EB load balancer and nginx. The migration adds hash, salt, expiry and attempt columns and deletes the old plaintext rows.
227 lines
11 KiB
C#
227 lines
11 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<IEmailSender> 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, 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<IEmailSender>(), 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<IEmailSender>(), 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<IEmailSender>(), 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<IEmailSender>(), 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();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ForgetPassword_RegisteredEmail_StoresOnlyASaltedHashAndEmailsTheCode()
|
|
{
|
|
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<IEmailSender>();
|
|
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<CancellationToken>()))
|
|
.Callback<string, string, string, string, DateTime, CancellationToken>((_, _, h, s, e, _) => { stored = h; salt = s; expires = e; })
|
|
.Returns(Task.CompletedTask);
|
|
email.Setup(e => e.SendEmailAsync(user.Email!, "Forget Password Request.", It.IsAny<string>()))
|
|
.Callback<string, string, string>((_, _, b) => body = b)
|
|
.ReturnsAsync(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(salt!, code, stored!).Should().BeTrue();
|
|
expires.Should().BeCloseTo(before.AddMinutes(15), TimeSpan.FromSeconds(5));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ForgetPassword_UnknownEmail_SendsNothingButStillDoesTheDatabaseRoundTrip()
|
|
{
|
|
var userData = new Mock<IUserDataService>();
|
|
userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync((ApplicationUser?)null);
|
|
var forget = new Mock<IForgetPasswordDataService>();
|
|
var email = new Mock<IEmailSender>();
|
|
|
|
var service = NewService(userData, forget, email, out _, out _);
|
|
|
|
await service.ForgetPasswordAsync("nope@example.com", CancellationToken.None);
|
|
|
|
forget.Verify(f => f.RemoveByEmailAsync("nope@example.com", It.IsAny<CancellationToken>()), Times.Once);
|
|
forget.Verify(f => f.ReplaceCodeAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()), Times.Never);
|
|
email.Verify(e => e.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
|
}
|
|
|
|
[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<IEmailSender>();
|
|
|
|
var service = NewService(userData, forget, email, out _, out _);
|
|
|
|
await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None);
|
|
|
|
email.Verify(e => e.SendEmailAsync(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<IEmailSender>(), 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<IEmailSender>(), 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("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<IEmailSender>(), out var store, out _);
|
|
|
|
var result = await service.ResetPasswordAsync("a@b.com", "123456", "New@67890", CancellationToken.None);
|
|
|
|
result.Should().BeFalse();
|
|
forget.Verify(f => f.RemoveAsync(7, It.IsAny<CancellationToken>()), Times.Once);
|
|
store.Verify(s => s.FindByIdAsync(It.IsAny<string>(), 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(salt, issued);
|
|
|
|
PasswordResetCodeSecrets.Matches(salt, candidate, hash).Should().Be(expected);
|
|
PasswordResetCodeSecrets.Hash(PasswordResetCodeSecrets.NewSalt(), issued).Should().NotBe(hash);
|
|
PasswordResetCodeSecrets.Matches("", issued, hash).Should().BeFalse();
|
|
PasswordResetCodeSecrets.Matches(salt, issued, "").Should().BeFalse();
|
|
}
|
|
}
|