Files
SGU-CredentialProvider/tests/SGU.AuthBroker.Tests/NtlmCredentialValidatorTests.cs
T

378 lines
15 KiB
C#

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 Administrative = new(
"AD017045",
"AD",
"017045",
InstitutionalRole.Administrative);
private static readonly UserIdentity Student = new(
"AL123456",
"AL",
"123456",
InstitutionalRole.Student);
private static readonly UserIdentity Professor = new(
"DO123456",
"DO",
"123456",
InstitutionalRole.Professor);
[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]);
}
[Fact]
public async Task VerifiedAdministrativeProfileIsEnrichedFromPersonalAndLocationPages()
{
SequenceHandler handler = new(
Challenge(),
Response(HttpStatusCode.OK),
Response(
HttpStatusCode.OK,
"""
<span id="ctl00_contenedor_decEncabezado_lblNombre">017045 - NOMBRE ANTERIOR</span>
<span id="ctl00_contenedor_decEncabezado_lblCorreo">persona@lasalle.mx</span>
<span id="ctl00_contenedor_decEncabezado_lblPuesto">ANALISTA</span>
"""),
Response(
HttpStatusCode.OK,
"""
<input id="ctl00_contenedor_txtNombre" value="MARÍA DEL CARMEN" />
<input id="ctl00_contenedor_txtApaterno" value="DE LA FUENTE" />
<input id="ctl00_contenedor_txtAmaterno" value="O'CONNOR" />
"""),
Response(
HttpStatusCode.OK,
"""
<input id="ctl00_contenedor_txtCalle" value="CALLE DEL SOL" />
<input id="ctl00_contenedor_txtNoExt" value="15" />
<input id="ctl00_contenedor_txtCP" value="01000" />
<select id="ctl00_contenedor_ddlEstado">
<option selected="selected">CIUDAD DE MÉXICO</option>
</select>
<select id="ctl00_contenedor_ddlLocalidad">
<option selected="selected">ÁLVARO OBREGÓN</option>
</select>
<select id="ctl00_contenedor_ddlColonia">
<option>FLORIDA</option>
</select>
"""));
NtlmCredentialValidator validator = CreateValidator(handler);
NtlmValidationResult result = await validator.ValidateAsync(
Administrative,
"test-password",
TestContext.Current.CancellationToken);
Assert.Equal(NtlmValidationStatus.Valid, result.Status);
Assert.NotNull(result.Profile);
Assert.Equal("017045", result.Profile.EmployeeNumber);
Assert.Equal("María del Carmen de la Fuente O'Connor", result.Profile.DisplayName);
Assert.Equal("María del Carmen", result.Profile.GivenName);
Assert.Equal("de la Fuente O'Connor", result.Profile.Surname);
Assert.Equal("persona@lasalle.mx", result.Profile.Email);
Assert.Equal("Analista", result.Profile.JobTitle);
Assert.Equal("Calle del Sol 15\r\nFlorida", result.Profile.StreetAddress);
Assert.Equal("Álvaro Obregón", result.Profile.City);
Assert.Equal("Ciudad de México", result.Profile.State);
Assert.Equal("01000", result.Profile.PostalCode);
Assert.Equal(
[
"/psulsa/",
"/psulsa/",
"/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx",
"/psulsa/gadmon/capitalhumano/datos/personales.aspx",
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx"
],
handler.RequestPaths);
}
[Fact]
public async Task AdministrativeSupplementalPagesRequireAVerifiedEmployeeNumber()
{
SequenceHandler handler = new(
Challenge(),
Response(HttpStatusCode.OK),
Response(HttpStatusCode.OK, "<html><body>Datos inesperados</body></html>"));
NtlmCredentialValidator validator = CreateValidator(handler);
NtlmValidationResult result = await validator.ValidateAsync(
Administrative,
"test-password",
TestContext.Current.CancellationToken);
Assert.Equal(NtlmValidationStatus.Valid, result.Status);
Assert.Null(result.Profile);
Assert.Equal(3, handler.RequestPaths.Count);
}
[Fact]
public async Task ProfessorProfileIsEnrichedFromSharedStaffPages()
{
SequenceHandler handler = new(
Challenge(),
Response(HttpStatusCode.OK),
Response(
HttpStatusCode.OK,
"""
<span id="ctl00_lblNombreUsuario">MARÍA DEL CARMEN</span>
"""),
Response(
HttpStatusCode.OK,
"""
<span id="ctl00_contenedor_decEncabezado_lblNombre">123456 - MARÍA DEL CARMEN</span>
<span id="ctl00_contenedor_decEncabezado_lblCorreo">docente@lasallistas.org.mx</span>
<span id="ctl00_contenedor_decEncabezado_lblIndicadorValue">SINDICALIZADO QUINCENAL (ACTIVO)</span>
<span id="ctl00_contenedor_decEncabezado_lblPuesto">DOCENTE</span>
<span id="ctl00_contenedor_decEncabezado_lblDependencia"></span>
"""),
Response(
HttpStatusCode.OK,
"""
<input id="ctl00_contenedor_txtNombre" value="MARÍA DEL CARMEN" />
<input id="ctl00_contenedor_txtApaterno" value="DE LA FUENTE" />
<input id="ctl00_contenedor_txtAmaterno" value="O'CONNOR" />
"""),
Response(
HttpStatusCode.OK,
"""
<input id="ctl00_contenedor_txtCalle" value="CALLE DEL SOL" />
<input id="ctl00_contenedor_txtNoExt" value="15" />
<input id="ctl00_contenedor_txtCP" value="01000" />
<select id="ctl00_contenedor_ddlEstado">
<option selected="selected">CIUDAD DE MÉXICO</option>
</select>
<select id="ctl00_contenedor_ddlLocalidad">
<option selected="selected">ÁLVARO OBREGÓN</option>
</select>
<select id="ctl00_contenedor_ddlColonia">
<option selected="selected">FLORIDA</option>
</select>
"""));
NtlmCredentialValidator validator = CreateValidator(handler);
NtlmValidationResult result = await validator.ValidateAsync(
Professor,
"test-password",
TestContext.Current.CancellationToken);
Assert.Equal(NtlmValidationStatus.Valid, result.Status);
Assert.NotNull(result.Profile);
Assert.Equal("María del Carmen de la Fuente O'Connor", result.Profile.DisplayName);
Assert.Equal("María del Carmen", result.Profile.GivenName);
Assert.Equal("de la Fuente O'Connor", result.Profile.Surname);
Assert.Equal("123456", result.Profile.EmployeeNumber);
Assert.Equal("docente@lasallistas.org.mx", result.Profile.Email);
Assert.Equal("Sindicalizado quincenal (activo)", result.Profile.EmployeeType);
Assert.Equal("Docente", result.Profile.JobTitle);
Assert.Null(result.Profile.Department);
Assert.Equal("Calle del Sol 15\r\nFlorida", result.Profile.StreetAddress);
Assert.Equal("Álvaro Obregón", result.Profile.City);
Assert.Equal("Ciudad de México", result.Profile.State);
Assert.Equal("01000", result.Profile.PostalCode);
Assert.Equal(
[
"/psulsa/",
"/psulsa/",
"/psulsa/menu.aspx",
"/psulsa/gadmon/nomina/consultanomina.aspx",
"/psulsa/gadmon/capitalhumano/datos/personales.aspx",
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx"
],
handler.RequestPaths);
}
[Fact]
public async Task MissingProfessorStaffPageOrElementIdsPreservesMenuProfile()
{
SequenceHandler handler = new(
Challenge(),
Response(HttpStatusCode.OK),
Response(
HttpStatusCode.OK,
"""
<span id="ctl00_lblNombreUsuario">MIGUEL DE CERVANTES</span>
"""),
Response(HttpStatusCode.NotFound),
Response(HttpStatusCode.OK, "<html><body>Unrecognized layout</body></html>"),
Response(HttpStatusCode.NotFound));
NtlmCredentialValidator validator = CreateValidator(handler);
NtlmValidationResult result = await validator.ValidateAsync(
Professor,
"test-password",
TestContext.Current.CancellationToken);
Assert.Equal(NtlmValidationStatus.Valid, result.Status);
Assert.NotNull(result.Profile);
Assert.Equal("Miguel de Cervantes", result.Profile.DisplayName);
Assert.Null(result.Profile.StreetAddress);
Assert.Equal(6, handler.RequestPaths.Count);
Assert.Equal(
"/psulsa/gadmon/nomina/consultanomina.aspx",
handler.RequestPaths[3]);
}
private static NtlmCredentialValidator CreateValidator(SequenceHandler handler)
{
BrokerOptions options = new()
{
Ntlm = new NtlmOptions
{
Endpoint = "https://sgu.example/",
AuthenticationPath = "/psulsa/",
AdministrativeProfilePath = "/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx",
AdministrativePersonalProfilePath = "/psulsa/gadmon/capitalhumano/datos/personales.aspx",
AdministrativeLocationProfilePath = "/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx",
StudentProfilePath = "/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx",
ProfessorPayrollProfilePath = "/psulsa/gadmon/nomina/consultanomina.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,
string content = "<html></html>") =>
new(statusCode)
{
Content = new StringContent(content)
};
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);
}
}
}