Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 177 additions & 0 deletions SgfDevs.Tests/MemberPasswordResetTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
#nullable enable

using System.Net;
using System.Reflection;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using SGFDevs.Controllers;
using SGFDevs.Models;
using SGFDevs.ViewModels;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Mail;
using Umbraco.Cms.Core.Models.Email;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Web.Common.Security;
using Xunit;

namespace SgfDevs.Tests;

public class MemberPasswordResetTests
{
[Fact]
public async Task ForgotIsGenericIncludingSendFailureAndEncodesCanonicalLinkAndName()
{
var f = new Fixture();
var found = await f.Controller.ForgotPassword(new() { Email = "fictional@example.test" });
var message = Assert.IsType<EmailMessage>(f.Message);
var body = Assert.IsType<string>(message.Body);
Assert.DoesNotContain("<script>", body);
Assert.Contains("&lt;script&gt;", body);
var link = WebUtility.HtmlDecode(Regex.Match(body, "href=\"([^\"]+)\"").Groups[1].Value);
var url = new Uri(link);
Assert.Equal("https://frontend.example.test/reset-password", url.GetLeftPart(UriPartial.Path));
var query = QueryHelpers.ParseQuery(url.Query);
Assert.Equal(f.Member.Id, query["memberId"]);
Assert.Equal(Fixture.Token, query["token"]);
Assert.Equal(f.Expiration, f.SentExpiration);
f.Exists = false;
var absent = await f.Controller.ForgotPassword(new() { Email = "absent@example.test" });
Assert.Equal(Result(found).Succeeded, Result(absent).Succeeded);
Assert.Empty(Result(found).Errors);
f.Exists = true;
f.SendFailure = true;
Assert.True(Result(await f.Controller.ForgotPassword(new() { Email = "fictional@example.test" })).Succeeded);
}

[Theory]
[InlineData("", true, "sender@example.test")]
[InlineData("http://frontend.example.test", true, "sender@example.test")]
[InlineData("https://frontend.example.test/path", true, "sender@example.test")]
[InlineData("https://frontend.example.test", false, "sender@example.test")]
[InlineData("https://frontend.example.test", true, "")]
public async Task UnavailableBeforeAccountLookup(string origin, bool ready, string from)
{
var f = new Fixture(origin, ready, from);
var response = Assert.IsType<ObjectResult>((await f.Controller.ForgotPassword(new() { Email = "fictional@example.test" })).Result);
Assert.Equal(503, response.StatusCode);
Assert.Empty(f.Calls);
Assert.Null(f.Message);
}

[Fact]
public async Task MissingTokenAndConfirmationMismatchNeverReachIdentity()
{
var f = new Fixture();
var request = Valid();
request.Token = "";
Assert.Equal("This reset link is invalid or has expired.", Assert.Single(Result(await f.Controller.ResetPassword(request)).Errors[""]));
request = Valid();
request.ConfirmPassword = "Different9!";
Assert.Equal("Passwords do not match.", Assert.Single(Result(await f.Controller.ResetPassword(request)).Errors["confirmPassword"]));
Assert.Empty(f.Calls);
}

[Theory]
[InlineData("InvalidToken", "")]
[InlineData("PasswordTooShort", "password")]
[InlineData("UnknownPrivateError", "")]
public async Task MapsIdentityErrorsWithoutDescriptions(string code, string field)
{
var f = new Fixture { ResetResult = IdentityResult.Failed(new IdentityError { Code = code, Description = "private token/password/member detail" }) };
var result = Result(await f.Controller.ResetPassword(Valid()));
Assert.False(result.Succeeded);
Assert.Equal(field == "password" ? PasswordValidationRules.ErrorMessage : "This reset link is invalid or has expired.", Assert.Single(result.Errors[field]));
Assert.DoesNotContain("private", System.Text.Json.JsonSerializer.Serialize(result));
}

[Fact]
public async Task SuccessfulResetPreservesOpaqueValuesAndDoesNotSignIn()
{
var f = new Fixture();
Assert.True(Result(await f.Controller.ResetPassword(Valid())).Succeeded);
Assert.Equal(new[] { "FindByIdAsync", "ResetPasswordAsync" }, f.Calls);
}

private static MemberResetPasswordRequest Valid() => new()
{
MemberId = "opaque/id+ =", Token = Fixture.Token, Password = "Fictional9!", ConfirmPassword = "Fictional9!"
};
private static MemberPasswordResetResult Result(ActionResult<MemberPasswordResetResult> result) =>
Assert.IsType<MemberPasswordResetResult>(Assert.IsType<OkObjectResult>(result.Result).Value);

private sealed class Fixture
{
public const string Token = " +/opaque&=?%token= ";
public bool Exists = true, SendFailure;
public IdentityResult ResetResult = IdentityResult.Success;
public List<string> Calls = new();
public EmailMessage? Message;
public TimeSpan Expiration = TimeSpan.FromHours(1);
public object? SentExpiration;
public MemberIdentityUser Member = MemberIdentityUser.CreateNew("Fictional", "fictional@example.test", "Member", true, "<script>");
public MemberPasswordResetController Controller;
public Fixture(string origin = "https://frontend.example.test", bool ready = true, string from = "sender@example.test")
{
Member.Id = "opaque/id+ =";
var manager = Proxy<IMemberManager>((method, args) =>
{
Calls.Add(method.Name);
return method.Name switch
{
"FindByEmailAsync" => Task.FromResult(Exists ? Member : null),
"GeneratePasswordResetTokenAsync" => Task.FromResult(Token),
"FindByIdAsync" => Find(args),
"ResetPasswordAsync" => Reset(args),
_ => throw new Exception("Unexpected identity operation")
};
});
var mail = Proxy<IEmailSender>((method, args) =>
{
if (method.Name == "CanSendRequiredEmail") return ready;
Message = Assert.IsType<EmailMessage>(args![0]);
Assert.Equal("PasswordReset", args[1]);
Assert.Equal(true, args[2]);
SentExpiration = args[3];
if (SendFailure) throw new Exception("private mail details");
return Task.CompletedTask;
});
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["SGFDevs:MemberBridge:FrontendOrigin"] = origin
}).Build();
Controller = new(manager, mail, Options.Create(new GlobalSettings { Smtp = new SmtpSettings { From = from, EmailExpiration = Expiration } }),
config, new Host(), NullLogger<MemberPasswordResetController>.Instance);
}
private Task<MemberIdentityUser?> Find(object?[]? args)
{
Assert.Equal("opaque/id+ =", args![0]);
return Task.FromResult<MemberIdentityUser?>(Member);
}
private Task<IdentityResult> Reset(object?[]? args)
{
Assert.Equal(Token, args![1]);
Assert.Equal("Fictional9!", args[2]);
return Task.FromResult(ResetResult);
}
}
private sealed class Host : IHostEnvironment
{
public string EnvironmentName { get; set; } = Environments.Production;
public string ApplicationName { get; set; } = "Synthetic";
public string ContentRootPath { get; set; } = "/";
public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider();
}
private static T Proxy<T>(Func<MethodInfo, object?[]?, object?> handler) where T : class
{
var proxy = DispatchProxy.Create<T, MemberLoginTests.InterfaceProxy>();
((MemberLoginTests.InterfaceProxy)(object)proxy).Handler = handler;
return proxy;
}
}
151 changes: 151 additions & 0 deletions SgfDevs/Controllers/MemberPasswordResetController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
#nullable enable

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SGFDevs.Models;
using SGFDevs.ViewModels;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Mail;
using Umbraco.Cms.Core.Models.Email;
using Umbraco.Cms.Core.Security;

