Harden NTLM authentication and restore SGU profiles
This commit is contained in:
@@ -19,6 +19,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SGU.CredentialProvider.Test
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SGU.CredentialProvider.SmokeProbe", "tests\SGU.CredentialProvider.SmokeProbe\SGU.CredentialProvider.SmokeProbe.csproj", "{B5171244-2BBD-465B-BBAF-96D5C6F9A84C}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SGU.CredentialProvider.SmokeProbe", "tests\SGU.CredentialProvider.SmokeProbe\SGU.CredentialProvider.SmokeProbe.csproj", "{B5171244-2BBD-465B-BBAF-96D5C6F9A84C}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SGU.AuthBroker.Tests", "tests\SGU.AuthBroker.Tests\SGU.AuthBroker.Tests.csproj", "{8B3E5C97-5EE6-4F83-95AC-A7955B650094}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -101,6 +103,18 @@ Global
|
|||||||
{B5171244-2BBD-465B-BBAF-96D5C6F9A84C}.Release|x64.Build.0 = Release|x64
|
{B5171244-2BBD-465B-BBAF-96D5C6F9A84C}.Release|x64.Build.0 = Release|x64
|
||||||
{B5171244-2BBD-465B-BBAF-96D5C6F9A84C}.Release|x86.ActiveCfg = Release|x64
|
{B5171244-2BBD-465B-BBAF-96D5C6F9A84C}.Release|x86.ActiveCfg = Release|x64
|
||||||
{B5171244-2BBD-465B-BBAF-96D5C6F9A84C}.Release|x86.Build.0 = Release|x64
|
{B5171244-2BBD-465B-BBAF-96D5C6F9A84C}.Release|x86.Build.0 = Release|x64
|
||||||
|
{8B3E5C97-5EE6-4F83-95AC-A7955B650094}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{8B3E5C97-5EE6-4F83-95AC-A7955B650094}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{8B3E5C97-5EE6-4F83-95AC-A7955B650094}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{8B3E5C97-5EE6-4F83-95AC-A7955B650094}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{8B3E5C97-5EE6-4F83-95AC-A7955B650094}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{8B3E5C97-5EE6-4F83-95AC-A7955B650094}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{8B3E5C97-5EE6-4F83-95AC-A7955B650094}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{8B3E5C97-5EE6-4F83-95AC-A7955B650094}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{8B3E5C97-5EE6-4F83-95AC-A7955B650094}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{8B3E5C97-5EE6-4F83-95AC-A7955B650094}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{8B3E5C97-5EE6-4F83-95AC-A7955B650094}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{8B3E5C97-5EE6-4F83-95AC-A7955B650094}.Release|x86.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
@@ -112,5 +126,6 @@ Global
|
|||||||
{5749FA85-9760-4884-9475-C760879B1953} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
{5749FA85-9760-4884-9475-C760879B1953} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||||
{72DF14BC-9050-4AF3-B311-36F2A4140366} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
{72DF14BC-9050-4AF3-B311-36F2A4140366} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||||
{B5171244-2BBD-465B-BBAF-96D5C6F9A84C} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
{B5171244-2BBD-465B-BBAF-96D5C6F9A84C} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||||
|
{8B3E5C97-5EE6-4F83-95AC-A7955B650094} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
+1
-1
@@ -124,7 +124,7 @@ On Windows 10:
|
|||||||
-BrokerEndpoint https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate `
|
-BrokerEndpoint https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate `
|
||||||
-ClientCertificateThumbprint CLIENT_CERT_THUMBPRINT `
|
-ClientCertificateThumbprint CLIENT_CERT_THUMBPRINT `
|
||||||
-ServerCertificateThumbprint SERVER_CERT_THUMBPRINT `
|
-ServerCertificateThumbprint SERVER_CERT_THUMBPRINT `
|
||||||
-TimeoutSeconds 35 `
|
-TimeoutSeconds 90 `
|
||||||
-InstallDotNetRuntime `
|
-InstallDotNetRuntime `
|
||||||
-DotNetRuntimeInstallerPath C:\SGUDeploy\prerequisites\dotnet-runtime-10.0.11-win-x64.exe
|
-DotNetRuntimeInstallerPath C:\SGUDeploy\prerequisites\dotnet-runtime-10.0.11-win-x64.exe
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass `
|
|||||||
-BrokerEndpoint https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate `
|
-BrokerEndpoint https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate `
|
||||||
-ClientCertificateThumbprint CLIENT_CERT_THUMBPRINT `
|
-ClientCertificateThumbprint CLIENT_CERT_THUMBPRINT `
|
||||||
-ServerCertificateThumbprint SERVER_CERT_THUMBPRINT `
|
-ServerCertificateThumbprint SERVER_CERT_THUMBPRINT `
|
||||||
-TimeoutSeconds 35
|
-TimeoutSeconds 90
|
||||||
```
|
```
|
||||||
|
|
||||||
Los binarios se guardan en un subdirectorio `versions` identificado por su
|
Los binarios se guardan en un subdirectorio `versions` identificado por su
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ param(
|
|||||||
[string]$DefaultCompany = 'Universidad La Salle',
|
[string]$DefaultCompany = 'Universidad La Salle',
|
||||||
[ValidateRange(10, 60)]
|
[ValidateRange(10, 60)]
|
||||||
[int]$NtlmTimeoutSeconds = 20,
|
[int]$NtlmTimeoutSeconds = 20,
|
||||||
[ValidateRange(2, 30)]
|
[ValidateRange(2, 90)]
|
||||||
[int]$ProfileTimeoutSeconds = 10,
|
[int]$ProfileTimeoutSeconds = 60,
|
||||||
[switch]$CreateMissingOus,
|
[switch]$CreateMissingOus,
|
||||||
[switch]$DisableCertificateRevocationCheckForLab
|
[switch]$DisableCertificateRevocationCheckForLab
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ $installParams = @{
|
|||||||
ClientCertificateThumbprint = $ClientCertificateThumbprint
|
ClientCertificateThumbprint = $ClientCertificateThumbprint
|
||||||
ServerCertificateThumbprint = $ServerCertificateThumbprint
|
ServerCertificateThumbprint = $ServerCertificateThumbprint
|
||||||
DomainNetbios = $DomainNetbios
|
DomainNetbios = $DomainNetbios
|
||||||
TimeoutSeconds = 35
|
TimeoutSeconds = 90
|
||||||
}
|
}
|
||||||
if ($DotNetRuntimeInstallerPath) {
|
if ($DotNetRuntimeInstallerPath) {
|
||||||
$installParams.InstallDotNetRuntime = $true
|
$installParams.InstallDotNetRuntime = $true
|
||||||
@@ -68,7 +68,7 @@ $guardParams = @{
|
|||||||
ClientCertificateThumbprint = $ClientCertificateThumbprint
|
ClientCertificateThumbprint = $ClientCertificateThumbprint
|
||||||
ServerCertificateThumbprint = $ServerCertificateThumbprint
|
ServerCertificateThumbprint = $ServerCertificateThumbprint
|
||||||
DomainNetbios = $DomainNetbios
|
DomainNetbios = $DomainNetbios
|
||||||
TimeoutSeconds = 35
|
TimeoutSeconds = 90
|
||||||
RemoteDesktopPrincipal = $RemoteDesktopPrincipal
|
RemoteDesktopPrincipal = $RemoteDesktopPrincipal
|
||||||
DotNetRuntimeInstallerPath = $DotNetRuntimeInstallerPath
|
DotNetRuntimeInstallerPath = $DotNetRuntimeInstallerPath
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ param(
|
|||||||
|
|
||||||
[string]$DomainNetbios = 'LCI',
|
[string]$DomainNetbios = 'LCI',
|
||||||
|
|
||||||
[ValidateRange(2, 60)]
|
[ValidateRange(2, 90)]
|
||||||
[int]$TimeoutSeconds = 35,
|
[int]$TimeoutSeconds = 90,
|
||||||
|
|
||||||
[switch]$DoNotSetAsDefaultCredentialProvider,
|
[switch]$DoNotSetAsDefaultCredentialProvider,
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ param(
|
|||||||
[string]$ServerCertificateThumbprint,
|
[string]$ServerCertificateThumbprint,
|
||||||
|
|
||||||
[string]$DomainNetbios = 'LCI',
|
[string]$DomainNetbios = 'LCI',
|
||||||
[ValidateRange(2, 60)]
|
[ValidateRange(2, 90)]
|
||||||
[int]$TimeoutSeconds = 35,
|
[int]$TimeoutSeconds = 90,
|
||||||
[string]$RemoteDesktopPrincipal = 'LCI\SG-Laboratorio-Usuarios-RDP',
|
[string]$RemoteDesktopPrincipal = 'LCI\SG-Laboratorio-Usuarios-RDP',
|
||||||
[string]$DotNetRuntimeInstallerPath
|
[string]$DotNetRuntimeInstallerPath
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ if (-not $certificate -or -not $certificate.HasPrivateKey) {
|
|||||||
try {
|
try {
|
||||||
$body = @{ clave = $clave; password = $password } | ConvertTo-Json -Compress
|
$body = @{ clave = $clave; password = $password } | ConvertTo-Json -Compress
|
||||||
Invoke-RestMethod -Method Post -Uri $BrokerEndpoint -Certificate $certificate `
|
Invoke-RestMethod -Method Post -Uri $BrokerEndpoint -Certificate $certificate `
|
||||||
-ContentType 'application/json' -Body $body -TimeoutSec 20
|
-ContentType 'application/json' -Body $body -TimeoutSec 90
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
$password = $null
|
$password = $null
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ public sealed class BrokerOptions
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (Ntlm.TimeoutSeconds is < 2 or > 60 ||
|
if (Ntlm.TimeoutSeconds is < 2 or > 60 ||
|
||||||
Ntlm.ProfileTimeoutSeconds is < 2 or > 30 ||
|
Ntlm.ProfileTimeoutSeconds is < 2 or > 90 ||
|
||||||
Ntlm.MaxRedirects is < 0 or > 10)
|
Ntlm.MaxRedirects is < 0 or > 10)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("NTLM timeout or redirect limits are outside the supported range.");
|
throw new InvalidOperationException("NTLM timeout or redirect limits are outside the supported range.");
|
||||||
@@ -122,7 +122,7 @@ public sealed class NtlmOptions
|
|||||||
|
|
||||||
public int TimeoutSeconds { get; init; } = 20;
|
public int TimeoutSeconds { get; init; } = 20;
|
||||||
|
|
||||||
public int ProfileTimeoutSeconds { get; init; } = 10;
|
public int ProfileTimeoutSeconds { get; init; } = 60;
|
||||||
|
|
||||||
public int MaxRedirects { get; init; } = 5;
|
public int MaxRedirects { get; init; } = 5;
|
||||||
|
|
||||||
|
|||||||
@@ -12,4 +12,10 @@
|
|||||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.11" />
|
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.11" />
|
||||||
<PackageReference Include="System.DirectoryServices" Version="10.0.11" />
|
<PackageReference Include="System.DirectoryServices" Version="10.0.11" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
|
||||||
|
<_Parameter1>SGU.AuthBroker.Tests</_Parameter1>
|
||||||
|
</AssemblyAttribute>
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using SGU.AuthBroker.Core.Authentication;
|
using SGU.AuthBroker.Core.Authentication;
|
||||||
using SGU.AuthBroker.Core.Identity;
|
using SGU.AuthBroker.Core.Identity;
|
||||||
@@ -6,9 +7,28 @@ using SGU.AuthBroker.Options;
|
|||||||
|
|
||||||
namespace SGU.AuthBroker.Services;
|
namespace SGU.AuthBroker.Services;
|
||||||
|
|
||||||
public sealed class NtlmCredentialValidator(BrokerOptions options) : INtlmCredentialValidator
|
public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
||||||
{
|
{
|
||||||
private readonly NtlmOptions options = options.Ntlm;
|
private readonly NtlmOptions options;
|
||||||
|
private readonly ILogger<NtlmCredentialValidator> logger;
|
||||||
|
private readonly Func<CredentialCache, CookieContainer, HttpMessageHandler> handlerFactory;
|
||||||
|
|
||||||
|
public NtlmCredentialValidator(
|
||||||
|
BrokerOptions options,
|
||||||
|
ILogger<NtlmCredentialValidator> logger)
|
||||||
|
: this(options, logger, CreateHandler)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
internal NtlmCredentialValidator(
|
||||||
|
BrokerOptions options,
|
||||||
|
ILogger<NtlmCredentialValidator> logger,
|
||||||
|
Func<CredentialCache, CookieContainer, HttpMessageHandler> handlerFactory)
|
||||||
|
{
|
||||||
|
this.options = options.Ntlm;
|
||||||
|
this.logger = logger;
|
||||||
|
this.handlerFactory = handlerFactory;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<NtlmValidationResult> ValidateAsync(
|
public async Task<NtlmValidationResult> ValidateAsync(
|
||||||
UserIdentity identity,
|
UserIdentity identity,
|
||||||
@@ -24,10 +44,372 @@ public sealed class NtlmCredentialValidator(BrokerOptions options) : INtlmCreden
|
|||||||
|
|
||||||
NetworkCredential credential = new(identity.UserName, password, this.options.Domain);
|
NetworkCredential credential = new(identity.UserName, password, this.options.Domain);
|
||||||
CredentialCache credentialCache = new();
|
CredentialCache credentialCache = new();
|
||||||
HashSet<string> credentialedAuthorities = new(StringComparer.OrdinalIgnoreCase);
|
|
||||||
CookieContainer cookieContainer = new();
|
CookieContainer cookieContainer = new();
|
||||||
|
|
||||||
using HttpClientHandler handler = new()
|
using HttpMessageHandler handler = handlerFactory(credentialCache, cookieContainer);
|
||||||
|
using HttpClient client = new(handler)
|
||||||
|
{
|
||||||
|
Timeout = Timeout.InfiniteTimeSpan,
|
||||||
|
DefaultRequestVersion = HttpVersion.Version11,
|
||||||
|
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact
|
||||||
|
};
|
||||||
|
client.DefaultRequestHeaders.UserAgent.ParseAdd("SGU-AuthBroker/1.0");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
(NtlmValidationResult? authenticationFailure, Uri? continuationUri) = await ValidateCredentialsAsync(
|
||||||
|
client,
|
||||||
|
authenticationUri,
|
||||||
|
allowedHosts,
|
||||||
|
credentialCache,
|
||||||
|
credential,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
if (authenticationFailure is not null)
|
||||||
|
{
|
||||||
|
return authenticationFailure;
|
||||||
|
}
|
||||||
|
|
||||||
|
InstitutionalProfile? profile = await TryFetchProfileAsync(
|
||||||
|
client,
|
||||||
|
identity,
|
||||||
|
allowedHosts,
|
||||||
|
continuationUri,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
return NtlmValidationResult.Valid(profile);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
credential.Password = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(NtlmValidationResult? Failure, Uri? ContinuationUri)> ValidateCredentialsAsync(
|
||||||
|
HttpClient client,
|
||||||
|
Uri authenticationUri,
|
||||||
|
HashSet<string> allowedHosts,
|
||||||
|
CredentialCache credentialCache,
|
||||||
|
NetworkCredential credential,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!IsAllowedHttpsUri(authenticationUri, allowedHosts))
|
||||||
|
{
|
||||||
|
return (NtlmValidationResult.Unavailable("NTLM_AUTH_ENDPOINT_REJECTED"), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
timeout.CancelAfter(TimeSpan.FromSeconds(options.TimeoutSeconds));
|
||||||
|
Stopwatch elapsed = Stopwatch.StartNew();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Uri current = authenticationUri;
|
||||||
|
for (int hop = 0; hop <= options.MaxRedirects; hop++)
|
||||||
|
{
|
||||||
|
if (!IsAllowedHttpsUri(current, allowedHosts))
|
||||||
|
{
|
||||||
|
return (NtlmValidationResult.Unavailable("NTLM_AUTH_ENDPOINT_REJECTED"), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
using HttpRequestMessage probeRequest = new(HttpMethod.Get, current);
|
||||||
|
using HttpResponseMessage probeResponse = await client
|
||||||
|
.SendAsync(probeRequest, HttpCompletionOption.ResponseHeadersRead, timeout.Token)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
int probeStatus = (int)probeResponse.StatusCode;
|
||||||
|
if (probeStatus is >= 300 and < 400)
|
||||||
|
{
|
||||||
|
Uri? redirect = ResolveAllowedRedirect(current, probeResponse, allowedHosts);
|
||||||
|
if (redirect is null)
|
||||||
|
{
|
||||||
|
return (NtlmValidationResult.Unavailable("NTLM_AUTH_REDIRECT_REJECTED"), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogDebug(
|
||||||
|
"SGU authentication discovery followed redirect hop {Hop} to {Path}.",
|
||||||
|
hop + 1,
|
||||||
|
redirect.AbsolutePath);
|
||||||
|
current = redirect;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (probeResponse.StatusCode == HttpStatusCode.Unauthorized)
|
||||||
|
{
|
||||||
|
if (!OffersNtlmChallenge(probeResponse))
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"SGU returned 401 without an NTLM challenge at {Path}.",
|
||||||
|
current.AbsolutePath);
|
||||||
|
return (NtlmValidationResult.Unavailable("NTLM_CHALLENGE_MISSING"), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
await DrainResponseAsync(probeResponse, timeout.Token).ConfigureAwait(false);
|
||||||
|
AddCredential(current, credentialCache, credential);
|
||||||
|
(NtlmValidationResult? failure, Uri? continuationUri) = await AuthenticateChallengedEndpointAsync(
|
||||||
|
client,
|
||||||
|
current,
|
||||||
|
allowedHosts,
|
||||||
|
timeout.Token).ConfigureAwait(false);
|
||||||
|
if (failure is not null)
|
||||||
|
{
|
||||||
|
return (failure, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"SGU accepted credentials after an explicit NTLM challenge in {ElapsedMilliseconds} ms.",
|
||||||
|
elapsed.ElapsedMilliseconds);
|
||||||
|
return (null, continuationUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (probeResponse.StatusCode == HttpStatusCode.Forbidden)
|
||||||
|
{
|
||||||
|
return (NtlmValidationResult.Unavailable("NTLM_CHALLENGE_REJECTED"), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (probeStatus is >= 200 and < 300)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"SGU authentication discovery reached {StatusCode} at {Path} without an NTLM challenge; credentials were not accepted.",
|
||||||
|
probeStatus,
|
||||||
|
current.AbsolutePath);
|
||||||
|
return (NtlmValidationResult.Unavailable("NTLM_CHALLENGE_MISSING"), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return probeStatus is 429 or >= 500
|
||||||
|
? (NtlmValidationResult.Unavailable("NTLM_UPSTREAM_ERROR"), null)
|
||||||
|
: (NtlmValidationResult.Unavailable("NTLM_UNEXPECTED_RESPONSE"), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (NtlmValidationResult.Unavailable("NTLM_REDIRECT_LIMIT"), null);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"SGU NTLM authentication timed out after {ElapsedMilliseconds} ms.",
|
||||||
|
elapsed.ElapsedMilliseconds);
|
||||||
|
return (NtlmValidationResult.Unavailable("NTLM_TIMEOUT"), null);
|
||||||
|
}
|
||||||
|
catch (HttpRequestException exception)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
exception,
|
||||||
|
"SGU NTLM authentication failed after {ElapsedMilliseconds} ms.",
|
||||||
|
elapsed.ElapsedMilliseconds);
|
||||||
|
return (NtlmValidationResult.Unavailable(), null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<(NtlmValidationResult? Failure, Uri? ContinuationUri)> AuthenticateChallengedEndpointAsync(
|
||||||
|
HttpClient client,
|
||||||
|
Uri challengeUri,
|
||||||
|
HashSet<string> allowedHosts,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using HttpRequestMessage authenticationRequest = new(HttpMethod.Get, challengeUri);
|
||||||
|
using HttpResponseMessage authenticationResponse = await client
|
||||||
|
.SendAsync(authenticationRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (authenticationResponse.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
||||||
|
{
|
||||||
|
return (NtlmValidationResult.Invalid(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
int statusCode = (int)authenticationResponse.StatusCode;
|
||||||
|
if (statusCode is >= 200 and < 300)
|
||||||
|
{
|
||||||
|
await DrainResponseAsync(authenticationResponse, cancellationToken).ConfigureAwait(false);
|
||||||
|
return (null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusCode is >= 300 and < 400)
|
||||||
|
{
|
||||||
|
Uri? continuationUri = ResolveAllowedRedirect(
|
||||||
|
challengeUri,
|
||||||
|
authenticationResponse,
|
||||||
|
allowedHosts);
|
||||||
|
if (continuationUri is null)
|
||||||
|
{
|
||||||
|
return (NtlmValidationResult.Unavailable("NTLM_AUTH_REDIRECT_REJECTED"), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
await DrainResponseAsync(authenticationResponse, cancellationToken).ConfigureAwait(false);
|
||||||
|
return (null, continuationUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
return statusCode is 429 or >= 500
|
||||||
|
? (NtlmValidationResult.Unavailable("NTLM_UPSTREAM_ERROR"), null)
|
||||||
|
: (NtlmValidationResult.Unavailable("NTLM_UNEXPECTED_RESPONSE"), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<InstitutionalProfile?> TryFetchProfileAsync(
|
||||||
|
HttpClient client,
|
||||||
|
UserIdentity identity,
|
||||||
|
HashSet<string> allowedHosts,
|
||||||
|
Uri? continuationUri,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Stopwatch elapsed = Stopwatch.StartNew();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Uri originalProfileUri = GetProfileUri(identity.Role);
|
||||||
|
Uri menuUri = GetProfileUri(InstitutionalRole.Professor);
|
||||||
|
Uri current = continuationUri ?? originalProfileUri;
|
||||||
|
bool bootstrappingSession = continuationUri is not null;
|
||||||
|
bool retriedAfterSessionBootstrap = continuationUri is not null;
|
||||||
|
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
timeout.CancelAfter(TimeSpan.FromSeconds(options.ProfileTimeoutSeconds));
|
||||||
|
|
||||||
|
for (int hop = 0; hop <= options.MaxRedirects; hop++)
|
||||||
|
{
|
||||||
|
if (!IsAllowedHttpsUri(current, allowedHosts))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
using HttpRequestMessage request = new(HttpMethod.Get, current);
|
||||||
|
using HttpResponseMessage response = await client
|
||||||
|
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
int statusCode = (int)response.StatusCode;
|
||||||
|
logger.LogDebug(
|
||||||
|
"SGU profile response for role {Role} returned HTTP {StatusCode} at hop {Hop} after {ElapsedMilliseconds} ms.",
|
||||||
|
identity.Role,
|
||||||
|
statusCode,
|
||||||
|
hop,
|
||||||
|
elapsed.ElapsedMilliseconds);
|
||||||
|
if (statusCode is >= 300 and < 400)
|
||||||
|
{
|
||||||
|
Uri? redirect = ResolveAllowedRedirect(current, response, allowedHosts);
|
||||||
|
if (redirect is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"SGU profile redirect was rejected for role {Role} at hop {Hop}.",
|
||||||
|
identity.Role,
|
||||||
|
hop + 1);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await DrainResponseAsync(response, timeout.Token).ConfigureAwait(false);
|
||||||
|
current = redirect;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusCode is >= 200 and < 300)
|
||||||
|
{
|
||||||
|
if (bootstrappingSession)
|
||||||
|
{
|
||||||
|
await DrainResponseAsync(response, timeout.Token).ConfigureAwait(false);
|
||||||
|
bootstrappingSession = false;
|
||||||
|
current = originalProfileUri;
|
||||||
|
logger.LogInformation(
|
||||||
|
"SGU post-authentication session bootstrap completed for role {Role}; requesting the original profile page.",
|
||||||
|
identity.Role);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (identity.Role != InstitutionalRole.Professor &&
|
||||||
|
!retriedAfterSessionBootstrap &&
|
||||||
|
HasSamePath(current, menuUri))
|
||||||
|
{
|
||||||
|
await DrainResponseAsync(response, timeout.Token).ConfigureAwait(false);
|
||||||
|
retriedAfterSessionBootstrap = true;
|
||||||
|
current = originalProfileUri;
|
||||||
|
logger.LogInformation(
|
||||||
|
"SGU ASP.NET session bootstrap completed for role {Role}; retrying the original profile page.",
|
||||||
|
identity.Role);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
InstitutionalProfile? profile = await TryReadProfileAsync(
|
||||||
|
response,
|
||||||
|
identity,
|
||||||
|
timeout.Token).ConfigureAwait(false);
|
||||||
|
if (profile is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"SGU returned a profile page for role {Role}, but no supported profile fields were found after {ElapsedMilliseconds} ms.",
|
||||||
|
identity.Role,
|
||||||
|
elapsed.ElapsedMilliseconds);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"SGU profile enrichment completed for role {Role} in {ElapsedMilliseconds} ms.",
|
||||||
|
identity.Role,
|
||||||
|
elapsed.ElapsedMilliseconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogWarning(
|
||||||
|
"SGU profile request for role {Role} returned HTTP {StatusCode} after {ElapsedMilliseconds} ms.",
|
||||||
|
identity.Role,
|
||||||
|
statusCode,
|
||||||
|
elapsed.ElapsedMilliseconds);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogWarning(
|
||||||
|
"SGU profile request for role {Role} exceeded the redirect limit after {ElapsedMilliseconds} ms.",
|
||||||
|
identity.Role,
|
||||||
|
elapsed.ElapsedMilliseconds);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"SGU profile request for role {Role} timed out after {ElapsedMilliseconds} ms.",
|
||||||
|
identity.Role,
|
||||||
|
elapsed.ElapsedMilliseconds);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
exception,
|
||||||
|
"SGU profile enrichment failed for role {Role} after {ElapsedMilliseconds} ms.",
|
||||||
|
identity.Role,
|
||||||
|
elapsed.ElapsedMilliseconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddCredential(
|
||||||
|
Uri uri,
|
||||||
|
CredentialCache credentialCache,
|
||||||
|
NetworkCredential credential)
|
||||||
|
{
|
||||||
|
string authority = uri.GetLeftPart(UriPartial.Authority);
|
||||||
|
credentialCache.Add(new Uri(authority + "/"), "NTLM", credential);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool OffersNtlmChallenge(HttpResponseMessage response) =>
|
||||||
|
response.Headers.WwwAuthenticate.Any(value =>
|
||||||
|
string.Equals(value.Scheme, "NTLM", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
private static Uri? ResolveAllowedRedirect(
|
||||||
|
Uri current,
|
||||||
|
HttpResponseMessage response,
|
||||||
|
HashSet<string> allowedHosts)
|
||||||
|
{
|
||||||
|
Uri? location = response.Headers.Location;
|
||||||
|
Uri? redirect = location is null
|
||||||
|
? null
|
||||||
|
: location.IsAbsoluteUri ? location : new Uri(current, location);
|
||||||
|
return redirect is not null && IsAllowedHttpsUri(redirect, allowedHosts)
|
||||||
|
? redirect
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpMessageHandler CreateHandler(
|
||||||
|
CredentialCache credentialCache,
|
||||||
|
CookieContainer cookieContainer) =>
|
||||||
|
new HttpClientHandler
|
||||||
{
|
{
|
||||||
AllowAutoRedirect = false,
|
AllowAutoRedirect = false,
|
||||||
AutomaticDecompression = DecompressionMethods.All,
|
AutomaticDecompression = DecompressionMethods.All,
|
||||||
@@ -42,190 +424,38 @@ public sealed class NtlmCredentialValidator(BrokerOptions options) : INtlmCreden
|
|||||||
UseProxy = false
|
UseProxy = false
|
||||||
};
|
};
|
||||||
|
|
||||||
using HttpClient client = new(handler)
|
private static bool HasSamePath(Uri left, Uri right) =>
|
||||||
{
|
string.Equals(
|
||||||
Timeout = Timeout.InfiniteTimeSpan,
|
left.AbsolutePath.TrimEnd('/'),
|
||||||
DefaultRequestVersion = HttpVersion.Version11,
|
right.AbsolutePath.TrimEnd('/'),
|
||||||
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact
|
StringComparison.OrdinalIgnoreCase);
|
||||||
};
|
|
||||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("SGU-AuthBroker/1.0");
|
|
||||||
|
|
||||||
try
|
private static async Task DrainResponseAsync(
|
||||||
{
|
HttpResponseMessage response,
|
||||||
NtlmValidationResult? authenticationFailure = await ValidateCredentialsAsync(
|
|
||||||
client,
|
|
||||||
authenticationUri,
|
|
||||||
allowedHosts,
|
|
||||||
credentialCache,
|
|
||||||
credentialedAuthorities,
|
|
||||||
credential,
|
|
||||||
cancellationToken).ConfigureAwait(false);
|
|
||||||
if (authenticationFailure is not null)
|
|
||||||
{
|
|
||||||
return authenticationFailure;
|
|
||||||
}
|
|
||||||
|
|
||||||
InstitutionalProfile? profile = await TryFetchProfileAsync(
|
|
||||||
client,
|
|
||||||
identity,
|
|
||||||
allowedHosts,
|
|
||||||
credentialCache,
|
|
||||||
credentialedAuthorities,
|
|
||||||
credential,
|
|
||||||
cancellationToken).ConfigureAwait(false);
|
|
||||||
return NtlmValidationResult.Valid(profile);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
credential.Password = string.Empty;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<NtlmValidationResult?> ValidateCredentialsAsync(
|
|
||||||
HttpClient client,
|
|
||||||
Uri authenticationUri,
|
|
||||||
HashSet<string> allowedHosts,
|
|
||||||
CredentialCache credentialCache,
|
|
||||||
HashSet<string> credentialedAuthorities,
|
|
||||||
NetworkCredential credential,
|
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (!IsAllowedHttpsUri(authenticationUri, allowedHosts))
|
const int maximumDrainBytes = 64 * 1024;
|
||||||
|
if (response.Content.Headers.ContentLength is > maximumDrainBytes)
|
||||||
{
|
{
|
||||||
return NtlmValidationResult.Unavailable("NTLM_AUTH_ENDPOINT_REJECTED");
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
AddCredential(
|
await using Stream stream = await response.Content
|
||||||
authenticationUri,
|
.ReadAsStreamAsync(cancellationToken)
|
||||||
credentialCache,
|
|
||||||
credentialedAuthorities,
|
|
||||||
credential);
|
|
||||||
using HttpRequestMessage request = new(HttpMethod.Get, authenticationUri);
|
|
||||||
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
||||||
timeout.CancelAfter(TimeSpan.FromSeconds(options.TimeoutSeconds));
|
|
||||||
|
|
||||||
HttpResponseMessage response;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
response = await client
|
|
||||||
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token)
|
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
byte[] buffer = new byte[8192];
|
||||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
int total = 0;
|
||||||
|
while (total <= maximumDrainBytes)
|
||||||
{
|
{
|
||||||
return NtlmValidationResult.Unavailable("NTLM_TIMEOUT");
|
int read = await stream
|
||||||
}
|
.ReadAsync(buffer.AsMemory(), cancellationToken)
|
||||||
catch (HttpRequestException)
|
|
||||||
{
|
|
||||||
return NtlmValidationResult.Unavailable();
|
|
||||||
}
|
|
||||||
|
|
||||||
using (response)
|
|
||||||
{
|
|
||||||
if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
|
||||||
{
|
|
||||||
return NtlmValidationResult.Invalid();
|
|
||||||
}
|
|
||||||
|
|
||||||
int statusCode = (int)response.StatusCode;
|
|
||||||
if (statusCode is >= 200 and < 300)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (statusCode is >= 300 and < 400)
|
|
||||||
{
|
|
||||||
Uri? location = response.Headers.Location;
|
|
||||||
Uri? redirect = location is null
|
|
||||||
? null
|
|
||||||
: location.IsAbsoluteUri ? location : new Uri(authenticationUri, location);
|
|
||||||
return redirect is not null && IsAllowedHttpsUri(redirect, allowedHosts)
|
|
||||||
? null
|
|
||||||
: NtlmValidationResult.Unavailable("NTLM_AUTH_REDIRECT_REJECTED");
|
|
||||||
}
|
|
||||||
|
|
||||||
return statusCode is 429 or >= 500
|
|
||||||
? NtlmValidationResult.Unavailable("NTLM_UPSTREAM_ERROR")
|
|
||||||
: NtlmValidationResult.Unavailable("NTLM_UNEXPECTED_RESPONSE");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<InstitutionalProfile?> TryFetchProfileAsync(
|
|
||||||
HttpClient client,
|
|
||||||
UserIdentity identity,
|
|
||||||
HashSet<string> allowedHosts,
|
|
||||||
CredentialCache credentialCache,
|
|
||||||
HashSet<string> credentialedAuthorities,
|
|
||||||
NetworkCredential credential,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Uri current = GetProfileUri(identity.Role);
|
|
||||||
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
||||||
timeout.CancelAfter(TimeSpan.FromSeconds(options.ProfileTimeoutSeconds));
|
|
||||||
|
|
||||||
for (int hop = 0; hop <= options.MaxRedirects; hop++)
|
|
||||||
{
|
|
||||||
if (!IsAllowedHttpsUri(current, allowedHosts))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
AddCredential(current, credentialCache, credentialedAuthorities, credential);
|
|
||||||
using HttpRequestMessage request = new(HttpMethod.Get, current);
|
|
||||||
using HttpResponseMessage response = await client
|
|
||||||
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token)
|
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
if (read == 0)
|
||||||
int statusCode = (int)response.StatusCode;
|
|
||||||
if (statusCode is >= 300 and < 400)
|
|
||||||
{
|
{
|
||||||
Uri? location = response.Headers.Location;
|
return;
|
||||||
if (location is null)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
current = location.IsAbsoluteUri ? location : new Uri(current, location);
|
total += read;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (statusCode is >= 200 and < 300)
|
|
||||||
{
|
|
||||||
return await TryReadProfileAsync(
|
|
||||||
response,
|
|
||||||
identity,
|
|
||||||
timeout.Token,
|
|
||||||
cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Enrichment is optional once the lightweight NTLM endpoint has
|
|
||||||
// authoritatively accepted the credentials.
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void AddCredential(
|
|
||||||
Uri uri,
|
|
||||||
CredentialCache credentialCache,
|
|
||||||
HashSet<string> credentialedAuthorities,
|
|
||||||
NetworkCredential credential)
|
|
||||||
{
|
|
||||||
string authority = uri.GetLeftPart(UriPartial.Authority);
|
|
||||||
if (credentialedAuthorities.Add(authority))
|
|
||||||
{
|
|
||||||
credentialCache.Add(new Uri(authority + "/"), "NTLM", credential);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,10 +475,7 @@ public sealed class NtlmCredentialValidator(BrokerOptions options) : INtlmCreden
|
|||||||
private async Task<InstitutionalProfile?> TryReadProfileAsync(
|
private async Task<InstitutionalProfile?> TryReadProfileAsync(
|
||||||
HttpResponseMessage response,
|
HttpResponseMessage response,
|
||||||
UserIdentity identity,
|
UserIdentity identity,
|
||||||
CancellationToken timeoutToken,
|
CancellationToken timeoutToken)
|
||||||
CancellationToken requestCancellationToken)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
string html = await ReadLimitedStringAsync(
|
string html = await ReadLimitedStringAsync(
|
||||||
response.Content,
|
response.Content,
|
||||||
@@ -266,17 +493,6 @@ public sealed class NtlmCredentialValidator(BrokerOptions options) : INtlmCreden
|
|||||||
_ => null
|
_ => null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (requestCancellationToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Profile enrichment is optional. A successful NTLM response must still
|
|
||||||
// synchronize the exact password even if SGU changes its presentation HTML.
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<string> ReadLimitedStringAsync(
|
private static async Task<string> ReadLimitedStringAsync(
|
||||||
HttpContent content,
|
HttpContent content,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
"Endpoint": "https://sgu.ulsa.edu.mx/",
|
"Endpoint": "https://sgu.ulsa.edu.mx/",
|
||||||
"Domain": "",
|
"Domain": "",
|
||||||
"TimeoutSeconds": 20,
|
"TimeoutSeconds": 20,
|
||||||
"ProfileTimeoutSeconds": 10,
|
"ProfileTimeoutSeconds": 60,
|
||||||
"MaxRedirects": 5,
|
"MaxRedirects": 5,
|
||||||
"AuthenticationPath": "/psulsa/",
|
"AuthenticationPath": "/psulsa/",
|
||||||
"AdministrativeProfilePath": "/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx",
|
"AdministrativeProfilePath": "/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ internal sealed class ProviderSettings
|
|||||||
|
|
||||||
public string DomainNetbios { get; init; } = "LCI";
|
public string DomainNetbios { get; init; } = "LCI";
|
||||||
|
|
||||||
public int TimeoutSeconds { get; init; } = 35;
|
public int TimeoutSeconds { get; init; } = 90;
|
||||||
|
|
||||||
public string ClientCertificateThumbprint { get; init; } = string.Empty;
|
public string ClientCertificateThumbprint { get; init; } = string.Empty;
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ internal sealed class ProviderSettings
|
|||||||
throw new InvalidOperationException("BrokerEndpoint must target /v1/authenticate.");
|
throw new InvalidOperationException("BrokerEndpoint must target /v1/authenticate.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(DomainNetbios) || TimeoutSeconds is < 2 or > 60)
|
if (string.IsNullOrWhiteSpace(DomainNetbios) || TimeoutSeconds is < 2 or > 90)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("DomainNetbios or TimeoutSeconds is invalid.");
|
throw new InvalidOperationException("DomainNetbios or TimeoutSeconds is invalid.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ internal static class ProviderTileIcon
|
|||||||
using Graphics graphics = Graphics.FromImage(bitmap);
|
using Graphics graphics = Graphics.FromImage(bitmap);
|
||||||
graphics.SmoothingMode = SmoothingMode.AntiAlias;
|
graphics.SmoothingMode = SmoothingMode.AntiAlias;
|
||||||
graphics.PixelOffsetMode = PixelOffsetMode.HighQuality;
|
graphics.PixelOffsetMode = PixelOffsetMode.HighQuality;
|
||||||
graphics.Clear(Color.FromArgb(0, 83, 155));
|
graphics.Clear(Color.Transparent);
|
||||||
|
|
||||||
|
using SolidBrush background = new(Color.FromArgb(0, 83, 155));
|
||||||
|
graphics.FillEllipse(background, 1, 1, Size - 2, Size - 2);
|
||||||
|
|
||||||
using Pen key = new(Color.White, 5.5f)
|
using Pen key = new(Color.White, 5.5f)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"BrokerEndpoint": "https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate",
|
"BrokerEndpoint": "https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate",
|
||||||
"DomainNetbios": "LCI",
|
"DomainNetbios": "LCI",
|
||||||
"TimeoutSeconds": 35,
|
"TimeoutSeconds": 90,
|
||||||
"ClientCertificateThumbprint": "0000000000000000000000000000000000000000",
|
"ClientCertificateThumbprint": "0000000000000000000000000000000000000000",
|
||||||
"ServerCertificateThumbprint": "0000000000000000000000000000000000000000"
|
"ServerCertificateThumbprint": "0000000000000000000000000000000000000000"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
[Fact]
|
||||||
public void DefaultClientTimeoutLeavesMarginForThePortalAndBroker()
|
public void DefaultClientTimeoutLeavesMarginForThePortalAndBroker()
|
||||||
{
|
{
|
||||||
Assert.Equal(35, new ProviderSettings().TimeoutSeconds);
|
Assert.Equal(90, new ProviderSettings().TimeoutSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -16,7 +16,13 @@ public sealed class ProviderTileIconTests
|
|||||||
|
|
||||||
Assert.Equal(ProviderTileIcon.Size, logo.Bitmap.Width);
|
Assert.Equal(ProviderTileIcon.Size, logo.Bitmap.Width);
|
||||||
Assert.Equal(ProviderTileIcon.Size, logo.Bitmap.Height);
|
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;
|
int lightPixels = 0;
|
||||||
for (int x = 0; x < logo.Bitmap.Width; x++)
|
for (int x = 0; x < logo.Bitmap.Width; x++)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user