Skip to content

Commit 62b0c51

Browse files
authored
Merge pull request #1302 from marcelo-maciel/fix/identity-reset-link
fix(identity): correct password-reset email link (trailing slash, tenant, URL-encoding)
2 parents 8c216ad + 34985d9 commit 62b0c51

2 files changed

Lines changed: 111 additions & 1 deletion

File tree

src/Modules/Identity/Modules.Identity/Services/UserPasswordService.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,18 @@ public async Task ForgotPasswordAsync(string email, string origin, CancellationT
3939
var token = await userManager.GeneratePasswordResetTokenAsync(user);
4040
token = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(token));
4141

42-
var resetPasswordUri = $"{origin}/reset-password?token={token}&email={email}";
42+
// Build the SPA reset link with QueryHelpers (matches GetEmailVerificationUriAsync): trim any trailing
43+
// slash from the configured origin (Uri.ToString adds one for a host-only URL → "//reset-password"
44+
// misses the client route) and include the tenant the reset page requires. QueryHelpers URL-encodes
45+
// each value, so reserved chars in the email (e.g. '+') survive.
46+
var resetPasswordUri = QueryHelpers.AddQueryString(
47+
$"{origin.TrimEnd('/')}/reset-password",
48+
new Dictionary<string, string?>
49+
{
50+
["token"] = token,
51+
["email"] = email,
52+
["tenant"] = multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id,
53+
});
4354
var mailRequest = new MailRequest(
4455
new Collection<string> { user.Email },
4556
"Reset Password",
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
using System.Linq.Expressions;
2+
using Finbuckle.MultiTenant.Abstractions;
3+
using FSH.Framework.Jobs.Services;
4+
using FSH.Framework.Mailing;
5+
using FSH.Framework.Mailing.Services;
6+
using FSH.Framework.Shared.Multitenancy;
7+
using FSH.Modules.Identity.Domain;
8+
using FSH.Modules.Identity.Services;
9+
using Microsoft.AspNetCore.Identity;
10+
using NSubstitute;
11+
12+
namespace Identity.Tests.Services;
13+
14+
/// <summary>
15+
/// Tests for UserPasswordService.ForgotPasswordAsync — focuses on the reset-link format
16+
/// (regression cover for the double-slash, missing-tenant and unencoded-email defects).
17+
/// </summary>
18+
public sealed class UserPasswordServiceTests
19+
{
20+
private const string TenantId = "codefi";
21+
22+
private readonly UserManager<FshUser> _userManager;
23+
private readonly IJobService _jobService;
24+
private readonly IMailService _mailService;
25+
private readonly IMultiTenantContextAccessor<AppTenantInfo> _tenantAccessor;
26+
27+
public UserPasswordServiceTests()
28+
{
29+
_userManager = Substitute.For<UserManager<FshUser>>(
30+
Substitute.For<IUserStore<FshUser>>(), null, null, null, null, null, null, null, null);
31+
_jobService = Substitute.For<IJobService>();
32+
_mailService = Substitute.For<IMailService>();
33+
_tenantAccessor = Substitute.For<IMultiTenantContextAccessor<AppTenantInfo>>();
34+
35+
var mtContext = Substitute.For<IMultiTenantContext<AppTenantInfo>>();
36+
mtContext.TenantInfo.Returns(new AppTenantInfo(TenantId, TenantId, "Codefi"));
37+
_tenantAccessor.MultiTenantContext.Returns(mtContext);
38+
39+
// The mail job is enqueued as an expression; compile + invoke it so the captured MailRequest
40+
// reaches the (mocked) mail service exactly as production would build it.
41+
_jobService.Enqueue(Arg.Any<Expression<Func<Task>>>())
42+
.Returns(ci =>
43+
{
44+
ci.Arg<Expression<Func<Task>>>().Compile().Invoke();
45+
return "job-1";
46+
});
47+
_mailService.SendAsync(Arg.Any<MailRequest>(), Arg.Any<CancellationToken>()).Returns(Task.CompletedTask);
48+
}
49+
50+
private UserPasswordService CreateSut() =>
51+
new(_userManager, null!, _jobService, _mailService, _tenantAccessor, null!, null!);
52+
53+
private MailRequest CaptureSentMail()
54+
{
55+
var call = _mailService.ReceivedCalls().Single();
56+
return (MailRequest)call.GetArguments()[0]!;
57+
}
58+
59+
[Fact]
60+
public async Task ForgotPasswordAsync_Should_BuildResetLink_WithSingleSlash_Tenant_AndEncodedEmail()
61+
{
62+
// Arrange — trailing slash on the origin (as Uri.ToString() produces for a host-only URL) and an
63+
// email with reserved characters ('+', '@') to exercise all three defects at once.
64+
const string email = "marcelo+reset@codefi.com.br";
65+
var user = new FshUser { Email = email, UserName = email };
66+
_userManager.FindByEmailAsync(email).Returns(user);
67+
_userManager.GeneratePasswordResetTokenAsync(user).Returns("raw-token");
68+
69+
var sut = CreateSut();
70+
71+
// Act
72+
await sut.ForgotPasswordAsync(email, "https://appbase.codefi.com.br/", CancellationToken.None);
73+
74+
// Assert
75+
var body = CaptureSentMail().Body!;
76+
body.ShouldContain("https://appbase.codefi.com.br/reset-password?");
77+
body.ShouldNotContain("//reset-password"); // defect 3: no double slash
78+
body.ShouldContain($"&tenant={TenantId}"); // defect 4: tenant present
79+
// defect 5: reserved chars are encoded — '+' must become %2B (an unencoded '+' would decode to a
80+
// space). '@' is left as-is, which is valid in a query component per RFC 3986 (QueryHelpers encodes
81+
// only what is required, matching GetEmailVerificationUriAsync).
82+
body.ShouldContain("email=marcelo%2Breset");
83+
body.ShouldNotContain("email=marcelo+reset"); // raw '+' must not leak
84+
}
85+
86+
[Fact]
87+
public async Task ForgotPasswordAsync_Should_NotEnqueueMail_When_UserIsUnknown()
88+
{
89+
// Arrange — anti-enumeration: unknown user silently no-ops (no mail), still a 200 upstream.
90+
_userManager.FindByEmailAsync(Arg.Any<string>()).Returns((FshUser?)null);
91+
var sut = CreateSut();
92+
93+
// Act
94+
await sut.ForgotPasswordAsync("ghost@codefi.com.br", "https://appbase.codefi.com.br/", CancellationToken.None);
95+
96+
// Assert
97+
_jobService.DidNotReceive().Enqueue(Arg.Any<Expression<Func<Task>>>());
98+
}
99+
}

0 commit comments

Comments
 (0)