1
0
Fork 0
mirror of https://github.com/ansible-collections/community.general.git synced 2026-06-11 10:35:34 +00:00
community.general/plugins/modules/htpasswd.py
patchback[bot] 544b3ce678
[PR #12123/49ca175f backport][stable-13] htpasswd: fix hash_scheme aliases and Apache-compatible bcrypt (#12156)
htpasswd: fix `hash_scheme` aliases and Apache-compatible bcrypt (#12123)

* fix(htpasswd): support HtpasswdFile aliases and Apache-compatible bcrypt

CryptContext does not recognise HtpasswdFile alias names such as
portable, portable_apache_24, host_apache_24, causing a KeyError.
In addition, building a CryptContext for bcrypt produced $2b$ hashes
that Apache rejects (it only accepts $2y$/$2a$).

Use htpasswd_context for schemes it already supports, fall back to
htpasswd_context on KeyError for aliases, and import CryptContext
from module_utils/_crypt.py instead of passlib directly.

Fixes #6135



* feat(changelog): add fragment for PR 12123



* fix(_crypt): silence DeprecationWarning when importing stdlib crypt

On Python 3.11/3.12, `import crypt` emits a DeprecationWarning that
ansible-test sanity --test import treats as an error. Suppress it since
the import is an intentional fallback when passlib is not available.



* fix(htpasswd): fix sanity ignores and bcrypt version constraint

- Revert _crypt.py DeprecationWarning suppression; add sanity ignore
  entries for htpasswd.py import-3.11/3.12 instead (mirrors existing
  entries for _crypt.py itself)
- Pin bcrypt<4.2 in integration tests: bcrypt 4.2 removed __about__
  which passlib 1.7.x uses, breaking passlib.apache import
- Fix regex_search assertion to use 'is not none' for a boolean result
- Add bcrypt version constraint note to module documentation



* fix(htpasswd): handle system-installed bcrypt in integration tests

On Debian/Ubuntu, bcrypt may be installed by the system package manager
with no RECORD file, making pip downgrade impossible. Move bcrypt
installation into a self-contained block in test_schemes.yml with
ignore_errors, a functional passlib+bcrypt check, and always-cleanup.
Bcrypt tests are skipped when a compatible version cannot be used.



* refactor(htpasswd): extract obtain_crypt_context(); import CryptContext from passlib directly

Extract context selection logic into obtain_crypt_context(). Import
CryptContext inside the deps.declare("passlib") block instead of from
module_utils/_crypt.py — passlib is already a hard dependency and
other symbols are imported from it there. Remove now-unnecessary
htpasswd.py sanity import ignore entries.



---------


(cherry picked from commit 49ca175f01)

Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 16:58:57 +02:00

259 lines
9 KiB
Python

#!/usr/bin/python
# Copyright (c) 2013, Nimbis Services, Inc.
# 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
from __future__ import annotations
DOCUMENTATION = r"""
module: htpasswd
short_description: Manage user files for basic authentication
description:
- Add and remove username/password entries in a password file using htpasswd.
- This is used by web servers such as Apache and Nginx for basic authentication.
attributes:
check_mode:
support: full
diff_mode:
support: none
options:
path:
type: path
required: true
aliases: [dest, destfile]
description:
- Path to the file that contains the usernames and passwords.
name:
type: str
required: true
aliases: [username]
description:
- User name to add or remove.
password:
type: str
description:
- Password associated with user.
- Must be specified if user does not exist yet.
hash_scheme:
type: str
default: "apr_md5_crypt"
description:
- Hashing scheme to be used. As well as the four choices listed here, you can also use any other hash supported by passlib,
such as V(portable_apache22) and V(host_apache24); or V(md5_crypt) and V(sha256_crypt), which are Linux passwd hashes.
Only some schemes in addition to the four choices below are compatible with Apache or Nginx, and supported schemes
depend on C(passlib) version and its dependencies.
- See U(https://passlib.readthedocs.io/en/stable/lib/passlib.apache.html#passlib.apache.HtpasswdFile) parameter C(default_scheme).
- 'Some of the available choices might be: V(apr_md5_crypt), V(des_crypt), V(ldap_sha1), V(plaintext).'
- 'B(WARNING): The module has no mechanism to determine the O(hash_scheme) of an existing entry, therefore, it does
not detect whether the O(hash_scheme) has changed. If you want to change the scheme, you must remove the existing
entry and then create a new one using the new scheme.'
aliases: [crypt_scheme]
state:
type: str
choices: [present, absent]
default: "present"
description:
- Whether the user entry should be present or not.
create:
type: bool
default: true
description:
- Used with O(state=present). If V(true), the file is created if it does not exist. Conversely, if set to V(false) and
the file does not exist, it fails.
notes:
- This module depends on the C(passlib) Python library, which needs to be installed on all target systems.
- '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:
- ansible.builtin.files
- community.general._attributes
"""
EXAMPLES = r"""
- name: Add a user to a password file and ensure permissions are set
community.general.htpasswd:
path: /etc/nginx/passwdfile
name: janedoe
password: '9s36?;fyNp'
owner: root
group: www-data
mode: '0640'
- name: Remove a user from a password file
community.general.htpasswd:
path: /etc/apache2/passwdfile
name: foobar
state: absent
- name: Add a user to a password file suitable for use by libpam-pwdfile
community.general.htpasswd:
path: /etc/mail/passwords
name: alex
password: oedu2eGh
hash_scheme: md5_crypt
"""
import os
import tempfile
from ansible.module_utils.basic import AnsibleModule
from ansible_collections.community.general.plugins.module_utils import _deps as deps
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 # type: ignore[assignment]
apache_hashes = ["apr_md5_crypt", "des_crypt", "ldap_sha1", "plaintext"]
def create_missing_directories(dest):
destpath = os.path.dirname(dest)
if not os.path.exists(destpath):
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)"""
context = obtain_crypt_context(hash_scheme)
if not os.path.exists(dest):
if not create:
raise ValueError(f"Destination {dest} does not exist")
if check_mode:
return (f"Create {dest}", True)
create_missing_directories(dest)
ht = HtpasswdFile(dest, new=True, default_scheme=hash_scheme, context=context)
ht.set_password(username, password)
ht.save()
return (f"Created {dest} and added {username}", True)
else:
ht = HtpasswdFile(dest, new=False, default_scheme=hash_scheme, context=context)
found = ht.check_password(username, password)
if found:
return (f"{username} already present", False)
else:
if not check_mode:
ht.set_password(username, password)
ht.save()
return (f"Add/update {username}", True)
def absent(dest, username, check_mode):
"""Ensures user is absent
Returns (msg, changed)"""
ht = HtpasswdFile(dest, new=False)
if username not in ht.users():
return (f"{username} not present", False)
else:
if not check_mode:
ht.delete(username)
ht.save()
return (f"Remove {username}", True)
def check_file_attrs(module, changed, message):
file_args = module.load_file_common_arguments(module.params)
if module.set_fs_attributes_if_different(file_args, False):
if changed:
message += " and "
changed = True
message += "ownership, perms or SE linux context changed"
return message, changed
def main():
arg_spec = dict(
path=dict(type="path", required=True, aliases=["dest", "destfile"]),
name=dict(type="str", required=True, aliases=["username"]),
password=dict(type="str", no_log=True),
hash_scheme=dict(type="str", default="apr_md5_crypt", aliases=["crypt_scheme"]),
state=dict(type="str", default="present", choices=["present", "absent"]),
create=dict(type="bool", default=True),
)
module = AnsibleModule(argument_spec=arg_spec, add_file_common_args=True, supports_check_mode=True)
path = module.params["path"]
username = module.params["name"]
password = module.params["password"]
hash_scheme = module.params["hash_scheme"]
state = module.params["state"]
create = module.params["create"]
check_mode = module.check_mode
deps.validate(module)
# TODO double check if this hack below is still needed.
# Check file for blank lines in effort to avoid "need more than 1 value to unpack" error.
try:
with open(path) as f:
lines = f.readlines()
# If the file gets edited, it returns true, so only edit the file if it has blank lines
strip = False
for line in lines:
if not line.strip():
strip = True
break
if strip:
# If check mode, create a temporary file
if check_mode:
temp = tempfile.NamedTemporaryFile()
path = temp.name
with open(path, "w") as f:
f.writelines(line for line in lines if line.strip())
except OSError:
# No preexisting file to remove blank lines from
pass
try:
if state == "present":
(msg, changed) = present(path, username, password, hash_scheme, create, check_mode)
elif state == "absent":
if not os.path.exists(path):
module.warn(f"{path} does not exist")
module.exit_json(msg=f"{username} not present", changed=False)
(msg, changed) = absent(path, username, check_mode)
else:
module.fail_json(msg=f"Invalid state: {state}")
return # needed to make pylint happy
(msg, changed) = check_file_attrs(module, changed, msg)
module.exit_json(msg=msg, changed=changed)
except Exception as e:
module.fail_json(msg=f"{e}")
if __name__ == "__main__":
main()