diff --git a/SgfDevs.Tests/MemberLoginTests.cs b/SgfDevs.Tests/MemberLoginTests.cs new file mode 100644 index 0000000..73125c5 --- /dev/null +++ b/SgfDevs.Tests/MemberLoginTests.cs @@ -0,0 +1,136 @@ +#nullable enable + +using System.Reflection; +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using SGFDevs.Controllers; +using SGFDevs.ViewModels; +using SgfDevs.Dev; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Web.Common.Security; +using Xunit; +using SignInResult = Microsoft.AspNetCore.Identity.SignInResult; + +namespace SgfDevs.Tests; + +public class MemberLoginTests +{ + [Theory] + [InlineData(null, null, false)] + [InlineData("", "", false)] + [InlineData("configured", null, false)] + [InlineData("configured", "wrong", false)] + [InlineData("configured", "configured", true)] + public async Task BridgeFailsClosed(string? secret, string? supplied, bool allowed) + { + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { ["SGFDevs:MemberBridge:Secret"] = secret }).Build(); + var invoked = false; + var middleware = new MemberBridge(_ => { invoked = true; return Task.CompletedTask; }, config); + var context = new DefaultHttpContext(); + context.Request.Path = "/api/v1/member/session"; + if (supplied is not null) context.Request.Headers[MemberBridge.Header] = supplied; + await middleware.InvokeAsync(context); + Assert.Equal(allowed, invoked); + Assert.Equal("no-store", context.Response.Headers.CacheControl); + Assert.Equal(allowed ? 200 : 401, context.Response.StatusCode); + if (allowed) + { + invoked = false; + context.Request.Headers.Origin = "https://browser.example"; + await middleware.InvokeAsync(context); + Assert.False(invoked); + Assert.Equal(401, context.Response.StatusCode); + Assert.False(context.Response.Headers.ContainsKey("Access-Control-Allow-Origin")); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task LoginUsesUmbracoNormalizationRememberMeAndLockout(bool remember) + { + var signIn = Proxy((method, args) => + { + Assert.Equal("PasswordSignInAsync", method.Name); + Assert.Equal(new object[] { " Alice ", "untouched password ", remember, true }, args); + return Task.FromResult(SignInResult.Success); + }); + var controller = new MemberSessionController(signIn, Proxy((_, _) => throw new Exception())); + var result = await controller.Login(new(" Alice ", "untouched password ", remember)); + Assert.True(Assert.IsType(Assert.IsType(result.Result).Value).Succeeded); + // Installed Umbraco 18.2 member sign-in uses ASP.NET Identity's member application scheme. + var instance = System.Runtime.CompilerServices.RuntimeHelpers.GetUninitializedObject(typeof(MemberSignInManager)); + var scheme = typeof(MemberSignInManager).GetProperty("AuthenticationType", BindingFlags.NonPublic | BindingFlags.Instance)!; + Assert.Equal(IdentityConstants.ApplicationScheme, scheme.GetValue(instance)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FailedAndLockedOutLoginsHaveSameGenericResult(bool locked) + { + var signIn = Proxy((_, _) => Task.FromResult(locked ? SignInResult.LockedOut : SignInResult.Failed)); + var controller = new MemberSessionController(signIn, Proxy((_, _) => null)); + var result = await controller.Login(new("Alice", "incorrect")); + Assert.Equal(new MemberLoginResult(false), Assert.IsType(result.Result).Value); + } + + [Fact] + public async Task SessionAndLogoutAuthenticateOnlyMemberSchemeAndExposeOnlyMinimalIdentity() + { + var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, "Alice") }, IdentityConstants.ApplicationScheme)); + var authenticated = true; + var auth = Proxy((method, args) => + { + Assert.Equal("AuthenticateAsync", method.Name); + Assert.Equal(IdentityConstants.ApplicationScheme, args![1]); + return Task.FromResult(authenticated + ? AuthenticateResult.Success(new AuthenticationTicket(principal, IdentityConstants.ApplicationScheme)) + : AuthenticateResult.NoResult()); + }); + var signedOut = false; + var signIn = Proxy((method, _) => + { + Assert.Equal("SignOutAsync", method.Name); + signedOut = true; + return Task.CompletedTask; + }); + var member = Proxy((method, _) => + { + Assert.Equal("GetCurrentMemberAsync", method.Name); + return Task.FromResult(new MemberIdentityUser { UserName = "Alice", Name = "Alice Example", Email = "private@example.test" }); + }); + using var services = new ServiceCollection().AddSingleton(auth).BuildServiceProvider(); + var controller = new MemberSessionController(signIn, member) + { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { RequestServices = services } } }; + var session = await controller.Session(); + Assert.Equal(new MemberSessionDto("Alice", "Alice Example"), Assert.IsType(session.Result).Value); + Assert.Equal(new[] { "Name", "Username" }, typeof(MemberSessionDto).GetProperties().Select(p => p.Name).Order()); + Assert.Same(principal, controller.User); + authenticated = false; + Assert.IsType((await controller.Session()).Result); + Assert.IsType(await controller.Logout()); + Assert.True(signedOut); + Assert.False(controller.User.Identity?.IsAuthenticated ?? false); + Assert.IsType(typeof(MemberSessionController).GetMethod("Logout")!.GetCustomAttribute()); + } + + public class InterfaceProxy : DispatchProxy + { + public Func Handler { get; set; } = null!; + protected override object? Invoke(MethodInfo? method, object?[]? args) => Handler(method!, args); + } + + private static T Proxy(Func handler) where T : class + { + var proxy = DispatchProxy.Create(); + ((InterfaceProxy)(object)proxy).Handler = handler; + return proxy; + } +} diff --git a/SgfDevs/Controllers/MemberSessionController.cs b/SgfDevs/Controllers/MemberSessionController.cs new file mode 100644 index 0000000..07e539c --- /dev/null +++ b/SgfDevs/Controllers/MemberSessionController.cs @@ -0,0 +1,67 @@ +#nullable enable + +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Cors; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using SGFDevs.ViewModels; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Web.Common.Security; + +namespace SGFDevs.Controllers; + +[ApiController] +[AllowAnonymous] +[DisableCors] +public sealed class MemberSessionController(IMemberSignInManager signInManager, IMemberManager memberManager) : ControllerBase +{ + [HttpPost("api/v1/member/login", Name = "Member_Login")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> Login(MemberLoginRequest request) + { + if (string.IsNullOrWhiteSpace(request.Username) || request.Username.Length > 256 || + string.IsNullOrEmpty(request.Password) || request.Password.Length > 4096) + return Ok(new MemberLoginResult(false)); + + // Umbraco trims usernames and uses its configured identity normalizer and password hasher. + var result = await signInManager.PasswordSignInAsync( + request.Username, request.Password, request.RememberMe, lockoutOnFailure: true); + return Ok(new MemberLoginResult(result.Succeeded)); + } + + [HttpGet("api/v1/member/session", Name = "Member_Session")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task> Session() + { + if (!await AuthenticateMemberAsync()) + return Unauthorized(); + + var member = await memberManager.GetCurrentMemberAsync(); + return member?.UserName is not { Length: > 0 } username + ? Unauthorized() + : Ok(new MemberSessionDto(username, member.Name ?? username)); + } + + [HttpPost("api/v1/member/logout", Name = "Member_Logout")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task Logout() + { + // Idempotent, including expired sessions. Never use the backoffice/default scheme. + await AuthenticateMemberAsync(); + await signInManager.SignOutAsync(); + return NoContent(); + } + + private async Task AuthenticateMemberAsync() + { + var result = await HttpContext.AuthenticateAsync(IdentityConstants.ApplicationScheme); + HttpContext.User = result.Succeeded && result.Principal is not null + ? result.Principal + : new System.Security.Claims.ClaimsPrincipal(); + return result.Succeeded; + } +} diff --git a/SgfDevs/Dev/MemberBridge.cs b/SgfDevs/Dev/MemberBridge.cs new file mode 100644 index 0000000..045c7eb --- /dev/null +++ b/SgfDevs/Dev/MemberBridge.cs @@ -0,0 +1,41 @@ +#nullable enable + +using System; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; + +namespace SgfDevs.Dev; + +// This API is only for the frontend server. It is not a browser CORS endpoint. +public sealed class MemberBridge(RequestDelegate next, IConfiguration configuration) +{ + public const string Header = "X-SGF-Member-Bridge"; + public const string Prefix = "/api/v1/member"; + + public async Task InvokeAsync(HttpContext context) + { + if (!context.Request.Path.StartsWithSegments(Prefix)) + { + await next(context); + return; + } + + context.Response.Headers.CacheControl = "no-store"; + var secret = configuration["SGFDevs:MemberBridge:Secret"]; + var supplied = context.Request.Headers[Header]; + if (string.IsNullOrWhiteSpace(secret) || supplied.Count != 1 || + context.Request.Headers.ContainsKey("Origin") || + !CryptographicOperations.FixedTimeEquals( + SHA256.HashData(Encoding.UTF8.GetBytes(secret)), + SHA256.HashData(Encoding.UTF8.GetBytes(supplied[0] ?? "")))) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + + await next(context); + } +} diff --git a/SgfDevs/Program.cs b/SgfDevs/Program.cs index 2ba2cd6..cd6e498 100644 --- a/SgfDevs/Program.cs +++ b/SgfDevs/Program.cs @@ -1,6 +1,8 @@ using System; using System.Data.Common; using System.Threading.Tasks; +using System.Threading.RateLimiting; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.Hosting; @@ -90,6 +92,28 @@ serverRole is ServerRole.Unknown || }); builder.Services.AddOpenApiDocumentToUi("sgf-public-v1", "SGF public API v1"); +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"; + options.AddDocumentTransformer((document, _, _) => + { + document.Info = new OpenApiInfo { Title = "SGF private member bridge", Version = "1.0" }; + return Task.CompletedTask; + }); +}); +builder.Services.AddRateLimiter(options => +{ + 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 + { + PermitLimit = 20, Window = TimeSpan.FromMinutes(1), QueueLimit = 0, AutoReplenishment = true + }) + : RateLimitPartition.GetNoLimiter("other")); +}); + builder.Services.AddHealthChecks() .AddCheck("ready", tags: ["ready"]); builder.Services.AddHttpClient(); @@ -149,6 +173,9 @@ serverRole is ServerRole.Unknown || ) ).AllowAnonymous(); +app.UseMiddleware(); +app.UseRateLimiter(); + app.UseUmbraco() .WithMiddleware(u => { diff --git a/SgfDevs/ViewModels/MemberSessionDto.cs b/SgfDevs/ViewModels/MemberSessionDto.cs new file mode 100644 index 0000000..56fe054 --- /dev/null +++ b/SgfDevs/ViewModels/MemberSessionDto.cs @@ -0,0 +1,7 @@ +#nullable enable + +namespace SGFDevs.ViewModels; + +public sealed record MemberLoginRequest(string? Username, string? Password, bool RememberMe = true); +public sealed record MemberLoginResult(bool Succeeded); +public sealed record MemberSessionDto(string Username, string Name);