Add SGU credential provider and authentication broker
This commit is contained in:
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
@@ -1,256 +1,72 @@
|
||||

|
||||
# SGU Windows Credential Provider
|
||||
|
||||
# Windows Credential Provider
|
||||

|
||||

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