From 1f43f200b4957cfea6a51f823e1d60168d5762de Mon Sep 17 00:00:00 2001 From: Alejandro Rosales Date: Mon, 31 Aug 2026 17:48:18 -0600 Subject: [PATCH] Add SGU credential provider and authentication broker --- .vsconfig | 11 + Directory.Build.props | 12 + README.md | 288 ++------- SGU-CredentialProvider.sln | 116 ++++ docs/architecture.md | 48 ++ docs/lab-runbook.md | 144 +++++ docs/security.md | 53 ++ docs/toolchain.md | 37 ++ global.json | 10 + scripts/Deploy-AuthBroker.ps1 | 168 +++++ scripts/Import-LabPeerCertificate.ps1 | 15 + scripts/Install-CredentialProvider.ps1 | 160 +++++ scripts/New-LabCertificate.ps1 | 52 ++ scripts/Publish-Lab.ps1 | 44 ++ scripts/Set-LabBrokerDns.ps1 | 31 + scripts/Test-Broker.ps1 | 32 + scripts/Uninstall-CredentialProvider.ps1 | 21 + .../AuthenticationFlowResult.cs | 18 + .../Authentication/AuthenticationWorkflow.cs | 80 +++ .../INtlmCredentialValidator.cs | 9 + .../Authentication/NtlmValidationResult.cs | 18 + .../Directory/DirectorySyncResult.cs | 8 + .../Directory/IActiveDirectorySynchronizer.cs | 11 + .../Identity/InstitutionalRole.cs | 8 + .../Identity/UserIdentity.cs | 7 + .../Identity/UserIdentityClassifier.cs | 50 ++ .../SGU.AuthBroker.Core.csproj | 7 + .../Contracts/AuthenticationRequest.cs | 14 + .../Contracts/AuthenticationResponse.cs | 14 + src/SGU.AuthBroker/Options/BrokerOptions.cs | 114 ++++ src/SGU.AuthBroker/Program.cs | 127 ++++ src/SGU.AuthBroker/SGU.AuthBroker.csproj | 15 + .../Services/ActiveDirectorySynchronizer.cs | 198 ++++++ .../Services/NtlmCredentialValidator.cs | 125 ++++ src/SGU.AuthBroker/appsettings.json | 49 ++ src/SGU.CredentialProvider/BrokerClient.cs | 165 +++++ src/SGU.CredentialProvider/BrokerDecision.cs | 24 + src/SGU.CredentialProvider/ControlKeys.cs | 10 + .../ProviderSettings.cs | 71 ++ .../SGU.CredentialProvider.csproj | 31 + .../SguCredentialProvider.cs | 38 ++ .../SguCredentialTile.cs | 132 ++++ .../settings.example.json | 7 + .../AuthenticationWorkflowTests.cs | 99 +++ .../SGU.AuthBroker.Core.Tests.csproj | 17 + .../UserIdentityClassifierTests.cs | 31 + .../Program.cs | 610 ++++++++++++++++++ .../SGU.CredentialProvider.SmokeProbe.csproj | 8 + .../BrokerClientTests.cs | 87 +++ .../SGU.CredentialProvider.Tests.csproj | 18 + 50 files changed, 3226 insertions(+), 236 deletions(-) create mode 100644 .vsconfig create mode 100644 Directory.Build.props create mode 100644 SGU-CredentialProvider.sln create mode 100644 docs/architecture.md create mode 100644 docs/lab-runbook.md create mode 100644 docs/security.md create mode 100644 docs/toolchain.md create mode 100644 global.json create mode 100644 scripts/Deploy-AuthBroker.ps1 create mode 100644 scripts/Import-LabPeerCertificate.ps1 create mode 100644 scripts/Install-CredentialProvider.ps1 create mode 100644 scripts/New-LabCertificate.ps1 create mode 100644 scripts/Publish-Lab.ps1 create mode 100644 scripts/Set-LabBrokerDns.ps1 create mode 100644 scripts/Test-Broker.ps1 create mode 100644 scripts/Uninstall-CredentialProvider.ps1 create mode 100644 src/SGU.AuthBroker.Core/Authentication/AuthenticationFlowResult.cs create mode 100644 src/SGU.AuthBroker.Core/Authentication/AuthenticationWorkflow.cs create mode 100644 src/SGU.AuthBroker.Core/Authentication/INtlmCredentialValidator.cs create mode 100644 src/SGU.AuthBroker.Core/Authentication/NtlmValidationResult.cs create mode 100644 src/SGU.AuthBroker.Core/Directory/DirectorySyncResult.cs create mode 100644 src/SGU.AuthBroker.Core/Directory/IActiveDirectorySynchronizer.cs create mode 100644 src/SGU.AuthBroker.Core/Identity/InstitutionalRole.cs create mode 100644 src/SGU.AuthBroker.Core/Identity/UserIdentity.cs create mode 100644 src/SGU.AuthBroker.Core/Identity/UserIdentityClassifier.cs create mode 100644 src/SGU.AuthBroker.Core/SGU.AuthBroker.Core.csproj create mode 100644 src/SGU.AuthBroker/Contracts/AuthenticationRequest.cs create mode 100644 src/SGU.AuthBroker/Contracts/AuthenticationResponse.cs create mode 100644 src/SGU.AuthBroker/Options/BrokerOptions.cs create mode 100644 src/SGU.AuthBroker/Program.cs create mode 100644 src/SGU.AuthBroker/SGU.AuthBroker.csproj create mode 100644 src/SGU.AuthBroker/Services/ActiveDirectorySynchronizer.cs create mode 100644 src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs create mode 100644 src/SGU.AuthBroker/appsettings.json create mode 100644 src/SGU.CredentialProvider/BrokerClient.cs create mode 100644 src/SGU.CredentialProvider/BrokerDecision.cs create mode 100644 src/SGU.CredentialProvider/ControlKeys.cs create mode 100644 src/SGU.CredentialProvider/ProviderSettings.cs create mode 100644 src/SGU.CredentialProvider/SGU.CredentialProvider.csproj create mode 100644 src/SGU.CredentialProvider/SguCredentialProvider.cs create mode 100644 src/SGU.CredentialProvider/SguCredentialTile.cs create mode 100644 src/SGU.CredentialProvider/settings.example.json create mode 100644 tests/SGU.AuthBroker.Core.Tests/AuthenticationWorkflowTests.cs create mode 100644 tests/SGU.AuthBroker.Core.Tests/SGU.AuthBroker.Core.Tests.csproj create mode 100644 tests/SGU.AuthBroker.Core.Tests/UserIdentityClassifierTests.cs create mode 100644 tests/SGU.CredentialProvider.SmokeProbe/Program.cs create mode 100644 tests/SGU.CredentialProvider.SmokeProbe/SGU.CredentialProvider.SmokeProbe.csproj create mode 100644 tests/SGU.CredentialProvider.Tests/BrokerClientTests.cs create mode 100644 tests/SGU.CredentialProvider.Tests/SGU.CredentialProvider.Tests.csproj diff --git a/.vsconfig b/.vsconfig new file mode 100644 index 0000000..f99b4e3 --- /dev/null +++ b/.vsconfig @@ -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" + ] +} diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..5865df0 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,12 @@ + + + latest + enable + disable + enable + disable + true + true + true + + diff --git a/README.md b/README.md index 683a176..3b611ef 100644 --- a/README.md +++ b/README.md @@ -1,256 +1,72 @@ -![](https://github.com/lithnet/miis-powershell/wiki/images/logo-ex-small.png) +# SGU Windows Credential Provider -# Windows Credential Provider -![](https://img.shields.io/nuget/vpre/lithnet.credentialprovider?label=Current%20prerelease)![](https://img.shields.io/nuget/v/Lithnet.CredentialProvider?label=Current%20release) -![](https://img.shields.io/nuget/dt/lithnet.credentialprovider) +Windows Credential Provider and ASP.NET Core authentication broker for the +`lci.lasalle.mx` Active Directory laboratory. -A library for creating secure Windows Credential Providers in .NET, without the COM complications. +The repository starts from the current +[Lithnet Windows Credential Provider](https://github.com/lithnet/windows-credential-provider) +source and adds an SGU-specific provider, an mTLS-protected broker, Active +Directory synchronization, deployment scripts, and tests. -The Lithnet Credential Provider for Windows provides an easy way to create a credential provider, without having to implement the COM components. The COM components are still there, but abstracted away into a fully managed implementation. +## Authentication contract -## Getting started -* Create a new Class Library project. You can use .NET Framework 4.7.2 or later, or .NET 8.0, 9.0, or 10.0. You must build an x64 or x86 binary. You cannot use AnyCPU. -* Install the package from nuget `Install-Package Lithnet.CredentialProvider` +1. The Windows tile collects a `DO`, `AL`, or `AD` institutional key and a password. +2. It sends that exact password over mutually authenticated TLS to the broker. +3. The broker validates the same key/password pair against the configured SGU + NTLM endpoint. +4. On success, the broker creates or moves the AD user and sets the AD password + to the exact submitted password. +5. The Credential Provider serializes the original `SecureString` to Windows. -* Modify the `csproj` file and set `RegisterForComInterop` to `false` -```xml - - net472 - false - x64 - -``` +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 - - net8.0-windows - false - x64 - true - -``` +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 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("ConfirmPasswordField"); - } - - this.PasswordControl = this.Controls.GetControl("PasswordField"); - this.UsernameControl = this.Controls.GetControl("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("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 GetControls(UsageScenario cpus) -{ - Bitmap image = LoadTransparentBitmap(); - yield return new UserTileControl("UserTile", "User tile image", image); - yield return new CredentialProviderLogoControl("ProviderLogo", "Credential provider logo", image); -} -``` - -## Installing the credential provider -You can use the traditional methods of registering a credential provider (regasm, regsvr32, create registry keys etc), but we've provided a PowerShell module to automatically register your credential provider with a single command. +Prerequisites are captured in `.vsconfig`; the pinned SDK is .NET `10.0.400`. ```powershell -Install-Module Lithnet.CredentialProvider.Management -Register-CredentialProvider -File C:\path-to-your-provider.dll +dotnet restore .\SGU-CredentialProvider.sln +dotnet build .\SGU-CredentialProvider.sln -c Release --no-restore +dotnet test .\SGU-CredentialProvider.sln -c Release --no-build --no-restore +.\scripts\Publish-Lab.ps1 ``` -You can disable, enable, and uninstall the provider with the following commands +The provider's .NET COM host is framework-dependent, so the Windows client needs +the latest .NET 10 x64 runtime. The broker is published self-contained. -```powershell -Disable-CredentialProvider -File "C:\path-to-your-provider.dll" -Enable-CredentialProvider -File "C:\path-to-your-provider.dll" -Unregister-CredentialProvider -File "C:\path-to-your-provider.dll" -``` +## Deployment and test -Once the credential provider is registered, you can use the `Invoke-CredUI` cmdlet provided as part of the module, to bring up CredUI window and render your credential provider. +Follow [docs/lab-runbook.md](docs/lab-runbook.md). Review +[docs/security.md](docs/security.md) before production deployment and +[docs/architecture.md](docs/architecture.md) for the component contract. -## How can I contribute to the project? -* Found an issue and want us to fix it? [Log it](https://github.com/lithnet/windows-credential-provider/issues) -* Want to fix an issue yourself or add functionality? Clone the project and submit a pull request +Never disable the built-in Microsoft password Credential Provider. It is the +supported recovery path if a third-party provider fails to load. -## Enteprise support -Enterprise support is not currently offered for this product. +## Upstream license -## Keep up to date -* [Visit our blog](http://blog.lithnet.io) -* [Follow us on twitter](https://twitter.com/lithnet_io)![](http://twitter.com/favicon.ico) +The Lithnet source remains under its MIT license in [LICENSE](LICENSE). Project +additions are distributed under the same license. diff --git a/SGU-CredentialProvider.sln b/SGU-CredentialProvider.sln new file mode 100644 index 0000000..e5a887a --- /dev/null +++ b/SGU-CredentialProvider.sln @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..352efee --- /dev/null +++ b/docs/architecture.md @@ -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. diff --git a/docs/lab-runbook.md b/docs/lab-runbook.md new file mode 100644 index 0000000..684973e --- /dev/null +++ b/docs/lab-runbook.md @@ -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. diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..d79d789 --- /dev/null +++ b/docs/security.md @@ -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. diff --git a/docs/toolchain.md b/docs/toolchain.md new file mode 100644 index 0000000..7e9b4e8 --- /dev/null +++ b/docs/toolchain.md @@ -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`. diff --git a/global.json b/global.json new file mode 100644 index 0000000..786b012 --- /dev/null +++ b/global.json @@ -0,0 +1,10 @@ +{ + "sdk": { + "version": "10.0.400", + "rollForward": "latestFeature", + "allowPrerelease": false + }, + "test": { + "runner": "Microsoft.Testing.Platform" + } +} diff --git a/scripts/Deploy-AuthBroker.ps1 b/scripts/Deploy-AuthBroker.ps1 new file mode 100644 index 0000000..04e76f0 --- /dev/null +++ b/scripts/Deploy-AuthBroker.ps1 @@ -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 diff --git a/scripts/Import-LabPeerCertificate.ps1 b/scripts/Import-LabPeerCertificate.ps1 new file mode 100644 index 0000000..cc9c9ca --- /dev/null +++ b/scripts/Import-LabPeerCertificate.ps1 @@ -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 diff --git a/scripts/Install-CredentialProvider.ps1 b/scripts/Install-CredentialProvider.ps1 new file mode 100644 index 0000000..3c2549b --- /dev/null +++ b/scripts/Install-CredentialProvider.ps1 @@ -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 +} diff --git a/scripts/New-LabCertificate.ps1 b/scripts/New-LabCertificate.ps1 new file mode 100644 index 0000000..d956fab --- /dev/null +++ b/scripts/New-LabCertificate.ps1 @@ -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() +} diff --git a/scripts/Publish-Lab.ps1 b/scripts/Publish-Lab.ps1 new file mode 100644 index 0000000..931229b --- /dev/null +++ b/scripts/Publish-Lab.ps1 @@ -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 +} diff --git a/scripts/Set-LabBrokerDns.ps1 b/scripts/Set-LabBrokerDns.ps1 new file mode 100644 index 0000000..08dad65 --- /dev/null +++ b/scripts/Set-LabBrokerDns.ps1 @@ -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) +} diff --git a/scripts/Test-Broker.ps1 b/scripts/Test-Broker.ps1 new file mode 100644 index 0000000..a3b5413 --- /dev/null +++ b/scripts/Test-Broker.ps1 @@ -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 +} diff --git a/scripts/Uninstall-CredentialProvider.ps1 b/scripts/Uninstall-CredentialProvider.ps1 new file mode 100644 index 0000000..48aea3f --- /dev/null +++ b/scripts/Uninstall-CredentialProvider.ps1 @@ -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.' diff --git a/src/SGU.AuthBroker.Core/Authentication/AuthenticationFlowResult.cs b/src/SGU.AuthBroker.Core/Authentication/AuthenticationFlowResult.cs new file mode 100644 index 0000000..9610f2f --- /dev/null +++ b/src/SGU.AuthBroker.Core/Authentication/AuthenticationFlowResult.cs @@ -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); diff --git a/src/SGU.AuthBroker.Core/Authentication/AuthenticationWorkflow.cs b/src/SGU.AuthBroker.Core/Authentication/AuthenticationWorkflow.cs new file mode 100644 index 0000000..d22b842 --- /dev/null +++ b/src/SGU.AuthBroker.Core/Authentication/AuthenticationWorkflow.cs @@ -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 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); + } + } +} diff --git a/src/SGU.AuthBroker.Core/Authentication/INtlmCredentialValidator.cs b/src/SGU.AuthBroker.Core/Authentication/INtlmCredentialValidator.cs new file mode 100644 index 0000000..703fcfd --- /dev/null +++ b/src/SGU.AuthBroker.Core/Authentication/INtlmCredentialValidator.cs @@ -0,0 +1,9 @@ +namespace SGU.AuthBroker.Core.Authentication; + +public interface INtlmCredentialValidator +{ + Task ValidateAsync( + string userName, + string password, + CancellationToken cancellationToken); +} diff --git a/src/SGU.AuthBroker.Core/Authentication/NtlmValidationResult.cs b/src/SGU.AuthBroker.Core/Authentication/NtlmValidationResult.cs new file mode 100644 index 0000000..2d1d1ff --- /dev/null +++ b/src/SGU.AuthBroker.Core/Authentication/NtlmValidationResult.cs @@ -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); +} diff --git a/src/SGU.AuthBroker.Core/Directory/DirectorySyncResult.cs b/src/SGU.AuthBroker.Core/Directory/DirectorySyncResult.cs new file mode 100644 index 0000000..b4a4592 --- /dev/null +++ b/src/SGU.AuthBroker.Core/Directory/DirectorySyncResult.cs @@ -0,0 +1,8 @@ +namespace SGU.AuthBroker.Core.Directory; + +public sealed record DirectorySyncResult( + string DomainNetbios, + string UserName, + string UserPrincipalName, + bool Created, + bool Moved); diff --git a/src/SGU.AuthBroker.Core/Directory/IActiveDirectorySynchronizer.cs b/src/SGU.AuthBroker.Core/Directory/IActiveDirectorySynchronizer.cs new file mode 100644 index 0000000..4674c8d --- /dev/null +++ b/src/SGU.AuthBroker.Core/Directory/IActiveDirectorySynchronizer.cs @@ -0,0 +1,11 @@ +using SGU.AuthBroker.Core.Identity; + +namespace SGU.AuthBroker.Core.Directory; + +public interface IActiveDirectorySynchronizer +{ + Task SynchronizeAsync( + UserIdentity identity, + string password, + CancellationToken cancellationToken); +} diff --git a/src/SGU.AuthBroker.Core/Identity/InstitutionalRole.cs b/src/SGU.AuthBroker.Core/Identity/InstitutionalRole.cs new file mode 100644 index 0000000..1a29a21 --- /dev/null +++ b/src/SGU.AuthBroker.Core/Identity/InstitutionalRole.cs @@ -0,0 +1,8 @@ +namespace SGU.AuthBroker.Core.Identity; + +public enum InstitutionalRole +{ + Professor, + Student, + Administrative +} diff --git a/src/SGU.AuthBroker.Core/Identity/UserIdentity.cs b/src/SGU.AuthBroker.Core/Identity/UserIdentity.cs new file mode 100644 index 0000000..2345f17 --- /dev/null +++ b/src/SGU.AuthBroker.Core/Identity/UserIdentity.cs @@ -0,0 +1,7 @@ +namespace SGU.AuthBroker.Core.Identity; + +public sealed record UserIdentity( + string UserName, + string Prefix, + string NumericId, + InstitutionalRole Role); diff --git a/src/SGU.AuthBroker.Core/Identity/UserIdentityClassifier.cs b/src/SGU.AuthBroker.Core/Identity/UserIdentityClassifier.cs new file mode 100644 index 0000000..39f1bb0 --- /dev/null +++ b/src/SGU.AuthBroker.Core/Identity/UserIdentityClassifier.cs @@ -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("^(?DO|AL|AD)(?[0-9]{6})$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex InstitutionalUserName(); +} diff --git a/src/SGU.AuthBroker.Core/SGU.AuthBroker.Core.csproj b/src/SGU.AuthBroker.Core/SGU.AuthBroker.Core.csproj new file mode 100644 index 0000000..b061b37 --- /dev/null +++ b/src/SGU.AuthBroker.Core/SGU.AuthBroker.Core.csproj @@ -0,0 +1,7 @@ + + + net10.0 + SGU.AuthBroker.Core + SGU.AuthBroker.Core + + diff --git a/src/SGU.AuthBroker/Contracts/AuthenticationRequest.cs b/src/SGU.AuthBroker/Contracts/AuthenticationRequest.cs new file mode 100644 index 0000000..1d71382 --- /dev/null +++ b/src/SGU.AuthBroker/Contracts/AuthenticationRequest.cs @@ -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; +} diff --git a/src/SGU.AuthBroker/Contracts/AuthenticationResponse.cs b/src/SGU.AuthBroker/Contracts/AuthenticationResponse.cs new file mode 100644 index 0000000..69ed387 --- /dev/null +++ b/src/SGU.AuthBroker/Contracts/AuthenticationResponse.cs @@ -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); diff --git a/src/SGU.AuthBroker/Options/BrokerOptions.cs b/src/SGU.AuthBroker/Options/BrokerOptions.cs new file mode 100644 index 0000000..b899a13 --- /dev/null +++ b/src/SGU.AuthBroker/Options/BrokerOptions.cs @@ -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()) + { + 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) + }; +} diff --git a/src/SGU.AuthBroker/Program.cs b/src/SGU.AuthBroker/Program.cs new file mode 100644 index 0000000..6db45c9 --- /dev/null +++ b/src/SGU.AuthBroker/Program.cs @@ -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() ?? throw new InvalidOperationException("Broker configuration is missing."); +brokerOptions.Validate(); + +HashSet 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(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); +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(); diff --git a/src/SGU.AuthBroker/SGU.AuthBroker.csproj b/src/SGU.AuthBroker/SGU.AuthBroker.csproj new file mode 100644 index 0000000..5930771 --- /dev/null +++ b/src/SGU.AuthBroker/SGU.AuthBroker.csproj @@ -0,0 +1,15 @@ + + + net10.0-windows + win-x64 + SGU.AuthBroker + SGU.AuthBroker + false + + + + + + + + diff --git a/src/SGU.AuthBroker/Services/ActiveDirectorySynchronizer.cs b/src/SGU.AuthBroker/Services/ActiveDirectorySynchronizer.cs new file mode 100644 index 0000000..eb1ac37 --- /dev/null +++ b/src/SGU.AuthBroker/Services/ActiveDirectorySynchronizer.cs @@ -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 userLocks = + new(StringComparer.OrdinalIgnoreCase); + + public async Task 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(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); +} diff --git a/src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs b/src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs new file mode 100644 index 0000000..01dcbc7 --- /dev/null +++ b/src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs @@ -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 ValidateAsync( + string userName, + string password, + CancellationToken cancellationToken) + { + Uri current = new(this.options.Endpoint, UriKind.Absolute); + HashSet allowedHosts = new( + this.options.AllowedRedirectHosts, + StringComparer.OrdinalIgnoreCase); + + NetworkCredential credential = new(userName, password, this.options.Domain); + CredentialCache credentialCache = new(); + HashSet 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 allowedHosts) => + uri.Scheme == Uri.UriSchemeHttps && + string.IsNullOrEmpty(uri.UserInfo) && + allowedHosts.Contains(uri.IdnHost); +} diff --git a/src/SGU.AuthBroker/appsettings.json b/src/SGU.AuthBroker/appsettings.json new file mode 100644 index 0000000..f60e263 --- /dev/null +++ b/src/SGU.AuthBroker/appsettings.json @@ -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 + } + } +} diff --git a/src/SGU.CredentialProvider/BrokerClient.cs b/src/SGU.CredentialProvider/BrokerClient.cs new file mode 100644 index 0000000..d679d20 --- /dev/null +++ b/src/SGU.CredentialProvider/BrokerClient.cs @@ -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 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(response, timeout.Token).ConfigureAwait(false); + return BrokerDecision.Invalid(error?.Code); + } + + if (response.StatusCode == HttpStatusCode.OK) + { + AuthorizedBody? authorized = await ReadJsonAsync(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 ReadJsonAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + if (response.Content.Headers.ContentLength is > 16 * 1024) + { + return default; + } + + try + { + return await response.Content.ReadFromJsonAsync(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() + .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); +} diff --git a/src/SGU.CredentialProvider/BrokerDecision.cs b/src/SGU.CredentialProvider/BrokerDecision.cs new file mode 100644 index 0000000..7698c00 --- /dev/null +++ b/src/SGU.CredentialProvider/BrokerDecision.cs @@ -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); +} diff --git a/src/SGU.CredentialProvider/ControlKeys.cs b/src/SGU.CredentialProvider/ControlKeys.cs new file mode 100644 index 0000000..d04d31d --- /dev/null +++ b/src/SGU.CredentialProvider/ControlKeys.cs @@ -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"; +} diff --git a/src/SGU.CredentialProvider/ProviderSettings.cs b/src/SGU.CredentialProvider/ProviderSettings.cs new file mode 100644 index 0000000..9b1199c --- /dev/null +++ b/src/SGU.CredentialProvider/ProviderSettings.cs @@ -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(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(); +} diff --git a/src/SGU.CredentialProvider/SGU.CredentialProvider.csproj b/src/SGU.CredentialProvider/SGU.CredentialProvider.csproj new file mode 100644 index 0000000..29bc647 --- /dev/null +++ b/src/SGU.CredentialProvider/SGU.CredentialProvider.csproj @@ -0,0 +1,31 @@ + + + net10.0-windows + win-x64 + x64 + x64 + Library + SGU.CredentialProvider + SGU.CredentialProvider + false + true + true + true + false + false + false + + + + + Configuration=$(Configuration) + + + + + + + <_Parameter1>SGU.CredentialProvider.Tests + + + diff --git a/src/SGU.CredentialProvider/SguCredentialProvider.cs b/src/SGU.CredentialProvider/SguCredentialProvider.cs new file mode 100644 index 0000000..7e7d538 --- /dev/null +++ b/src/SGU.CredentialProvider/SguCredentialProvider.cs @@ -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 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); +} diff --git a/src/SGU.CredentialProvider/SguCredentialTile.cs b/src/SGU.CredentialProvider/SguCredentialTile.cs new file mode 100644 index 0000000..6cc950e --- /dev/null +++ b/src/SGU.CredentialProvider/SguCredentialTile.cs @@ -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(ControlKeys.UserName); + passwordControl = Controls.GetControl(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); + } + } + } +} diff --git a/src/SGU.CredentialProvider/settings.example.json b/src/SGU.CredentialProvider/settings.example.json new file mode 100644 index 0000000..84b654f --- /dev/null +++ b/src/SGU.CredentialProvider/settings.example.json @@ -0,0 +1,7 @@ +{ + "BrokerEndpoint": "https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate", + "DomainNetbios": "LCI", + "TimeoutSeconds": 6, + "ClientCertificateThumbprint": "0000000000000000000000000000000000000000", + "ServerCertificateThumbprint": "0000000000000000000000000000000000000000" +} diff --git a/tests/SGU.AuthBroker.Core.Tests/AuthenticationWorkflowTests.cs b/tests/SGU.AuthBroker.Core.Tests/AuthenticationWorkflowTests.cs new file mode 100644 index 0000000..fe5c24b --- /dev/null +++ b/tests/SGU.AuthBroker.Core.Tests/AuthenticationWorkflowTests.cs @@ -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 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 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)); + } + } +} diff --git a/tests/SGU.AuthBroker.Core.Tests/SGU.AuthBroker.Core.Tests.csproj b/tests/SGU.AuthBroker.Core.Tests/SGU.AuthBroker.Core.Tests.csproj new file mode 100644 index 0000000..6804abd --- /dev/null +++ b/tests/SGU.AuthBroker.Core.Tests/SGU.AuthBroker.Core.Tests.csproj @@ -0,0 +1,17 @@ + + + net10.0 + false + true + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/tests/SGU.AuthBroker.Core.Tests/UserIdentityClassifierTests.cs b/tests/SGU.AuthBroker.Core.Tests/UserIdentityClassifierTests.cs new file mode 100644 index 0000000..8672168 --- /dev/null +++ b/tests/SGU.AuthBroker.Core.Tests/UserIdentityClassifierTests.cs @@ -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 _)); + } +} diff --git a/tests/SGU.CredentialProvider.SmokeProbe/Program.cs b/tests/SGU.CredentialProvider.SmokeProbe/Program.cs new file mode 100644 index 0000000..4c3729d --- /dev/null +++ b/tests/SGU.CredentialProvider.SmokeProbe/Program.cs @@ -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 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(descriptorPointer); + labels.Add(descriptor.Label ?? string.Empty); + } + finally + { + Marshal.DestroyStructure(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 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( + Marshal.ReadIntPtr(vtable, 14 * IntPtr.Size)); + GetSerializationDelegate getSerialization = Marshal.GetDelegateForFunctionPointer( + 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(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( + 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() + .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); +} diff --git a/tests/SGU.CredentialProvider.SmokeProbe/SGU.CredentialProvider.SmokeProbe.csproj b/tests/SGU.CredentialProvider.SmokeProbe/SGU.CredentialProvider.SmokeProbe.csproj new file mode 100644 index 0000000..d60214b --- /dev/null +++ b/tests/SGU.CredentialProvider.SmokeProbe/SGU.CredentialProvider.SmokeProbe.csproj @@ -0,0 +1,8 @@ + + + Exe + net10.0-windows + win-x64 + x64 + + diff --git a/tests/SGU.CredentialProvider.Tests/BrokerClientTests.cs b/tests/SGU.CredentialProvider.Tests/BrokerClientTests.cs new file mode 100644 index 0000000..37f5b4c --- /dev/null +++ b/tests/SGU.CredentialProvider.Tests/BrokerClientTests.cs @@ -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 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") + }; + } + } +} diff --git a/tests/SGU.CredentialProvider.Tests/SGU.CredentialProvider.Tests.csproj b/tests/SGU.CredentialProvider.Tests/SGU.CredentialProvider.Tests.csproj new file mode 100644 index 0000000..a3bb460 --- /dev/null +++ b/tests/SGU.CredentialProvider.Tests/SGU.CredentialProvider.Tests.csproj @@ -0,0 +1,18 @@ + + + net10.0-windows + win-x64 + false + true + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + +