Skip to content

Instantly share code, notes, and snippets.

@mklement0
Last active April 18, 2024 01:33
Show Gist options
  • Star 9 You must be signed in to star a gist
  • Fork 6 You must be signed in to fork a gist
  • Save mklement0/83e8e6a2b39ecec7b0a14a8e631769ce to your computer and use it in GitHub Desktop.
Save mklement0/83e8e6a2b39ecec7b0a14a8e631769ce to your computer and use it in GitHub Desktop.
Test-WinCredential: PowerShell function for validating Windows domain / local user credentials.
<#
Prerequisites: Windows PowerShell 5.1, PowerShell (Core) (v6+) - MAY work in earlier versions
License: MIT
Author: Michael Klement <mklement0@gmail.com>
DOWNLOAD and INSTANT DEFINITION OF THE FUNCTION:
irm https://gist.github.com/mklement0/83e8e6a2b39ecec7b0a14a8e631769ce/raw/Test-WinCredential.ps1 | iex
The above directly defines the function below in your session and offers guidance for making it available in future
sessions too. To silence the guidance information, append 4>$null
CAVEAT: If you run this command *from a script*, you'll get a spurious warning about dot-sourcing, which you can ignore
and suppress by appending 3>$null. However, it's best to avoid calling this command from scripts, because later versions
of this Gist aren't guaranteed to be backward-compatible; howevever, you can modify the command to lock in a
*specific revision* of this Gist, which is guaranteed not to change: see the instructions at
https://gist.github.com/mklement0/880624fd665073bb439dfff5d71da886?permalink_comment_id=4296669#gistcomment-4296669
DOWNLOAD ONLY:
irm https://gist.github.com/mklement0/83e8e6a2b39ecec7b0a14a8e631769ce/raw | Set-Content -Encoding utf8 ./Test-WinCredential.ps1
The above downloads to the specified file, which you then need to dot-source to make the function available
in the current session (again, use 4>$null to silence the guidance information):
. ./Test-WinCredential.ps1
To learn what the function does:
* see the next comment block
* or, once downloaded and defined, invoke the function with -? or pass its name to Get-Help.
To define an ALIAS for the function, (also) add something like the following to your $PROFILE:
Set-Alias twc Test-WinCredential.ps1
#>
function Test-WinCredential {
<#
.SYNOPSIS
Validates Windows user credentials.
.DESCRIPTION
Validates a [pscredential] instance representing user-account credentials
against the current user's logon domain or local machine.
.PARAMETER Credential
The [pscredential] instance to validate, typically obtained with
Get-Credential.
The .UserName value may be:
* a mere username: e.g, "jdoe"
* prefixed with a NETBIOS domain name (NTLM format): e.g., "us\jdoe"
* in UPN format: e.g., "jdoe@us.example.org"
IMPORTANT:
* If the logon domain is the current machine, validation happens against
the local user database.
* IRRESPECTIVE OF THE DOMAIN NAME SPECIFIED, VALIDATION IS
ONLY EVER PERFORMED AGAINST THE CURRENT USER'S LOGON DOMAIN.
* If an NTLM-format username is specified, the NETBIOS domain prefix, if
specified, must match the NETBIOS logon domain as reflected in
$env:USERDOMAIN
* If a UPN-format username is specified, its domain suffix should match
$env:USERDNSDOMAIN, although if it doesn't, only a warning is issued
and an attempt to validate against the logon domain is still attempted,
so as to support UPNs whose domain suffix differs from the logon DNS
name. To avoid the warning, use the NTLM-format username with the
NETBIOS domain prefix, or omit the domain part altogether.
* If the credentials are valid in principle, but using them with the
target account is in effect not possible - such as due to the account
being disabled or having expired - a warning to that effect is issued
and $False is returned.
* The SecureString instance containing the decrypted password in the input
credentials is decrypted *in local memory*, though it is again encrypted
*in transit* when querying Active Directory.
.PARAMETER Local
Use this switch to validate perform validation against the local machine's
user database rather than against the current logon domain.
If you're not currently logged on to a domain, use of this switch is
optional.
Conversely, however, the only way to validate against a domain
is to be logged on to it.
.OUTPUTS
A Boolean indicating whether the credentials were successfully validated.
.NOTES
Gratefully adapted from https://gallery.technet.microsoft.com/scriptcenter/Test-Credential-dda902c6,
via https://stackoverflow.com/q/10802850/45375; WinAPI solution for local-account validation inspired
by https://stackoverflow.com/a/15644447/45375
.EXAMPLE
Test-WinCredential -Credential jdoe
True
Prompts for the password for user "jdoe" and validates it against the current
logon domain (which may be the local machine). 'True' ($True) as the output
indicates successful validation.
.EXAMPLE
Test-WinCredential us\jdoe
Prompts for the password for user "us\jdoe" and validates it against
the current logon domain, whose NETBIOS name (as reflected in $env:USERDOMAIN)
must match.
.EXAMPLE
Test-WinCredential jdoe@us.example.org
Prompts for the password for user "jdoe@us.example.org" and validates it against
the current logon domain, whose DNS name (as reflected in $env:USERDNSDOMAIN)
is expected to match; if not, a warning is issued, but validation is still
attempted.
.EXAMPLE
Test-WinCredential Administrator -Local
Prompts for the password of the machine-local administrator account and
validates it against the local user database.
#>
[CmdletBinding(PositionalBinding = $False)]
param(
[Parameter(Position = 0)]
[System.Management.Automation.CredentialAttribute()]
[pscredential] $Credential = (Get-Credential '')
,
[switch] $Local
)
$ErrorActionPreference = 'Stop'; Set-StrictMode -Version 1
if ($env:OS -ne 'Windows_NT') { Throw "This command runs on WINDOWS ONLY." }
# Note: Not necessary in PowerShell Core.
Add-Type -AssemblyName System.DirectoryServices.AccountManagement
$logonDomain = $env:USERDOMAIN # NETBIOS domain name or local machine name.
$username = $Credential.UserName
$isUpn = $Credential.UserName -match '@'
$specifiedDomain = '' # A domain contained $Credential.UserName, if any, extracted below.
# See if we're logged on to an actual domain or just to the local machine...
$loggedOnToDomain = $env:COMPUTERNAME -ne $env:USERDOMAIN
# ... and set the validation context accordingly.
$contextType = (
[System.DirectoryServices.AccountManagement.ContextType]::Machine, # !! LOCAL account - not actually used; see below.
[System.DirectoryServices.AccountManagement.ContextType]::Domain # AD DS
)[$loggedOnToDomain -and -not $Local]
# Extract the domain-name portion, if any, from the username.
# Recognizes formats NTLM (domain\username) and UPN (username@dns.domain)
if ($Credential.UserName -match '^[^\\]+(?=\\)|(?<=@).+$') {
$specifiedDomain = $Matches[0]
# Note: We must pass a mere username to .ValidateCredentials below.
# .GetNetworkCredential().UserName conveniently strips the NTLM-style
# domain prefix, but we must strip the UPN-style suffix manually.
$username = $Credential.GetNetworkCredential().UserName -replace '@.+$'
}
# If a domain name was specified, validate it.
if ($specifiedDomain) {
if ($Local -and $specifiedDomain -ne $env:COMPUTERNAME) { Throw "You've requested validation of machine-local credentials with -Local, so your username must not have a domain component that differs from the local machine name." }
elseif (-not $isUpn -and $specifiedDomain -ne $logonDomain) { Throw "Specified NETBIOS domain prefix ($specifiedDomain) does not match the logon domain ($logonDomain)." }
elseif ($isUpn -and -not $loggedOnToDomain) { Throw "You've specified a UPN, but you're not logged on to a domain: $($Credential.UserName)" }
elseif ($isUpn -and $specifiedDomain -ne $env:USERDNSDOMAIN) {
Write-Warning @"
You've specified a UPN, but its domain-name part ($specifiedDomain) does not match the logon DNS domain name ($env:USERDNSDOMAIN).
Proceeding on the assumption that the UPN still refers to an account in the logon domain.
To avoid this warning, use the NTLM username form ($env:USERDOMAIN\$username),
or omit the domain part altogether ($username).
"@
}
}
Write-Verbose ("Validating: " + (@{
username = $username
domain = $logonDomain
contextType = $contextType
} | Out-String))
if ($Local -or -not $loggedOnToDomain) { # LOCAL account
# !! System.DirectoryServices.AccountManagement.PrincipalContext with non-domain-joined machines with context 'Machine' doesn't work
# !! reliably - can result in the following exceptoin:
# !! Multiple connections to a server or shared resource by the same user, using more than one user name, are not allowed. Disconnect all previous
# !! connections to the server or shared resource and try again.
# Therefore, we use the WinAPI's LogonUser function instead, which requires compiling a helper type on demand.
(Add-Type -PassThru -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
namespace net.same2u.util {
public class WinCredentialHelper {
[DllImport("advapi32.dll", SetLastError=true)]
private static extern bool LogonUser(string lpszUsername, string lpszDomain, string lpszPassword, int dwLogonType, int dwLogonProvider, out IntPtr phToken);
[DllImport("kernel32.dll", SetLastError=true)]
private static extern bool CloseHandle(IntPtr hObject);
// Validates the specified credentials (username and password) agains the account database of the specified domain,
// which defaults to the current domain or, for non-domain-joined machines, the local machine.
// Note: Strictly speaking, the ability to log on locally, at the time of the call is tested;
// especially in domain environments, incidental restrictions such as time of day or what workstations a user may log on to
// could prevent logon, even if the credentials are valid in principle.
// See the error codes at https://docs.microsoft.com/en-us/windows/win32/debug/system-error-codes--1300-1699-, starting with 1326 (ERROR_LOGON_FAILURE).
public static bool Validate(string username, string password, string domain = "") {
IntPtr hToken = IntPtr.Zero;
if (domain == String.Empty) domain = System.Environment.GetEnvironmentVariable("USERDOMAIN");
// Note: * On a machine not connected to a domain, the domain parameter is seemingly IGNORED.
// * In a domain environment:
// * to explicitly target only the local user-account database, pass "." for `domain`.
// * if you pass null for `domain`, `username` must be in UPN format (user@domain.com)
bool ok = LogonUser(username, domain, password, 3 /*LOGON32_LOGON_NETWORK*/, 0 /*LOGON32_PROVIDER_DEFAULT*/, out hToken);
// int lastErr = Marshal.GetLastWin32Error(); Console.WriteLine(lastErr);
if (hToken != IntPtr.Zero) CloseHandle(hToken);
return ok;
}
}
}
'@)::Validate($username, $Credential.GetNetworkCredential().password)
}
else { # DOMAIN == AD DS account.
# Note: While .GetNetworkCredential().password retrieves the *plain-text* password *locally*, the *connection
# to AD* (in the case of [System.DirectoryServices.AccountManagement.ContextType]::Domain) is
# encrypted, because "When the context options are not specified by the application,
# the Account Management API uses the following combination of options:
# ContextOptions.Negotiate | ContextOptions.Signing | ContextOptions.Sealing
# " - https://docs.microsoft.com/en-us/dotnet/api/system.directoryservices.accountmanagement.contextoptions
$principalContext = New-Object System.DirectoryServices.AccountManagement.PrincipalContext $contextType, $logonDomain
try {
$principalContext.ValidateCredentials($username, $Credential.GetNetworkCredential().password)
}
catch {
# An exception occurring in .ValidateCredentials() often suggests that the credentials were valid in principle,
# but there's a problem with the *account*, such as it being disabled or
# the password having expired; we return $False in that case, but issue a *warning*
# with the cause of the problem.
# !! However, it can also indicate the inability to connect to the server.
# Note: The underlying exception message is wrapped as follows, so we must extract it:
# Exception calling "ValidateCredentials" with "2" argument(s): "<msg>"<newline>
Write-Warning ($_.exception.message -replace '\r?\n' -split '"' -ne '')[-1]
$False # Output $False, given that *in effect* the credentials do not work.
}
finally {
$principalContext.Dispose()
}
}
}
# --------------------------------
# GENERIC INSTALLATION HELPER CODE
# --------------------------------
# Provides guidance for making the function persistently available when
# this script is either directly invoked from the originating Gist or
# dot-sourced after download.
# IMPORTANT:
# * DO NOT USE `exit` in the code below, because it would exit
# the calling shell when Invoke-Expression is used to directly
# execute this script's content from GitHub.
# * Because the typical invocation is DOT-SOURCED (via Invoke-Expression),
# do not define variables or alter the session state via Set-StrictMode, ...
# *except in child scopes*, via & { ... }
if ($MyInvocation.Line -eq '') {
# Most likely, this code is being executed via Invoke-Expression directly
# from gist.github.com
# To simulate for testing with a local script, use the following:
# Note: Be sure to use a path and to use "/" as the separator.
# iex (Get-Content -Raw ./script.ps1)
# Derive the function name from the invocation command, via the enclosing
# script name presumed to be contained in the URL.
# NOTE: Unfortunately, when invoked via Invoke-Expression, $MyInvocation.MyCommand.ScriptBlock
# with the actual script content is NOT available, so we cannot extract
# the function name this way.
& {
param($invocationCmdLine)
# Try to extract the function name from the URL.
$funcName = $invocationCmdLine -replace '^.+/(.+?)(?:\.ps1).*$', '$1'
if ($funcName -eq $invocationCmdLine) {
# Function name could not be extracted, just provide a generic message.
# Note: Hypothetically, we could try to extract the Gist ID from the URL
# and use the REST API to determine the first filename.
Write-Verbose -Verbose "Function is now defined in this session."
}
else {
# Indicate that the function is now defined and also show how to
# add it to the $PROFILE or convert it to a script file.
Write-Verbose -Verbose @"
Function `"$funcName`" is now defined in this session.
* If you want to add this function to your `$PROFILE, run the following:
"``nfunction $funcName {``n`${function:$funcName}``n}" | Add-Content `$PROFILE
* If you want to convert this function to a script file that you can invoke
directly, run:
"`${function:$funcName}" | Set-Content ./$funcName.ps1 -Encoding $('utf8' + ('', 'bom')[[bool] (Get-Variable -ErrorAction Ignore IsCoreCLR -ValueOnly)])
"@
}
} $MyInvocation.MyCommand.Definition # Pass the original invocation command line to the script block.
}
else {
# Invocation presumably as a local file after manual download,
# either dot-sourced (as it should be) or mistakenly directly.
& {
param($originalInvocation)
# Parse this file to reliably extract the name of the embedded function,
# irrespective of the name of the script file.
$ast = $originalInvocation.MyCommand.ScriptBlock.Ast
$funcName = $ast.Find( { $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $false).Name
if ($originalInvocation.InvocationName -eq '.') {
# Being dot-sourced as a file.
# Provide a hint that the function is now loaded and provide
# guidance for how to add it to the $PROFILE.
Write-Verbose -Verbose @"
Function `"$funcName`" is now defined in this session.
If you want to add this function to your `$PROFILE, run the following:
"``nfunction $funcName {``n`${function:$funcName}``n}" | Add-Content `$PROFILE
"@
}
else {
# Mistakenly directly invoked.
# Issue a warning that the function definition didn't take effect and
# provide guidance for reinvocation and adding to the $PROFILE.
Write-Warning @"
This script contains a definition for function "$funcName", but this definition
only takes effect if you dot-source this script.
To define this function for the current session, run:
. "$($originalInvocation.MyCommand.Path)"
"@
}
} $MyInvocation # Pass the original invocation info to the helper script block.
}
@diogocatossi
Copy link

Excellent code, well structured and documented. Specially the local account which is exaclty what I needed to work the PrincipalContext "The network path was not found" issue around. Kudos!

@mklement0
Copy link
Author

I'm glad to hear you found the code useful, @diogocatossi, and I appreciate the nice feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment