#!/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()
