Add SGU credential provider and authentication broker

This commit is contained in:
2026-08-31 17:48:18 -06:00
parent 5e216f42a4
commit 1f43f200b4
50 changed files with 3226 additions and 236 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "1.0",
"components": [
"Microsoft.VisualStudio.Workload.NativeDesktop",
"Microsoft.VisualStudio.Workload.ManagedDesktop",
"Microsoft.VisualStudio.Workload.NetWeb",
"Microsoft.VisualStudio.Component.VC.Tools.x86.x64",
"Microsoft.VisualStudio.Component.VC.CMake.Project",
"Microsoft.VisualStudio.Component.Windows11SDK.28000"
]
}
+12
View File
@@ -0,0 +1,12 @@
<Project>
<PropertyGroup>
<LangVersion>latest</LangVersion>
<Nullable Condition="'$(MSBuildProjectName)' != 'Lithnet.CredentialProvider'">enable</Nullable>
<Nullable Condition="'$(MSBuildProjectName)' == 'Lithnet.CredentialProvider'">disable</Nullable>
<ImplicitUsings Condition="'$(MSBuildProjectName)' != 'Lithnet.CredentialProvider'">enable</ImplicitUsings>
<ImplicitUsings Condition="'$(MSBuildProjectName)' == 'Lithnet.CredentialProvider'">disable</ImplicitUsings>
<Deterministic>true</Deterministic>
<ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild>
<TreatWarningsAsErrors Condition="'$(CI)' == 'true' and '$(MSBuildProjectName)' != 'Lithnet.CredentialProvider'">true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>
+52 -236
View File
@@ -1,256 +1,72 @@
![](https://github.com/lithnet/miis-powershell/wiki/images/logo-ex-small.png)
# SGU Windows Credential Provider
# Windows Credential Provider
![](https://img.shields.io/nuget/vpre/lithnet.credentialprovider?label=Current%20prerelease)![](https://img.shields.io/nuget/v/Lithnet.CredentialProvider?label=Current%20release)
![](https://img.shields.io/nuget/dt/lithnet.credentialprovider)
Windows Credential Provider and ASP.NET Core authentication broker for the
`lci.lasalle.mx` Active Directory laboratory.
A library for creating secure Windows Credential Providers in .NET, without the COM complications.
The repository starts from the current
[Lithnet Windows Credential Provider](https://github.com/lithnet/windows-credential-provider)
source and adds an SGU-specific provider, an mTLS-protected broker, Active
Directory synchronization, deployment scripts, and tests.
The Lithnet Credential Provider for Windows provides an easy way to create a credential provider, without having to implement the COM components. The COM components are still there, but abstracted away into a fully managed implementation.
## Authentication contract
## Getting started
* Create a new Class Library project. You can use .NET Framework 4.7.2 or later, or .NET 8.0, 9.0, or 10.0. You must build an x64 or x86 binary. You cannot use AnyCPU.
* Install the package from nuget `Install-Package Lithnet.CredentialProvider`
1. The Windows tile collects a `DO`, `AL`, or `AD` institutional key and a password.
2. It sends that exact password over mutually authenticated TLS to the broker.
3. The broker validates the same key/password pair against the configured SGU
NTLM endpoint.
4. On success, the broker creates or moves the AD user and sets the AD password
to the exact submitted password.
5. The Credential Provider serializes the original `SecureString` to Windows.
* Modify the `csproj` file and set `RegisterForComInterop` to `false`
```xml
<PropertyGroup>
<TargetFramework>net472</TargetFramework>
<RegisterForComInterop>false</RegisterForComInterop>
<Platform>x64</Platform>
</PropertyGroup>
```
No derived password is created. Passwords are not written to a database, file,
event log, application log, command line, or response.
* If you are using .NET 8.0, 9.0, or 10.0, you must also set `EnableComHosting` to `true`.
| Prefix | Role | Default OU |
|---|---|---|
| `DO` | Professor / docente | `OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx` |
| `AL` | Student / alumno | `OU=Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx` |
| `AD` | Administrative | `OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx` |
```xml
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<RegisterForComInterop>false</RegisterForComInterop>
<Platform>x64</Platform>
<EnableComHosting>true</EnableComHosting>
</PropertyGroup>
```
If the broker or institutional NTLM authority is unavailable, the provider
submits the unchanged credentials to Windows for normal AD/cached-domain
validation. This is not an unauthenticated bypass: Windows LSA must still accept
the last password registered in AD. An explicit NTLM `401` is rejected and is
not treated as an outage.
* Create a new class an inherit from `CredentialProviderBase`, as shown below, replacing the `ProgId` and `Guid` values with ones of your own
## Projects
```cs
[ComVisible(true)]
[ClassInterface(ClassInterfaceType.None)]
[ProgId("MyCredentialProvider")]
[Guid("00000000-0000-0000-0000-000000000000")]
public class MyCredentialProvider : CredentialProviderBase
{
}
```
- `src/SGU.CredentialProvider` — x64 .NET 10 COM Credential Provider based on Lithnet.
- `src/SGU.AuthBroker` — Windows-hosted ASP.NET Core broker with mTLS, NTLM validation,
and Active Directory provisioning.
- `src/SGU.AuthBroker.Core` — testable authentication workflow and prefix classifier.
- `tests` — exact-password, role mapping, rejection, and outage-fallback tests.
- `scripts` — publishing, certificate, server deployment, client installation,
broker testing, and rollback.
* Override the `IsUsageScenarioSupported` method, to specify which scenarios you want to support with your credential provider
## Build
```cs
public override bool IsUsageScenarioSupported(UsageScenario cpus, CredUIWinFlags dwFlags)
{
switch (cpus)
{
case UsageScenario.Logon:
case UsageScenario.UnlockWorkstation:
case UsageScenario.CredUI:
case UsageScenario.ChangePassword:
return true;
default:
return false;
}
}
```
* Override the `GetControls` method, and provide the controls to render your UI. You can conditionally render based on the current scenario
```cs
public override IEnumerable<ControlBase> GetControls(UsageScenario cpus)
{
yield return new CredentialProviderLabelControl("CredProviderLabel", "My first credential provider");
var infoLabel = new SmallLabelControl("InfoLabel", "Enter your username and password please!");
infoLabel.State = FieldState.DisplayInSelectedTile;
yield return infoLabel;
yield return new TextboxControl("UsernameField", "Username");
var password = new SecurePasswordTextboxControl("PasswordField", "Password");
yield return password;
if (cpus == UsageScenario.ChangePassword)
{
var confirmPassword = new SecurePasswordTextboxControl("ConfirmPasswordField", "Confirm password");
yield return confirmPassword;
yield return new SubmitButtonControl("SubmitButton", "Submit", confirmPassword);
}
else
{
yield return new SubmitButtonControl("SubmitButton", "Submit", password);
}
}
```
* Windows will ask for the tiles to show. You can determine if you want to show a generic tile (that is, a tile not associated with a user), or a user-specific tile. Windows will provide the list of known users for you to create tiles for.
```cs
public override bool ShouldIncludeUserTile(CredentialProviderUser user)
{
return true;
}
public override bool ShouldIncludeGenericTile()
{
return true;
}
public override CredentialTile CreateGenericTile()
{
return new MyTile(this);
}
public override CredentialTile2 CreateUserTile(CredentialProviderUser user)
{
return new MyTile(this, user);
}
```
* Create your tile class. Inherit from `CredentialTile2` if you want to create personalized tiles supported by Windows 8 and later, or `CredentialTile` if you only want to implement a generic tile. Use `CredentialTile3` when an image must preserve transparency. Grab the instances of your controls in the `Initialize` method, so you can attach to their properties to read and respond to value changes. Finally, override the `GetCredentials` method, which is called when the user clicks the submit button.
```cs
public class MyTile : CredentialTile2
{
private TextboxControl UsernameControl;
private SecurePasswordTextboxControl PasswordControl;
private SecurePasswordTextboxControl PasswordConfirmControl;
public MyTile(CredentialProviderBase credentialProvider) : base(credentialProvider)
{
}
public MyTile(CredentialProviderBase credentialProvider, CredentialProviderUser user) : base(credentialProvider, user)
{
}
public string Username
{
get => UsernameControl.Text;
set => UsernameControl.Text = value;
}
public SecureString Password
{
get => PasswordControl.Password;
set => PasswordControl.Password = value;
}
public SecureString ConfirmPassword
{
get => PasswordConfirmControl.Password;
set => PasswordConfirmControl.Password = value;
}
public override void Initialize()
{
if (UsageScenario == UsageScenario.ChangePassword)
{
this.PasswordConfirmControl = this.Controls.GetControl<SecurePasswordTextboxControl>("ConfirmPasswordField");
}
this.PasswordControl = this.Controls.GetControl<SecurePasswordTextboxControl>("PasswordField");
this.UsernameControl = this.Controls.GetControl<TextboxControl>("UsernameField");
Username = this.User?.QualifiedUserName;
}
protected override CredentialResponseBase GetCredentials()
{
string username;
string domain;
if (Username.Contains("\\"))
{
domain = Username.Split('\\')[0];
username = Username.Split('\\')[1];
}
else
{
username = Username;
domain = Environment.MachineName;
}
var spassword = Controls.GetControl<SecurePasswordTextboxControl>("PasswordField").Password;
return new CredentialResponseSecure()
{
IsSuccess = true,
Password = spassword,
Domain = domain,
Username = username
};
}
}
```
* Build your project and you have a functional credential provider!
## Bitmap transparency
`CredentialProviderLogoControl` and `UserTileControl` both accept a `Bitmap`. The tile base class controls how Windows receives images that contain transparent or partially transparent pixels.
| Tile base class | Image behaviour |
|-----------------|-----------------|
| `CredentialTile` | Renders transparency against the control's `BackgroundColor`. |
| `CredentialTile2` | Renders transparency against the control's `BackgroundColor`. |
| `CredentialTile3` | Preserves the image's alpha channel and ignores `BackgroundColor`. |
Existing providers that inherit from `CredentialTile` or `CredentialTile2` keep their current behaviour. The default `BackgroundColor` is `#464646`.
To preserve transparency, inherit your tile class from `CredentialTile3` and provide a `Bitmap` with an alpha channel. The library selects the image representation required by Windows, so your provider does not need to handle that conversion.
```cs
public class MyTile : CredentialTile3
{
public MyTile(CredentialProviderBase credentialProvider) : base(credentialProvider)
{
}
public MyTile(CredentialProviderBase credentialProvider, CredentialProviderUser user) : base(credentialProvider, user)
{
}
}
public override IEnumerable<ControlBase> GetControls(UsageScenario cpus)
{
Bitmap image = LoadTransparentBitmap();
yield return new UserTileControl("UserTile", "User tile image", image);
yield return new CredentialProviderLogoControl("ProviderLogo", "Credential provider logo", image);
}
```
## Installing the credential provider
You can use the traditional methods of registering a credential provider (regasm, regsvr32, create registry keys etc), but we've provided a PowerShell module to automatically register your credential provider with a single command.
Prerequisites are captured in `.vsconfig`; the pinned SDK is .NET `10.0.400`.
```powershell
Install-Module Lithnet.CredentialProvider.Management
Register-CredentialProvider -File C:\path-to-your-provider.dll
dotnet restore .\SGU-CredentialProvider.sln
dotnet build .\SGU-CredentialProvider.sln -c Release --no-restore
dotnet test .\SGU-CredentialProvider.sln -c Release --no-build --no-restore
.\scripts\Publish-Lab.ps1
```
You can disable, enable, and uninstall the provider with the following commands
The provider's .NET COM host is framework-dependent, so the Windows client needs
the latest .NET 10 x64 runtime. The broker is published self-contained.
```powershell
Disable-CredentialProvider -File "C:\path-to-your-provider.dll"
Enable-CredentialProvider -File "C:\path-to-your-provider.dll"
Unregister-CredentialProvider -File "C:\path-to-your-provider.dll"
```
## Deployment and test
Once the credential provider is registered, you can use the `Invoke-CredUI` cmdlet provided as part of the module, to bring up CredUI window and render your credential provider.
Follow [docs/lab-runbook.md](docs/lab-runbook.md). Review
[docs/security.md](docs/security.md) before production deployment and
[docs/architecture.md](docs/architecture.md) for the component contract.
## How can I contribute to the project?
* Found an issue and want us to fix it? [Log it](https://github.com/lithnet/windows-credential-provider/issues)
* Want to fix an issue yourself or add functionality? Clone the project and submit a pull request
Never disable the built-in Microsoft password Credential Provider. It is the
supported recovery path if a third-party provider fails to load.
## Enteprise support
Enterprise support is not currently offered for this product.
## Upstream license
## Keep up to date
* [Visit our blog](http://blog.lithnet.io)
* [Follow us on twitter](https://twitter.com/lithnet_io)![](http://twitter.com/favicon.ico)
The Lithnet source remains under its MIT license in [LICENSE](LICENSE). Project
additions are distributed under the same license.
+116
View File
@@ -0,0 +1,116 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SGU.AuthBroker.Core", "src\SGU.AuthBroker.Core\SGU.AuthBroker.Core.csproj", "{2B89086E-7B72-423F-AD6A-39CAB730F22A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SGU.AuthBroker", "src\SGU.AuthBroker\SGU.AuthBroker.csproj", "{0832D10C-DE2B-468E-82BB-229F20BC8996}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SGU.CredentialProvider", "src\SGU.CredentialProvider\SGU.CredentialProvider.csproj", "{30CE3366-9C37-4095-86D0-CE59637B1D1E}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SGU.AuthBroker.Core.Tests", "tests\SGU.AuthBroker.Core.Tests\SGU.AuthBroker.Core.Tests.csproj", "{5749FA85-9760-4884-9475-C760879B1953}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SGU.CredentialProvider.Tests", "tests\SGU.CredentialProvider.Tests\SGU.CredentialProvider.Tests.csproj", "{72DF14BC-9050-4AF3-B311-36F2A4140366}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SGU.CredentialProvider.SmokeProbe", "tests\SGU.CredentialProvider.SmokeProbe\SGU.CredentialProvider.SmokeProbe.csproj", "{B5171244-2BBD-465B-BBAF-96D5C6F9A84C}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{2B89086E-7B72-423F-AD6A-39CAB730F22A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2B89086E-7B72-423F-AD6A-39CAB730F22A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2B89086E-7B72-423F-AD6A-39CAB730F22A}.Debug|x64.ActiveCfg = Debug|Any CPU
{2B89086E-7B72-423F-AD6A-39CAB730F22A}.Debug|x64.Build.0 = Debug|Any CPU
{2B89086E-7B72-423F-AD6A-39CAB730F22A}.Debug|x86.ActiveCfg = Debug|Any CPU
{2B89086E-7B72-423F-AD6A-39CAB730F22A}.Debug|x86.Build.0 = Debug|Any CPU
{2B89086E-7B72-423F-AD6A-39CAB730F22A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2B89086E-7B72-423F-AD6A-39CAB730F22A}.Release|Any CPU.Build.0 = Release|Any CPU
{2B89086E-7B72-423F-AD6A-39CAB730F22A}.Release|x64.ActiveCfg = Release|Any CPU
{2B89086E-7B72-423F-AD6A-39CAB730F22A}.Release|x64.Build.0 = Release|Any CPU
{2B89086E-7B72-423F-AD6A-39CAB730F22A}.Release|x86.ActiveCfg = Release|Any CPU
{2B89086E-7B72-423F-AD6A-39CAB730F22A}.Release|x86.Build.0 = Release|Any CPU
{0832D10C-DE2B-468E-82BB-229F20BC8996}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0832D10C-DE2B-468E-82BB-229F20BC8996}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0832D10C-DE2B-468E-82BB-229F20BC8996}.Debug|x64.ActiveCfg = Debug|Any CPU
{0832D10C-DE2B-468E-82BB-229F20BC8996}.Debug|x64.Build.0 = Debug|Any CPU
{0832D10C-DE2B-468E-82BB-229F20BC8996}.Debug|x86.ActiveCfg = Debug|Any CPU
{0832D10C-DE2B-468E-82BB-229F20BC8996}.Debug|x86.Build.0 = Debug|Any CPU
{0832D10C-DE2B-468E-82BB-229F20BC8996}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0832D10C-DE2B-468E-82BB-229F20BC8996}.Release|Any CPU.Build.0 = Release|Any CPU
{0832D10C-DE2B-468E-82BB-229F20BC8996}.Release|x64.ActiveCfg = Release|Any CPU
{0832D10C-DE2B-468E-82BB-229F20BC8996}.Release|x64.Build.0 = Release|Any CPU
{0832D10C-DE2B-468E-82BB-229F20BC8996}.Release|x86.ActiveCfg = Release|Any CPU
{0832D10C-DE2B-468E-82BB-229F20BC8996}.Release|x86.Build.0 = Release|Any CPU
{30CE3366-9C37-4095-86D0-CE59637B1D1E}.Debug|Any CPU.ActiveCfg = Debug|x64
{30CE3366-9C37-4095-86D0-CE59637B1D1E}.Debug|Any CPU.Build.0 = Debug|x64
{30CE3366-9C37-4095-86D0-CE59637B1D1E}.Debug|x64.ActiveCfg = Debug|x64
{30CE3366-9C37-4095-86D0-CE59637B1D1E}.Debug|x64.Build.0 = Debug|x64
{30CE3366-9C37-4095-86D0-CE59637B1D1E}.Debug|x86.ActiveCfg = Debug|x64
{30CE3366-9C37-4095-86D0-CE59637B1D1E}.Debug|x86.Build.0 = Debug|x64
{30CE3366-9C37-4095-86D0-CE59637B1D1E}.Release|Any CPU.ActiveCfg = Release|x64
{30CE3366-9C37-4095-86D0-CE59637B1D1E}.Release|Any CPU.Build.0 = Release|x64
{30CE3366-9C37-4095-86D0-CE59637B1D1E}.Release|x64.ActiveCfg = Release|x64
{30CE3366-9C37-4095-86D0-CE59637B1D1E}.Release|x64.Build.0 = Release|x64
{30CE3366-9C37-4095-86D0-CE59637B1D1E}.Release|x86.ActiveCfg = Release|x64
{30CE3366-9C37-4095-86D0-CE59637B1D1E}.Release|x86.Build.0 = Release|x64
{5749FA85-9760-4884-9475-C760879B1953}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5749FA85-9760-4884-9475-C760879B1953}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5749FA85-9760-4884-9475-C760879B1953}.Debug|x64.ActiveCfg = Debug|Any CPU
{5749FA85-9760-4884-9475-C760879B1953}.Debug|x64.Build.0 = Debug|Any CPU
{5749FA85-9760-4884-9475-C760879B1953}.Debug|x86.ActiveCfg = Debug|Any CPU
{5749FA85-9760-4884-9475-C760879B1953}.Debug|x86.Build.0 = Debug|Any CPU
{5749FA85-9760-4884-9475-C760879B1953}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5749FA85-9760-4884-9475-C760879B1953}.Release|Any CPU.Build.0 = Release|Any CPU
{5749FA85-9760-4884-9475-C760879B1953}.Release|x64.ActiveCfg = Release|Any CPU
{5749FA85-9760-4884-9475-C760879B1953}.Release|x64.Build.0 = Release|Any CPU
{5749FA85-9760-4884-9475-C760879B1953}.Release|x86.ActiveCfg = Release|Any CPU
{5749FA85-9760-4884-9475-C760879B1953}.Release|x86.Build.0 = Release|Any CPU
{72DF14BC-9050-4AF3-B311-36F2A4140366}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{72DF14BC-9050-4AF3-B311-36F2A4140366}.Debug|Any CPU.Build.0 = Debug|Any CPU
{72DF14BC-9050-4AF3-B311-36F2A4140366}.Debug|x64.ActiveCfg = Debug|Any CPU
{72DF14BC-9050-4AF3-B311-36F2A4140366}.Debug|x64.Build.0 = Debug|Any CPU
{72DF14BC-9050-4AF3-B311-36F2A4140366}.Debug|x86.ActiveCfg = Debug|Any CPU
{72DF14BC-9050-4AF3-B311-36F2A4140366}.Debug|x86.Build.0 = Debug|Any CPU
{72DF14BC-9050-4AF3-B311-36F2A4140366}.Release|Any CPU.ActiveCfg = Release|Any CPU
{72DF14BC-9050-4AF3-B311-36F2A4140366}.Release|Any CPU.Build.0 = Release|Any CPU
{72DF14BC-9050-4AF3-B311-36F2A4140366}.Release|x64.ActiveCfg = Release|Any CPU
{72DF14BC-9050-4AF3-B311-36F2A4140366}.Release|x64.Build.0 = Release|Any CPU
{72DF14BC-9050-4AF3-B311-36F2A4140366}.Release|x86.ActiveCfg = Release|Any CPU
{72DF14BC-9050-4AF3-B311-36F2A4140366}.Release|x86.Build.0 = Release|Any CPU
{B5171244-2BBD-465B-BBAF-96D5C6F9A84C}.Debug|Any CPU.ActiveCfg = Debug|x64
{B5171244-2BBD-465B-BBAF-96D5C6F9A84C}.Debug|Any CPU.Build.0 = Debug|x64
{B5171244-2BBD-465B-BBAF-96D5C6F9A84C}.Debug|x64.ActiveCfg = Debug|x64
{B5171244-2BBD-465B-BBAF-96D5C6F9A84C}.Debug|x64.Build.0 = Debug|x64
{B5171244-2BBD-465B-BBAF-96D5C6F9A84C}.Debug|x86.ActiveCfg = Debug|x64
{B5171244-2BBD-465B-BBAF-96D5C6F9A84C}.Debug|x86.Build.0 = Debug|x64
{B5171244-2BBD-465B-BBAF-96D5C6F9A84C}.Release|Any CPU.ActiveCfg = Release|x64
{B5171244-2BBD-465B-BBAF-96D5C6F9A84C}.Release|Any CPU.Build.0 = Release|x64
{B5171244-2BBD-465B-BBAF-96D5C6F9A84C}.Release|x64.ActiveCfg = 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.Build.0 = Release|x64
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{2B89086E-7B72-423F-AD6A-39CAB730F22A} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{0832D10C-DE2B-468E-82BB-229F20BC8996} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{30CE3366-9C37-4095-86D0-CE59637B1D1E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{5749FA85-9760-4884-9475-C760879B1953} = {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}
EndGlobalSection
EndGlobal
+48
View File
@@ -0,0 +1,48 @@
# Architecture
## Online authentication
```text
LogonUI
-> SGU Credential Provider (SecureString)
-> HTTPS 1.1 + client certificate
-> SGU Auth Broker
-> SGU IIS NTLM endpoint (original password)
-> Active Directory (same original password)
<- domain + canonical username; never a password
-> Windows credential serialization (original SecureString)
-> LSA / Kerberos / cached domain logon
```
The broker performs the NTLM exchange and AD update before returning `200 OK`.
It follows only HTTPS redirects whose host appears in `AllowedRedirectHosts`,
which prevents credential forwarding to an unexpected redirect target. HTTP/1.1
is forced because NTLM authentication is connection-bound.
## Offline authentication
```text
Broker timeout, TLS failure, 429, or 5xx
-> provider preserves the entered username/password
-> Windows LSA validates against AD or its cached domain verifier
-> only the last AD password succeeds
```
An explicit `400` or `401` from the broker is different: the provider displays
an error and does not serialize the rejected credential.
## Account synchronization
The broker normalizes the username to uppercase and accepts exactly two letters
plus six digits. It searches `BaseDn` by `sAMAccountName`, creates the user when
absent, moves it to the mapped OU when required, sets `userPrincipalName`, and
passes the submitted password directly to ADSI `SetPassword`.
The managed hierarchy is rooted at `OU=Usuarios-SGU`: `Docentes`, `Alumnos`,
and `Administrativos` are direct child OUs beneath it.
Per-user synchronization is serialized inside the broker to prevent concurrent
create/reset races. Production deployments should run the broker as a gMSA with
delegated create-user, move-user, write-property, enable-account, and reset-password
rights limited to `Usuarios-SGU` and its three managed child OUs. The lab can run it on the domain
controller as LocalSystem.
+144
View File
@@ -0,0 +1,144 @@
# Hyper-V lab runbook
Validated lab inventory:
- Domain controller/broker: Windows Server 2025 Standard,
`WIN-1AIQMMA1EPR.lci.lasalle.mx`, `192.168.50.10`.
- Client: Windows 10 Pro 22H2,
`DESKTOP-U1I3BNN.lci.lasalle.mx`, `192.168.50.20`.
- Domain: `lci.lasalle.mx` / `LCI`.
- Private switch: `Laboratorio AD`.
Run guest commands from an elevated PowerShell console inside each VM. Do not
put an institutional password on a command line or in a script file.
## 1. Build on the Windows 11 host
```powershell
Set-Location C:\Users\alex\Documents\projects\SGU-CredentialProvider
dotnet restore .\SGU-CredentialProvider.sln
dotnet build .\SGU-CredentialProvider.sln -c Release --no-restore
dotnet test .\SGU-CredentialProvider.sln -c Release --no-build --no-restore
.\scripts\Publish-Lab.ps1
```
Copy `artifacts\broker` and the deployment/certificate scripts to Windows Server.
Copy `artifacts\credential-provider` and the installation/certificate scripts to
Windows 10. Hyper-V Guest Service Interface or an ISO can be used because the
lab switch is private.
## 2. Create non-exportable lab certificates
On Windows Server:
```powershell
.\New-LabCertificate.ps1 -Role BrokerServer
```
On Windows 10:
```powershell
.\New-LabCertificate.ps1 -Role CredentialProviderClient
```
Exchange only the two generated `.cer` public files. Never move a private key.
The helper also trusts each self-signed public certificate on the machine where
it was created. This is required because the provider deliberately refuses
client certificates whose chain is not locally valid.
On Windows Server, import the client public certificate; on Windows 10, import
the server public certificate:
```powershell
.\Import-LabPeerCertificate.ps1 -CertificatePath .\peer.cer
```
Record both reported thumbprints. For a production CA, import the issuing CA
chain instead and leave revocation checking enabled.
## 3. DNS and broker
The broker VM needs an internet-capable adapter in addition to the private lab
adapter. On Windows Server, create the broker DNS record and set explicit lab
forwarders so public SGU resolution survives a reboot. Use the Hyper-V Default
Switch gateway shown by `Get-NetIPConfiguration` as the first forwarder; the
public resolvers below are lab fallbacks. Production must use organization-
approved DNS forwarders.
```powershell
Get-NetIPConfiguration
.\Set-LabBrokerDns.ps1 `
-ExternalForwarders 172.30.32.1,1.1.1.1,8.8.8.8
Resolve-DnsName sgu-auth.lci.lasalle.mx
Resolve-DnsName sgu.ulsa.edu.mx
```
Deploy the broker, supplying the server certificate subject and client
certificate thumbprint:
```powershell
.\Deploy-AuthBroker.ps1 `
-PublishPath C:\Deploy\broker `
-ServerCertificateSubject sgu-auth.lci.lasalle.mx `
-AllowedClientThumbprints CLIENT_CERT_THUMBPRINT `
-CreateMissingOus `
-DisableCertificateRevocationCheckForLab
```
Verify the service and managed OUs:
```powershell
Get-Service SGUAuthBroker
Get-ADOrganizationalUnit -Filter * -SearchBase 'OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx'
```
## 4. Broker preflight from Windows 10
Use the interactive credential prompt so the password is not placed in shell
history:
```powershell
.\Test-Broker.ps1 `
-BrokerEndpoint https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate `
-ClientCertificateThumbprint CLIENT_CERT_THUMBPRINT
```
Verify the returned domain/username, then confirm the user exists in the mapped
OU on Windows Server. Use separate authorized test accounts for `DO`, `AL`, and
`AD` when available.
## 5. Install the Credential Provider
On Windows 10:
```powershell
.\Install-CredentialProvider.ps1 `
-PublishPath C:\Deploy\credential-provider `
-BrokerEndpoint https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate `
-ClientCertificateThumbprint CLIENT_CERT_THUMBPRINT `
-ServerCertificateThumbprint SERVER_CERT_THUMBPRINT `
-InstallDotNetRuntime `
-DotNetRuntimeInstallerPath C:\SGUDeploy\prerequisites\dotnet-runtime-10.0.11-win-x64.exe
```
Use Lithnet's `Invoke-CredUI` test utility when available, or lock the VM and
select **Acceso institucional SGU** under sign-in options. Keep the built-in
Windows password tile visible.
## 6. Required end-to-end cases
1. Online valid `DO`, `AL`, and `AD` logons; verify each OU.
2. Explicit bad institutional password; verify rejection and no AD password reset.
3. Change the institutional password, log on online once, and verify the new value
becomes the AD password.
4. Stop `SGUAuthBroker`; verify the last synchronized AD password still logs on
through Windows cached/domain validation.
5. While the broker is stopped, verify a different password fails.
6. Start `SGUAuthBroker`; verify online synchronization recovers.
7. Verify the Microsoft password Credential Provider still works throughout.
## Rollback
Run `Uninstall-CredentialProvider.ps1` in an elevated Windows 10 session. By
default it removes only registration; add `-RemoveFiles` after reboot when the
COM DLL is no longer loaded. Stop/remove the `SGUAuthBroker` service and firewall
rule separately only after clients have been rolled back.
+53
View File
@@ -0,0 +1,53 @@
# Security model
## Password handling
- The Credential Provider receives the password in Lithnet's secure password
control and keeps that `SecureString` for Windows serialization.
- A temporary managed string is unavoidable when creating the HTTPS JSON body.
The unmanaged conversion buffer is zeroed immediately with
`Marshal.ZeroFreeGlobalAllocUnicode`; managed references are released as soon
as each request completes.
- The broker uses the exact received value for both NTLM and AD `SetPassword`.
- There is no HMAC password, pepper, local password cache, Supabase password, or
other derived credential in this Windows path.
- Neither application logs request bodies or passwords. Deployment configuration
contains certificate thumbprints, not passwords or private keys.
## Transport
- HTTPS is mandatory on both links.
- Credential Provider to broker uses mutual TLS. The provider requires normal
Windows certificate validation and pins the configured server certificate.
- The broker requires a trusted client certificate and an explicit allow-listed
thumbprint.
- Client private keys are non-exportable and reside in `LocalMachine\My`.
- The NTLM validator rejects non-HTTPS redirects, URI user information, and hosts
outside its explicit redirect allow-list.
Lab self-signed certificates are appropriate only for the isolated VM network.
Use an enterprise CA with revocation checking in production.
## Recovery and availability
The provider distinguishes an authoritative rejection from unavailable
infrastructure:
- `400`/`401`: fail closed and show an error.
- timeout, TLS/connectivity error, `429`, or `5xx`: submit the unchanged credential
to Windows. Windows must still validate it against AD or the cached domain
verifier, so this does not grant access without the last synchronized password.
The installer never registers a Credential Provider filter and never disables
Microsoft's password, PIN, smart-card, or Windows Hello providers.
## Operational controls
- Limit the firewall rule to the Domain profile and required client networks.
- Delegate the broker service account only to `OU=Usuarios-SGU` and its managed child OUs.
- Keep broker/client certificates short lived and rotate their allow-list entries.
- Monitor service availability and AD provisioning events without enabling HTTP
body logging.
- Test uninstall and the built-in password provider before broad rollout.
- Do not test a new build first on a physical production endpoint; use a VM with
a recent checkpoint and a known local administrator recovery account.
+37
View File
@@ -0,0 +1,37 @@
# Toolchain
## Development host
- Windows 11 with Hyper-V.
- Visual Studio 2026 with `.vsconfig` workloads.
- .NET SDK 10.0.400 or a compatible later 10.0 feature band.
- Windows 11 SDK 10.0.28000.
- MSVC x64/x86 tools, CMake, Ninja, WinDbg, Git, and PowerShell 7/Windows PowerShell.
The SGU additions are C#, but the repository keeps the upstream native/COM
interop source and benefits from the full Windows desktop toolchain.
## Windows Server target
The broker is published self-contained for `win-x64`. It requires:
- Windows Server 2025 or supported Windows Server with AD management APIs.
- An HTTPS server certificate in `LocalMachine\My`.
- Trusted and allow-listed client certificates.
- Delegated AD rights for `OU=Usuarios-SGU` and its managed child OUs.
- Network access to the configured HTTPS NTLM endpoint.
## Windows client target
- Windows 10 22H2 for the lab; Windows 11 is the production target.
- Latest .NET 10 x64 runtime. Managed COM hosting cannot be self-contained.
- Client certificate with non-exportable private key in `LocalMachine\My`.
- Trust for the broker certificate or issuing CA.
- Domain membership and DNS resolution for the broker.
No Visual Studio, compiler, SDK, PowerShell module, or source tree is required on
the target client.
For an isolated target, download the current `Microsoft.DotNet.Runtime.10`
offline installer on the development host, verify its publisher/signature, stage
it with the provider, and pass its path to `Install-CredentialProvider.ps1`.
+10
View File
@@ -0,0 +1,10 @@
{
"sdk": {
"version": "10.0.400",
"rollForward": "latestFeature",
"allowPrerelease": false
},
"test": {
"runner": "Microsoft.Testing.Platform"
}
}
+168
View File
@@ -0,0 +1,168 @@
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[string]$PublishPath,
[Parameter(Mandatory)]
[string]$ServerCertificateSubject,
[Parameter(Mandatory)]
[ValidatePattern('^[0-9A-Fa-f ]{40,59}$')]
[string[]]$AllowedClientThumbprints,
[string]$NtlmEndpoint = 'https://sgu.ulsa.edu.mx/',
[string[]]$AllowedNtlmRedirectHosts = @('sgu.ulsa.edu.mx'),
[string]$LdapHost = 'localhost',
[string]$BaseDn = 'DC=lci,DC=lasalle,DC=mx',
[string]$DomainNetbios = 'LCI',
[string]$UpnSuffix = 'lci.lasalle.mx',
[switch]$CreateMissingOus,
[switch]$DisableCertificateRevocationCheckForLab
)
$ErrorActionPreference = 'Stop'
$serviceName = 'SGUAuthBroker'
$installPath = Join-Path $env:ProgramFiles 'SGU\AuthBroker'
$normalizedClientThumbprints = @($AllowedClientThumbprints | ForEach-Object { $_ -replace ' ', '' })
if ($normalizedClientThumbprints.Where({ $_.Length -ne 40 }).Count -gt 0) {
throw 'Client certificate thumbprints must contain exactly 40 hexadecimal characters.'
}
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'Run this script from an elevated PowerShell session on the broker server.'
}
$serverCertificate = Get-ChildItem Cert:\LocalMachine\My |
Where-Object { $_.Subject -like "*$ServerCertificateSubject*" -and $_.HasPrivateKey } |
Sort-Object NotAfter -Descending |
Select-Object -First 1
if (-not $serverCertificate) {
throw 'The HTTPS server certificate with private key was not found in LocalMachine\My.'
}
if (-not $serverCertificate.Verify()) {
throw 'The HTTPS server certificate chain is not trusted or is outside its validity period. Import the issuing CA chain; for a self-signed lab certificate, trust its public .cer in LocalMachine\Root.'
}
if ($CreateMissingOus) {
Import-Module ActiveDirectory -ErrorAction Stop
$usersOuName = 'Usuarios-SGU'
$usersOuDn = "OU=$usersOuName,$BaseDn"
if (-not (Get-ADOrganizationalUnit -LDAPFilter "(ou=$usersOuName)" -SearchBase $BaseDn -SearchScope OneLevel -Server $LdapHost -ErrorAction SilentlyContinue)) {
New-ADOrganizationalUnit -Name $usersOuName -Path $BaseDn -ProtectedFromAccidentalDeletion $true -Server $LdapHost | Out-Null
}
foreach ($ouName in @('Docentes', 'Alumnos', 'Administrativos')) {
$targetOu = Get-ADOrganizationalUnit -LDAPFilter "(ou=$ouName)" -SearchBase $usersOuDn -SearchScope OneLevel -Properties ProtectedFromAccidentalDeletion -Server $LdapHost -ErrorAction SilentlyContinue
if ($targetOu) {
if (-not $targetOu.ProtectedFromAccidentalDeletion) {
$targetOuDn = [string]$targetOu.DistinguishedName
Set-ADOrganizationalUnit -Identity $targetOuDn -ProtectedFromAccidentalDeletion $true -Server $LdapHost -Confirm:$false
}
continue
}
$legacyOu = Get-ADOrganizationalUnit -LDAPFilter "(ou=$ouName)" -SearchBase $BaseDn -SearchScope OneLevel -Properties ProtectedFromAccidentalDeletion -Server $LdapHost -ErrorAction SilentlyContinue
if ($legacyOu) {
$legacyOuDn = [string]$legacyOu.DistinguishedName
try {
if ($legacyOu.ProtectedFromAccidentalDeletion) {
Set-ADOrganizationalUnit -Identity $legacyOuDn -ProtectedFromAccidentalDeletion $false -Server $LdapHost -Confirm:$false
Start-Sleep -Seconds 1
}
Move-ADObject -Identity $legacyOuDn -TargetPath $usersOuDn -Server $LdapHost -Confirm:$false -ErrorAction Stop
}
finally {
$currentOu = Get-ADOrganizationalUnit -LDAPFilter "(ou=$ouName)" -SearchBase $usersOuDn -SearchScope OneLevel -Properties ProtectedFromAccidentalDeletion -Server $LdapHost -ErrorAction SilentlyContinue
if (-not $currentOu) {
$currentOu = Get-ADOrganizationalUnit -LDAPFilter "(ou=$ouName)" -SearchBase $BaseDn -SearchScope OneLevel -Properties ProtectedFromAccidentalDeletion -Server $LdapHost -ErrorAction SilentlyContinue
}
if ($currentOu) {
$currentOuDn = [string]$currentOu.DistinguishedName
Set-ADOrganizationalUnit -Identity $currentOuDn -ProtectedFromAccidentalDeletion $true -Server $LdapHost -Confirm:$false
}
}
}
else {
New-ADOrganizationalUnit -Name $ouName -Path $usersOuDn -ProtectedFromAccidentalDeletion $true -Server $LdapHost | Out-Null
}
}
}
foreach ($file in @('SGU.AuthBroker.exe', 'SGU.AuthBroker.dll', 'appsettings.json')) {
if (-not (Test-Path -LiteralPath (Join-Path $PublishPath $file))) {
throw "PublishPath is missing $file."
}
}
$productionSettings = @{
Kestrel = @{
Endpoints = @{
Https = @{
Url = 'https://0.0.0.0:8443'
Certificate = @{
Subject = $ServerCertificateSubject
Store = 'My'
Location = 'LocalMachine'
AllowInvalid = $false
}
}
}
}
Broker = @{
Tls = @{
AllowedClientThumbprints = $normalizedClientThumbprints
CheckCertificateRevocation = -not $DisableCertificateRevocationCheckForLab
}
Ntlm = @{
Endpoint = $NtlmEndpoint
Domain = ''
TimeoutSeconds = 15
MaxRedirects = 5
AllowedRedirectHosts = $AllowedNtlmRedirectHosts
}
Directory = @{
LdapHost = $LdapHost
BaseDn = $BaseDn
DomainNetbios = $DomainNetbios
UpnSuffix = $UpnSuffix
ProfessorOuDn = "OU=Docentes,OU=Usuarios-SGU,$BaseDn"
StudentOuDn = "OU=Alumnos,OU=Usuarios-SGU,$BaseDn"
AdministrativeOuDn = "OU=Administrativos,OU=Usuarios-SGU,$BaseDn"
CreateMissingOus = [bool]$CreateMissingOus
}
}
}
if ($PSCmdlet.ShouldProcess($installPath, 'Install the SGU Authentication Broker Windows service')) {
if (Get-Service -Name $serviceName -ErrorAction SilentlyContinue) {
Stop-Service -Name $serviceName -Force
}
New-Item -ItemType Directory -Path $installPath -Force | Out-Null
Copy-Item -Path (Join-Path $PublishPath '*') -Destination $installPath -Recurse -Force
$settingsJson = $productionSettings | ConvertTo-Json -Depth 8
$utf8WithoutBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText(
(Join-Path $installPath 'appsettings.Production.json'),
$settingsJson,
$utf8WithoutBom)
if (-not (Get-Service -Name $serviceName -ErrorAction SilentlyContinue)) {
New-Service -Name $serviceName `
-DisplayName 'SGU Authentication Broker' `
-Description 'Validates SGU NTLM credentials and synchronizes Active Directory accounts.' `
-BinaryPathName ('"{0}"' -f (Join-Path $installPath 'SGU.AuthBroker.exe')) `
-StartupType Automatic
}
if (-not (Get-NetFirewallRule -DisplayName 'SGU Authentication Broker (mTLS)' -ErrorAction SilentlyContinue)) {
New-NetFirewallRule -DisplayName 'SGU Authentication Broker (mTLS)' `
-Direction Inbound -Action Allow -Protocol TCP -LocalPort 8443 -Profile Domain | Out-Null
}
Start-Service -Name $serviceName
}
Get-Service -Name $serviceName | Select-Object Name, Status, StartType
+15
View File
@@ -0,0 +1,15 @@
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$CertificatePath
)
$ErrorActionPreference = 'Stop'
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'Run this script from an elevated PowerShell session.'
}
$certificate = Import-Certificate -FilePath $CertificatePath -CertStoreLocation Cert:\LocalMachine\Root
$certificate | Select-Object Subject, Thumbprint, NotAfter
+160
View File
@@ -0,0 +1,160 @@
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[string]$PublishPath,
[Parameter(Mandatory)]
[ValidatePattern('^https://')]
[string]$BrokerEndpoint,
[Parameter(Mandatory)]
[ValidatePattern('^[0-9A-Fa-f ]{40,59}$')]
[string]$ClientCertificateThumbprint,
[Parameter(Mandatory)]
[ValidatePattern('^[0-9A-Fa-f ]{40,59}$')]
[string]$ServerCertificateThumbprint,
[string]$DomainNetbios = 'LCI',
[ValidateRange(2, 30)]
[int]$TimeoutSeconds = 6,
[switch]$InstallDotNetRuntime,
[string]$DotNetRuntimeInstallerPath
)
$ErrorActionPreference = 'Stop'
$providerClassId = '{D789CFD8-5AD4-489F-9B83-7EB5D9D09335}'
$installPath = Join-Path $env:ProgramFiles 'SGU\CredentialProvider'
$settingsPath = Join-Path $env:ProgramData 'SGU\CredentialProvider\settings.json'
$providerRegistryPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers\$providerClassId"
$classRegistryPath = "HKLM:\SOFTWARE\Classes\CLSID\$providerClassId\InprocServer32"
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'Run this script from an elevated PowerShell session.'
}
function Test-DotNet10Runtime {
$dotnetCandidates = @(
(Get-Command dotnet -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source -ErrorAction SilentlyContinue),
(Join-Path $env:ProgramFiles 'dotnet\dotnet.exe')
) | Where-Object { $_ -and (Test-Path -LiteralPath $_ -PathType Leaf) } | Select-Object -Unique
foreach ($dotnet in $dotnetCandidates) {
if (& $dotnet --list-runtimes | Select-String '^Microsoft\.NETCore\.App 10\.') {
return $true
}
}
return $false
}
if (-not (Test-DotNet10Runtime)) {
if (-not $InstallDotNetRuntime) {
throw 'Microsoft .NET 10 x64 runtime is required. Re-run with -InstallDotNetRuntime or install it first.'
}
if ($DotNetRuntimeInstallerPath) {
if (-not (Test-Path -LiteralPath $DotNetRuntimeInstallerPath -PathType Leaf)) {
throw 'DotNetRuntimeInstallerPath does not exist.'
}
$runtimeInstaller = Start-Process -FilePath $DotNetRuntimeInstallerPath `
-ArgumentList @('/install', '/quiet', '/norestart') -Wait -PassThru
if ($runtimeInstaller.ExitCode -notin @(0, 1641, 3010)) {
throw "The Microsoft .NET 10 runtime installer returned $($runtimeInstaller.ExitCode)."
}
}
else {
$winget = Get-Command winget -ErrorAction SilentlyContinue
if (-not $winget) {
throw 'winget is unavailable. Supply the offline installer with -DotNetRuntimeInstallerPath.'
}
& $winget.Source install --id Microsoft.DotNet.Runtime.10 --exact --silent `
--accept-package-agreements --accept-source-agreements --disable-interactivity
if ($LASTEXITCODE -ne 0) {
throw 'winget could not install the Microsoft .NET 10 runtime.'
}
}
if (-not (Test-DotNet10Runtime)) {
throw 'The Microsoft .NET 10 runtime installation failed.'
}
}
$requiredFiles = @(
'SGU.CredentialProvider.dll',
'SGU.CredentialProvider.comhost.dll',
'SGU.CredentialProvider.runtimeconfig.json',
'SGU.CredentialProvider.deps.json',
'Lithnet.CredentialProvider.dll',
'SGU.AuthBroker.Core.dll'
)
foreach ($file in $requiredFiles) {
if (-not (Test-Path -LiteralPath (Join-Path $PublishPath $file))) {
throw "PublishPath is missing $file."
}
}
$clientThumbprint = $ClientCertificateThumbprint -replace ' ', ''
$serverThumbprint = $ServerCertificateThumbprint -replace ' ', ''
if ($clientThumbprint.Length -ne 40 -or $serverThumbprint.Length -ne 40) {
throw 'Certificate thumbprints must contain exactly 40 hexadecimal characters.'
}
$clientCertificate = Get-ChildItem Cert:\LocalMachine\My |
Where-Object Thumbprint -eq $clientThumbprint |
Select-Object -First 1
if (-not $clientCertificate -or -not $clientCertificate.HasPrivateKey) {
throw 'The client certificate with private key is not installed in LocalMachine\My.'
}
if (-not $clientCertificate.Verify()) {
throw 'The client certificate chain is not trusted or is outside its validity period. Import the issuing CA chain; for a self-signed lab certificate, trust its public .cer in LocalMachine\Root.'
}
$serverCertificate = Get-ChildItem Cert:\LocalMachine\Root, Cert:\LocalMachine\CA | Where-Object Thumbprint -eq $serverThumbprint
if (-not $serverCertificate) {
throw 'The broker server certificate or its issuing CA is not trusted by LocalMachine.'
}
if ($PSCmdlet.ShouldProcess($installPath, 'Install and register the SGU Credential Provider')) {
New-Item -ItemType Directory -Path $installPath -Force | Out-Null
Copy-Item -Path (Join-Path $PublishPath '*') -Destination $installPath -Recurse -Force
New-Item -ItemType Directory -Path (Split-Path $settingsPath -Parent) -Force | Out-Null
$settingsJson = @{
BrokerEndpoint = $BrokerEndpoint
DomainNetbios = $DomainNetbios
TimeoutSeconds = $TimeoutSeconds
ClientCertificateThumbprint = $clientThumbprint
ServerCertificateThumbprint = $serverThumbprint
} | ConvertTo-Json
$utf8WithoutBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($settingsPath, $settingsJson, $utf8WithoutBom)
$acl = Get-Acl -LiteralPath (Split-Path $settingsPath -Parent)
$acl.SetAccessRuleProtection($true, $false)
$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new(
'SYSTEM', 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))
$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new(
'BUILTIN\Administrators', 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))
Set-Acl -LiteralPath (Split-Path $settingsPath -Parent) -AclObject $acl
New-Item -Path $classRegistryPath -Force | Out-Null
Set-Item -Path $classRegistryPath -Value (Join-Path $installPath 'SGU.CredentialProvider.comhost.dll')
New-ItemProperty -Path $classRegistryPath -Name ThreadingModel -Value Both -PropertyType String -Force | Out-Null
New-Item -Path $providerRegistryPath -Force | Out-Null
Set-Item -Path $providerRegistryPath -Value 'SGU Institutional Login'
}
[pscustomobject]@{
ProviderClassId = $providerClassId
InstallPath = $installPath
SettingsPath = $settingsPath
Registered = Test-Path -LiteralPath $providerRegistryPath
SystemPasswordProviderPreserved = $true
}
+52
View File
@@ -0,0 +1,52 @@
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateSet('BrokerServer', 'CredentialProviderClient')]
[string]$Role,
[string]$BrokerDnsName = 'sgu-auth.lci.lasalle.mx',
[string]$OutputDirectory = "$env:PUBLIC\Documents\SGU-Certificates"
)
$ErrorActionPreference = 'Stop'
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'Run this script from an elevated PowerShell session.'
}
New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null
if ($Role -eq 'BrokerServer') {
$certificate = New-SelfSignedCertificate `
-DnsName $BrokerDnsName `
-CertStoreLocation Cert:\LocalMachine\My `
-KeyAlgorithm RSA -KeyLength 3072 -HashAlgorithm SHA256 `
-KeyExportPolicy NonExportable `
-NotAfter (Get-Date).AddYears(2) `
-TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.1')
$output = Join-Path $OutputDirectory 'sgu-auth-broker.cer'
}
else {
$certificate = New-SelfSignedCertificate `
-Subject 'CN=SGU Credential Provider Client' `
-CertStoreLocation Cert:\LocalMachine\My `
-KeyAlgorithm RSA -KeyLength 3072 -HashAlgorithm SHA256 `
-KeyExportPolicy NonExportable `
-NotAfter (Get-Date).AddYears(2) `
-TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.2')
$output = Join-Path $OutputDirectory 'sgu-credential-provider-client.cer'
}
Export-Certificate -Cert $certificate -FilePath $output -Force | Out-Null
# These certificates are self-signed end-entity certificates. Trust the public
# half locally as well as on the peer so valid-only certificate lookup and the
# local TLS server both reject expired/untrusted lab certificates deterministically.
Import-Certificate -FilePath $output -CertStoreLocation Cert:\LocalMachine\Root | Out-Null
[pscustomobject]@{
Role = $Role
Thumbprint = $certificate.Thumbprint
PublicCertificatePath = $output
PrivateKeyExportable = $false
TrustedLocally = $certificate.Verify()
}
+44
View File
@@ -0,0 +1,44 @@
[CmdletBinding()]
param(
[string]$Configuration = 'Release',
[string]$OutputRoot = (Join-Path $PSScriptRoot '..\artifacts')
)
$ErrorActionPreference = 'Stop'
$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$dotnet = (Get-Command dotnet -ErrorAction Stop).Source
$brokerOutput = Join-Path $OutputRoot 'broker'
$providerOutput = Join-Path $OutputRoot 'credential-provider'
& $dotnet publish (Join-Path $repositoryRoot 'src\SGU.AuthBroker\SGU.AuthBroker.csproj') `
--configuration $Configuration `
--runtime win-x64 `
--self-contained true `
--output $brokerOutput
if ($LASTEXITCODE -ne 0) { throw 'Auth Broker publish failed.' }
& $dotnet publish (Join-Path $repositoryRoot 'src\SGU.CredentialProvider\SGU.CredentialProvider.csproj') `
--configuration $Configuration `
--runtime win-x64 `
--self-contained false `
--output $providerOutput
if ($LASTEXITCODE -ne 0) { throw 'Credential Provider publish failed.' }
$requiredProviderFiles = @(
'SGU.CredentialProvider.dll',
'SGU.CredentialProvider.comhost.dll',
'SGU.CredentialProvider.runtimeconfig.json',
'Lithnet.CredentialProvider.dll',
'SGU.AuthBroker.Core.dll'
)
foreach ($file in $requiredProviderFiles) {
if (-not (Test-Path -LiteralPath (Join-Path $providerOutput $file))) {
throw "Credential Provider output is missing $file."
}
}
[pscustomobject]@{
BrokerOutput = (Resolve-Path $brokerOutput).Path
CredentialProviderOutput = (Resolve-Path $providerOutput).Path
}
+31
View File
@@ -0,0 +1,31 @@
[CmdletBinding()]
param(
[string]$ZoneName = 'lci.lasalle.mx',
[string]$RecordName = 'sgu-auth',
[ipaddress]$IPv4Address = '192.168.50.10',
[ipaddress[]]$ExternalForwarders = @()
)
$ErrorActionPreference = 'Stop'
$existing = Get-DnsServerResourceRecord -ZoneName $ZoneName -Name $RecordName -RRType A -ErrorAction SilentlyContinue
if ($existing) {
$current = @($existing.RecordData.IPv4Address.IPAddressToString)
if ($current -notcontains $IPv4Address.IPAddressToString) {
throw "$RecordName.$ZoneName already exists with a different address: $($current -join ', ')."
}
}
else {
Add-DnsServerResourceRecordA -ZoneName $ZoneName -Name $RecordName -IPv4Address $IPv4Address
}
if ($ExternalForwarders.Count -gt 0) {
Set-DnsServerForwarder -IPAddress $ExternalForwarders -UseRootHint $false
Clear-DnsServerCache -Force
Clear-DnsClientCache
}
[pscustomobject]@{
BrokerRecord = Resolve-DnsName "$RecordName.$ZoneName" | Select-Object Name, Type, IPAddress
ExternalForwarders = @(Get-DnsServerForwarder | Select-Object -ExpandProperty IPAddress | ForEach-Object IPAddressToString)
}
+32
View File
@@ -0,0 +1,32 @@
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidatePattern('^https://')]
[string]$BrokerEndpoint,
[Parameter(Mandatory)]
[string]$ClientCertificateThumbprint
)
$ErrorActionPreference = 'Stop'
$credential = Get-Credential -Message 'Enter an institutional DO, AL, or AD account. The password is sent only to the configured mTLS broker.'
$clave = $credential.UserName -replace '^.*\\', '' -replace '@.*$', ''
$password = $credential.GetNetworkCredential().Password
$certificate = Get-ChildItem Cert:\LocalMachine\My |
Where-Object Thumbprint -eq ($ClientCertificateThumbprint -replace ' ', '') |
Select-Object -First 1
if (-not $certificate -or -not $certificate.HasPrivateKey) {
throw 'The client certificate with private key was not found in LocalMachine\My.'
}
try {
$body = @{ clave = $clave; password = $password } | ConvertTo-Json -Compress
Invoke-RestMethod -Method Post -Uri $BrokerEndpoint -Certificate $certificate `
-ContentType 'application/json' -Body $body -TimeoutSec 20
}
finally {
$password = $null
$body = $null
$credential = $null
}
+21
View File
@@ -0,0 +1,21 @@
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
param(
[switch]$RemoveFiles
)
$ErrorActionPreference = 'Stop'
$providerClassId = '{D789CFD8-5AD4-489F-9B83-7EB5D9D09335}'
$installPath = Join-Path $env:ProgramFiles 'SGU\CredentialProvider'
$providerRegistryPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers\$providerClassId"
$classRegistryPath = "HKLM:\SOFTWARE\Classes\CLSID\$providerClassId"
if ($PSCmdlet.ShouldProcess($providerClassId, 'Unregister the SGU Credential Provider')) {
Remove-Item -LiteralPath $providerRegistryPath -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $classRegistryPath -Recurse -Force -ErrorAction SilentlyContinue
}
if ($RemoveFiles -and $PSCmdlet.ShouldProcess($installPath, 'Remove Credential Provider files')) {
Remove-Item -LiteralPath $installPath -Recurse -Force -ErrorAction SilentlyContinue
}
Write-Output 'The built-in Windows password Credential Provider was not changed.'
@@ -0,0 +1,18 @@
using SGU.AuthBroker.Core.Directory;
using SGU.AuthBroker.Core.Identity;
namespace SGU.AuthBroker.Core.Authentication;
public enum AuthenticationFlowOutcome
{
Authorized,
InvalidCredentials,
InvalidUserName,
Unavailable
}
public sealed record AuthenticationFlowResult(
AuthenticationFlowOutcome Outcome,
string? ErrorCode = null,
UserIdentity? Identity = null,
DirectorySyncResult? Directory = null);
@@ -0,0 +1,80 @@
using SGU.AuthBroker.Core.Directory;
using SGU.AuthBroker.Core.Identity;
namespace SGU.AuthBroker.Core.Authentication;
public sealed class AuthenticationWorkflow(
INtlmCredentialValidator ntlmValidator,
IActiveDirectorySynchronizer directorySynchronizer)
{
public async Task<AuthenticationFlowResult> AuthenticateAsync(
string userName,
string password,
CancellationToken cancellationToken)
{
if (!UserIdentityClassifier.TryParse(userName, out UserIdentity? identity) || identity is null)
{
return new AuthenticationFlowResult(
AuthenticationFlowOutcome.InvalidUserName,
"INVALID_USERNAME_FORMAT");
}
NtlmValidationResult validation;
try
{
validation = await ntlmValidator
.ValidateAsync(identity.UserName, password, cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return new AuthenticationFlowResult(
AuthenticationFlowOutcome.Unavailable,
"INSTITUTIONAL_AUTHORITY_UNAVAILABLE",
identity);
}
if (validation.Status == NtlmValidationStatus.Invalid)
{
return new AuthenticationFlowResult(
AuthenticationFlowOutcome.InvalidCredentials,
validation.ErrorCode,
identity);
}
if (validation.Status == NtlmValidationStatus.Unavailable)
{
return new AuthenticationFlowResult(
AuthenticationFlowOutcome.Unavailable,
validation.ErrorCode,
identity);
}
try
{
DirectorySyncResult directory = await directorySynchronizer
.SynchronizeAsync(identity, password, cancellationToken)
.ConfigureAwait(false);
return new AuthenticationFlowResult(
AuthenticationFlowOutcome.Authorized,
Identity: identity,
Directory: directory);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return new AuthenticationFlowResult(
AuthenticationFlowOutcome.Unavailable,
"DIRECTORY_SYNCHRONIZATION_FAILED",
identity);
}
}
}
@@ -0,0 +1,9 @@
namespace SGU.AuthBroker.Core.Authentication;
public interface INtlmCredentialValidator
{
Task<NtlmValidationResult> ValidateAsync(
string userName,
string password,
CancellationToken cancellationToken);
}
@@ -0,0 +1,18 @@
namespace SGU.AuthBroker.Core.Authentication;
public enum NtlmValidationStatus
{
Valid,
Invalid,
Unavailable
}
public sealed record NtlmValidationResult(NtlmValidationStatus Status, string? ErrorCode = null)
{
public static NtlmValidationResult Valid() => new(NtlmValidationStatus.Valid);
public static NtlmValidationResult Invalid() => new(NtlmValidationStatus.Invalid, "INVALID_INSTITUTIONAL_CREDENTIALS");
public static NtlmValidationResult Unavailable(string errorCode = "INSTITUTIONAL_AUTHORITY_UNAVAILABLE") =>
new(NtlmValidationStatus.Unavailable, errorCode);
}
@@ -0,0 +1,8 @@
namespace SGU.AuthBroker.Core.Directory;
public sealed record DirectorySyncResult(
string DomainNetbios,
string UserName,
string UserPrincipalName,
bool Created,
bool Moved);
@@ -0,0 +1,11 @@
using SGU.AuthBroker.Core.Identity;
namespace SGU.AuthBroker.Core.Directory;
public interface IActiveDirectorySynchronizer
{
Task<DirectorySyncResult> SynchronizeAsync(
UserIdentity identity,
string password,
CancellationToken cancellationToken);
}
@@ -0,0 +1,8 @@
namespace SGU.AuthBroker.Core.Identity;
public enum InstitutionalRole
{
Professor,
Student,
Administrative
}
@@ -0,0 +1,7 @@
namespace SGU.AuthBroker.Core.Identity;
public sealed record UserIdentity(
string UserName,
string Prefix,
string NumericId,
InstitutionalRole Role);
@@ -0,0 +1,50 @@
using System.Text.RegularExpressions;
namespace SGU.AuthBroker.Core.Identity;
public static partial class UserIdentityClassifier
{
public static bool TryParse(string? value, out UserIdentity? identity)
{
identity = null;
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
string candidate = value.Trim();
int slash = candidate.LastIndexOf('\\');
if (slash >= 0)
{
candidate = candidate[(slash + 1)..];
}
int at = candidate.IndexOf('@');
if (at >= 0)
{
candidate = candidate[..at];
}
Match match = InstitutionalUserName().Match(candidate);
if (!match.Success)
{
return false;
}
string prefix = match.Groups["prefix"].Value.ToUpperInvariant();
InstitutionalRole role = prefix switch
{
"DO" => InstitutionalRole.Professor,
"AL" => InstitutionalRole.Student,
"AD" => InstitutionalRole.Administrative,
_ => throw new InvalidOperationException("Validated prefix was not mapped.")
};
string numericId = match.Groups["id"].Value;
identity = new UserIdentity(prefix + numericId, prefix, numericId, role);
return true;
}
[GeneratedRegex("^(?<prefix>DO|AL|AD)(?<id>[0-9]{6})$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex InstitutionalUserName();
}
@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<AssemblyName>SGU.AuthBroker.Core</AssemblyName>
<RootNamespace>SGU.AuthBroker.Core</RootNamespace>
</PropertyGroup>
</Project>
@@ -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);
+114
View File
@@ -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)
};
}
+127
View File
@@ -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();
+15
View File
@@ -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);
}
+49
View File
@@ -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
}
}
}
+165
View File
@@ -0,0 +1,165 @@
using System.Net;
using System.Net.Http.Json;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
using System.Text.Json.Serialization;
namespace SGU.CredentialProvider;
internal sealed class BrokerClient : IDisposable
{
private readonly ProviderSettings settings;
private readonly HttpClient client;
public BrokerClient(ProviderSettings settings)
: this(settings, CreateHandler(settings))
{
}
internal BrokerClient(ProviderSettings settings, HttpMessageHandler handler)
{
this.settings = settings;
client = new HttpClient(handler, disposeHandler: true)
{
Timeout = Timeout.InfiniteTimeSpan,
DefaultRequestVersion = HttpVersion.Version11,
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact
};
}
public async Task<BrokerDecision> AuthenticateAsync(
string userName,
string password,
CancellationToken cancellationToken)
{
BrokerRequest body = new() { Clave = userName, Password = password };
using HttpRequestMessage request = new(HttpMethod.Post, settings.BrokerEndpoint)
{
Content = JsonContent.Create(body)
};
request.Headers.CacheControl = new System.Net.Http.Headers.CacheControlHeaderValue { NoStore = true };
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(settings.TimeoutSeconds));
try
{
using HttpResponseMessage response = await client
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token)
.ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.Unauthorized || response.StatusCode == HttpStatusCode.BadRequest)
{
ErrorBody? error = await ReadJsonAsync<ErrorBody>(response, timeout.Token).ConfigureAwait(false);
return BrokerDecision.Invalid(error?.Code);
}
if (response.StatusCode == HttpStatusCode.OK)
{
AuthorizedBody? authorized = await ReadJsonAsync<AuthorizedBody>(response, timeout.Token).ConfigureAwait(false);
if (authorized is null ||
string.IsNullOrWhiteSpace(authorized.Domain) ||
string.IsNullOrWhiteSpace(authorized.UserName))
{
return BrokerDecision.Unavailable("INVALID_BROKER_RESPONSE");
}
return BrokerDecision.Authorized(authorized.Domain, authorized.UserName);
}
return BrokerDecision.Unavailable($"BROKER_HTTP_{(int)response.StatusCode}");
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return BrokerDecision.Unavailable("BROKER_TIMEOUT");
}
catch (HttpRequestException)
{
return BrokerDecision.Unavailable("BROKER_UNAVAILABLE");
}
finally
{
body.ReleasePasswordReference();
}
}
public void Dispose() => client.Dispose();
private static async Task<T?> ReadJsonAsync<T>(HttpResponseMessage response, CancellationToken cancellationToken)
{
if (response.Content.Headers.ContentLength is > 16 * 1024)
{
return default;
}
try
{
return await response.Content.ReadFromJsonAsync<T>(cancellationToken).ConfigureAwait(false);
}
catch (Exception exception) when (exception is System.Text.Json.JsonException or NotSupportedException)
{
return default;
}
}
private static HttpMessageHandler CreateHandler(ProviderSettings settings)
{
X509Certificate2 clientCertificate = LoadClientCertificate(settings.ClientCertificateThumbprint);
string expectedServerThumbprint = ProviderSettings.NormalizeThumbprint(settings.ServerCertificateThumbprint);
HttpClientHandler handler = new()
{
AllowAutoRedirect = false,
CheckCertificateRevocationList = true,
ClientCertificateOptions = ClientCertificateOption.Manual,
MaxConnectionsPerServer = 2,
MaxResponseHeadersLength = 32,
UseCookies = false,
UseDefaultCredentials = false,
UseProxy = false,
ServerCertificateCustomValidationCallback = (_, certificate, _, policyErrors) =>
policyErrors == SslPolicyErrors.None &&
certificate is not null &&
string.Equals(
ProviderSettings.NormalizeThumbprint(certificate.GetCertHashString()),
expectedServerThumbprint,
StringComparison.OrdinalIgnoreCase)
};
handler.ClientCertificates.Add(clientCertificate);
return handler;
}
private static X509Certificate2 LoadClientCertificate(string thumbprint)
{
using X509Store store = new(StoreName.My, StoreLocation.LocalMachine);
store.Open(OpenFlags.ReadOnly | OpenFlags.OpenExistingOnly);
X509Certificate2Collection matches = store.Certificates.Find(
X509FindType.FindByThumbprint,
ProviderSettings.NormalizeThumbprint(thumbprint),
validOnly: true);
X509Certificate2? certificate = matches
.OfType<X509Certificate2>()
.FirstOrDefault(item => item.HasPrivateKey);
return certificate is null
? throw new InvalidOperationException("The Credential Provider client certificate is unavailable.")
: new X509Certificate2(certificate);
}
private sealed class BrokerRequest
{
[JsonPropertyName("clave")]
public string Clave { get; init; } = string.Empty;
[JsonPropertyName("password")]
public string Password { get; set; } = string.Empty;
public void ReleasePasswordReference() => Password = string.Empty;
}
private sealed record AuthorizedBody(
[property: JsonPropertyName("domain")] string Domain,
[property: JsonPropertyName("username")] string UserName);
private sealed record ErrorBody([property: JsonPropertyName("code")] string? Code);
}
@@ -0,0 +1,24 @@
namespace SGU.CredentialProvider;
internal enum BrokerDecisionKind
{
Authorized,
InvalidCredentials,
Unavailable
}
internal sealed record BrokerDecision(
BrokerDecisionKind Kind,
string? Domain = null,
string? UserName = null,
string? ErrorCode = null)
{
public static BrokerDecision Authorized(string domain, string userName) =>
new(BrokerDecisionKind.Authorized, domain, userName);
public static BrokerDecision Invalid(string? errorCode = null) =>
new(BrokerDecisionKind.InvalidCredentials, ErrorCode: errorCode);
public static BrokerDecision Unavailable(string? errorCode = null) =>
new(BrokerDecisionKind.Unavailable, ErrorCode: errorCode);
}
+10
View File
@@ -0,0 +1,10 @@
namespace SGU.CredentialProvider;
internal static class ControlKeys
{
public const string ProviderLabel = "ProviderLabel";
public const string InformationLabel = "InformationLabel";
public const string UserName = "UserName";
public const string Password = "Password";
public const string Submit = "Submit";
}
@@ -0,0 +1,71 @@
using System.Text.Json;
namespace SGU.CredentialProvider;
internal sealed class ProviderSettings
{
private const int MaximumSettingsBytes = 16 * 1024;
public Uri BrokerEndpoint { get; init; } = new("https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate");
public string DomainNetbios { get; init; } = "LCI";
public int TimeoutSeconds { get; init; } = 6;
public string ClientCertificateThumbprint { get; init; } = string.Empty;
public string ServerCertificateThumbprint { get; init; } = string.Empty;
public static string DefaultPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"SGU",
"CredentialProvider",
"settings.json");
public static ProviderSettings Load(string? path = null)
{
path ??= Environment.GetEnvironmentVariable("SGU_CREDENTIAL_PROVIDER_CONFIG") ?? DefaultPath;
FileInfo file = new(path);
if (!file.Exists || file.Length is <= 0 or > MaximumSettingsBytes)
{
throw new InvalidOperationException("Credential Provider settings are missing or invalid.");
}
using FileStream stream = new(path, FileMode.Open, FileAccess.Read, FileShare.Read);
ProviderSettings settings = JsonSerializer.Deserialize<ProviderSettings>(stream, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
}) ?? throw new InvalidOperationException("Credential Provider settings could not be read.");
settings.Validate();
return settings;
}
public void Validate()
{
if (!BrokerEndpoint.IsAbsoluteUri ||
BrokerEndpoint.Scheme != Uri.UriSchemeHttps ||
!string.IsNullOrEmpty(BrokerEndpoint.UserInfo))
{
throw new InvalidOperationException("BrokerEndpoint must be an absolute HTTPS URL without user information.");
}
if (!string.Equals(BrokerEndpoint.AbsolutePath.TrimEnd('/'), "/v1/authenticate", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("BrokerEndpoint must target /v1/authenticate.");
}
if (string.IsNullOrWhiteSpace(DomainNetbios) || TimeoutSeconds is < 2 or > 30)
{
throw new InvalidOperationException("DomainNetbios or TimeoutSeconds is invalid.");
}
if (NormalizeThumbprint(ClientCertificateThumbprint).Length != 40 ||
NormalizeThumbprint(ServerCertificateThumbprint).Length != 40)
{
throw new InvalidOperationException("Client and server SHA-1 certificate thumbprints are required.");
}
}
public static string NormalizeThumbprint(string value) =>
value.Replace(" ", string.Empty, StringComparison.Ordinal).ToUpperInvariant();
}
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<Platform>x64</Platform>
<Platforms>x64</Platforms>
<OutputType>Library</OutputType>
<AssemblyName>SGU.CredentialProvider</AssemblyName>
<RootNamespace>SGU.CredentialProvider</RootNamespace>
<RegisterForComInterop>false</RegisterForComInterop>
<EnableComHosting>true</EnableComHosting>
<EnableDynamicLoading>true</EnableDynamicLoading>
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
<SelfContained>false</SelfContained>
<PublishSingleFile>false</PublishSingleFile>
<PublishTrimmed>false</PublishTrimmed>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Lithnet.CredentialProvider\Lithnet.CredentialProvider.csproj">
<AdditionalProperties>Configuration=$(Configuration)</AdditionalProperties>
</ProjectReference>
<ProjectReference Include="..\SGU.AuthBroker.Core\SGU.AuthBroker.Core.csproj" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>SGU.CredentialProvider.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project>
@@ -0,0 +1,38 @@
using System.Collections.Generic;
using System.Runtime.InteropServices;
using Lithnet.CredentialProvider;
namespace SGU.CredentialProvider;
[ComVisible(true)]
[ClassInterface(ClassInterfaceType.None)]
[ProgId("SGU.CredentialProvider")]
[Guid(ProviderClassId)]
public sealed class SguCredentialProvider : CredentialProviderBase
{
public const string ProviderClassId = "D789CFD8-5AD4-489F-9B83-7EB5D9D09335";
public override IEnumerable<ControlBase> GetControls(UsageScenario cpus)
{
yield return new CredentialProviderLabelControl(ControlKeys.ProviderLabel, "Acceso institucional SGU");
yield return new SmallLabelControl(
ControlKeys.InformationLabel,
"Usa tu clave institucional (DO, AL o AD + 6 dígitos) y contraseña.");
yield return new TextboxControl(ControlKeys.UserName, "Clave institucional");
SecurePasswordTextboxControl password = new(ControlKeys.Password, "Contraseña");
yield return password;
yield return new SubmitButtonControl(ControlKeys.Submit, "Iniciar sesión", password);
}
public override bool IsUsageScenarioSupported(UsageScenario cpus, CredUIWinFlags dwFlags) =>
cpus is UsageScenario.Logon or UsageScenario.UnlockWorkstation or UsageScenario.CredUI;
public override bool ShouldIncludeUserTile(CredentialProviderUser user) => false;
public override bool ShouldIncludeGenericTile() => true;
public override CredentialTile CreateGenericTile() => new SguCredentialTile(this);
public override CredentialTile2 CreateUserTile(CredentialProviderUser user) =>
new SguCredentialTile(this, user);
}
@@ -0,0 +1,132 @@
using System.Runtime.InteropServices;
using System.Security;
using Lithnet.CredentialProvider;
using SGU.AuthBroker.Core.Identity;
namespace SGU.CredentialProvider;
internal sealed class SguCredentialTile : CredentialTile2
{
private TextboxControl userNameControl = null!;
private SecurePasswordTextboxControl passwordControl = null!;
public SguCredentialTile(CredentialProviderBase credentialProvider)
: base(credentialProvider)
{
}
public SguCredentialTile(CredentialProviderBase credentialProvider, CredentialProviderUser user)
: base(credentialProvider, user)
{
}
public override void Initialize()
{
userNameControl = Controls.GetControl<TextboxControl>(ControlKeys.UserName);
passwordControl = Controls.GetControl<SecurePasswordTextboxControl>(ControlKeys.Password);
userNameControl.Text = User?.QualifiedUserName ?? string.Empty;
}
protected override CredentialResponseBase GetCredentials()
{
if (!UserIdentityClassifier.TryParse(userNameControl.Text, out UserIdentity? identity) || identity is null)
{
return Failure("La clave debe usar DO, AL o AD seguido de seis dígitos.");
}
SecureString securePassword = passwordControl.Password;
if (securePassword.Length == 0)
{
return Failure("La contraseña es requerida.");
}
string plainTextPassword = CopyToManagedString(securePassword);
try
{
ProviderSettings settings;
BrokerDecision decision;
try
{
settings = ProviderSettings.Load();
using BrokerClient broker = new(settings);
decision = broker
.AuthenticateAsync(identity.UserName, plainTextPassword, CancellationToken.None)
.GetAwaiter()
.GetResult();
}
catch
{
settings = TryLoadDomainOnlySettings();
decision = BrokerDecision.Unavailable("LOCAL_CONFIGURATION_OR_TLS_ERROR");
}
if (decision.Kind == BrokerDecisionKind.InvalidCredentials)
{
return Failure("Credenciales institucionales inválidas.");
}
string domain = decision.Kind == BrokerDecisionKind.Authorized
? decision.Domain!
: settings.DomainNetbios;
string userName = decision.Kind == BrokerDecisionKind.Authorized
? decision.UserName!
: identity.UserName;
return new CredentialResponseSecure
{
IsSuccess = true,
StatusIcon = decision.Kind == BrokerDecisionKind.Unavailable ? StatusIcon.Warning : StatusIcon.None,
StatusText = decision.Kind == BrokerDecisionKind.Unavailable
? "Servicio institucional no disponible; Windows validará la última contraseña de dominio registrada."
: null,
Domain = domain,
Username = userName,
Password = securePassword
};
}
finally
{
// The immutable managed string cannot be zeroed; release our reference immediately.
// The unmanaged copy used to create it is zeroed by CopyToManagedString.
plainTextPassword = string.Empty;
}
}
private static ProviderSettings TryLoadDomainOnlySettings()
{
try
{
return ProviderSettings.Load();
}
catch
{
// LCI is the configured lab domain. This fallback still delegates the actual
// password decision to Windows LSA/cached domain credentials.
return new ProviderSettings { DomainNetbios = "LCI" };
}
}
private static CredentialResponseSecure Failure(string message) => new()
{
IsSuccess = false,
StatusIcon = StatusIcon.Error,
StatusText = message
};
private static string CopyToManagedString(SecureString value)
{
IntPtr pointer = IntPtr.Zero;
try
{
pointer = Marshal.SecureStringToGlobalAllocUnicode(value);
return Marshal.PtrToStringUni(pointer, value.Length) ?? string.Empty;
}
finally
{
if (pointer != IntPtr.Zero)
{
Marshal.ZeroFreeGlobalAllocUnicode(pointer);
}
}
}
}
@@ -0,0 +1,7 @@
{
"BrokerEndpoint": "https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate",
"DomainNetbios": "LCI",
"TimeoutSeconds": 6,
"ClientCertificateThumbprint": "0000000000000000000000000000000000000000",
"ServerCertificateThumbprint": "0000000000000000000000000000000000000000"
}
@@ -0,0 +1,99 @@
using SGU.AuthBroker.Core.Authentication;
using SGU.AuthBroker.Core.Directory;
using SGU.AuthBroker.Core.Identity;
using Xunit;
namespace SGU.AuthBroker.Core.Tests;
public sealed class AuthenticationWorkflowTests
{
[Fact]
public async Task PassesTheExactOriginalPasswordToNtlmAndActiveDirectory()
{
const string original = "Árbol-Exacto-🔐-NoDerivar-27!";
CapturingNtlmValidator ntlm = new(NtlmValidationResult.Valid());
CapturingDirectorySynchronizer directory = new();
AuthenticationWorkflow workflow = new(ntlm, directory);
AuthenticationFlowResult result = await workflow.AuthenticateAsync(
"do123456",
original,
TestContext.Current.CancellationToken);
Assert.Equal(AuthenticationFlowOutcome.Authorized, result.Outcome);
Assert.Same(original, ntlm.Password);
Assert.Same(original, directory.Password);
Assert.Equal("DO123456", ntlm.UserName);
Assert.Equal(InstitutionalRole.Professor, directory.Identity?.Role);
}
[Fact]
public async Task InvalidNtlmCredentialsNeverReachActiveDirectory()
{
CapturingDirectorySynchronizer directory = new();
AuthenticationWorkflow workflow = new(
new CapturingNtlmValidator(NtlmValidationResult.Invalid()),
directory);
AuthenticationFlowResult result = await workflow.AuthenticateAsync(
"AL123456",
"Wrong",
TestContext.Current.CancellationToken);
Assert.Equal(AuthenticationFlowOutcome.InvalidCredentials, result.Outcome);
Assert.Null(directory.Password);
}
[Fact]
public async Task NtlmOutageIsReportedAsUnavailableForProviderFallback()
{
CapturingDirectorySynchronizer directory = new();
AuthenticationWorkflow workflow = new(
new CapturingNtlmValidator(NtlmValidationResult.Unavailable()),
directory);
AuthenticationFlowResult result = await workflow.AuthenticateAsync(
"AD123456",
"LastKnownPassword",
TestContext.Current.CancellationToken);
Assert.Equal(AuthenticationFlowOutcome.Unavailable, result.Outcome);
Assert.Null(directory.Password);
}
private sealed class CapturingNtlmValidator(NtlmValidationResult result) : INtlmCredentialValidator
{
public string? UserName { get; private set; }
public string? Password { get; private set; }
public Task<NtlmValidationResult> ValidateAsync(string userName, string password, CancellationToken cancellationToken)
{
UserName = userName;
Password = password;
return Task.FromResult(result);
}
}
private sealed class CapturingDirectorySynchronizer : IActiveDirectorySynchronizer
{
public UserIdentity? Identity { get; private set; }
public string? Password { get; private set; }
public Task<DirectorySyncResult> SynchronizeAsync(
UserIdentity identity,
string password,
CancellationToken cancellationToken)
{
Identity = identity;
Password = password;
return Task.FromResult(new DirectorySyncResult(
"LCI",
identity.UserName,
$"{identity.UserName}@lci.lasalle.mx",
true,
false));
}
}
}
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\SGU.AuthBroker.Core\SGU.AuthBroker.Core.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>
@@ -0,0 +1,31 @@
using SGU.AuthBroker.Core.Identity;
using Xunit;
namespace SGU.AuthBroker.Core.Tests;
public sealed class UserIdentityClassifierTests
{
[Theory]
[InlineData("DO123456", "DO123456", InstitutionalRole.Professor)]
[InlineData("al000001", "AL000001", InstitutionalRole.Student)]
[InlineData("LCI\\AD654321", "AD654321", InstitutionalRole.Administrative)]
[InlineData("do123456@lci.lasalle.mx", "DO123456", InstitutionalRole.Professor)]
public void MapsPrefixesToExpectedRoles(string input, string expectedUserName, InstitutionalRole expectedRole)
{
Assert.True(UserIdentityClassifier.TryParse(input, out UserIdentity? identity));
Assert.NotNull(identity);
Assert.Equal(expectedUserName, identity.UserName);
Assert.Equal(expectedRole, identity.Role);
}
[Theory]
[InlineData("")]
[InlineData("XX123456")]
[InlineData("DO12345")]
[InlineData("AL1234567")]
[InlineData("AD12A456")]
public void RejectsUnknownOrMalformedUserNames(string input)
{
Assert.False(UserIdentityClassifier.TryParse(input, out _));
}
}
@@ -0,0 +1,610 @@
using System.Runtime.InteropServices;
using System.Net;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
namespace SGU.CredentialProvider.SmokeProbe;
internal static class Program
{
private static readonly Guid ProviderClassId = new("D789CFD8-5AD4-489F-9B83-7EB5D9D09335");
private static readonly string[] ExpectedLabels =
[
"Acceso institucional SGU",
"Usa tu clave institucional (DO, AL o AD + 6 dígitos) y contraseña.",
"Clave institucional",
"Contraseña",
"Iniciar sesión"
];
private static int Main(string[] args)
{
string mode = args.Length == 0 ? "enumeration" : args.Single();
if (mode is not ("enumeration" or "direct-broker" or "online-rejection" or "offline-fallback"))
{
Console.Error.WriteLine("Usage: SGU.CredentialProvider.SmokeProbe.exe [enumeration|direct-broker|online-rejection|offline-fallback]");
return 64;
}
if (mode == "direct-broker")
{
return RunDirectBrokerProbe();
}
object? instance = null;
IntPtr credential = IntPtr.Zero;
NativeEmptyUserArray? users = null;
try
{
Type providerType = Type.GetTypeFromCLSID(ProviderClassId, throwOnError: true)
?? throw new InvalidOperationException("The SGU Credential Provider CLSID is not registered.");
instance = Activator.CreateInstance(providerType)
?? throw new InvalidOperationException("COM activation returned no provider instance.");
ICredentialProvider provider = (ICredentialProvider)instance;
ICredentialProviderSetUserArray setUserArray = (ICredentialProviderSetUserArray)instance;
users = new NativeEmptyUserArray();
ThrowIfFailed(provider.SetUsageScenario(UsageScenario.Logon, 0), "SetUsageScenario");
ThrowIfFailed(setUserArray.SetUserArray(users.Pointer), "SetUserArray");
ThrowIfFailed(provider.GetFieldDescriptorCount(out uint fieldCount), "GetFieldDescriptorCount");
List<string> labels = [];
for (uint index = 0; index < fieldCount; index++)
{
ThrowIfFailed(provider.GetFieldDescriptorAt(index, out IntPtr descriptorPointer), "GetFieldDescriptorAt");
if (descriptorPointer == IntPtr.Zero)
{
throw new InvalidOperationException($"Field descriptor {index} was null.");
}
try
{
FieldDescriptor descriptor = Marshal.PtrToStructure<FieldDescriptor>(descriptorPointer);
labels.Add(descriptor.Label ?? string.Empty);
}
finally
{
Marshal.DestroyStructure<FieldDescriptor>(descriptorPointer);
Marshal.FreeCoTaskMem(descriptorPointer);
}
}
ThrowIfFailed(provider.GetCredentialCount(out uint credentialCount, out uint defaultIndex, out int autoLogon), "GetCredentialCount");
if (credentialCount > 0)
{
ThrowIfFailed(provider.GetCredentialAt(0, out credential), "GetCredentialAt");
}
bool passed = fieldCount == ExpectedLabels.Length &&
credentialCount == 1 &&
credential != IntPtr.Zero &&
labels.SequenceEqual(ExpectedLabels, StringComparer.Ordinal);
if (mode != "enumeration" && passed)
{
return RunSerializationProbe(mode, credential, labels);
}
Console.WriteLine(JsonSerializer.Serialize(new
{
passed,
mode,
providerClassId = ProviderClassId,
usageScenario = "Logon",
fieldCount,
labels,
credentialCount,
defaultIndex,
autoLogon = autoLogon != 0
}));
return passed ? 0 : 1;
}
catch (Exception ex)
{
Console.Error.WriteLine(ex);
return 2;
}
finally
{
if (credential != IntPtr.Zero)
{
Marshal.Release(credential);
}
if (instance is not null && Marshal.IsComObject(instance))
{
Marshal.FinalReleaseComObject(instance);
}
users?.Dispose();
}
}
private static void ThrowIfFailed(int hresult, string operation)
{
if (hresult < 0)
{
Marshal.ThrowExceptionForHR(hresult);
throw new COMException($"{operation} failed.", hresult);
}
}
private static int RunSerializationProbe(string mode, IntPtr credential, IReadOnlyList<string> labels)
{
string[] labelArray = labels.ToArray();
uint userNameFieldId = checked((uint)Array.IndexOf(labelArray, "Clave institucional"));
uint passwordFieldId = checked((uint)Array.IndexOf(labelArray, "Contraseña"));
string userName = "DO000000";
string password = mode == "offline-fallback"
? $"Probe-{Guid.NewGuid():N}-áΩ"
: $"Probe-{Guid.NewGuid():N}";
IntPtr vtable = Marshal.ReadIntPtr(credential);
SetStringValueDelegate setStringValue = Marshal.GetDelegateForFunctionPointer<SetStringValueDelegate>(
Marshal.ReadIntPtr(vtable, 14 * IntPtr.Size));
GetSerializationDelegate getSerialization = Marshal.GetDelegateForFunctionPointer<GetSerializationDelegate>(
Marshal.ReadIntPtr(vtable, 18 * IntPtr.Size));
SetCredentialString(setStringValue, credential, userNameFieldId, userName, "SetStringValue(username)");
SetCredentialString(setStringValue, credential, passwordFieldId, password, "SetStringValue(password)");
CredentialSerialization serialization = default;
IntPtr statusTextPointer = IntPtr.Zero;
try
{
ThrowIfFailed(
getSerialization(credential, out int response, out serialization, out statusTextPointer, out int statusIcon),
"GetSerialization");
string statusText = statusTextPointer == IntPtr.Zero
? string.Empty
: Marshal.PtrToStringUni(statusTextPointer) ?? string.Empty;
if (mode == "online-rejection")
{
bool passed = response == (int)SerializationResponse.NoCredentialNotFinished &&
serialization.SerializationData == IntPtr.Zero &&
statusIcon == (int)StatusIcon.Error &&
statusText == "Credenciales institucionales inválidas.";
Console.WriteLine(JsonSerializer.Serialize(new
{
passed,
mode,
response = (SerializationResponse)response,
statusIcon = (StatusIcon)statusIcon,
statusText,
credentialReturned = serialization.SerializationData != IntPtr.Zero
}));
return passed ? 0 : 1;
}
KerberosInteractiveUnlockLogon logon = serialization.SerializationData == IntPtr.Zero
? default
: Marshal.PtrToStructure<KerberosInteractiveUnlockLogon>(serialization.SerializationData);
string packedDomain = ReadPackedString(serialization.SerializationData, logon.LogonDomainName);
string packedUserName = ReadPackedString(serialization.SerializationData, logon.Username);
string packedPassword = ReadPackedString(serialization.SerializationData, logon.Password);
bool passwordPreserved = string.Equals(packedPassword, password, StringComparison.Ordinal);
packedPassword = string.Empty;
bool fallbackPassed = response == (int)SerializationResponse.ReturnCredentialFinished &&
serialization.SerializationData != IntPtr.Zero &&
serialization.SerializationSize > 0 &&
statusIcon == (int)StatusIcon.Warning &&
statusText == "Servicio institucional no disponible; Windows validará la última contraseña de dominio registrada." &&
string.Equals(packedDomain, "LCI", StringComparison.Ordinal) &&
string.Equals(packedUserName, userName, StringComparison.Ordinal) &&
passwordPreserved;
Console.WriteLine(JsonSerializer.Serialize(new
{
passed = fallbackPassed,
mode,
response = (SerializationResponse)response,
statusIcon = (StatusIcon)statusIcon,
statusText,
packedDomain,
packedUserName,
passwordPreserved,
serializationSize = serialization.SerializationSize
}));
return fallbackPassed ? 0 : 1;
}
finally
{
password = string.Empty;
if (statusTextPointer != IntPtr.Zero)
{
Marshal.FreeCoTaskMem(statusTextPointer);
}
if (serialization.SerializationData != IntPtr.Zero)
{
byte[] zeroes = new byte[serialization.SerializationSize];
Marshal.Copy(zeroes, 0, serialization.SerializationData, zeroes.Length);
Marshal.FreeCoTaskMem(serialization.SerializationData);
}
}
}
private static int RunDirectBrokerProbe()
{
string settingsPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"SGU",
"CredentialProvider",
"settings.json");
ProbeSettings settings = JsonSerializer.Deserialize<ProbeSettings>(
File.ReadAllText(settingsPath),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? throw new InvalidOperationException("Provider settings could not be read.");
using X509Store store = new(StoreName.My, StoreLocation.LocalMachine);
store.Open(OpenFlags.ReadOnly | OpenFlags.OpenExistingOnly);
using X509Certificate2 certificate = store.Certificates
.Find(X509FindType.FindByThumbprint, settings.ClientCertificateThumbprint, validOnly: true)
.OfType<X509Certificate2>()
.First(item => item.HasPrivateKey);
string expectedThumbprint = NormalizeThumbprint(settings.ServerCertificateThumbprint);
using HttpClientHandler handler = new()
{
AllowAutoRedirect = false,
CheckCertificateRevocationList = true,
ClientCertificateOptions = ClientCertificateOption.Manual,
MaxConnectionsPerServer = 2,
MaxResponseHeadersLength = 32,
UseCookies = false,
UseDefaultCredentials = false,
UseProxy = false,
ServerCertificateCustomValidationCallback = (_, serverCertificate, _, policyErrors) =>
policyErrors == SslPolicyErrors.None &&
serverCertificate is not null &&
string.Equals(
NormalizeThumbprint(serverCertificate.GetCertHashString()),
expectedThumbprint,
StringComparison.OrdinalIgnoreCase)
};
handler.ClientCertificates.Add(certificate);
using HttpClient client = new(handler)
{
Timeout = TimeSpan.FromSeconds(settings.TimeoutSeconds),
DefaultRequestVersion = HttpVersion.Version11,
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact
};
string password = $"Probe-{Guid.NewGuid():N}";
try
{
string json = JsonSerializer.Serialize(new { clave = "DO000000", password });
using StringContent content = new(json, Encoding.UTF8, "application/json");
using HttpResponseMessage response = client.PostAsync(settings.BrokerEndpoint, content).GetAwaiter().GetResult();
string responseBody = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
string? responseCode = null;
try
{
responseCode = JsonDocument.Parse(responseBody).RootElement.GetProperty("code").GetString();
}
catch (Exception exception) when (exception is JsonException or InvalidOperationException or KeyNotFoundException)
{
}
bool passed = response.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.Unauthorized;
Console.WriteLine(JsonSerializer.Serialize(new
{
passed,
mode = "direct-broker",
statusCode = (int)response.StatusCode,
responseCode
}));
return passed ? 0 : 1;
}
catch (Exception exception)
{
Console.WriteLine(JsonSerializer.Serialize(new
{
passed = false,
mode = "direct-broker",
exception = exception.GetType().FullName,
innerException = exception.InnerException?.GetType().FullName,
hresult = exception.HResult
}));
return 1;
}
finally
{
password = string.Empty;
}
}
private static string NormalizeThumbprint(string value) =>
value.Replace(" ", string.Empty, StringComparison.Ordinal).ToUpperInvariant();
private static void SetCredentialString(
SetStringValueDelegate setStringValue,
IntPtr credential,
uint fieldId,
string value,
string operation)
{
IntPtr valuePointer = Marshal.StringToCoTaskMemUni(value);
try
{
ThrowIfFailed(setStringValue(credential, fieldId, valuePointer), operation);
}
finally
{
Marshal.ZeroFreeCoTaskMemUnicode(valuePointer);
}
}
private static string ReadPackedString(IntPtr buffer, PackedUnicodeString value)
{
if (buffer == IntPtr.Zero || value.Length == 0)
{
return string.Empty;
}
return Marshal.PtrToStringUni(
IntPtr.Add(buffer, checked((int)value.Buffer.ToInt64())),
value.Length / sizeof(char)) ?? string.Empty;
}
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
private delegate int SetStringValueDelegate(IntPtr instance, uint fieldId, IntPtr value);
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
private delegate int GetSerializationDelegate(
IntPtr instance,
out int response,
out CredentialSerialization serialization,
out IntPtr statusText,
out int statusIcon);
}
internal sealed class ProbeSettings
{
public Uri BrokerEndpoint { get; init; } = null!;
public int TimeoutSeconds { get; init; }
public string ClientCertificateThumbprint { get; init; } = string.Empty;
public string ServerCertificateThumbprint { get; init; } = string.Empty;
}
internal enum SerializationResponse
{
NoCredentialNotFinished = 0,
NoCredentialFinished = 1,
ReturnCredentialFinished = 2,
ReturnNoCredentialFinished = 3
}
internal enum StatusIcon
{
None = 0,
Error = 1,
Warning = 2,
Success = 3
}
internal enum UsageScenario
{
Invalid = 0,
Logon = 1
}
internal enum FieldType
{
Invalid = 0,
LargeText,
SmallText,
CommandLink,
EditText,
PasswordText,
TileImage,
CheckBox,
ComboBox,
SubmitButton
}
[StructLayout(LayoutKind.Sequential, Pack = 4, CharSet = CharSet.Unicode)]
internal struct FieldDescriptor
{
public uint FieldId;
public FieldType FieldType;
[MarshalAs(UnmanagedType.LPWStr)]
public string? Label;
public Guid FieldTypeGuid;
}
[StructLayout(LayoutKind.Sequential, Pack = 4)]
internal struct CredentialSerialization
{
public uint AuthenticationPackage;
public Guid ProviderClassGuid;
public uint SerializationSize;
public IntPtr SerializationData;
}
[StructLayout(LayoutKind.Sequential)]
internal struct PackedUnicodeString
{
public ushort Length;
public ushort MaxLength;
public IntPtr Buffer;
}
[StructLayout(LayoutKind.Sequential)]
internal struct KerberosInteractiveUnlockLogon
{
public int SubmitType;
public PackedUnicodeString LogonDomainName;
public PackedUnicodeString Username;
public PackedUnicodeString Password;
public long LoginId;
}
[ComImport]
[Guid("D27C3481-5A1C-45B2-8AAA-C20EBBE8229E")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface ICredentialProvider
{
[PreserveSig]
int SetUsageScenario(UsageScenario usageScenario, uint flags);
[PreserveSig]
int SetSerialization(IntPtr serialization);
[PreserveSig]
int Advise(IntPtr events, IntPtr adviseContext);
[PreserveSig]
int UnAdvise();
[PreserveSig]
int GetFieldDescriptorCount(out uint count);
[PreserveSig]
int GetFieldDescriptorAt(uint index, out IntPtr descriptor);
[PreserveSig]
int GetCredentialCount(out uint count, out uint defaultIndex, out int autoLogonWithDefault);
[PreserveSig]
int GetCredentialAt(
uint index,
out IntPtr credential);
}
[ComImport]
[Guid("095C1484-1C0C-4388-9C6D-500E61BF84BD")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface ICredentialProviderSetUserArray
{
[PreserveSig]
int SetUserArray(IntPtr users);
}
internal sealed class NativeEmptyUserArray : IDisposable
{
private static readonly Guid UserArrayInterfaceId = new("90C119AE-0F18-4520-A1F1-114366A40FE8");
private static readonly Guid UnknownInterfaceId = new("00000000-0000-0000-C000-000000000046");
private readonly QueryInterfaceDelegate queryInterface;
private readonly AddRefDelegate addRef;
private readonly ReleaseDelegate release;
private readonly SetProviderFilterDelegate setProviderFilter;
private readonly GetAccountOptionsDelegate getAccountOptions;
private readonly GetCountDelegate getCount;
private readonly GetAtDelegate getAt;
private IntPtr instance;
private IntPtr vtable;
private int referenceCount = 1;
public NativeEmptyUserArray()
{
queryInterface = QueryInterface;
addRef = AddRef;
release = Release;
setProviderFilter = SetProviderFilter;
getAccountOptions = GetAccountOptions;
getCount = GetCount;
getAt = GetAt;
vtable = Marshal.AllocHGlobal(IntPtr.Size * 7);
Marshal.WriteIntPtr(vtable, IntPtr.Size * 0, Marshal.GetFunctionPointerForDelegate(queryInterface));
Marshal.WriteIntPtr(vtable, IntPtr.Size * 1, Marshal.GetFunctionPointerForDelegate(addRef));
Marshal.WriteIntPtr(vtable, IntPtr.Size * 2, Marshal.GetFunctionPointerForDelegate(release));
Marshal.WriteIntPtr(vtable, IntPtr.Size * 3, Marshal.GetFunctionPointerForDelegate(setProviderFilter));
Marshal.WriteIntPtr(vtable, IntPtr.Size * 4, Marshal.GetFunctionPointerForDelegate(getAccountOptions));
Marshal.WriteIntPtr(vtable, IntPtr.Size * 5, Marshal.GetFunctionPointerForDelegate(getCount));
Marshal.WriteIntPtr(vtable, IntPtr.Size * 6, Marshal.GetFunctionPointerForDelegate(getAt));
instance = Marshal.AllocHGlobal(IntPtr.Size);
Marshal.WriteIntPtr(instance, vtable);
}
public IntPtr Pointer => instance != IntPtr.Zero
? instance
: throw new ObjectDisposedException(nameof(NativeEmptyUserArray));
private int QueryInterface(IntPtr self, ref Guid interfaceId, out IntPtr result)
{
if (interfaceId == UnknownInterfaceId || interfaceId == UserArrayInterfaceId)
{
result = self;
AddRef(self);
return 0;
}
result = IntPtr.Zero;
return unchecked((int)0x80004002);
}
private uint AddRef(IntPtr self) => unchecked((uint)Interlocked.Increment(ref referenceCount));
private uint Release(IntPtr self) => unchecked((uint)Math.Max(0, Interlocked.Decrement(ref referenceCount)));
private static int SetProviderFilter(IntPtr self, ref Guid providerToFilterTo) => 0;
private static int GetAccountOptions(IntPtr self, out uint accountOptions)
{
accountOptions = 0;
return 0;
}
private static int GetCount(IntPtr self, out uint userCount)
{
userCount = 0;
return 0;
}
private static int GetAt(IntPtr self, uint userIndex, out IntPtr user)
{
user = IntPtr.Zero;
return unchecked((int)0x80070057);
}
public void Dispose()
{
if (instance != IntPtr.Zero)
{
Marshal.FreeHGlobal(instance);
instance = IntPtr.Zero;
}
if (vtable != IntPtr.Zero)
{
Marshal.FreeHGlobal(vtable);
vtable = IntPtr.Zero;
}
GC.KeepAlive(queryInterface);
GC.KeepAlive(addRef);
GC.KeepAlive(release);
GC.KeepAlive(setProviderFilter);
GC.KeepAlive(getAccountOptions);
GC.KeepAlive(getCount);
GC.KeepAlive(getAt);
}
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
private delegate int QueryInterfaceDelegate(IntPtr self, ref Guid interfaceId, out IntPtr result);
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
private delegate uint AddRefDelegate(IntPtr self);
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
private delegate uint ReleaseDelegate(IntPtr self);
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
private delegate int SetProviderFilterDelegate(IntPtr self, ref Guid providerToFilterTo);
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
private delegate int GetAccountOptionsDelegate(IntPtr self, out uint accountOptions);
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
private delegate int GetCountDelegate(IntPtr self, out uint userCount);
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
private delegate int GetAtDelegate(IntPtr self, uint userIndex, out IntPtr user);
}
@@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<PlatformTarget>x64</PlatformTarget>
</PropertyGroup>
</Project>
@@ -0,0 +1,87 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Xunit;
namespace SGU.CredentialProvider.Tests;
public sealed class BrokerClientTests
{
[Fact]
public async Task SendsTheOriginalPasswordWithoutDerivation()
{
const string original = "Exacta-Árbol-🔐-27!";
CapturingHandler handler = new(HttpStatusCode.OK, """
{"domain":"LCI","username":"DO123456","upn":"DO123456@lci.lasalle.mx","created":true,"moved":false}
""");
using BrokerClient client = new(CreateSettings(), handler);
BrokerDecision decision = await client.AuthenticateAsync(
"DO123456",
original,
TestContext.Current.CancellationToken);
Assert.Equal(BrokerDecisionKind.Authorized, decision.Kind);
using JsonDocument requestJson = JsonDocument.Parse(handler.RequestBody);
Assert.Equal(original, requestJson.RootElement.GetProperty("password").GetString());
Assert.DoesNotContain("derived", handler.RequestBody, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task ExplicitUnauthorizedResponseStopsTheLogin()
{
using BrokerClient client = new(
CreateSettings(),
new CapturingHandler(HttpStatusCode.Unauthorized, "{\"code\":\"INVALID_INSTITUTIONAL_CREDENTIALS\"}"));
BrokerDecision decision = await client.AuthenticateAsync(
"AL123456",
"Wrong",
TestContext.Current.CancellationToken);
Assert.Equal(BrokerDecisionKind.InvalidCredentials, decision.Kind);
}
[Fact]
public async Task BrokerOutageRequestsWindowsCachedCredentialFallback()
{
using BrokerClient client = new(
CreateSettings(),
new CapturingHandler(HttpStatusCode.ServiceUnavailable, "{\"code\":\"NTLM_UPSTREAM_ERROR\"}"));
BrokerDecision decision = await client.AuthenticateAsync(
"AD123456",
"LastKnown",
TestContext.Current.CancellationToken);
Assert.Equal(BrokerDecisionKind.Unavailable, decision.Kind);
}
private static ProviderSettings CreateSettings() => new()
{
BrokerEndpoint = new Uri("https://broker.example.test/v1/authenticate"),
DomainNetbios = "LCI",
TimeoutSeconds = 5,
ClientCertificateThumbprint = new string('A', 40),
ServerCertificateThumbprint = new string('B', 40)
};
private sealed class CapturingHandler(HttpStatusCode statusCode, string responseBody) : HttpMessageHandler
{
public string RequestBody { get; private set; } = string.Empty;
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
RequestBody = request.Content is null
? string.Empty
: await request.Content.ReadAsStringAsync(cancellationToken);
return new HttpResponseMessage(statusCode)
{
Content = new StringContent(responseBody, Encoding.UTF8, "application/json")
};
}
}
}
@@ -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.CredentialProvider\SGU.CredentialProvider.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>