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
165 changes: 165 additions & 0 deletions SgfDevs.Tests/MemberRegistrationTests.cs
Original file line number Diff line number Diff line change
@@ -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<OkObjectResult>((await f.Controller.Register(request)).Result);
var result = Assert.IsType<MemberRegistrationResult>(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<ObjectResult>((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<MemberRegistrationResult>(Assert.IsType<OkObjectResult>((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<MemberRegistrationResult>(Assert.IsType<OkObjectResult>((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<string>("firstName"));
Assert.Equal("Member", f.Member.GetValue<string>("lastName"));
Assert.Equal("Synthetic", f.Member.GetValue<string>("username"));
}

[Fact]
public async Task FailedGroupAssignmentReturnsUnavailableAndDeletesPartialAccount()
{
var f = new Fixture { RoleFailure = true };
var result = Assert.IsType<ObjectResult>((await f.Controller.Register(Valid())).Result);
Assert.Equal(503, result.StatusCode);
Assert.False(Assert.IsType<MemberRegistrationResult>(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<string> 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<IMemberTypeService>((method, _) => Missing == "type" ? null :
Missing == "properties" ? new MemberType(helper, -1) { Alias = "Member" } : type);
var groups = Proxy<IMemberGroupService>((_, _) => Missing == "group" ? null : new MemberGroup { Name = "SGF Devs" });
var members = Proxy<IMemberService>((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<IMemberManager>((method, args) =>
{
Calls.Add(method.Name);
if (method.Name == "CreateAsync")
{
var identity = Assert.IsType<MemberIdentityUser>(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<IEnumerable<string>>(args![1]));
return Task.FromResult(RoleFailure ? IdentityResult.Failed(new IdentityError { Description = "private service detail" }) : IdentityResult.Success);
}
if (method.Name == "GetRolesAsync") return Task.FromResult<IList<string>>(new[] { "SGF Devs" });
if (method.Name == "DeleteAsync") return Task.FromResult(IdentityResult.Success);
throw new Exception(method.Name);
});
var signIn = Proxy<IMemberSignInManager>((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<MemberRegistrationController>.Instance);
}
private Attempt<OperationResult?> Save()
{
Calls.Add("Save");
return Attempt.Succeed<OperationResult?>(null);
}
}

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;
}
}
134 changes: 134 additions & 0 deletions SgfDevs/Controllers/MemberRegistrationController.cs
Original file line number Diff line number Diff line change
@@ -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<MemberRegistrationController> logger) : ControllerBase
{
[HttpPost("api/v1/member/register", Name = "Member_Register")]
[ProducesResponseType<MemberRegistrationResult>(StatusCodes.Status200OK)]
[ProducesResponseType<MemberRegistrationResult>(StatusCodes.Status503ServiceUnavailable)]
public async Task<ActionResult<MemberRegistrationResult>> 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<string, string>
{
["firstName"] = model.FirstName, ["lastName"] = model.LastName,
["email"] = model.Email, ["username"] = model.Username,
["password"] = model.Password, ["challengeQuestion"] = model.ChallengeQuestion
};
var errors = new Dictionary<string, string[]>();
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<ValidationResult>();
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<string>("firstName") != model.FirstName ||
saved.GetValue<string>("lastName") != model.LastName ||
saved.GetValue<string>("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<MemberRegistrationResult> Failed(string field, string message) =>
Ok(new MemberRegistrationResult(false, new() { [field] = [message] }));

private ActionResult<MemberRegistrationResult> Unavailable() =>
StatusCode(StatusCodes.Status503ServiceUnavailable,
new MemberRegistrationResult(false, new() { [""] = ["Registration is unavailable right now."] }));
}
7 changes: 4 additions & 3 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";
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" };
Expand All @@ -106,8 +106,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) && 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
})
Expand Down
18 changes: 18 additions & 0 deletions SgfDevs/ViewModels/MemberRegistrationDto.cs
Original file line number Diff line number Diff line change
@@ -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<string, string[]> Errors);
Loading