From 58725364c14e6ba261efc2ab17010645af3f53c0 Mon Sep 17 00:00:00 2001
From: Rafael Guterres Jeffman <rjeffman@redhat.com>
Date: Fri, 4 Nov 2022 22:36:43 -0300
Subject: [PATCH] pwpolicy: Add support for password check and grace limit.

On recent versions of FreeIPA option to verify passwords and for
controlling a password grace period have been added to IPA API.

This patch adds support for the parameters maxrepeat, maxsequence,
dictcheck and usercheck, available since FreeIPA, 4.9 and gracelimit,
available since FreeIPA 4.9.10.

Test playbooks for the module have been updated with the new supported
parameters.

New example playbooks can be found at:

    playbooks/pwpolicy/pwpolicy_grace_limit.yml
    playbooks/pwpolicy/pwpolicy_password_check.yml
---
 README-pwpolicy.md                            |  35 +++++
 playbooks/pwpolicy/pwpolicy_grace_limit.yml   |  11 ++
 .../pwpolicy/pwpolicy_password_check.yml      |  14 ++
 plugins/modules/ipapwpolicy.py                | 108 ++++++++++++-
 tests/pwpolicy/test_pwpolicy.yml              | 145 ++++++++++++++++++
 5 files changed, 310 insertions(+), 3 deletions(-)
 create mode 100644 playbooks/pwpolicy/pwpolicy_grace_limit.yml
 create mode 100644 playbooks/pwpolicy/pwpolicy_password_check.yml

