Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions changelog/41347.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Added ``shadow.verify_password`` to ``salt.modules.win_shadow``, which
validates a Windows user's password via ``LogonUser`` with
``LOGON32_LOGON_NETWORK`` (Microsoft's recommended approach per
`KB180548 <https://support.microsoft.com/en-us/help/180548>`_) without
creating an interactive session. If the check causes an account lockout,
the account is automatically unlocked. Updated ``user.present`` on Windows
to use ``shadow.verify_password`` so the password is only changed when it
differs from the current value, matching the idempotent behaviour on other
platforms.
146 changes: 143 additions & 3 deletions salt/modules/win_shadow.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,29 @@
minion, and it is using a different module (or gives an error similar to
*'shadow.info' is not available*), see :ref:`here
<module-provider-override>`.

:depends:
- pywintypes
- win32security
- winerror
"""

import logging

import salt.utils.platform
import salt.utils.win_runas
from salt.exceptions import CommandExecutionError

log = logging.getLogger(__name__)

try:
import pywintypes
import win32security
import winerror

HAS_WIN32 = True
except ImportError:
HAS_WIN32 = False

# Define the module's virtual name
__virtualname__ = "shadow"
Expand All @@ -18,9 +38,11 @@ def __virtual__():
"""
Only works on Windows systems
"""
if salt.utils.platform.is_windows():
return __virtualname__
return (False, "Module win_shadow: module only works on Windows systems.")
if not salt.utils.platform.is_windows():
return (False, "Module win_shadow: module only works on Windows systems.")
if not HAS_WIN32:
return (False, "Module win_shadow: Missing Win32 modules")
return __virtualname__


def info(name):
Expand Down Expand Up @@ -150,3 +172,121 @@ def set_password(name, password):
salt '*' shadow.set_password root mysecretpassword
"""
return __salt__["user.update"](name=name, password=password)


def verify_password(name, password):
"""
Verify the password for a Windows user account by attempting a network
logon. This uses ``LOGON32_LOGON_NETWORK`` which does not create an
interactive session and typically does not generate audit log events.

.. note::
This is Microsoft's documented recommended method for validating
credentials on Windows. There is no equivalent of ``/etc/shadow`` on
Windows — the NT hash stored in the SAM database is inaccessible even
to SYSTEM at runtime. ``LogonUser`` with ``LOGON32_LOGON_NETWORK`` is
the only supported approach.

See `How to validate user credentials on Microsoft operating systems
<https://support.microsoft.com/en-us/help/180548/how-to-validate-user-credentials-on-microsoft-operating-systems>`_

.. warning::
A wrong password will increment the account's bad-logon counter. If
the counter reaches the lockout threshold, the account will be locked.
This function detects that situation and automatically unlocks the
account if the lockout was caused by this call (i.e. the account was
not already locked beforehand). If the account was already locked,
a ``CommandExecutionError`` is raised because the password cannot be
verified in that state.

If the logon attempt causes the account to become locked (i.e. the bad
password pushed the counter over the threshold), the account is
automatically unlocked — but only if it was not already locked before
this call.

Args:

name (str): The username to verify. Accepts plain names (local
accounts), UPN format (``user@domain``), or down-level format
(``DOMAIN\\user``).

password (str): The password to verify.

Returns:
bool: ``True`` if the password is correct (or correct but the account
has some other restriction such as being disabled or expired).
``False`` if the password is wrong.

Raises:
CommandExecutionError: If the account is locked (cannot verify) or an
unexpected error occurs.

CLI Example:

.. code-block:: bash

salt '*' shadow.verify_password <username> <password>
"""
user_name, domain = salt.utils.win_runas.split_username(name)

pre_info = __salt__["user.info"](name)
pre_locked = pre_info.get("account_locked", False) if pre_info else False

try:
handle = win32security.LogonUser(
user_name,
domain,
password,
win32security.LOGON32_LOGON_NETWORK,
win32security.LOGON32_PROVIDER_DEFAULT,
)
except pywintypes.error as exc:
if exc.winerror in (
winerror.ERROR_LOGON_FAILURE,
winerror.ERROR_WRONG_PASSWORD,
):
# Wrong password. If our attempt pushed the account into lockout,
# undo it — but only if the account was not already locked.
if not pre_locked:
post_info = __salt__["user.info"](name)
if post_info and post_info.get("account_locked", False):
log.debug(
"shadow.verify_password: password check locked account %s, "
"unlocking",
name,
)
__salt__["user.update"](name, unlock_account=True)
log.debug("shadow.verify_password: password is not valid: %s", exc.strerror)
return False

# These errors occur after a successful credential check — the password
# is correct but some other account restriction prevents logon.
if exc.winerror in (
winerror.ERROR_ACCOUNT_DISABLED,
winerror.ERROR_ACCOUNT_EXPIRED,
winerror.ERROR_PASSWORD_EXPIRED,
winerror.ERROR_PASSWORD_MUST_CHANGE,
winerror.ERROR_ACCOUNT_RESTRICTION,
winerror.ERROR_INVALID_LOGON_HOURS,
winerror.ERROR_INVALID_WORKSTATION,
winerror.ERROR_LOGON_NOT_GRANTED,
winerror.ERROR_LOGON_TYPE_NOT_GRANTED,
):
log.debug(
"shadow.verify_password: password is valid (restricted: %s)",
exc.strerror,
)
return True

