Harden NTLM authentication and restore SGU profiles

This commit is contained in:
2026-09-01 16:42:25 -06:00
parent a166193b66
commit fe77229b48
19 changed files with 657 additions and 226 deletions
@@ -0,0 +1,167 @@
using System.Net;
using System.Net.Http.Headers;
using Microsoft.Extensions.Logging.Abstractions;
using SGU.AuthBroker.Core.Authentication;
using SGU.AuthBroker.Core.Identity;
using SGU.AuthBroker.Options;
using SGU.AuthBroker.Services;
using Xunit;
namespace SGU.AuthBroker.Tests;
public sealed class NtlmCredentialValidatorTests
{
private static readonly UserIdentity Student = new(
"AL123456",
"AL",
"123456",
InstitutionalRole.Student);
[Fact]
public async Task RedirectAndSuccessWithoutChallengeNeverAuthorizes()
{
SequenceHandler handler = new(
Redirect("/psulsa/login.aspx?AspxAutoDetectCookieSupport=1"),
Response(HttpStatusCode.OK));
NtlmCredentialValidator validator = CreateValidator(handler);
NtlmValidationResult result = await validator.ValidateAsync(
Student,
"test-password",
TestContext.Current.CancellationToken);
Assert.Equal(NtlmValidationStatus.Unavailable, result.Status);
Assert.Equal("NTLM_CHALLENGE_MISSING", result.ErrorCode);
Assert.Equal(2, handler.RequestPaths.Count);
}
[Fact]
public async Task CredentialsAreAcceptedOnlyAfterExplicitNtlmChallenge()
{
SequenceHandler handler = new(
Challenge(),
Response(HttpStatusCode.OK),
Response(HttpStatusCode.OK));
NtlmCredentialValidator validator = CreateValidator(handler);
NtlmValidationResult result = await validator.ValidateAsync(
Student,
"test-password",
TestContext.Current.CancellationToken);
Assert.Equal(NtlmValidationStatus.Valid, result.Status);
Assert.Equal(3, handler.RequestPaths.Count);
Assert.Equal("/psulsa/", handler.RequestPaths[0]);
Assert.Equal("/psulsa/", handler.RequestPaths[1]);
Assert.Equal(
"/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx",
handler.RequestPaths[2]);
}
[Fact]
public async Task RejectedCredentialsAfterChallengeAreInvalid()
{
SequenceHandler handler = new(
Challenge(),
Challenge());
NtlmCredentialValidator validator = CreateValidator(handler);
NtlmValidationResult result = await validator.ValidateAsync(
Student,
"wrong-password",
TestContext.Current.CancellationToken);
Assert.Equal(NtlmValidationStatus.Invalid, result.Status);
Assert.Equal("INVALID_INSTITUTIONAL_CREDENTIALS", result.ErrorCode);
Assert.Equal(2, handler.RequestPaths.Count);
}
[Fact]
public async Task SessionBootstrapRetriesOriginalRoleProfile()
{
SequenceHandler handler = new(
Challenge(),
Redirect("/psulsa/login.aspx?AspxAutoDetectCookieSupport=1"),
Redirect("/psulsa/menu.aspx"),
Response(HttpStatusCode.OK),
Response(HttpStatusCode.OK));
NtlmCredentialValidator validator = CreateValidator(handler);
NtlmValidationResult result = await validator.ValidateAsync(
Student,
"test-password",
TestContext.Current.CancellationToken);
Assert.Equal(NtlmValidationStatus.Valid, result.Status);
Assert.Equal(5, handler.RequestPaths.Count);
Assert.Equal("/psulsa/login.aspx", handler.RequestPaths[2]);
Assert.Equal("/psulsa/menu.aspx", handler.RequestPaths[3]);
Assert.Equal(
"/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx",
handler.RequestPaths[4]);
}
private static NtlmCredentialValidator CreateValidator(SequenceHandler handler)
{
BrokerOptions options = new()
{
Ntlm = new NtlmOptions
{
Endpoint = "https://sgu.example/",
AuthenticationPath = "/psulsa/",
StudentProfilePath = "/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx",
AllowedRedirectHosts = ["sgu.example"],
TimeoutSeconds = 5,
ProfileTimeoutSeconds = 5
}
};
return new NtlmCredentialValidator(
options,
NullLogger<NtlmCredentialValidator>.Instance,
(_, _) => handler);
}
private static HttpResponseMessage Challenge()
{
HttpResponseMessage response = Response(HttpStatusCode.Unauthorized);
response.Headers.WwwAuthenticate.Add(new AuthenticationHeaderValue("Negotiate"));
response.Headers.WwwAuthenticate.Add(new AuthenticationHeaderValue("NTLM"));
return response;
}
private static HttpResponseMessage Redirect(string location)
{
HttpResponseMessage response = Response(HttpStatusCode.Found);
response.Headers.Location = new Uri(location, UriKind.Relative);
return response;
}
private static HttpResponseMessage Response(HttpStatusCode statusCode) =>
new(statusCode)
{
Content = new StringContent("<html></html>")
};
private sealed class SequenceHandler(params HttpResponseMessage[] responses) : HttpMessageHandler
{
private readonly Queue<HttpResponseMessage> responses = new(responses);
public List<string> RequestPaths { get; } = [];
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
RequestPaths.Add(request.RequestUri!.AbsolutePath);
if (responses.Count == 0)
{
throw new InvalidOperationException("The validator sent more requests than expected.");
}
HttpResponseMessage response = responses.Dequeue();
response.RequestMessage = request;
return Task.FromResult(response);
}
}
}
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\SGU.AuthBroker\SGU.AuthBroker.csproj" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
<PackageReference Include="xunit.v3" Version="4.0.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
@@ -10,7 +10,7 @@ public sealed class BrokerClientTests
[Fact]
public void DefaultClientTimeoutLeavesMarginForThePortalAndBroker()
{
Assert.Equal(35, new ProviderSettings().TimeoutSeconds);
Assert.Equal(90, new ProviderSettings().TimeoutSeconds);
}
[Fact]
@@ -16,7 +16,13 @@ public sealed class ProviderTileIconTests
Assert.Equal(ProviderTileIcon.Size, logo.Bitmap.Width);
Assert.Equal(ProviderTileIcon.Size, logo.Bitmap.Height);
Assert.Equal(Color.FromArgb(0, 83, 155).ToArgb(), logo.Bitmap.GetPixel(0, 0).ToArgb());
Assert.Equal(0, logo.Bitmap.GetPixel(0, 0).A);
Assert.Equal(0, logo.Bitmap.GetPixel(ProviderTileIcon.Size - 1, 0).A);
Assert.Equal(0, logo.Bitmap.GetPixel(0, ProviderTileIcon.Size - 1).A);
Assert.Equal(0, logo.Bitmap.GetPixel(ProviderTileIcon.Size - 1, ProviderTileIcon.Size - 1).A);
Assert.Equal(
Color.FromArgb(0, 83, 155).ToArgb(),
logo.Bitmap.GetPixel(6, ProviderTileIcon.Size / 2).ToArgb());
int lightPixels = 0;
for (int x = 0; x < logo.Bitmap.Width; x++)
{