diff --git a/README-pwpolicy.md b/README-pwpolicy.md
index 7e1eb898..7fd05164 100644
--- a/README-pwpolicy.md
+++ b/README-pwpolicy.md
@@ -87,6 +87,36 @@ Example playbook to ensure maxlife is set to 49 in global policy:
       maxlife: 49
 ```
 
+Example playbook to ensure password grace period is set to 3 in global policy:
+
+```yaml
+---
+- name: Playbook to handle pwpolicies
+  hosts: ipaserver
+  become: true
+
+  tasks:
+  # Ensure maxlife is set to 49 in global policy
+  - ipapwpolicy:
+      ipaadmin_password: SomeADMINpassword
+      gracelimit: 3
+```
+
+Example playbook to ensure password grace period is set to unlimited in global policy:
+
+```yaml
+---
+- name: Playbook to handle pwpolicies
+  hosts: ipaserver
+  become: true
+
+  tasks:
+  # Ensure maxlife is set to 49 in global policy
+  - ipapwpolicy:
+      ipaadmin_password: SomeADMINpassword
+      gracelimit: -1
+```
+
 
 Variables
 =========
@@ -107,6 +137,11 @@ Variable | Description | Required
 `maxfail` \| `krbpwdmaxfailure` | Consecutive failures before lockout. (int) | no
 `failinterval` \| `krbpwdfailurecountinterval` | Period after which failure count will be reset in seconds. (int) | no
 `lockouttime` \| `krbpwdlockoutduration` | Period for which lockout is enforced in seconds. (int) | no
+`maxrepeat` \| `ipapwdmaxrepeat` | Maximum number of same consecutive characters. Requires IPA 4.9+ (int) | no
+`maxsequence` \| `ipapwdmaxsequence` |  The maximum length of monotonic character sequences (abcd). Requires IPA 4.9+ (int) | no
+`dictcheck` \| `ipapwdictcheck` | Check if the password is a dictionary word. Requires IPA 4.9+ (int) | no
+`usercheck` \| `ipapwdusercheck` | Check if the password contains the username. Requires IPA 4.9+ (int) | no
+`gracelimit` \| `passwordgracelimit` |  Number of LDAP authentications allowed after expiration. Requires IPA 4.9.10 (int) | no
 `state` | The state to ensure. It can be one of `present` or `absent`, default: `present`. | yes
 
 
diff --git a/playbooks/pwpolicy/pwpolicy_grace_limit.yml b/playbooks/pwpolicy/pwpolicy_grace_limit.yml
new file mode 100644
index 00000000..e1ed3076
--- /dev/null
+++ b/playbooks/pwpolicy/pwpolicy_grace_limit.yml
@@ -0,0 +1,11 @@
+---
+- name: Playbook to manage password policy
+  hosts: ipaserver
+  become: no
+  gather_facts: no
+
+  tasks:
+  - name: Set password policy grace limit.
+    ipapwpolicy:
+      ipaadmin_password: SomeADMINpassword
+      gracelimit: 3
diff --git a/playbooks/pwpolicy/pwpolicy_password_check.yml b/playbooks/pwpolicy/pwpolicy_password_check.yml
new file mode 100644
index 00000000..6ae237e0
--- /dev/null
+++ b/playbooks/pwpolicy/pwpolicy_password_check.yml
@@ -0,0 +1,14 @@
+---
+- name: Playbook to manage password policy
+  hosts: ipaserver
+  become: no
+  gather_facts: no
+
+  tasks:
+  - name: Set password checking parameters.
+    ipapwpolicy:
+      ipaadmin_password: SomeADMINpassword
+      maxrepeat: 2
+      maxsequence: 3
+      dictcheck: yes
+      usercheck: yes
diff --git a/plugins/modules/ipapwpolicy.py b/plugins/modules/ipapwpolicy.py
index c29bb27b..4398c4e8 100644
--- a/plugins/modules/ipapwpolicy.py
+++ b/plugins/modules/ipapwpolicy.py
@@ -2,6 +2,7 @@
 
 # Authors:
 #   Thomas Woerner <twoerner@redhat.com>
+#   Rafael Guterres Jeffman <rjeffman@redhat.com>
 #
 # Copyright (C) 2019-2022 Red Hat
 # see file 'COPYING' for use and warranty information
@@ -88,6 +89,41 @@ options:
     type: int
     required: false
     aliases: ["krbpwdlockoutduration"]
+  maxrepeat:
+    description: >
+      Maximum number of same consecutive characters.
+      Requires IPA 4.9+
+    type: int
+    required: false
+    aliases: ["ipapwdmaxrepeat"]
+  maxsequence:
+    description: >
+      The maximum length of monotonic character sequences (abcd).
+      Requires IPA 4.9+
+    type: int
+    required: false
+    aliases: ["ipapwdmaxsequence"]
+  dictcheck:
+    description: >
+      Check if the password is a dictionary word.
+      Requires IPA 4.9+
+    type: bool
+    required: false
+    aliases: ["ipapwdictcheck"]
+  usercheck:
+    description: >
+      Check if the password contains the username.
+      Requires IPA 4.9+
+    type: bool
+    required: false
+    aliases: ["ipapwdusercheck"]
+  gracelimit:
+    description: >
+      Number of LDAP authentications allowed after expiration.
+      Requires IPA 4.10.1+
+    type: int
+    required: false
+    aliases: ["passwordgracelimit"]
   state:
     description: State to ensure
     type: str
@@ -95,6 +131,7 @@ options:
     choices: ["present", "absent"]
 author:
   - Thomas Woerner (@t-woerner)
+  - Rafael Guterres Jeffman (@rjeffman)
 """
 
 EXAMPLES = """
@@ -135,7 +172,8 @@ def find_pwpolicy(module, name):
 
 
 def gen_args(maxlife, minlife, history, minclasses, minlength, priority,
-             maxfail, failinterval, lockouttime):
+             maxfail, failinterval, lockouttime, maxrepeat, maxsequence,
+             dictcheck, usercheck, gracelimit):
     _args = {}
     if maxlife is not None:
         _args["krbmaxpwdlife"] = maxlife
@@ -155,10 +193,47 @@ def gen_args(maxlife, minlife, history, minclasses, minlength, priority,
         _args["krbpwdfailurecountinterval"] = failinterval
     if lockouttime is not None:
         _args["krbpwdlockoutduration"] = lockouttime
+    if maxrepeat is not None:
+        _args["ipapwdmaxrepeat"] = maxrepeat
+    if maxsequence is not None:
+        _args["ipapwdmaxrsequence"] = maxsequence
+    if dictcheck is not None:
+        _args["ipapwddictcheck"] = dictcheck
+    if usercheck is not None:
+        _args["ipapwdusercheck"] = usercheck
+    if gracelimit is not None:
+        _args["passwordgracelimit"] = gracelimit
 
     return _args
 
 
+def check_supported_params(
+    module, maxrepeat, maxsequence, dictcheck, usercheck, gracelimit
+):
+    # All password checking parameters were added by the same commit,
+    # so we only need to test one of them.
+    has_password_check = module.ipa_command_param_exists(
+        "pwpolicy_add", "ipapwdmaxrepeat")
+    # check if gracelimit is supported
+    has_gracelimit = module.ipa_command_param_exists(
+        "pwpolicy_add", "passwordgracelimit")
+
+    # If needed, report unsupported password checking paramteres
+    if not has_password_check:
+        check_password_params = [maxrepeat, maxsequence, dictcheck, usercheck]
+        unsupported = [
+            x for x in check_password_params if x is not None
+        ]
+        if unsupported:
+            module.fail_json(
+                msg="Your IPA version does not support arguments: "
+                    "maxrepeat, maxsequence, dictcheck, usercheck.")
+
+    if gracelimit is not None and not has_gracelimit:
+        module.fail_json(
+            msg="Your IPA version does not support 'gracelimit'.")
+
+
 def main():
     ansible_module = IPAAnsibleModule(
         argument_spec=dict(
@@ -183,6 +258,16 @@ def main():
                               default=None),
             lockouttime=dict(type="int", aliases=["krbpwdlockoutduration"],
                              default=None),
+            maxrepeat=dict(type="int", aliases=["ipapwdmaxrepeat"],
+                           default=None),
+            maxsequence=dict(type="int", aliases=["ipapwdmaxsequence"],
+                             default=None),
+            dictcheck=dict(type="bool", aliases=["ipapwdictcheck"],
+                           default=None),
+            usercheck=dict(type="bool", aliases=["ipapwusercheck"],
+                           default=None),
+            gracelimit=dict(type="int", aliases=["passwordgracelimit"],
+                            default=None),
             # state
             state=dict(type="str", default="present",
                        choices=["present", "absent"]),
@@ -207,6 +292,11 @@ def main():
     maxfail = ansible_module.params_get("maxfail")
     failinterval = ansible_module.params_get("failinterval")
     lockouttime = ansible_module.params_get("lockouttime")
+    maxrepeat = ansible_module.params_get("maxrepeat")
+    maxsequence = ansible_module.params_get("maxsequence")
+    dictcheck = ansible_module.params_get("dictcheck")
+    usercheck = ansible_module.params_get("usercheck")
+    gracelimit = ansible_module.params_get("gracelimit")
 
     # state
     state = ansible_module.params_get("state")
@@ -230,10 +320,16 @@ def main():
                 msg="'global_policy' can not be made absent.")
         invalid = ["maxlife", "minlife", "history", "minclasses",
                    "minlength", "priority", "maxfail", "failinterval",
-                   "lockouttime"]
+                   "lockouttime", "maxrepeat", "maxsequence", "dictcheck",
+                   "usercheck", "gracelimit"]
 
     ansible_module.params_fail_used_invalid(invalid, state)
 
+    if gracelimit is not None:
+        if gracelimit < -1:
+            ansible_module.fail_json(
+                msg="'gracelimit' must be no less than -1")
+
     # Init
 
     changed = False
@@ -241,6 +337,11 @@ def main():
 
     with ansible_module.ipa_connect():
 
+        check_supported_params(
+            ansible_module, maxrepeat, maxsequence, dictcheck, usercheck,
+            gracelimit
+        )
+
         commands = []
 
         for name in names:
@@ -252,7 +353,8 @@ def main():
                 # Generate args
                 args = gen_args(maxlife, minlife, history, minclasses,
                                 minlength, priority, maxfail, failinterval,
-                                lockouttime)
+                                lockouttime, maxrepeat, maxsequence, dictcheck,
+                                usercheck, gracelimit)
 
                 # Found the pwpolicy
                 if res_find is not None:
diff --git a/tests/pwpolicy/test_pwpolicy.yml b/tests/pwpolicy/test_pwpolicy.yml
index 5ac18d76..e9868934 100644
--- a/tests/pwpolicy/test_pwpolicy.yml
+++ b/tests/pwpolicy/test_pwpolicy.yml
@@ -5,6 +5,9 @@
   gather_facts: false
 
   tasks:
+  - name: Setup FreeIPA test facts.
+    import_tasks: ../env_freeipa_facts.yml
+
   - name: Ensure maxlife of 90 for global_policy
     ipapwpolicy:
       ipaadmin_password: SomeADMINpassword
@@ -117,3 +120,145 @@
       state: absent
     register: result
     failed_when: result.changed or result.failed
+
+  - block:
+    - name: Ensure maxrepeat of 2 for global_policy
+      ipapwpolicy:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        maxrepeat: 2
+      register: result
+      failed_when: not result.changed or result.failed
+
+    - name: Ensure maxrepeat of 2 for global_policy, again
+      ipapwpolicy:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        maxrepeat: 2
+      register: result
+      failed_when: result.changed or result.failed
+
+    - name: Ensure maxrepeat of 0 for global_policy
+      ipapwpolicy:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        maxrepeat: 0
+      register: result
+      failed_when: not result.changed or result.failed
+
+    - name: Ensure maxsequence of 4 for global_policy
+      ipapwpolicy:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        maxrepeat: 4
+      register: result
+      failed_when: not result.changed or result.failed
+
+    - name: Ensure maxsequence of 4 for global_policy, again
+      ipapwpolicy:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        maxrepeat: 4
+      register: result
+      failed_when: result.changed or result.failed
+
+    - name: Ensure maxsequence of 0 for global_policy
+      ipapwpolicy:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        maxrepeat: 0
+      register: result
+      failed_when: not result.changed or result.failed
+
+    - name: Ensure dictcheck is set for global_policy
+      ipapwpolicy:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        dictcheck: yes
+      register: result
+      failed_when: not result.changed or result.failed
+
+    - name: Ensure dictcheck is set for global_policy, again
+      ipapwpolicy:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        dictcheck: yes
+      register: result
+      failed_when: result.changed or result.failed
+
+    - name: Ensure dictcheck is not set for global_policy
+      ipapwpolicy:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        dictcheck: no
+      register: result
+      failed_when: not result.changed or result.failed
+
+    - name: Ensure usercheck is set for global_policy
+      ipapwpolicy:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        usercheck: yes
+      register: result
+      failed_when: not result.changed or result.failed
+
+    - name: Ensure usercheck is set for global_policy, again
+      ipapwpolicy:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        usercheck: yes
+      register: result
+      failed_when: result.changed or result.failed
+
+    - name: Ensure usercheck is not set for global_policy
+      ipapwpolicy:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        usercheck: no
+      register: result
+      failed_when: not result.changed or result.failed
+
+    when: ipa_version is version("4.9", ">=")
+
+  - block:
+    - name: Ensure grace limit is set to 10 for global_policy
+      ipapwpolicy:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        gracelimit: 10
+      register: result
+      failed_when: not result.changed or result.failed
+
+    - name: Ensure grace limit is set to 0 for global_policy
+      ipapwpolicy:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        gracelimit: 0
+      register: result
+      failed_when: not result.changed or result.failed
+
+    - name: Ensure grace limit is set to 0 for global_policy
+      ipapwpolicy:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        gracelimit: 0
+      register: result
+      failed_when: result.changed or result.failed
+
+    - name: Ensure grace limit is set to 0 for global_policy
+      ipapwpolicy:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        gracelimit: -1
+      register: result
+      failed_when: not result.changed or result.failed
+
+    - name: Ensure grace limit is not set to -2 for global_policy
+      ipapwpolicy:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        gracelimit: -2
+      register: result
+      failed_when: not result.failed and "must be at least -1" not in result.msg
+
+    when: ipa_version is version("4.9.10", ">=")
-- 
GitLab