if exc.winerror == winerror.ERROR_ACCOUNT_LOCKED_OUT:
msg = f"shadow.verify_password: account '{name}' is locked, cannot verify password"
log.debug(msg)
raise CommandExecutionError(msg)

msg = f"shadow.verify_password: unexpected error {exc.winerror}: {exc.strerror}"
log.debug(msg)
raise CommandExecutionError(msg)
else:
handle.Close()
log.debug("shadow.verify_password: password is valid")
return True
7 changes: 7 additions & 0 deletions salt/states/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,10 @@ def _changes(
):
change["password_lock"] = password_lock
elif "shadow.info" in __salt__ and salt.utils.platform.is_windows():
if password and not empty_password and enforce_password:
if "shadow.verify_password" in __salt__:
if not __salt__["shadow.verify_password"](name, password):
change["passwd"] = password
if (
expire
and expire != -1
Expand Down Expand Up @@ -709,6 +713,7 @@ def present(

# Make changes

_passwd_changed = "passwd" in changes and not empty_password
if "passwd" in changes:
del changes["passwd"]
if not empty_password:
Expand Down Expand Up @@ -826,6 +831,8 @@ def _change_homedir(name, val):
ret["changes"][key] = "XXX-REDACTED-XXX"
else:
ret["changes"][key] = spost[key]
if salt.utils.platform.is_windows() and _passwd_changed:
ret["changes"]["passwd"] = "XXX-REDACTED-XXX"
if __grains__["kernel"] in ("OpenBSD", "FreeBSD") and lcpost != lcpre:
ret["changes"]["loginclass"] = lcpost
if ret["changes"]:
Expand Down
71 changes: 71 additions & 0 deletions tests/pytests/functional/modules/test_win_shadow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import pytest
from saltfactories.utils import random_string

pytestmark = [
pytest.mark.destructive_test,
pytest.mark.skip_unless_on_windows,
pytest.mark.windows_whitelisted,
]


@pytest.fixture(scope="module")
def shadow(modules):
return modules.shadow


@pytest.fixture(scope="module")
def lgpo(modules):
return modules.lgpo


@pytest.fixture(scope="module")
def user(modules):
return modules.user


@pytest.fixture
def account(modules):
_username = random_string("test-shadow-", uppercase=False)
with pytest.helpers.create_account(username=_username) as acct:
yield acct


@pytest.fixture
def lockout_threshold_one(lgpo):
"""
Temporarily set the account lockout threshold to 1 so that a single bad
password attempt locks the account. Restores the original value on teardown.
"""
original = lgpo.get_policy("LockoutThreshold", "machine")
lgpo.set_computer_policy("LockoutThreshold", 1)
try:
yield
finally:
lgpo.set_computer_policy("LockoutThreshold", original)


def test_verify_password_correct(shadow, account):
"""
verify_password returns True when the correct password is supplied.
"""
assert shadow.verify_password(account.username, account.password) is True


def test_verify_password_wrong(shadow, account):
"""
verify_password returns False when the wrong password is supplied.
"""
assert shadow.verify_password(account.username, "definitely-wrong-pw!") is False


def test_verify_password_unlocks_account_on_lockout(
shadow, user, account, lockout_threshold_one
):
"""
When a wrong password locks the account, verify_password should
automatically unlock it and still return False.
"""
result = shadow.verify_password(account.username, "definitely-wrong-pw!")
assert result is False
info = user.info(account.username)
assert info["account_locked"] is False
50 changes: 50 additions & 0 deletions tests/pytests/functional/states/test_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -552,3 +552,53 @@ def test_user_present_no_groups(modules, states, username, user_present_groups,
user_info = modules.user.info(username)
assert user_info
assert user_info["groups"] == [username, *user_present_groups]


# ---------------------------------------------------------------------------
# Windows password tests
# ---------------------------------------------------------------------------


@pytest.fixture
def account_with_password(states, username):
"""Create a test account via user.present with a known password."""
ret = states.user.present(name=username, password="P@ssW0rd!")
assert ret.result is True
yield username


@pytest.mark.skip_unless_on_windows
def test_win_user_present_same_password(states, account_with_password):
"""
Running user.present with the same password a second time should be a
no-op: result True, no changes, comment contains 'up to date'.
"""
ret = states.user.present(name=account_with_password, password="P@ssW0rd!")
assert ret.result is True
assert ret.changes == {}
assert "up to date" in ret.comment


@pytest.mark.skip_unless_on_windows
def test_win_user_present_new_password_test_mode(states, account_with_password):
"""
Running user.present with a different password and test=True should show
a pending passwd change in the comment without applying it.
"""
ret = states.user.present(
name=account_with_password, password="N3wP@ssW0rd!", test=True
)
assert ret.result is None
assert ret.changes == {}
assert "passwd: XXX-REDACTED-XXX" in ret.comment


@pytest.mark.skip_unless_on_windows
def test_win_user_present_new_password(states, account_with_password):
"""
Running user.present with a different password should change it and
report the change as passwd: XXX-REDACTED-XXX.
"""
ret = states.user.present(name=account_with_password, password="N3wP@ssW0rd!")
assert ret.result is True
assert ret.changes == {"passwd": "XXX-REDACTED-XXX"}
Loading
Loading