diff --git a/changelogs/fragments/12123-htpasswd-crypt-schemes.yml b/changelogs/fragments/12123-htpasswd-crypt-schemes.yml new file mode 100644 index 0000000000..cf90944377 --- /dev/null +++ b/changelogs/fragments/12123-htpasswd-crypt-schemes.yml @@ -0,0 +1,4 @@ +bugfixes: + - "htpasswd - fix ``hash_scheme`` aliases and Apache-compatible ``bcrypt`` hashes + (https://github.com/ansible-collections/community.general/issues/6135, + https://github.com/ansible-collections/community.general/pull/12123)." diff --git a/plugins/modules/htpasswd.py b/plugins/modules/htpasswd.py index 6d41e64083..4e43c05ab0 100644 --- a/plugins/modules/htpasswd.py +++ b/plugins/modules/htpasswd.py @@ -66,6 +66,8 @@ notes: - 'On Debian < 11, Ubuntu <= 20.04, or Fedora: install C(python-passlib).' - 'On Debian, Ubuntu: install C(python3-passlib).' - 'On RHEL or CentOS: Enable EPEL, then install C(python-passlib).' + - To use V(bcrypt) as O(hash_scheme), the C(bcrypt) Python library must also be installed. + Due to incompatibilities in C(passlib) 1.7.x, use C(bcrypt<4.2). requirements: [passlib>=1.6] author: "Ansible Core Team" extends_documentation_fragment: @@ -109,7 +111,7 @@ with deps.declare("passlib"): # Apparently the type infos don't know htpasswd_context, which *does* exist # (but isn't mentioned in the documentation for some reason) from passlib.apache import HtpasswdFile, htpasswd_context # type: ignore[attr-defined] - from passlib.context import CryptContext + from passlib.context import CryptContext # type: ignore[assignment] apache_hashes = ["apr_md5_crypt", "des_crypt", "ldap_sha1", "plaintext"] @@ -121,14 +123,24 @@ def create_missing_directories(dest): os.makedirs(destpath) +def obtain_crypt_context(hash_scheme): + if hash_scheme in apache_hashes or hash_scheme in htpasswd_context.schemes(): + # Use htpasswd_context for all officially-supported schemes, including bcrypt + # (htpasswd_context produces Apache-compatible $2y$ bcrypt, not $2b$) + return htpasswd_context + try: + return CryptContext(schemes=[hash_scheme] + apache_hashes) + except KeyError: + # hash_scheme is a HtpasswdFile alias (e.g. portable, portable_apache_24) + # that is not a valid passlib scheme name; let HtpasswdFile resolve it natively + return htpasswd_context + + def present(dest, username, password, hash_scheme, create, check_mode): """Ensures user is present Returns (msg, changed)""" - if hash_scheme in apache_hashes: - context = htpasswd_context - else: - context = CryptContext(schemes=[hash_scheme] + apache_hashes) + context = obtain_crypt_context(hash_scheme) if not os.path.exists(dest): if not create: raise ValueError(f"Destination {dest} does not exist") diff --git a/tests/integration/targets/htpasswd/tasks/main.yml b/tests/integration/targets/htpasswd/tasks/main.yml index 2e3e568e00..17fa34ffba 100644 --- a/tests/integration/targets/htpasswd/tasks/main.yml +++ b/tests/integration/targets/htpasswd/tasks/main.yml @@ -122,3 +122,6 @@ - del_bob_check is changed - del_bob is changed - del_bob_idempot is not changed + + - name: test non-standard hash schemes (issue 6135) + ansible.builtin.include_tasks: test_schemes.yml diff --git a/tests/integration/targets/htpasswd/tasks/test_schemes.yml b/tests/integration/targets/htpasswd/tasks/test_schemes.yml new file mode 100644 index 0000000000..624acce134 --- /dev/null +++ b/tests/integration/targets/htpasswd/tasks/test_schemes.yml @@ -0,0 +1,116 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# Tests for non-standard hash_scheme values (issue #6135): +# - HtpasswdFile aliases (portable_apache_24, portable, host_apache_24) +# - bcrypt producing Apache-compatible $2y$ prefix instead of $2b$ + +- name: add user with portable_apache_24 alias + community.general.htpasswd: + path: "{{ htpasswd_schemes_path }}" + name: user_portable_24 + password: testpass + hash_scheme: portable_apache_24 + register: scheme_portable24 + +- name: add user with portable_apache_24 alias (idempotency) + community.general.htpasswd: + path: "{{ htpasswd_schemes_path }}" + name: user_portable_24 + password: testpass + hash_scheme: portable_apache_24 + register: scheme_portable24_idempot + +- name: assert portable_apache_24 alias + ansible.builtin.assert: + that: + - scheme_portable24 is changed + - scheme_portable24_idempot is not changed + +- name: add user with portable alias + community.general.htpasswd: + path: "{{ htpasswd_schemes_path }}" + name: user_portable + password: testpass + hash_scheme: portable + register: scheme_portable + +- name: add user with portable alias (idempotency) + community.general.htpasswd: + path: "{{ htpasswd_schemes_path }}" + name: user_portable + password: testpass + hash_scheme: portable + register: scheme_portable_idempot + +- name: assert portable alias + ansible.builtin.assert: + that: + - scheme_portable is changed + - scheme_portable_idempot is not changed + +- name: bcrypt scheme tests (requires bcrypt<4.2 to be compatible with passlib 1.7.x) + block: + - name: install compatible bcrypt + ansible.builtin.pip: + name: bcrypt<4.2 + register: bcrypt_pip_install + ignore_errors: true + + - name: check if passlib can use bcrypt + ansible.builtin.command: + cmd: >- + python3 -c " + from passlib.apache import HtpasswdFile; + import tempfile, os; + f = tempfile.mktemp(); + ht = HtpasswdFile(f, new=True, default_scheme='bcrypt'); + ht.set_password('t', 'p'); + ht.save(); + os.unlink(f)" + register: bcrypt_usable + ignore_errors: true + changed_when: false + + - name: run bcrypt tests + when: bcrypt_usable is not failed + block: + - name: add user with bcrypt scheme + community.general.htpasswd: + path: "{{ htpasswd_schemes_path }}" + name: user_bcrypt + password: testpass + hash_scheme: bcrypt + register: scheme_bcrypt + + - name: add user with bcrypt scheme (idempotency) + community.general.htpasswd: + path: "{{ htpasswd_schemes_path }}" + name: user_bcrypt + password: testpass + hash_scheme: bcrypt + register: scheme_bcrypt_idempot + + - name: read htpasswd file to verify bcrypt hash prefix + ansible.builtin.slurp: + src: "{{ htpasswd_schemes_path }}" + register: htpasswd_contents + + - name: assert bcrypt scheme + ansible.builtin.assert: + that: + - scheme_bcrypt is changed + - scheme_bcrypt_idempot is not changed + # Apache requires $2y$ prefix; passlib's CryptContext produces $2b$ — verify the fix + - >- + htpasswd_contents.content | b64decode | regex_search('^user_bcrypt:\$2y\$', multiline=True) is not none + + always: + - name: remove bcrypt if installed by pip + ansible.builtin.pip: + name: bcrypt + state: absent + when: bcrypt_pip_install is not failed + ignore_errors: true diff --git a/tests/integration/targets/htpasswd/vars/main.yml b/tests/integration/targets/htpasswd/vars/main.yml index ff81959c4f..2079b278a8 100644 --- a/tests/integration/targets/htpasswd/vars/main.yml +++ b/tests/integration/targets/htpasswd/vars/main.yml @@ -4,3 +4,4 @@ # SPDX-License-Identifier: GPL-3.0-or-later htpasswd_path: "{{ remote_tmp_dir }}/dot_htpasswd" +htpasswd_schemes_path: "{{ remote_tmp_dir }}/dot_htpasswd_schemes"