diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ResetDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ResetDialog.razor index 06a515aa..8d4c90d3 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ResetDialog.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ResetDialog.razor @@ -25,8 +25,7 @@ Request Reset @if (_resetRequested) { - Your reset code is: (Copy it before next step) - @_resetCode + Your reset code is on the server console. Use Reset Code } diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ResetDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ResetDialog.razor.cs index a819f039..f70034d2 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ResetDialog.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ResetDialog.razor.cs @@ -9,7 +9,6 @@ namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Components.Dialogs; public partial class ResetDialog { private bool _resetRequested = false; - private string? _resetCode; private string? _identifier; [CascadingParameter] @@ -34,7 +33,6 @@ private async Task RequestResetAsync() return; } - _resetCode = result.Value.Token; _resetRequested = true; } diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor index d09fcfa0..26863c14 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor @@ -1,6 +1,4 @@ -@using CodeBeam.UltimateAuth.Core.Contracts -@using CodeBeam.UltimateAuth.Users.Contracts -@inject IUAuthClient UAuthClient +@inject IUAuthClient UAuthClient @inject ISnackbar Snackbar @inject IDialogService DialogService diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor index 06a515aa..4809f4f7 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor @@ -1,7 +1,4 @@ -@using CodeBeam.UltimateAuth.Core.Contracts -@using CodeBeam.UltimateAuth.Credentials.Contracts -@using CodeBeam.UltimateAuth.Users.Contracts -@inject IUAuthClient UAuthClient +@inject IUAuthClient UAuthClient @inject ISnackbar Snackbar @inject IDialogService DialogService @inject IUAuthStateManager StateManager @@ -15,8 +12,8 @@ - This is a demonstration of how to implement a credential reset flow. - In a production application, you should use reset token or code in email, SMS etc. verification steps. + In this development sample, reset codes are written to the server console by a custom ICredentialResetNotifier. + Production applications should send them through a trusted email or SMS channel. Reset request always returns ok even with not found users due to security reasons. @@ -25,9 +22,8 @@ Request Reset @if (_resetRequested) { - Your reset code is: (Copy it before next step) - @_resetCode - Use Reset Code + Your reset code is on the server console. + Use Reset Code } diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor.cs index 9ca66fc3..96e81a55 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor.cs @@ -9,7 +9,6 @@ namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Dialogs; public partial class ResetDialog { private bool _resetRequested = false; - private string? _resetCode; private string? _identifier; [CascadingParameter] @@ -34,7 +33,6 @@ private async Task RequestResetAsync() return; } - _resetCode = result.Value.Token; _resetRequested = true; } diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Infrastructure/DevelopmentCredentialResetNotifier.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Infrastructure/DevelopmentCredentialResetNotifier.cs new file mode 100644 index 00000000..db379e8f --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Infrastructure/DevelopmentCredentialResetNotifier.cs @@ -0,0 +1,21 @@ +using CodeBeam.UltimateAuth.Credentials; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Infrastructure; + +internal sealed class DevelopmentCredentialResetNotifier(ILogger logger) : ICredentialResetNotifier +{ + public Task NotifyAsync(CredentialResetNotification notification, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + logger.LogWarning( + "DEVELOPMENT ONLY — Credential reset for tenant {Tenant}, " + + "user {UserKey}. Code: {Code}. Expires at: {ExpiresAt}", + notification.Tenant, + notification.UserKey, + notification.Token, + notification.ExpiresAt); + + return Task.CompletedTask; + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs index b939d487..fb79b4a0 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs @@ -3,6 +3,7 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.Credentials; using CodeBeam.UltimateAuth.InMemory; using CodeBeam.UltimateAuth.Sample.BlazorServer.Components; using CodeBeam.UltimateAuth.Sample.BlazorServer.Infrastructure; @@ -63,6 +64,11 @@ builder.Services.AddUltimateAuthSampleSeed(); +if (builder.Environment.IsDevelopment()) +{ + builder.Services.AddScoped(); +} + builder.Services.Configure(options => { options.ForwardedHeaders = diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResetDialog.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResetDialog.razor index 06a515aa..8d4c90d3 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResetDialog.razor +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResetDialog.razor @@ -25,8 +25,7 @@ Request Reset @if (_resetRequested) { - Your reset code is: (Copy it before next step) - @_resetCode + Your reset code is on the server console. Use Reset Code } diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResetDialog.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResetDialog.razor.cs index 977d1e38..7dab5f66 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResetDialog.razor.cs +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResetDialog.razor.cs @@ -9,7 +9,6 @@ namespace CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Components.Dialogs; public partial class ResetDialog { private bool _resetRequested = false; - private string? _resetCode; private string? _identifier; [CascadingParameter] @@ -34,7 +33,6 @@ private async Task RequestResetAsync() return; } - _resetCode = result.Value.Token; _resetRequested = true; } diff --git a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/ResetDialog.razor b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/ResetDialog.razor index 06a515aa..8d4c90d3 100644 --- a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/ResetDialog.razor +++ b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/ResetDialog.razor @@ -25,8 +25,7 @@ Request Reset @if (_resetRequested) { - Your reset code is: (Copy it before next step) - @_resetCode + Your reset code is on the server console. Use Reset Code } diff --git a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/ResetDialog.razor.cs b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/ResetDialog.razor.cs index 04657723..17ff4866 100644 --- a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/ResetDialog.razor.cs +++ b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/ResetDialog.razor.cs @@ -9,7 +9,6 @@ namespace CodeBeam.UAuth.Sample.IntWasm.Client.Components.Dialogs; public partial class ResetDialog { private bool _resetRequested = false; - private string? _resetCode; private string? _identifier; [CascadingParameter] @@ -34,7 +33,6 @@ private async Task RequestResetAsync() return; } - _resetCode = result.Value.Token; _resetRequested = true; } diff --git a/src/CodeBeam.UltimateAuth.Server/AssemblyVisibility.cs b/src/CodeBeam.UltimateAuth.Server/AssemblyVisibility.cs index d3c09386..247b2327 100644 --- a/src/CodeBeam.UltimateAuth.Server/AssemblyVisibility.cs +++ b/src/CodeBeam.UltimateAuth.Server/AssemblyVisibility.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Credentials.Reference")] [assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")] [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs index 690e5e98..dfa550da 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs @@ -358,6 +358,7 @@ internal static IServiceCollection AddUsersInternal(IServiceCollection services) internal static IServiceCollection AddCredentialsInternal(IServiceCollection services) { services.TryAddScoped(); + services.TryAddScoped(); return services; } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/NotConfiguredCredentialResetNotifier.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/NotConfiguredCredentialResetNotifier.cs new file mode 100644 index 00000000..2215e023 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/NotConfiguredCredentialResetNotifier.cs @@ -0,0 +1,20 @@ +using CodeBeam.UltimateAuth.Credentials; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed class NotConfiguredCredentialResetNotifier : ICredentialResetNotifier +{ + internal static InvalidOperationException CreateException() => new( + "Credential reset notification is not configured. " + + "Implement ICredentialResetNotifier and register it with " + + "services.AddScoped(). " + + "The implementation must send the reset code or token " + + "through a trusted channel associated with the user's account."); + + public Task NotifyAsync(CredentialResetNotification context, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + throw CreateException(); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/BeginCredentialResetResult.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/BeginCredentialResetResult.cs index c7f399c8..db8d2c8c 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/BeginCredentialResetResult.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/BeginCredentialResetResult.cs @@ -2,6 +2,5 @@ public sealed record BeginCredentialResetResult { - public string? Token { get; init; } public DateTimeOffset ExpiresAt { get; init; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs index 71d051d6..95735d6e 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs @@ -24,6 +24,7 @@ internal sealed class CredentialManagementService : ICredentialManagementService private readonly ITokenHasher _tokenHasher; private readonly ILoginIdentifierResolver _identifierResolver; private readonly ISessionStoreFactory _sessionFactory; + private readonly ICredentialResetNotifier _resetNotifier; private readonly UAuthServerOptions _options; private readonly IClock _clock; @@ -37,6 +38,7 @@ public CredentialManagementService( ITokenHasher tokenHasher, ILoginIdentifierResolver identifierResolver, ISessionStoreFactory sessionFactory, + ICredentialResetNotifier resetNotifier, IOptions options, IClock clock) { @@ -49,6 +51,7 @@ public CredentialManagementService( _tokenHasher = tokenHasher; _identifierResolver = identifierResolver; _sessionFactory = sessionFactory; + _resetNotifier = resetNotifier; _options = options.Value; _clock = clock; } @@ -199,22 +202,28 @@ public async Task BeginResetAsync(AccessContext cont var cmd = new AccessCommand(async innerCt => { + + if (_resetNotifier is NotConfiguredCredentialResetNotifier) + throw NotConfiguredCredentialResetNotifier.CreateException(); + if (string.IsNullOrWhiteSpace(request.Identifier)) throw new UAuthValidationException("identifier_required"); var now = _clock.UtcNow; - var validity = request.Validity ?? _options.ResetCredential.TokenValidity; + // Anonymous callers must not control the proof's lifetime. + var validity = _options.ResetCredential.TokenValidity; + var expiresAt = now.Add(validity); + + var result = new BeginCredentialResetResult + { + ExpiresAt = expiresAt + }; + var resolution = await _identifierResolver.ResolveAsync(context.ResourceTenant, request.Identifier, innerCt); if (resolution?.UserKey is not UserKey userKey) - { - return new BeginCredentialResetResult - { - Token = null, - ExpiresAt = now.Add(validity) - }; - } + return result; // Enumeration protection var state = await _authenticationSecurityManager .GetOrCreateFactorAsync(context.ResourceTenant, userKey, request.CredentialType, innerCt); @@ -239,11 +248,17 @@ public async Task BeginResetAsync(AccessContext cont var updatedState = state.BeginReset(tokenHash, now, validity); await _authenticationSecurityManager.UpdateAsync(updatedState, state.SecurityVersion, innerCt); - return new BeginCredentialResetResult - { - Token = token, - ExpiresAt = now.Add(validity) - }; + await _resetNotifier.NotifyAsync( + new CredentialResetNotification( + Tenant: context.ResourceTenant, + UserKey: userKey, + CredentialType: request.CredentialType, + CodeType: request.ResetCodeType, + Token: token, + ExpiresAt: expiresAt), + innerCt); + + return result; }); return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialResetNotifier.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialResetNotifier.cs new file mode 100644 index 00000000..d6ca6c3c --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialResetNotifier.cs @@ -0,0 +1,17 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Credentials; + +public interface ICredentialResetNotifier +{ + Task NotifyAsync(CredentialResetNotification notification, CancellationToken ct = default); +} + +public sealed record CredentialResetNotification( + TenantKey Tenant, + UserKey UserKey, + CredentialType CredentialType, + ResetCodeType CodeType, + string Token, + DateTimeOffset ExpiresAt); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs index 601b268e..b6f2bf97 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs @@ -5,6 +5,7 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials; using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Credentials.Reference; using CodeBeam.UltimateAuth.Credentials.Reference.Internal; @@ -746,6 +747,9 @@ private sealed class Fixture public Mock IdentifierResolver { get; } = new(MockBehavior.Strict); + public Mock ResetNotifier { get; } + = new(MockBehavior.Strict); + public Mock SessionStoreFactory { get; } = new(MockBehavior.Strict); @@ -830,6 +834,7 @@ public Fixture() TokenHasher.Object, IdentifierResolver.Object, SessionStoreFactory.Object, + ResetNotifier.Object, options, Clock.Object); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs index fced32ec..99785fda 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs @@ -1,98 +1,106 @@ using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Credentials; using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Tests.Unit.Helpers; using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using System.Text.Json; namespace CodeBeam.UltimateAuth.Tests.Unit; public class ResetPasswordTests { [Fact] - public async Task Begin_reset_with_token_should_return_token() + public async Task Begin_reset_with_token_should_notify_without_exposing_token() { - var runtime = new TestAuthRuntime(); + var (runtime, notifier) = CreateRuntime(); var service = runtime.GetCredentialManagementService(); - var context = TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous); + var result = await service.BeginResetAsync( + BeginContext(), + BeginRequest(ResetCodeType.Token)); - var result = await service.BeginResetAsync(context, - new BeginResetCredentialRequest - { - Identifier = "admin", - CredentialType = CredentialType.Password, - ResetCodeType = ResetCodeType.Token - }); + var notification = notifier.Notifications.Should() + .ContainSingle().Subject; - result.Token.Should().NotBeNull(); - result.Token!.Length.Should().BeGreaterThan(20); - result.ExpiresAt.Should().BeAfter(DateTimeOffset.UtcNow); - } + notification.Token.Should().NotBeNullOrWhiteSpace(); + notification.Token.Length.Should().BeGreaterThan(20); + notification.CodeType.Should().Be(ResetCodeType.Token); + notification.CredentialType.Should().Be(CredentialType.Password); + notification.ExpiresAt.Should().Be(result.ExpiresAt); + result.ExpiresAt.Should().BeAfter(runtime.Clock.UtcNow); - [Fact] - public async Task Begin_reset_with_code_should_return_numeric_code() - { - var runtime = new TestAuthRuntime(); - var service = runtime.GetCredentialManagementService(); + // The public JSON response must not contain the reset proof. + using var json = JsonDocument.Parse(JsonSerializer.Serialize(result, new JsonSerializerOptions(JsonSerializerDefaults.Web))); + + json.RootElement.TryGetProperty("token", out _).Should().BeFalse(); + JsonSerializer.Serialize(result) + .Should().NotContain(notification.Token); - var context = TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous); + var json1 = JsonSerializer.Serialize(result, new JsonSerializerOptions(JsonSerializerDefaults.Web)); - var result = await service.BeginResetAsync(context, - new BeginResetCredentialRequest - { - Identifier = "admin", - CredentialType = CredentialType.Password, - ResetCodeType = ResetCodeType.Code - }); + using var document = JsonDocument.Parse(json1); - result.Token.Should().NotBeNull(); - result.Token!.Should().MatchRegex("^[0-9]{6}$"); + document.RootElement + .EnumerateObject() + .Select(property => property.Name) + .Should() + .BeEquivalentTo( + new[] { "expiresAt" }, + "public reset response must contain only the approved fields"); + + json1.Should().NotContain( + notification.Token, + "the reset proof must be delivered only through the notifier"); } [Fact] - public async Task Begin_reset_for_unknown_user_should_not_fail() + public async Task Begin_reset_with_code_should_notify_numeric_code() { - var runtime = new TestAuthRuntime(); - var service = runtime.GetCredentialManagementService(); + var (runtime, notifier) = CreateRuntime(); - var context = TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous); + await runtime.GetCredentialManagementService().BeginResetAsync( + BeginContext(), + BeginRequest(ResetCodeType.Code)); - var result = await service.BeginResetAsync(context, - new BeginResetCredentialRequest - { - Identifier = "unknown@test.com", - CredentialType = CredentialType.Password, - ResetCodeType = ResetCodeType.Token - }); + var notification = notifier.Notifications.Should() + .ContainSingle().Subject; + + notification.CodeType.Should().Be(ResetCodeType.Code); + notification.Token.Should().MatchRegex("^[0-9]{6}$"); + } - result.Token.Should().BeNull(); + [Fact] + public async Task Begin_reset_for_unknown_user_should_not_notify() + { + var (runtime, notifier) = CreateRuntime(); + + var result = await runtime.GetCredentialManagementService() + .BeginResetAsync( + BeginContext(), + BeginRequest( + ResetCodeType.Token, + "unknown@test.com")); + + result.Should().NotBeNull(); + result.ExpiresAt.Should().BeAfter(runtime.Clock.UtcNow); + notifier.Notifications.Should().BeEmpty(); } [Fact] public async Task Reset_password_with_valid_token_should_succeed() { - var runtime = new TestAuthRuntime(); + var (runtime, notifier) = CreateRuntime(); var service = runtime.GetCredentialManagementService(); - var begin = await service.BeginResetAsync( - TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous), - new BeginResetCredentialRequest - { - Identifier = "admin", - CredentialType = CredentialType.Password, - ResetCodeType = ResetCodeType.Token - }); + var token = await BeginAndCaptureAsync( + runtime, notifier, ResetCodeType.Token); var result = await service.CompleteResetAsync( - TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), - new CompleteResetCredentialRequest - { - Identifier = "admin", - CredentialType = CredentialType.Password, - ResetToken = begin.Token!, - NewSecret = "newpass123" - }); + CompleteContext(), + CompleteRequest(token, "newpass123")); result.Succeeded.Should().BeTrue(); } @@ -100,28 +108,16 @@ public async Task Reset_password_with_valid_token_should_succeed() [Fact] public async Task Reset_password_with_same_password_should_fail() { - var runtime = new TestAuthRuntime(); + var (runtime, notifier) = CreateRuntime(); var service = runtime.GetCredentialManagementService(); - var begin = await service.BeginResetAsync( - TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous), - new BeginResetCredentialRequest - { - Identifier = "admin", - CredentialType = CredentialType.Password, - ResetCodeType = ResetCodeType.Token - }); + var token = await BeginAndCaptureAsync( + runtime, notifier, ResetCodeType.Token); Func act = async () => await service.CompleteResetAsync( - TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), - new CompleteResetCredentialRequest - { - Identifier = "admin", - CredentialType = CredentialType.Password, - ResetToken = begin.Token!, - NewSecret = "admin" - }); + CompleteContext(), + CompleteRequest(token, "admin")); await act.Should().ThrowAsync(); } @@ -129,45 +125,31 @@ await service.CompleteResetAsync( [Fact] public async Task Reset_token_should_lock_after_max_attempts() { - var runtime = new TestAuthRuntime(); + const int maxAttempts = 3; + var (runtime, notifier) = CreateRuntime(maxAttempts); var service = runtime.GetCredentialManagementService(); - var begin = await service.BeginResetAsync( - TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous), - new BeginResetCredentialRequest - { - Identifier = "admin", - CredentialType = CredentialType.Password, - ResetCodeType = ResetCodeType.Code - }); + var token = await BeginAndCaptureAsync( + runtime, notifier, ResetCodeType.Code); + + var wrongToken = token == "000000" ? "000001" : "000000"; - for (int i = 0; i < 3; i++) + for (var i = 0; i < maxAttempts; i++) { - try - { + Func invalidAttempt = async () => await service.CompleteResetAsync( - TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), - new CompleteResetCredentialRequest - { - Identifier = "admin", - CredentialType = CredentialType.Password, - ResetToken = begin!.Token == "000000" ? "000001" : "000000", - NewSecret = "newpass123" - }); - } - catch { } + CompleteContext(), + CompleteRequest(wrongToken, "newpass123")); + + // Do not swallow unrelated failures. + await invalidAttempt.Should() + .ThrowAsync(); } Func act = async () => await service.CompleteResetAsync( - TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), - new CompleteResetCredentialRequest - { - Identifier = "admin", - CredentialType = CredentialType.Password, - ResetToken = begin.Token!, - NewSecret = "newpass123" - }); + CompleteContext(), + CompleteRequest(token, "newpass123")); await act.Should().ThrowAsync(); } @@ -175,38 +157,22 @@ await service.CompleteResetAsync( [Fact] public async Task Reset_token_should_be_single_use() { - var runtime = new TestAuthRuntime(); + var (runtime, notifier) = CreateRuntime(); var service = runtime.GetCredentialManagementService(); - var begin = await service.BeginResetAsync( - TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous), - new BeginResetCredentialRequest - { - Identifier = "admin", - CredentialType = CredentialType.Password, - ResetCodeType = ResetCodeType.Token - }); - - await service.CompleteResetAsync( - TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), - new CompleteResetCredentialRequest - { - Identifier = "admin", - CredentialType = CredentialType.Password, - ResetToken = begin.Token!, - NewSecret = "newpass123" - }); + var token = await BeginAndCaptureAsync( + runtime, notifier, ResetCodeType.Token); + + var first = await service.CompleteResetAsync( + CompleteContext(), + CompleteRequest(token, "newpass123")); + + first.Succeeded.Should().BeTrue(); Func act = async () => await service.CompleteResetAsync( - TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), - new CompleteResetCredentialRequest - { - Identifier = "admin", - CredentialType = CredentialType.Password, - ResetToken = begin.Token!, - NewSecret = "anotherpass" - }); + CompleteContext(), + CompleteRequest(token, "anotherpass")); await act.Should().ThrowAsync(); } @@ -214,32 +180,97 @@ await service.CompleteResetAsync( [Fact] public async Task Reset_token_should_fail_if_expired() { - var runtime = new TestAuthRuntime(); + var (runtime, notifier) = CreateRuntime(); var service = runtime.GetCredentialManagementService(); - var clock = runtime.Clock; - var begin = await service.BeginResetAsync( - TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous), - new BeginResetCredentialRequest - { - Identifier = "admin", - CredentialType = CredentialType.Password, - ResetCodeType = ResetCodeType.Token - }); + var token = await BeginAndCaptureAsync( + runtime, notifier, ResetCodeType.Token); + + var notification = notifier.Notifications.Single(); - clock.Advance(TimeSpan.FromMinutes(45)); + // Advance past the actual configured expiry. + runtime.Clock.Advance( + notification.ExpiresAt - runtime.Clock.UtcNow + + TimeSpan.FromSeconds(1)); Func act = async () => await service.CompleteResetAsync( - TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), - new CompleteResetCredentialRequest - { - Identifier = "admin", - CredentialType = CredentialType.Password, - ResetToken = begin.Token!, - NewSecret = "newpass123" - }); + CompleteContext(), + CompleteRequest(token, "newpass123")); await act.Should().ThrowAsync(); } + + private static ( + TestAuthRuntime Runtime, + RecordingCredentialResetNotifier Notifier) + CreateRuntime(int maxAttempts = 3) + { + var notifier = new RecordingCredentialResetNotifier(); + + var runtime = new TestAuthRuntime( + configureServer: options => + options.ResetCredential.MaxAttempts = maxAttempts, + configureServices: services => + services.AddSingleton(notifier)); + + return (runtime, notifier); + } + + private static async Task BeginAndCaptureAsync( + TestAuthRuntime runtime, + RecordingCredentialResetNotifier notifier, + ResetCodeType codeType) + { + await runtime.GetCredentialManagementService().BeginResetAsync( + BeginContext(), + BeginRequest(codeType)); + + return notifier.Notifications.Should() + .ContainSingle().Subject.Token; + } + + private static CodeBeam.UltimateAuth.Core.Contracts.AccessContext + BeginContext() => + TestAccessContext.WithAction( + UAuthActions.Credentials.BeginResetAnonymous); + + private static CodeBeam.UltimateAuth.Core.Contracts.AccessContext + CompleteContext() => + TestAccessContext.WithAction( + UAuthActions.Credentials.CompleteResetAnonymous); + + private static BeginResetCredentialRequest BeginRequest( + ResetCodeType codeType, + string identifier = "admin") => new() + { + Identifier = identifier, + CredentialType = CredentialType.Password, + ResetCodeType = codeType + }; + + private static CompleteResetCredentialRequest CompleteRequest( + string token, + string newSecret) => new() + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetToken = token, + NewSecret = newSecret + }; + + private sealed class RecordingCredentialResetNotifier + : ICredentialResetNotifier + { + public List Notifications { get; } = []; + + public Task NotifyAsync( + CredentialResetNotification notification, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + Notifications.Add(notification); + return Task.CompletedTask; + } + } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs index b602f544..272c7a99 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs @@ -28,7 +28,7 @@ internal sealed class TestAuthRuntime where TUserId : notnull public IServiceProvider Services { get; } public TestClock Clock { get; } - public TestAuthRuntime(Action? configureServer = null, Action? configureCore = null) + public TestAuthRuntime(Action? configureServer = null, Action? configureCore = null, Action? configureServices = null) { Clock = new TestClock(); var services = new ServiceCollection(); @@ -53,6 +53,8 @@ public TestAuthRuntime(Action? configureServer = null, Actio services.AddSingleton(configuration); services.AddSingleton(Clock); + configureServices?.Invoke(services); + Services = services.BuildServiceProvider(); using (var scope = Services.CreateScope())