diff --git a/SgfDevs.Tests/MemberRegistrationTests.cs b/SgfDevs.Tests/MemberRegistrationTests.cs new file mode 100644 index 0000000..743e182 --- /dev/null +++ b/SgfDevs.Tests/MemberRegistrationTests.cs @@ -0,0 +1,165 @@ +#nullable enable + +using System.Reflection; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; +using SGFDevs.Controllers; +using SGFDevs.Models; +using SGFDevs.ViewModels; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Strings; +using Umbraco.Cms.Web.Common.Security; +using Umbraco.Extensions; +using Xunit; + +namespace SgfDevs.Tests; + +public class MemberRegistrationTests +{ + private static MemberRegistrationRequest Valid() => new() + { + FirstName = "Synthetic", LastName = "Member", Email = "synthetic@example.test", + Username = "Synthetic", Password = "Fictional9!", ChallengeQuestion = "SGF" + }; + + [Fact] + public async Task ValidationReusesSharedRulesAndExactChallengeWithoutCallingServices() + { + var f = new Fixture(); + var request = Valid(); + request.Username = "not allowed"; + request.Password = "weak"; + request.ChallengeQuestion = "Sgf"; + var response = Assert.IsType((await f.Controller.Register(request)).Result); + var result = Assert.IsType(response.Value); + Assert.False(result.Succeeded); + Assert.Equal(PasswordValidationRules.ErrorMessage, Assert.Single(result.Errors["password"])); + Assert.Equal("Better luck next time.", Assert.Single(result.Errors["challengeQuestion"])); + Assert.True(result.Errors.ContainsKey("username")); + Assert.Empty(f.Calls); + Assert.DoesNotContain("weak", System.Text.Json.JsonSerializer.Serialize(result)); + } + + [Theory] + [InlineData("type")] + [InlineData("properties")] + [InlineData("group")] + public async Task MissingPrerequisitesNeverCreateAnAccount(string missing) + { + var f = new Fixture { Missing = missing }; + var response = Assert.IsType((await f.Controller.Register(Valid())).Result); + Assert.Equal(503, response.StatusCode); + Assert.DoesNotContain("CreateAsync", f.Calls); + } + + [Theory] + [InlineData("email")] + [InlineData("username")] + public async Task DuplicateFieldsStaySafeAndDoNotCreate(string field) + { + var f = new Fixture { Duplicate = field }; + var result = Assert.IsType(Assert.IsType((await f.Controller.Register(Valid())).Result).Value); + Assert.False(result.Succeeded); + Assert.True(result.Errors.ContainsKey(field)); + Assert.DoesNotContain("CreateAsync", f.Calls); + } + + [Fact] + public async Task PersistsApprovedMemberFieldsAndGroupBeforePersistentSignIn() + { + var f = new Fixture(); + var request = Valid(); + request.ChallengeQuestion = "sgf"; + var result = Assert.IsType(Assert.IsType((await f.Controller.Register(request)).Result).Value); + Assert.True(result.Succeeded); + Assert.Empty(result.Errors); + Assert.Equal(new[] { "Save", "AddToRolesAsync", "GetRolesAsync", "SignInAsync" }, f.Calls.SkipWhile(x => x != "Save")); + Assert.True(f.Member.IsApproved); + Assert.Equal("Member", f.Member.ContentType.Alias); + Assert.Equal("Synthetic", f.Member.GetValue("firstName")); + Assert.Equal("Member", f.Member.GetValue("lastName")); + Assert.Equal("Synthetic", f.Member.GetValue("username")); + } + + [Fact] + public async Task FailedGroupAssignmentReturnsUnavailableAndDeletesPartialAccount() + { + var f = new Fixture { RoleFailure = true }; + var result = Assert.IsType((await f.Controller.Register(Valid())).Result); + Assert.Equal(503, result.StatusCode); + Assert.False(Assert.IsType(result.Value).Succeeded); + Assert.Contains("DeleteAsync", f.Calls); + Assert.DoesNotContain("SignInAsync", f.Calls); + } + + private sealed class Fixture + { + public string? Missing, Duplicate; + public bool RoleFailure; + public List Calls { get; } = new(); + public Member Member { get; } + public MemberRegistrationController Controller { get; } + public Fixture() + { + var helper = new DefaultShortStringHelper(new DefaultShortStringHelperConfig()); + var type = new MemberType(helper, -1) { Alias = "Member" }; + foreach (var alias in new[] { "firstName", "lastName", "username" }) + type.AddPropertyType(new PropertyType(helper, "Umbraco.TextBox", ValueStorageType.Nvarchar, alias)); + Member = new Member("Synthetic Member", "synthetic@example.test", "Synthetic", type); + var types = Proxy((method, _) => Missing == "type" ? null : + Missing == "properties" ? new MemberType(helper, -1) { Alias = "Member" } : type); + var groups = Proxy((_, _) => Missing == "group" ? null : new MemberGroup { Name = "SGF Devs" }); + var members = Proxy((method, _) => method.Name switch + { + "GetByEmail" => Duplicate == "email" ? Member : null, + "GetByUsername" => Duplicate == "username" ? Member : null, + "GetById" => Member, + "Save" => Save(), + _ => throw new Exception(method.Name) + }); + var manager = Proxy((method, args) => + { + Calls.Add(method.Name); + if (method.Name == "CreateAsync") + { + var identity = Assert.IsType(args![0]); + Assert.Equal("Synthetic", identity.UserName); + Assert.Equal("synthetic@example.test", identity.Email); + Assert.Equal("Synthetic Member", identity.Name); + return Task.FromResult(IdentityResult.Success); + } + if (method.Name == "AddToRolesAsync") + { + Assert.Equal(new[] { "SGF Devs" }, Assert.IsAssignableFrom>(args![1])); + return Task.FromResult(RoleFailure ? IdentityResult.Failed(new IdentityError { Description = "private service detail" }) : IdentityResult.Success); + } + if (method.Name == "GetRolesAsync") return Task.FromResult>(new[] { "SGF Devs" }); + if (method.Name == "DeleteAsync") return Task.FromResult(IdentityResult.Success); + throw new Exception(method.Name); + }); + var signIn = Proxy((method, args) => + { + Calls.Add(method.Name); + if (method.Name == "SignInAsync") Assert.Equal(true, args![1]); + return Task.CompletedTask; + }); + Controller = new(manager, signIn, members, types, groups, NullLogger.Instance); + } + private Attempt Save() + { + Calls.Add("Save"); + return Attempt.Succeed(null); + } + } + + private static T Proxy(Func handler) where T : class + { + var proxy = DispatchProxy.Create(); + ((MemberLoginTests.InterfaceProxy)(object)proxy).Handler = handler; + return proxy; + } +} diff --git a/SgfDevs/Controllers/MemberRegistrationController.cs b/SgfDevs/Controllers/MemberRegistrationController.cs new file mode 100644 index 0000000..ec71301 --- /dev/null +++ b/SgfDevs/Controllers/MemberRegistrationController.cs @@ -0,0 +1,134 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Cors; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using SGFDevs.Models; +using SGFDevs.ViewModels; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Web.Common.Security; +using Umbraco.Extensions; + +namespace SGFDevs.Controllers; + +[ApiController] +[AllowAnonymous] +[DisableCors] +public sealed class MemberRegistrationController( + IMemberManager memberManager, IMemberSignInManager signInManager, + IMemberService memberService, IMemberTypeService memberTypeService, + IMemberGroupService memberGroupService, ILogger logger) : ControllerBase +{ + [HttpPost("api/v1/member/register", Name = "Member_Register")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + public async Task> Register(MemberRegistrationRequest request) + { + // Reuse the legacy form's actual shared validation, including the exact SGF|sgf challenge. + var model = new RegisterModel + { + FirstName = request.FirstName?.Trim() ?? "", LastName = request.LastName?.Trim() ?? "", + Email = request.Email?.Trim() ?? "", Username = request.Username?.Trim() ?? "", + Password = request.Password ?? "", ChallengeQuestion = request.ChallengeQuestion ?? "" + }; + var values = new Dictionary + { + ["firstName"] = model.FirstName, ["lastName"] = model.LastName, + ["email"] = model.Email, ["username"] = model.Username, + ["password"] = model.Password, ["challengeQuestion"] = model.ChallengeQuestion + }; + var errors = new Dictionary(); + foreach (var (field, value) in values) + if (value.Length > (field == "password" ? 4096 : 256)) + errors[field] = ["This value is too long."]; + // Bound regex work before running the shared validator. + if (errors.Count > 0) return Ok(new MemberRegistrationResult(false, errors)); + var validation = new List(); + Validator.TryValidateObject(model, new ValidationContext(model), validation, validateAllProperties: true); + foreach (var item in validation) + foreach (var name in item.MemberNames) + errors[char.ToLowerInvariant(name[0]) + name[1..]] = [item.ErrorMessage ?? "Invalid value."]; + if (errors.Count > 0) return Ok(new MemberRegistrationResult(false, errors)); + + MemberIdentityUser? created = null; + try + { + // These are existing CMS prerequisites. Registration must never invent schema or roles. + var type = memberTypeService.Get("Member"); + if (type is null || !new[] { "firstName", "lastName", "username" }.All(type.PropertyTypeExists) || + memberGroupService.GetByName("SGF Devs") is null) + return Unavailable(); + + if (memberService.GetByEmail(model.Email) is not null) + return Failed("email", "A member with that email already exists."); + if (memberService.GetByUsername(model.Username) is not null) + return Failed("username", "Ope. This username is already taken."); + + var identity = MemberIdentityUser.CreateNew(model.Username, model.Email, "Member", true, + model.FirstName + " " + model.LastName); + var result = await memberManager.CreateAsync(identity, model.Password); + if (!result.Succeeded) + { + // Identity descriptions may contain submitted values. Map only known codes. + if (result.Errors.Any(e => e.Code == "DuplicateEmail")) + return Failed("email", "A member with that email already exists."); + if (result.Errors.Any(e => e.Code == "DuplicateUserName")) + return Failed("username", "Ope. This username is already taken."); + if (result.Errors.Any(e => e.Code.StartsWith("Password", StringComparison.Ordinal))) + return Failed("password", PasswordValidationRules.ErrorMessage); + return Failed("", "Unable to create your account."); + } + created = identity; + var member = memberService.GetById(identity.Key); + if (member is null) throw new InvalidOperationException(); + member.SetValue("firstName", model.FirstName); + member.SetValue("lastName", model.LastName); + member.SetValue("username", model.Username); + if (!memberService.Save(member).Success) throw new InvalidOperationException(); + var roles = await memberManager.AddToRolesAsync(identity, ["SGF Devs"]); + if (!roles.Succeeded) throw new InvalidOperationException(); + // Saves can be cancelled by CMS notifications. Do not claim success on partial persistence. + var saved = memberService.GetById(identity.Key); + if (saved is null || !saved.IsApproved || saved.ContentType.Alias != "Member" || + saved.GetValue("firstName") != model.FirstName || + saved.GetValue("lastName") != model.LastName || + saved.GetValue("username") != model.Username || + !(await memberManager.GetRolesAsync(identity)).Contains("SGF Devs")) + throw new InvalidOperationException(); + + await signInManager.SignInAsync(identity, isPersistent: true); + return Ok(new MemberRegistrationResult(true, new())); + } + catch + { + // No exception details: service exceptions can include personal/security values. + logger.LogWarning("Member registration could not complete."); + if (created is not null) + { + try { await signInManager.SignOutAsync(); } catch { } + try + { + if (!(await memberManager.DeleteAsync(created)).Succeeded) + logger.LogError("Incomplete member registration cleanup failed."); + } + catch { logger.LogError("Incomplete member registration cleanup failed."); } + } + return Unavailable(); + } + } + + private ActionResult Failed(string field, string message) => + Ok(new MemberRegistrationResult(false, new() { [field] = [message] })); + + private ActionResult Unavailable() => + StatusCode(StatusCodes.Status503ServiceUnavailable, + new MemberRegistrationResult(false, new() { [""] = ["Registration is unavailable right now."] })); +} diff --git a/SgfDevs/Program.cs b/SgfDevs/Program.cs index cd6e498..14e259f 100644 --- a/SgfDevs/Program.cs +++ b/SgfDevs/Program.cs @@ -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"; + action.AttributeRouteInfo?.Name is "Member_Login" or "Member_Logout" or "Member_Session" or "Member_Register"; options.AddDocumentTransformer((document, _, _) => { document.Info = new OpenApiInfo { Title = "SGF private member bridge", Version = "1.0" }; @@ -106,8 +106,9 @@ serverRole is ServerRole.Unknown || { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.GlobalLimiter = PartitionedRateLimiter.Create(context => - string.Equals(context.Request.Path.Value?.TrimEnd('/'), "/api/v1/member/login", StringComparison.OrdinalIgnoreCase) && HttpMethods.IsPost(context.Request.Method) - ? RateLimitPartition.GetFixedWindowLimiter("member-login", _ => new FixedWindowRateLimiterOptions + (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) + ? RateLimitPartition.GetFixedWindowLimiter(context.Request.Path.Value!.TrimEnd('/').ToLowerInvariant(), _ => new FixedWindowRateLimiterOptions { PermitLimit = 20, Window = TimeSpan.FromMinutes(1), QueueLimit = 0, AutoReplenishment = true }) diff --git a/SgfDevs/ViewModels/MemberRegistrationDto.cs b/SgfDevs/ViewModels/MemberRegistrationDto.cs new file mode 100644 index 0000000..d999b21 --- /dev/null +++ b/SgfDevs/ViewModels/MemberRegistrationDto.cs @@ -0,0 +1,18 @@ +#nullable enable + +using System.Collections.Generic; + +namespace SGFDevs.ViewModels; + +// Not an identity/member model. Never include the request in logs or responses. +public sealed class MemberRegistrationRequest +{ + public string? FirstName { get; set; } + public string? LastName { get; set; } + public string? Email { get; set; } + public string? Username { get; set; } + public string? Password { get; set; } + public string? ChallengeQuestion { get; set; } +} + +public sealed record MemberRegistrationResult(bool Succeeded, Dictionary Errors);