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
136 changes: 136 additions & 0 deletions SgfDevs.Tests/MemberLoginTests.cs
Original file line number Diff line number Diff line change
@@ -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<string, string?>
{ ["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<IMemberSignInManager>((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<IMemberManager>((_, _) => throw new Exception()));
var result = await controller.Login(new(" Alice ", "untouched password ", remember));
Assert.True(Assert.IsType<MemberLoginResult>(Assert.IsType<OkObjectResult>(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<IMemberSignInManager>((_, _) => Task.FromResult(locked ? SignInResult.LockedOut : SignInResult.Failed));
var controller = new MemberSessionController(signIn, Proxy<IMemberManager>((_, _) => null));
var result = await controller.Login(new("Alice", "incorrect"));
Assert.Equal(new MemberLoginResult(false), Assert.IsType<OkObjectResult>(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<IAuthenticationService>((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<IMemberSignInManager>((method, _) =>
{
Assert.Equal("SignOutAsync", method.Name);
signedOut = true;
return Task.CompletedTask;
});
var member = Proxy<IMemberManager>((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<OkObjectResult>(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<UnauthorizedResult>((await controller.Session()).Result);
Assert.IsType<NoContentResult>(await controller.Logout());
Assert.True(signedOut);
Assert.False(controller.User.Identity?.IsAuthenticated ?? false);
Assert.IsType<HttpPostAttribute>(typeof(MemberSessionController).GetMethod("Logout")!.GetCustomAttribute<HttpPostAttribute>());
}

public class InterfaceProxy : DispatchProxy
{
public Func<MethodInfo, object?[]?, object?> Handler { get; set; } = null!;
protected override object? Invoke(MethodInfo? method, object?[]? args) => Handler(method!, args);
}

private static T Proxy<T>(Func<MethodInfo, object?[]?, object?> handler) where T : class
{
var proxy = DispatchProxy.Create<T, InterfaceProxy>();
((InterfaceProxy)(object)proxy).Handler = handler;
return proxy;
}
}
67 changes: 67 additions & 0 deletions SgfDevs/Controllers/MemberSessionController.cs
Original file line number Diff line number Diff line change
@@ -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<MemberLoginResult>(StatusCodes.Status200OK)]
public async Task<ActionResult<MemberLoginResult>> 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<MemberSessionDto>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<MemberSessionDto>> 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<IActionResult> Logout()
{
// Idempotent, including expired sessions. Never use the backoffice/default scheme.
await AuthenticateMemberAsync();
await signInManager.SignOutAsync();
return NoContent();
}

private async Task<bool> 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;
}
}
41 changes: 41 additions & 0 deletions SgfDevs/Dev/MemberBridge.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
27 changes: 27 additions & 0 deletions SgfDevs/Program.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<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
{
PermitLimit = 20, Window = TimeSpan.FromMinutes(1), QueueLimit = 0, AutoReplenishment = true
})
: RateLimitPartition.GetNoLimiter("other"));
});

builder.Services.AddHealthChecks()
.AddCheck<ReadinessHealthCheck>("ready", tags: ["ready"]);
builder.Services.AddHttpClient();
Expand Down Expand Up @@ -149,6 +173,9 @@ serverRole is ServerRole.Unknown ||
)
).AllowAnonymous();

app.UseMiddleware<MemberBridge>();
app.UseRateLimiter();

app.UseUmbraco()
.WithMiddleware(u =>
{
Expand Down
7 changes: 7 additions & 0 deletions SgfDevs/ViewModels/MemberSessionDto.cs
Original file line number Diff line number Diff line change
@@ -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);
Loading