Add SGU credential provider and authentication broker
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SGU.AuthBroker.Contracts;
|
||||
|
||||
public sealed class AuthenticationRequest
|
||||
{
|
||||
[JsonPropertyName("clave")]
|
||||
public string Clave { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("password")]
|
||||
public string Password { get; set; } = string.Empty;
|
||||
|
||||
public void ReleasePasswordReference() => Password = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SGU.AuthBroker.Contracts;
|
||||
|
||||
public sealed record AuthenticationResponse(
|
||||
[property: JsonPropertyName("domain")] string Domain,
|
||||
[property: JsonPropertyName("username")] string UserName,
|
||||
[property: JsonPropertyName("upn")] string UserPrincipalName,
|
||||
[property: JsonPropertyName("created")] bool Created,
|
||||
[property: JsonPropertyName("moved")] bool Moved);
|
||||
|
||||
public sealed record ErrorResponse(
|
||||
[property: JsonPropertyName("code")] string Code,
|
||||
[property: JsonPropertyName("message")] string Message);
|
||||
@@ -0,0 +1,114 @@
|
||||
using SGU.AuthBroker.Core.Identity;
|
||||
|
||||
namespace SGU.AuthBroker.Options;
|
||||
|
||||
public sealed class BrokerOptions
|
||||
{
|
||||
public const string SectionName = "Broker";
|
||||
|
||||
public TlsOptions Tls { get; init; } = new();
|
||||
|
||||
public NtlmOptions Ntlm { get; init; } = new();
|
||||
|
||||
public ActiveDirectoryOptions Directory { get; init; } = new();
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (Tls.AllowedClientThumbprints.Length == 0 ||
|
||||
Tls.AllowedClientThumbprints.Any(value => !IsCertificateThumbprint(value)))
|
||||
{
|
||||
throw new InvalidOperationException("At least one client certificate thumbprint is required.");
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(Ntlm.Endpoint, UriKind.Absolute, out Uri? endpoint) || endpoint.Scheme != Uri.UriSchemeHttps)
|
||||
{
|
||||
throw new InvalidOperationException("The institutional NTLM endpoint must be an absolute HTTPS URL.");
|
||||
}
|
||||
|
||||
if (Ntlm.AllowedRedirectHosts.Length == 0 ||
|
||||
!Ntlm.AllowedRedirectHosts.Contains(endpoint.IdnHost, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("The NTLM endpoint host must be present in AllowedRedirectHosts.");
|
||||
}
|
||||
|
||||
if (Ntlm.TimeoutSeconds is < 2 or > 60 || Ntlm.MaxRedirects is < 0 or > 10)
|
||||
{
|
||||
throw new InvalidOperationException("NTLM timeout or redirect limits are outside the supported range.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Directory.LdapHost) ||
|
||||
string.IsNullOrWhiteSpace(Directory.BaseDn) ||
|
||||
string.IsNullOrWhiteSpace(Directory.DomainNetbios) ||
|
||||
string.IsNullOrWhiteSpace(Directory.UpnSuffix))
|
||||
{
|
||||
throw new InvalidOperationException("Active Directory connection and domain settings are required.");
|
||||
}
|
||||
|
||||
foreach (InstitutionalRole role in Enum.GetValues<InstitutionalRole>())
|
||||
{
|
||||
string ouDn = Directory.GetOuDn(role);
|
||||
if (string.IsNullOrWhiteSpace(ouDn))
|
||||
{
|
||||
throw new InvalidOperationException($"An OU mapping is required for {role}.");
|
||||
}
|
||||
|
||||
if (!ouDn.EndsWith($",{Directory.BaseDn}", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"The OU mapping for {role} must be beneath BaseDn.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsCertificateThumbprint(string value)
|
||||
{
|
||||
string normalized = value.Replace(" ", string.Empty, StringComparison.Ordinal);
|
||||
return normalized.Length == 40 && normalized.All(Uri.IsHexDigit);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TlsOptions
|
||||
{
|
||||
public string[] AllowedClientThumbprints { get; init; } = [];
|
||||
|
||||
public bool CheckCertificateRevocation { get; init; } = true;
|
||||
}
|
||||
|
||||
public sealed class NtlmOptions
|
||||
{
|
||||
public string Endpoint { get; init; } = "https://sgu.ulsa.edu.mx/";
|
||||
|
||||
public string Domain { get; init; } = string.Empty;
|
||||
|
||||
public int TimeoutSeconds { get; init; } = 15;
|
||||
|
||||
public int MaxRedirects { get; init; } = 5;
|
||||
|
||||
public string[] AllowedRedirectHosts { get; init; } = ["sgu.ulsa.edu.mx"];
|
||||
}
|
||||
|
||||
public sealed class ActiveDirectoryOptions
|
||||
{
|
||||
public string LdapHost { get; init; } = "localhost";
|
||||
|
||||
public string BaseDn { get; init; } = "DC=lci,DC=lasalle,DC=mx";
|
||||
|
||||
public string DomainNetbios { get; init; } = "LCI";
|
||||
|
||||
public string UpnSuffix { get; init; } = "lci.lasalle.mx";
|
||||
|
||||
public string ProfessorOuDn { get; init; } = "OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
|
||||
|
||||
public string StudentOuDn { get; init; } = "OU=Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
|
||||
|
||||
public string AdministrativeOuDn { get; init; } = "OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
|
||||
|
||||
public bool CreateMissingOus { get; init; }
|
||||
|
||||
public string GetOuDn(InstitutionalRole role) => role switch
|
||||
{
|
||||
InstitutionalRole.Professor => ProfessorOuDn,
|
||||
InstitutionalRole.Student => StudentOuDn,
|
||||
InstitutionalRole.Administrative => AdministrativeOuDn,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(role), role, null)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using System.Net.Security;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Threading.RateLimiting;
|
||||
using Microsoft.AspNetCore.Server.Kestrel.Https;
|
||||
using SGU.AuthBroker.Contracts;
|
||||
using SGU.AuthBroker.Core.Authentication;
|
||||
using SGU.AuthBroker.Core.Directory;
|
||||
using SGU.AuthBroker.Options;
|
||||
using SGU.AuthBroker.Services;
|
||||
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
builder.Host.UseWindowsService(options => options.ServiceName = "SGU Authentication Broker");
|
||||
|
||||
BrokerOptions brokerOptions = builder.Configuration
|
||||
.GetSection(BrokerOptions.SectionName)
|
||||
.Get<BrokerOptions>() ?? throw new InvalidOperationException("Broker configuration is missing.");
|
||||
brokerOptions.Validate();
|
||||
|
||||
HashSet<string> allowedClientThumbprints = brokerOptions.Tls.AllowedClientThumbprints
|
||||
.Select(NormalizeThumbprint)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
builder.WebHost.ConfigureKestrel(kestrel =>
|
||||
{
|
||||
kestrel.AddServerHeader = false;
|
||||
kestrel.Limits.MaxRequestBodySize = 4096;
|
||||
kestrel.ConfigureHttpsDefaults(https =>
|
||||
{
|
||||
https.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
|
||||
https.CheckCertificateRevocation = brokerOptions.Tls.CheckCertificateRevocation;
|
||||
https.ClientCertificateValidation = (certificate, _, policyErrors) =>
|
||||
policyErrors == SslPolicyErrors.None &&
|
||||
allowedClientThumbprints.Contains(NormalizeThumbprint(certificate.Thumbprint));
|
||||
});
|
||||
});
|
||||
|
||||
builder.Logging.AddFilter("Microsoft.AspNetCore", LogLevel.Warning);
|
||||
builder.Services.AddSingleton(brokerOptions);
|
||||
builder.Services.AddSingleton<INtlmCredentialValidator, NtlmCredentialValidator>();
|
||||
builder.Services.AddSingleton<IActiveDirectorySynchronizer, ActiveDirectorySynchronizer>();
|
||||
builder.Services.AddScoped<AuthenticationWorkflow>();
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
options.AddPolicy("credential-auth", context =>
|
||||
{
|
||||
string partition = NormalizeThumbprint(context.Connection.ClientCertificate?.Thumbprint ?? "none");
|
||||
return RateLimitPartition.GetFixedWindowLimiter(partition, _ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = 12,
|
||||
QueueLimit = 0,
|
||||
Window = TimeSpan.FromMinutes(1),
|
||||
AutoReplenishment = true
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
app.UseRateLimiter();
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
context.Response.Headers.CacheControl = "no-store";
|
||||
context.Response.Headers.Pragma = "no-cache";
|
||||
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
|
||||
await next(context).ConfigureAwait(false);
|
||||
});
|
||||
|
||||
app.MapGet("/health/live", () => Results.Ok(new { status = "ok" }));
|
||||
|
||||
app.MapPost("/v1/authenticate", async (
|
||||
AuthenticationRequest request,
|
||||
AuthenticationWorkflow workflow,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Password) || request.Password.Length > 256)
|
||||
{
|
||||
request.ReleasePasswordReference();
|
||||
return Results.BadRequest(new ErrorResponse("MISSING_PASSWORD", "La contraseña es requerida."));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
AuthenticationFlowResult result = await workflow
|
||||
.AuthenticateAsync(request.Clave, request.Password, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result.Outcome switch
|
||||
{
|
||||
AuthenticationFlowOutcome.Authorized => Results.Ok(new AuthenticationResponse(
|
||||
result.Directory!.DomainNetbios,
|
||||
result.Directory.UserName,
|
||||
result.Directory.UserPrincipalName,
|
||||
result.Directory.Created,
|
||||
result.Directory.Moved)),
|
||||
|
||||
AuthenticationFlowOutcome.InvalidUserName => Results.BadRequest(new ErrorResponse(
|
||||
result.ErrorCode ?? "INVALID_USERNAME_FORMAT",
|
||||
"La clave debe usar DO, AL o AD seguido de seis dígitos.")),
|
||||
|
||||
AuthenticationFlowOutcome.InvalidCredentials => Results.Json(
|
||||
new ErrorResponse(
|
||||
result.ErrorCode ?? "INVALID_INSTITUTIONAL_CREDENTIALS",
|
||||
"Credenciales institucionales inválidas."),
|
||||
statusCode: StatusCodes.Status401Unauthorized),
|
||||
|
||||
_ => Unavailable(context, result.ErrorCode)
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
request.ReleasePasswordReference();
|
||||
}
|
||||
}).RequireRateLimiting("credential-auth");
|
||||
|
||||
app.Run();
|
||||
|
||||
static IResult Unavailable(HttpContext context, string? errorCode)
|
||||
{
|
||||
context.Response.Headers.RetryAfter = "2";
|
||||
return Results.Json(
|
||||
new ErrorResponse(errorCode ?? "AUTHENTICATION_SERVICE_UNAVAILABLE", "El servicio no está disponible."),
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
static string NormalizeThumbprint(string value) =>
|
||||
value.Replace(" ", string.Empty, StringComparison.Ordinal).ToUpperInvariant();
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<AssemblyName>SGU.AuthBroker</AssemblyName>
|
||||
<RootNamespace>SGU.AuthBroker</RootNamespace>
|
||||
<InvariantGlobalization>false</InvariantGlobalization>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\SGU.AuthBroker.Core\SGU.AuthBroker.Core.csproj" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.11" />
|
||||
<PackageReference Include="System.DirectoryServices" Version="10.0.11" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,198 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.DirectoryServices;
|
||||
using SGU.AuthBroker.Core.Directory;
|
||||
using SGU.AuthBroker.Core.Identity;
|
||||
using SGU.AuthBroker.Options;
|
||||
|
||||
namespace SGU.AuthBroker.Services;
|
||||
|
||||
public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActiveDirectorySynchronizer
|
||||
{
|
||||
private const int AccountDisabled = 0x0002;
|
||||
private const int NormalAccount = 0x0200;
|
||||
private static readonly AuthenticationTypes BindFlags =
|
||||
AuthenticationTypes.Secure | AuthenticationTypes.Signing | AuthenticationTypes.Sealing;
|
||||
|
||||
private readonly ActiveDirectoryOptions options = options.Directory;
|
||||
private readonly ConcurrentDictionary<string, SemaphoreSlim> userLocks =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public async Task<DirectorySyncResult> SynchronizeAsync(
|
||||
UserIdentity identity,
|
||||
string password,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SemaphoreSlim gate = userLocks.GetOrAdd(identity.UserName, static _ => new SemaphoreSlim(1, 1));
|
||||
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
return await Task.Run(
|
||||
() => Synchronize(identity, password),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
if (gate.CurrentCount == 1)
|
||||
{
|
||||
userLocks.TryRemove(new KeyValuePair<string, SemaphoreSlim>(identity.UserName, gate));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private DirectorySyncResult Synchronize(UserIdentity identity, string password)
|
||||
{
|
||||
string targetOuDn = options.GetOuDn(identity.Role);
|
||||
using DirectoryEntry root = Bind(options.BaseDn);
|
||||
using DirectoryEntry targetOu = BindOrCreateOu(targetOuDn, root);
|
||||
|
||||
using DirectorySearcher searcher = new(root)
|
||||
{
|
||||
Filter = $"(&(objectCategory=person)(objectClass=user)(sAMAccountName={EscapeLdapFilter(identity.UserName)}))",
|
||||
SearchScope = SearchScope.Subtree,
|
||||
PageSize = 1,
|
||||
SizeLimit = 1
|
||||
};
|
||||
searcher.PropertiesToLoad.Add("distinguishedName");
|
||||
|
||||
SearchResult? result = searcher.FindOne();
|
||||
bool created = result is null;
|
||||
bool moved = false;
|
||||
DirectoryEntry? user = null;
|
||||
|
||||
try
|
||||
{
|
||||
if (created)
|
||||
{
|
||||
user = targetOu.Children.Add($"CN={EscapeRdn(identity.UserName)}", "user");
|
||||
user.Properties["sAMAccountName"].Value = identity.UserName;
|
||||
user.Properties["userPrincipalName"].Value = $"{identity.UserName}@{options.UpnSuffix}";
|
||||
user.Properties["displayName"].Value = identity.UserName;
|
||||
user.CommitChanges();
|
||||
}
|
||||
else
|
||||
{
|
||||
user = result!.GetDirectoryEntry();
|
||||
string distinguishedName = Convert.ToString(user.Properties["distinguishedName"].Value) ?? string.Empty;
|
||||
string parentDn = ParentDn(distinguishedName);
|
||||
if (!string.Equals(parentDn, targetOuDn, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
user.MoveTo(targetOu);
|
||||
moved = true;
|
||||
}
|
||||
|
||||
user.Properties["userPrincipalName"].Value = $"{identity.UserName}@{options.UpnSuffix}";
|
||||
user.CommitChanges();
|
||||
}
|
||||
|
||||
// The exact institutional password received by the broker is passed to AD.
|
||||
// It is not derived, transformed, written to disk, or included in logs.
|
||||
user.Invoke("SetPassword", [password]);
|
||||
int flags = user.Properties["userAccountControl"].Value is int currentFlags
|
||||
? currentFlags
|
||||
: NormalAccount;
|
||||
user.Properties["userAccountControl"].Value = (flags | NormalAccount) & ~AccountDisabled;
|
||||
user.Properties["pwdLastSet"].Value = -1;
|
||||
user.CommitChanges();
|
||||
|
||||
return new DirectorySyncResult(
|
||||
options.DomainNetbios,
|
||||
identity.UserName,
|
||||
$"{identity.UserName}@{options.UpnSuffix}",
|
||||
created,
|
||||
moved);
|
||||
}
|
||||
finally
|
||||
{
|
||||
user?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private DirectoryEntry BindOrCreateOu(string ouDn, DirectoryEntry root)
|
||||
{
|
||||
try
|
||||
{
|
||||
DirectoryEntry existing = Bind(ouDn);
|
||||
_ = existing.NativeObject;
|
||||
return existing;
|
||||
}
|
||||
catch (DirectoryServicesCOMException) when (options.CreateMissingOus)
|
||||
{
|
||||
string parent = ParentDn(ouDn);
|
||||
if (!ouDn.EndsWith($",{options.BaseDn}", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("Automatic OU creation is limited to descendants of BaseDn.");
|
||||
}
|
||||
|
||||
string rdn = ouDn[..FirstUnescapedComma(ouDn)];
|
||||
DirectoryEntry? parentEntry = null;
|
||||
try
|
||||
{
|
||||
DirectoryEntry container = root;
|
||||
if (!string.Equals(parent, options.BaseDn, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
parentEntry = BindOrCreateOu(parent, root);
|
||||
container = parentEntry;
|
||||
}
|
||||
|
||||
DirectoryEntry created = container.Children.Add(rdn, "organizationalUnit");
|
||||
created.CommitChanges();
|
||||
return created;
|
||||
}
|
||||
finally
|
||||
{
|
||||
parentEntry?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private DirectoryEntry Bind(string distinguishedName) =>
|
||||
new($"LDAP://{options.LdapHost}/{distinguishedName}", null, null, BindFlags);
|
||||
|
||||
private static string ParentDn(string distinguishedName)
|
||||
{
|
||||
int comma = FirstUnescapedComma(distinguishedName);
|
||||
return comma < 0 ? string.Empty : distinguishedName[(comma + 1)..];
|
||||
}
|
||||
|
||||
private static int FirstUnescapedComma(string value)
|
||||
{
|
||||
bool escaped = false;
|
||||
for (int i = 0; i < value.Length; i++)
|
||||
{
|
||||
if (escaped)
|
||||
{
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value[i] == '\\')
|
||||
{
|
||||
escaped = true;
|
||||
}
|
||||
else if (value[i] == ',')
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static string EscapeLdapFilter(string value) => value
|
||||
.Replace("\\", "\\5c", StringComparison.Ordinal)
|
||||
.Replace("*", "\\2a", StringComparison.Ordinal)
|
||||
.Replace("(", "\\28", StringComparison.Ordinal)
|
||||
.Replace(")", "\\29", StringComparison.Ordinal)
|
||||
.Replace("\0", "\\00", StringComparison.Ordinal);
|
||||
|
||||
private static string EscapeRdn(string value) => value
|
||||
.Replace("\\", "\\\\", StringComparison.Ordinal)
|
||||
.Replace(",", "\\,", StringComparison.Ordinal)
|
||||
.Replace("+", "\\+", StringComparison.Ordinal)
|
||||
.Replace("\"", "\\\"", StringComparison.Ordinal)
|
||||
.Replace("<", "\\<", StringComparison.Ordinal)
|
||||
.Replace(">", "\\>", StringComparison.Ordinal)
|
||||
.Replace(";", "\\;", StringComparison.Ordinal)
|
||||
.Replace("=", "\\=", StringComparison.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using System.Net;
|
||||
using SGU.AuthBroker.Core.Authentication;
|
||||
using SGU.AuthBroker.Options;
|
||||
|
||||
namespace SGU.AuthBroker.Services;
|
||||
|
||||
public sealed class NtlmCredentialValidator(BrokerOptions options) : INtlmCredentialValidator
|
||||
{
|
||||
private readonly NtlmOptions options = options.Ntlm;
|
||||
|
||||
public async Task<NtlmValidationResult> ValidateAsync(
|
||||
string userName,
|
||||
string password,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Uri current = new(this.options.Endpoint, UriKind.Absolute);
|
||||
HashSet<string> allowedHosts = new(
|
||||
this.options.AllowedRedirectHosts,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
NetworkCredential credential = new(userName, password, this.options.Domain);
|
||||
CredentialCache credentialCache = new();
|
||||
HashSet<string> credentialedAuthorities = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
using HttpClientHandler handler = new()
|
||||
{
|
||||
AllowAutoRedirect = false,
|
||||
AutomaticDecompression = DecompressionMethods.All,
|
||||
CheckCertificateRevocationList = true,
|
||||
Credentials = credentialCache,
|
||||
MaxConnectionsPerServer = 4,
|
||||
MaxResponseHeadersLength = 64,
|
||||
PreAuthenticate = false,
|
||||
UseCookies = false,
|
||||
UseDefaultCredentials = false,
|
||||
UseProxy = false
|
||||
};
|
||||
|
||||
using HttpClient client = new(handler)
|
||||
{
|
||||
Timeout = Timeout.InfiniteTimeSpan,
|
||||
DefaultRequestVersion = HttpVersion.Version11,
|
||||
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact
|
||||
};
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("SGU-AuthBroker/1.0");
|
||||
|
||||
try
|
||||
{
|
||||
for (int hop = 0; hop <= this.options.MaxRedirects; hop++)
|
||||
{
|
||||
if (!IsAllowedHttpsUri(current, allowedHosts))
|
||||
{
|
||||
return NtlmValidationResult.Unavailable("NTLM_REDIRECT_REJECTED");
|
||||
}
|
||||
|
||||
string authority = current.GetLeftPart(UriPartial.Authority);
|
||||
if (credentialedAuthorities.Add(authority))
|
||||
{
|
||||
credentialCache.Add(new Uri(authority + "/"), "NTLM", credential);
|
||||
}
|
||||
|
||||
using HttpRequestMessage request = new(HttpMethod.Get, current);
|
||||
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(this.options.TimeoutSeconds));
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await client
|
||||
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return NtlmValidationResult.Unavailable("NTLM_TIMEOUT");
|
||||
}
|
||||
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 >= 500)
|
||||
{
|
||||
return NtlmValidationResult.Unavailable("NTLM_UPSTREAM_ERROR");
|
||||
}
|
||||
|
||||
if (statusCode is >= 300 and < 400)
|
||||
{
|
||||
Uri? location = response.Headers.Location;
|
||||
if (location is null)
|
||||
{
|
||||
return NtlmValidationResult.Unavailable("NTLM_INVALID_REDIRECT");
|
||||
}
|
||||
|
||||
current = location.IsAbsoluteUri ? location : new Uri(current, location);
|
||||
continue;
|
||||
}
|
||||
|
||||
return statusCode is >= 200 and < 300
|
||||
? NtlmValidationResult.Valid()
|
||||
: NtlmValidationResult.Invalid();
|
||||
}
|
||||
}
|
||||
|
||||
return NtlmValidationResult.Unavailable("NTLM_REDIRECT_LIMIT");
|
||||
}
|
||||
finally
|
||||
{
|
||||
credential.Password = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsAllowedHttpsUri(Uri uri, HashSet<string> allowedHosts) =>
|
||||
uri.Scheme == Uri.UriSchemeHttps &&
|
||||
string.IsNullOrEmpty(uri.UserInfo) &&
|
||||
allowedHosts.Contains(uri.IdnHost);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"AllowedHosts": "*",
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"Kestrel": {
|
||||
"Endpoints": {
|
||||
"Https": {
|
||||
"Url": "https://0.0.0.0:8443",
|
||||
"Certificate": {
|
||||
"Subject": "sgu-auth.lci.lasalle.mx",
|
||||
"Store": "My",
|
||||
"Location": "LocalMachine",
|
||||
"AllowInvalid": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Broker": {
|
||||
"Tls": {
|
||||
"AllowedClientThumbprints": [
|
||||
"SET-BY-DEPLOYMENT"
|
||||
],
|
||||
"CheckCertificateRevocation": true
|
||||
},
|
||||
"Ntlm": {
|
||||
"Endpoint": "https://sgu.ulsa.edu.mx/",
|
||||
"Domain": "",
|
||||
"TimeoutSeconds": 15,
|
||||
"MaxRedirects": 5,
|
||||
"AllowedRedirectHosts": [
|
||||
"sgu.ulsa.edu.mx"
|
||||
]
|
||||
},
|
||||
"Directory": {
|
||||
"LdapHost": "localhost",
|
||||
"BaseDn": "DC=lci,DC=lasalle,DC=mx",
|
||||
"DomainNetbios": "LCI",
|
||||
"UpnSuffix": "lci.lasalle.mx",
|
||||
"ProfessorOuDn": "OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
|
||||
"StudentOuDn": "OU=Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
|
||||
"AdministrativeOuDn": "OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
|
||||
"CreateMissingOus": false
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user