diff --git a/src/Host/FSH.Starter.Api/appsettings.json b/src/Host/FSH.Starter.Api/appsettings.json index 9484241d44..3029e4de77 100644 --- a/src/Host/FSH.Starter.Api/appsettings.json +++ b/src/Host/FSH.Starter.Api/appsettings.json @@ -78,6 +78,9 @@ "PasswordExpiryWarningDays": 14, "EnforcePasswordExpiry": true }, + "TwoFactorOptions": { + "Issuer": "FullStackHero" + }, "AllowedHosts": "*", "OpenApiOptions": { "Enabled": true, diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/Enroll/EnrollTwoFactorCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/Enroll/EnrollTwoFactorCommandHandler.cs index 2a95e8678c..f762288140 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/Enroll/EnrollTwoFactorCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/Enroll/EnrollTwoFactorCommandHandler.cs @@ -6,21 +6,26 @@ using FSH.Modules.Identity.Domain; using Mediator; using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; namespace FSH.Modules.Identity.Features.v1.TwoFactor.Enroll; public sealed class EnrollTwoFactorCommandHandler : ICommandHandler { - private const string IssuerName = "FullStackHero"; - private readonly UserManager _userManager; private readonly ICurrentUser _currentUser; + private readonly TwoFactorOptions _options; - public EnrollTwoFactorCommandHandler(UserManager userManager, ICurrentUser currentUser) + public EnrollTwoFactorCommandHandler( + UserManager userManager, + ICurrentUser currentUser, + IOptions options) { + ArgumentNullException.ThrowIfNull(options); _userManager = userManager; _currentUser = currentUser; + _options = options.Value; } public async ValueTask Handle( @@ -47,7 +52,7 @@ public async ValueTask Handle( var authenticatorUri = string.Format( System.Globalization.CultureInfo.InvariantCulture, "otpauth://totp/{0}:{1}?secret={2}&issuer={0}&digits=6", - UrlEncoder.Default.Encode(IssuerName), + UrlEncoder.Default.Encode(_options.Issuer), UrlEncoder.Default.Encode(email), sharedKey); diff --git a/src/Modules/Identity/Modules.Identity/IdentityModule.cs b/src/Modules/Identity/Modules.Identity/IdentityModule.cs index 3c64f20eaf..0ec72e9db7 100644 --- a/src/Modules/Identity/Modules.Identity/IdentityModule.cs +++ b/src/Modules/Identity/Modules.Identity/IdentityModule.cs @@ -128,6 +128,11 @@ public void ConfigureServices(IHostApplicationBuilder builder) // Tenant subscription grace period (shared "Billing" section) — used by the login expiry check. services.Configure(builder.Configuration.GetSection(TenantGraceOptions.SectionName)); + services.AddOptions() + .BindConfiguration(nameof(TwoFactorOptions)) + .ValidateDataAnnotations() + .ValidateOnStart(); + // Register password history service services.AddScoped(); diff --git a/src/Modules/Identity/Modules.Identity/TwoFactorOptions.cs b/src/Modules/Identity/Modules.Identity/TwoFactorOptions.cs new file mode 100644 index 0000000000..2a484c4675 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/TwoFactorOptions.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace FSH.Modules.Identity; + +/// +/// TOTP settings (config section "TwoFactorOptions"). is the name an +/// authenticator app shows next to the account; changing it only affects new enrollments, existing +/// secrets keep working. +/// +public sealed class TwoFactorOptions +{ + [Required] + public string Issuer { get; set; } = "FullStackHero"; +} diff --git a/src/Tests/Identity.Tests/Handlers/EnrollTwoFactorCommandHandlerTests.cs b/src/Tests/Identity.Tests/Handlers/EnrollTwoFactorCommandHandlerTests.cs new file mode 100644 index 0000000000..78a5d3188d --- /dev/null +++ b/src/Tests/Identity.Tests/Handlers/EnrollTwoFactorCommandHandlerTests.cs @@ -0,0 +1,111 @@ +using System.ComponentModel.DataAnnotations; +using FSH.Framework.Core.Context; +using FSH.Framework.Core.Exceptions; +using FSH.Modules.Identity; +using FSH.Modules.Identity.Contracts.v1.TwoFactor; +using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Features.v1.TwoFactor.Enroll; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; +using NSubstitute; + +namespace Identity.Tests.Handlers; + +/// +/// Tests for EnrollTwoFactorCommandHandler - the otpauth URI an authenticator app scans. +/// +public sealed class EnrollTwoFactorCommandHandlerTests +{ + private const string SharedKey = "JBSWY3DPEHPK3PXP"; + private static readonly Guid UserId = Guid.Parse("7f1c2a4e-3b5d-4c6e-8f9a-0b1c2d3e4f50"); + + private readonly UserManager _userManager; + private readonly ICurrentUser _currentUser; + + public EnrollTwoFactorCommandHandlerTests() + { + _userManager = Substitute.For>( + Substitute.For>(), null, null, null, null, null, null, null, null); + _currentUser = Substitute.For(); + + var user = new FshUser { Id = UserId.ToString(), Email = "jane@example.com" }; + _currentUser.IsAuthenticated().Returns(true); + _currentUser.GetUserId().Returns(UserId); + _userManager.FindByIdAsync(UserId.ToString()).Returns(user); + _userManager.ResetAuthenticatorKeyAsync(user).Returns(IdentityResult.Success); + _userManager.GetAuthenticatorKeyAsync(user).Returns(SharedKey); + } + + private EnrollTwoFactorCommandHandler CreateHandler(TwoFactorOptions options) => + new(_userManager, _currentUser, Options.Create(options)); + + [Fact] + public async Task Handle_Should_UseDefaultIssuer_When_IssuerIsNotConfigured() + { + // The template rewrites the brand in scaffolded projects (possibly to a name with spaces), + // so the expectation is derived from the default instead of spelling it out. + var issuer = Uri.EscapeDataString(new TwoFactorOptions().Issuer); + var sut = CreateHandler(new TwoFactorOptions()); + + var result = await sut.Handle(new EnrollTwoFactorCommand(), CancellationToken.None); + + result.AuthenticatorUri.ShouldBe( + $"otpauth://totp/{issuer}:jane@example.com?secret={SharedKey}&issuer={issuer}&digits=6"); + } + + [Fact] + public void TwoFactorOptions_Should_DefaultIssuerToTheBrand() + { + new TwoFactorOptions().Issuer.ShouldBe("FullStackHero"); + } + + [Fact] + public async Task Handle_Should_UseConfiguredIssuer_InLabelAndIssuerParameter() + { + var sut = CreateHandler(new TwoFactorOptions { Issuer = "Acme" }); + + var result = await sut.Handle(new EnrollTwoFactorCommand(), CancellationToken.None); + + result.AuthenticatorUri.ShouldBe( + $"otpauth://totp/Acme:jane@example.com?secret={SharedKey}&issuer=Acme&digits=6"); + } + + [Theory] + [InlineData("Canal Correto", "Canal%20Correto")] + [InlineData("Ação", "A%C3%A7%C3%A3o")] + [InlineData("A:B & C", "A%3AB%20%26%20C")] + public async Task Handle_Should_PercentEncodeIssuer_When_ItHasSpacesAccentsOrReservedCharacters( + string issuer, string encoded) + { + var sut = CreateHandler(new TwoFactorOptions { Issuer = issuer }); + + var result = await sut.Handle(new EnrollTwoFactorCommand(), CancellationToken.None); + + result.AuthenticatorUri.ShouldBe( + $"otpauth://totp/{encoded}:jane@example.com?secret={SharedKey}&issuer={encoded}&digits=6"); + Uri.TryCreate(result.AuthenticatorUri, UriKind.Absolute, out _).ShouldBeTrue(); + } + + [Fact] + public async Task Handle_Should_ThrowUnauthorized_When_UserIsNotAuthenticated() + { + _currentUser.IsAuthenticated().Returns(false); + var sut = CreateHandler(new TwoFactorOptions()); + + await Should.ThrowAsync( + async () => await sut.Handle(new EnrollTwoFactorCommand(), CancellationToken.None)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void TwoFactorOptions_Should_RejectBlankIssuer(string issuer) + { + var options = new TwoFactorOptions { Issuer = issuer }; + + var isValid = Validator.TryValidateObject( + options, new ValidationContext(options), new List(), validateAllProperties: true); + + isValid.ShouldBeFalse(); + } +}