namespace SGFDevs.Controllers;

[ApiController]
[AllowAnonymous]
[DisableCors]
public sealed class MemberPasswordResetController(
IMemberManager memberManager, IEmailSender emailSender, IOptions<GlobalSettings> globalSettings,
IConfiguration configuration, IHostEnvironment environment,
ILogger<MemberPasswordResetController> logger) : ControllerBase
{
private const string InvalidLink = "This reset link is invalid or has expired.";
private const string Unavailable = "Password reset is unavailable right now.";

[HttpPost("api/v1/member/forgot-password", Name = "Member_ForgotPassword")]
[ProducesResponseType<MemberPasswordResetResult>(StatusCodes.Status200OK)]
[ProducesResponseType<MemberPasswordResetResult>(StatusCodes.Status503ServiceUnavailable)]
public async Task<ActionResult<MemberPasswordResetResult>> ForgotPassword(MemberForgotPasswordRequest request)
{
var model = new ForgotPasswordModel { Email = request.Email?.Trim() ?? "" };
if (model.Email.Length > 256) return Failed("email", "This value is too long.");
var errors = Validate(model);
if (errors.Count > 0) return Ok(new MemberPasswordResetResult(false, errors));
try
{
// Check deployment-wide readiness before looking up an account.
var resetUrl = ResetUrl();
var smtp = globalSettings.Value.Smtp;
if (resetUrl is null || !emailSender.CanSendRequiredEmail() || string.IsNullOrWhiteSpace(smtp?.From))
return NotAvailable();

var member = await memberManager.FindByEmailAsync(model.Email);
if (member is not null)
{
try
{
var token = await memberManager.GeneratePasswordResetTokenAsync(member);
var link = QueryHelpers.AddQueryString(resetUrl, new Dictionary<string, string?>
{
["memberId"] = member.Id, ["token"] = token
});
var body = $"<p>Hi {HtmlEncoder.Default.Encode(member.Name ?? "")},</p><p>Someone requested a password reset for your Springfield Devs account.</p><p>If that was you, use the link below to choose a new password:</p><p><a href=\"{HtmlEncoder.Default.Encode(link)}\">Reset your password</a></p><p>If you did not request this, you can ignore this email.</p>";
await emailSender.SendAsync(new EmailMessage(smtp.From, member.Email,
"Reset your Springfield Devs password", body, true), "PasswordReset", true, smtp.EmailExpiration);
}
catch
{
// Sending failure must not change the public response for an existing account.
logger.LogWarning("Password reset email could not be sent.");
}
}
return Ok(new MemberPasswordResetResult(true, new()));
}
catch
{
logger.LogWarning("Password reset is unavailable.");
return NotAvailable();
}
}

[HttpPost("api/v1/member/reset-password", Name = "Member_ResetPassword")]
[ProducesResponseType<MemberPasswordResetResult>(StatusCodes.Status200OK)]
[ProducesResponseType<MemberPasswordResetResult>(StatusCodes.Status503ServiceUnavailable)]
public async Task<ActionResult<MemberPasswordResetResult>> ResetPassword(MemberResetPasswordRequest request)
{
// Do not trim or decode opaque identity values. QueryHelpers encoded them in the email.
var model = new ResetPasswordModel
{
MemberId = request.MemberId ?? "", Token = request.Token ?? "",
Password = request.Password ?? "", ConfirmPassword = request.ConfirmPassword ?? ""
};
if (string.IsNullOrWhiteSpace(model.MemberId) || model.MemberId.Length > 256 ||
string.IsNullOrWhiteSpace(model.Token) || model.Token.Length > 8192)
return Failed("", InvalidLink);
if (model.Password.Length > 4096 || model.ConfirmPassword.Length > 4096)
return Failed("password", "This value is too long.");
var errors = Validate(model);
if (errors.Count > 0) return Ok(new MemberPasswordResetResult(false, errors));
try
{
var member = await memberManager.FindByIdAsync(model.MemberId);
if (member is null) return Failed("", InvalidLink);
var result = await memberManager.ResetPasswordAsync(member, model.Token, model.Password);
if (result.Succeeded) return Ok(new MemberPasswordResetResult(true, new()));
// Never forward Identity descriptions, which can include private submitted values.
if (result.Errors.Any(e => e.Code == "InvalidToken")) return Failed("", InvalidLink);
if (result.Errors.Any(e => e.Code.StartsWith("Password", StringComparison.Ordinal)))
return Failed("password", PasswordValidationRules.ErrorMessage);
return Failed("", InvalidLink);
}
catch
{
logger.LogWarning("Password reset could not complete.");
return NotAvailable();
}
}

private string? ResetUrl()
{
var value = configuration["SGFDevs:MemberBridge:FrontendOrigin"];
if (!Uri.TryCreate(value, UriKind.Absolute, out var origin) ||
!string.IsNullOrEmpty(origin.UserInfo) || origin.AbsolutePath != "/" ||
!string.IsNullOrEmpty(origin.Query) || !string.IsNullOrEmpty(origin.Fragment) ||
string.IsNullOrEmpty(origin.Host) ||
(origin.Scheme != Uri.UriSchemeHttps && !(environment.IsDevelopment() &&
origin.Scheme == Uri.UriSchemeHttp && origin.Host is "localhost" or "127.0.0.1" or "[::1]")))
return null;
return new Uri(origin, "/reset-password").AbsoluteUri;
}

private static Dictionary<string, string[]> Validate(object model)
{
var validation = new List<ValidationResult>();
Validator.TryValidateObject(model, new ValidationContext(model), validation, true);
var errors = new Dictionary<string, string[]>();
foreach (var item in validation)
foreach (var name in item.MemberNames)
errors[char.ToLowerInvariant(name[0]) + name[1..]] = [item.ErrorMessage ?? "Invalid value."];
return errors;
}

private ActionResult<MemberPasswordResetResult> Failed(string field, string message) =>
Ok(new MemberPasswordResetResult(false, new() { [field] = [message] }));

private ActionResult<MemberPasswordResetResult> NotAvailable() =>
StatusCode(StatusCodes.Status503ServiceUnavailable,
new MemberPasswordResetResult(false, new() { [""] = [Unavailable] }));
}
6 changes: 4 additions & 2 deletions SgfDevs/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ serverRole is ServerRole.Unknown ||
builder.Services.AddOpenApi("sgf-member-v1", options =>
{
options.ShouldInclude = description => description.ActionDescriptor is ControllerActionDescriptor action &&
action.AttributeRouteInfo?.Name is "Member_Login" or "Member_Logout" or "Member_Session" or "Member_Register";
action.AttributeRouteInfo?.Name is "Member_Login" or "Member_Logout" or "Member_Session" or "Member_Register" or "Member_ForgotPassword" or "Member_ResetPassword";
options.AddDocumentTransformer((document, _, _) =>
{
document.Info = new OpenApiInfo { Title = "SGF private member bridge", Version = "1.0" };
Expand All @@ -107,7 +107,9 @@ serverRole is ServerRole.Unknown ||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
(string.Equals(context.Request.Path.Value?.TrimEnd('/'), "/api/v1/member/login", StringComparison.OrdinalIgnoreCase) ||
string.Equals(context.Request.Path.Value?.TrimEnd('/'), "/api/v1/member/register", StringComparison.OrdinalIgnoreCase)) && HttpMethods.IsPost(context.Request.Method)
string.Equals(context.Request.Path.Value?.TrimEnd('/'), "/api/v1/member/register", StringComparison.OrdinalIgnoreCase) ||
string.Equals(context.Request.Path.Value?.TrimEnd('/'), "/api/v1/member/forgot-password", StringComparison.OrdinalIgnoreCase) ||
string.Equals(context.Request.Path.Value?.TrimEnd('/'), "/api/v1/member/reset-password", StringComparison.OrdinalIgnoreCase)) && HttpMethods.IsPost(context.Request.Method)
? RateLimitPartition.GetFixedWindowLimiter(context.Request.Path.Value!.TrimEnd('/').ToLowerInvariant(), _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 20, Window = TimeSpan.FromMinutes(1), QueueLimit = 0, AutoReplenishment = true
Expand Down
20 changes: 20 additions & 0 deletions SgfDevs/ViewModels/MemberPasswordResetDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#nullable enable

using System.Collections.Generic;

namespace SGFDevs.ViewModels;

public sealed class MemberForgotPasswordRequest
{
public string Email { get; set; } = "";
}

public sealed class MemberResetPasswordRequest
{
public string MemberId { get; set; } = "";
public string Token { get; set; } = "";
public string Password { get; set; } = "";
public string ConfirmPassword { get; set; } = "";
}

public sealed record MemberPasswordResetResult(bool Succeeded, Dictionary<string, string[]> Errors);
9 changes: 9 additions & 0 deletions member-bridge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Member password reset

The frontend server posts to `/api/v1/member/forgot-password` and `/api/v1/member/reset-password` through the existing private bridge. Both routes have separate aggregate limits of 20 requests per minute, no queue, and no-store responses. Browser Origin requests remain rejected.

Configure `SGFDevs:MemberBridge:FrontendOrigin`, or environment variable `SGFDevs__MemberBridge__FrontendOrigin`, with the canonical frontend origin, for example `https://sgf.dev`. Paths, credentials, queries and fragments are rejected. HTTP is allowed only for `localhost`, `127.0.0.1` and `[::1]` in Development. Missing or invalid configuration makes forgotten-password unavailable without affecting login or public pages. Links always target `/reset-password`; incoming hosts and CMS content never select the destination.

Mail uses Umbraco `IEmailSender.CanSendRequiredEmail()`, `Umbraco:CMS:Global:Smtp:From` and `Umbraco:CMS:Global:Smtp:EmailExpiration`. Readiness is checked before account lookup. Existing and nonexistent accounts receive the same confirmation. Account-specific send failures keep that response and log only a fixed message. Email HTML encodes the name and link. Identity member IDs and reset tokens appear only in the emailed link and private reset request, never in response DTOs or logs.

Reset reuses `ResetPasswordModel` and its shared password rules. Umbraco generates and verifies the token and updates the password. No new token database or session is introduced. Identity descriptions are not returned. The frontend redirects to login after success without signing in. The deployed Umbraco token provider, transport, data-protection key persistence and existing member store must be configured independently.
Loading