Introduce account locking functionality

This commit is contained in:
Jorge-Rodriguez 2020-11-17 12:57:33 +02:00
parent 31bd2b567f
commit be0244e5bc
No known key found for this signature in database
GPG key ID: 43153D1EFD8F7D90
3 changed files with 253 additions and 11 deletions

View file

@ -116,6 +116,14 @@ options:
- Used when I(state=present), ignored otherwise. - Used when I(state=present), ignored otherwise.
type: dict type: dict
version_added: '0.1.0' version_added: '0.1.0'
account_locking:
description:
- Configure user accounts such that too many consecutive login failures cause temporary account locking. Provided since MySQL 8.0.19.
- "Available options are C(FAILED_LOGIN_ATTEMPTS: num), C(PASSWORD_LOCK_TIME: num | UNBOUNDED)."
- Used when I(state=present) and target server is MySQL >= 8.0.19, ignored otherwise.
- U(https://dev.mysql.com/doc/refman/8.0/en/password-management.html#failed-login-tracking).
type: dict
version_added: '1.1.2'
notes: notes:
- "MySQL server installs with default I(login_user) of C(root) and no password. - "MySQL server installs with default I(login_user) of C(root) and no password.
@ -139,6 +147,7 @@ author:
- Jonathan Mainguy (@Jmainguy) - Jonathan Mainguy (@Jmainguy)
- Benjamin Malynovytch (@bmalynovytch) - Benjamin Malynovytch (@bmalynovytch)
- Lukasz Tomaszkiewicz (@tomaszkiewicz) - Lukasz Tomaszkiewicz (@tomaszkiewicz)
- Jorge Rodriguez (@Jorge-Rodriguez)
extends_documentation_fragment: extends_documentation_fragment:
- community.mysql.mysql - community.mysql.mysql
@ -188,6 +197,22 @@ EXAMPLES = r'''
'db1.*': 'ALL,GRANT' 'db1.*': 'ALL,GRANT'
'db2.*': 'ALL,GRANT' 'db2.*': 'ALL,GRANT'
- name: Create user with password and locking such that the account locks after three failed attempts
community.mysql.mysql_user:
name: bob
password: 12345
account_locking:
FAILED_LOGIN_ATTEMPTS: 3
PASSWORD_LOCK_TIME: UNBOUNDED
- name: Create user with password and locking such that the account locks for 5 days after three failed attempts
community.mysql.mysql_user:
name: bob
password: 12345
account_locking:
FAILED_LOGIN_ATTEMPTS: 3
PASSWORD_LOCK_TIME: 5
# Note that REQUIRESSL is a special privilege that should only apply to *.* by itself. # Note that REQUIRESSL is a special privilege that should only apply to *.* by itself.
# Setting this privilege in this manner is supported for backwards compatibility only. # Setting this privilege in this manner is supported for backwards compatibility only.
# Use 'tls_requires' instead. # Use 'tls_requires' instead.
@ -386,6 +411,50 @@ def supports_identified_by_password(cursor):
return LooseVersion(version_str) < LooseVersion('8') return LooseVersion(version_str) < LooseVersion('8')
def validate_account_locking(cursor, account_locking):
cursor.execute("SELECT VERSION()")
result = cursor.fetchone()
version_str = result[0]
version = version_str.split('.')
locking = {}
if 'mariadb' in version_str.lower():
msg = "MariaDB does not support this manner of account locking. Use the MAX_PASSWORD_ERRORS server variable instead."
else:
if int(version[0]) * 1000 + int(version[2]) < 8019:
msg = "MySQL is too old to support this manner of account locking."
else:
msg = None
if account_locking is not None:
locking = {
"FAILED_LOGIN_ATTEMPTS": str(account_locking.get("FAILED_LOGIN_ATTEMPTS", 0)),
"PASSWORD_LOCK_TIME": str(account_locking.get("PASSWORD_LOCK_TIME", 0))
}
return msg, locking
def get_account_locking(cursor, user, host):
cursor.execute("SELECT VERSION()")
result = cursor.fetchone()
version_str = result[0]
version = version_str.split('.')
locking = {}
if 'mariadb' in version_str.lower() or int(version[0]) * 1000 + int(version[2]) < 8019:
return locking
cursor.execute("SHOW CREATE USER %s@%s", (user, host))
result = cursor.fetchone()
for setting in ('FAILED_LOGIN_ATTEMPTS', 'PASSWORD_LOCK_TIME'):
match = re.search("%s (\\d+|UNBOUNDED)" % setting, result[0])
if match:
locking[setting] = match.groups()[0]
return locking
def get_mode(cursor): def get_mode(cursor):
cursor.execute('SELECT @@GLOBAL.sql_mode') cursor.execute('SELECT @@GLOBAL.sql_mode')
result = cursor.fetchone() result = cursor.fetchone()
@ -426,7 +495,7 @@ def sanitize_requires(tls_requires):
return None return None
def mogrify_requires(query, params, tls_requires): def mogrify_requires(query, params, tls_requires, account_locking):
if tls_requires: if tls_requires:
if isinstance(tls_requires, dict): if isinstance(tls_requires, dict):
k, v = zip(*tls_requires.items()) k, v = zip(*tls_requires.items())
@ -435,10 +504,17 @@ def mogrify_requires(query, params, tls_requires):
else: else:
requires_query = tls_requires requires_query = tls_requires
query = " REQUIRE ".join((query, requires_query)) query = " REQUIRE ".join((query, requires_query))
return mogrify_account_locking(query, params, account_locking)
def do_not_mogrify_requires(query, params, tls_requires, account_locking):
return query, params return query, params
def do_not_mogrify_requires(query, params, tls_requires): def mogrify_account_locking(query, params, account_locking):
if account_locking:
for k, v in account_locking.items():
query = ' '.join((query, k, str(v)))
return query, params return query, params
@ -477,13 +553,14 @@ def get_grants(cursor, user, host):
def user_add(cursor, user, host, host_all, password, encrypted, def user_add(cursor, user, host, host_all, password, encrypted,
plugin, plugin_hash_string, plugin_auth_string, new_priv, plugin, plugin_hash_string, plugin_auth_string, new_priv,
tls_requires, check_mode): tls_requires, account_locking, check_mode):
# we cannot create users without a proper hostname # we cannot create users without a proper hostname
if host_all: if host_all:
return False return False
msg, locking = validate_account_locking(cursor, account_locking)
if check_mode: if check_mode:
return True return (True, msg)
# Determine what user management method server uses # Determine what user management method server uses
old_user_mgmt = use_old_user_mgmt(cursor) old_user_mgmt = use_old_user_mgmt(cursor)
@ -519,7 +596,7 @@ def user_add(cursor, user, host, host_all, password, encrypted,
privileges_grant(cursor, user, host, db_table, priv, tls_requires) privileges_grant(cursor, user, host, db_table, priv, tls_requires)
if tls_requires is not None: if tls_requires is not None:
privileges_grant(cursor, user, host, "*.*", get_grants(cursor, user, host), tls_requires) privileges_grant(cursor, user, host, "*.*", get_grants(cursor, user, host), tls_requires)
return True return (True, msg)
def is_hash(password): def is_hash(password):
@ -532,7 +609,7 @@ def is_hash(password):
def user_mod(cursor, user, host, host_all, password, encrypted, def user_mod(cursor, user, host, host_all, password, encrypted,
plugin, plugin_hash_string, plugin_auth_string, new_priv, plugin, plugin_hash_string, plugin_auth_string, new_priv,
append_privs, tls_requires, module): append_privs, tls_requires, account_locking, module):
changed = False changed = False
msg = "User unchanged" msg = "User unchanged"
grant_option = False grant_option = False
@ -714,6 +791,20 @@ def user_mod(cursor, user, host, host_all, password, encrypted,
cursor.execute(*query_with_args) cursor.execute(*query_with_args)
changed = True changed = True
# Handle Account locking
note, locking = validate_account_locking(cursor, account_locking)
if note:
module.warn(note)
module.warn("Account locking settings are being ignored.")
current_locking = get_account_locking(cursor, user, host)
clear_locking = {x: y for x, y in locking.items() if y != '0'}
if current_locking != clear_locking:
msg = "Account locking updated"
if module.check_mode:
return (True, msg)
cursor.execute(*mogrify_account_locking("ALTER USER %s@%s", (user, host), locking))
changed = True
return (changed, msg) return (changed, msg)
@ -1031,6 +1122,7 @@ def main():
state=dict(type='str', default='present', choices=['absent', 'present']), state=dict(type='str', default='present', choices=['absent', 'present']),
priv=dict(type='raw'), priv=dict(type='raw'),
tls_requires=dict(type='dict'), tls_requires=dict(type='dict'),
account_locking=dict(type='dict', default={}),
append_privs=dict(type='bool', default=False), append_privs=dict(type='bool', default=False),
check_implicit_admin=dict(type='bool', default=False), check_implicit_admin=dict(type='bool', default=False),
update_password=dict(type='str', default='always', choices=['always', 'on_create'], no_log=False), update_password=dict(type='str', default='always', choices=['always', 'on_create'], no_log=False),
@ -1054,6 +1146,7 @@ def main():
state = module.params["state"] state = module.params["state"]
priv = module.params["priv"] priv = module.params["priv"]
tls_requires = sanitize_requires(module.params["tls_requires"]) tls_requires = sanitize_requires(module.params["tls_requires"])
account_locking = module.params['account_locking']
check_implicit_admin = module.params["check_implicit_admin"] check_implicit_admin = module.params["check_implicit_admin"]
connect_timeout = module.params["connect_timeout"] connect_timeout = module.params["connect_timeout"]
config_file = module.params["config_file"] config_file = module.params["config_file"]
@ -1112,12 +1205,12 @@ def main():
try: try:
if update_password == "always": if update_password == "always":
changed, msg = user_mod(cursor, user, host, host_all, password, encrypted, changed, msg = user_mod(cursor, user, host, host_all, password, encrypted,
plugin, plugin_hash_string, plugin_auth_string, plugin, plugin_hash_string, plugin_auth_string, priv,
priv, append_privs, tls_requires, module) append_privs, tls_requires, account_locking, module)
else: else:
changed, msg = user_mod(cursor, user, host, host_all, None, encrypted, changed, msg = user_mod(cursor, user, host, host_all, None, encrypted,
plugin, plugin_hash_string, plugin_auth_string, plugin, plugin_hash_string, plugin_auth_string, priv,
priv, append_privs, tls_requires, module) append_privs, tls_requires, account_locking, module)
except (SQLParseError, InvalidPrivsError, mysql_driver.Error) as e: except (SQLParseError, InvalidPrivsError, mysql_driver.Error) as e:
module.fail_json(msg=to_native(e)) module.fail_json(msg=to_native(e))
@ -1127,7 +1220,7 @@ def main():
try: try:
changed = user_add(cursor, user, host, host_all, password, encrypted, changed = user_add(cursor, user, host, host_all, password, encrypted,
plugin, plugin_hash_string, plugin_auth_string, plugin, plugin_hash_string, plugin_auth_string,
priv, tls_requires, module.check_mode) priv, tls_requires, account_locking, module.check_mode)
if changed: if changed:
msg = "User added" msg = "User added"

View file

@ -0,0 +1,148 @@
---
- vars:
mysql_parameters: &mysql_params
login_user: '{{ mysql_user }}'
login_password: '{{ mysql_password }}'
login_host: 127.0.0.1
login_port: '{{ mysql_primary_port }}'
block:
# ============================================================
- name: find out the database version
mysql_info:
<<: *mysql_params
filter: version
register: db_version
- set_fact:
version_string: "{{[db_version.version.major, db_version.version.minor, db_version.version.release] | join('.')}}"
- name: Drop mysql user if exists
mysql_user:
<<: *mysql_params
name: '{{ user_name_1 }}'
state: absent
ignore_errors: yes
- name: Create user with account locking in test mode
mysql_user:
<<: *mysql_params
name: '{{ user_name_1 }}'
password: '{{ user_password_1 }}'
account_locking:
PASSWORD_LOCK_TIME: 3
FAILED_LOGIN_ATTEMPTS: 3
check_mode: True
register: result
- assert:
that:
- result is changed
- include: assert_no_user.yml user_name={{ user_name_1 }}
- name: Create user with account locking
mysql_user:
<<: *mysql_params
name: '{{ user_name_1 }}'
password: '{{ user_password_1 }}'
account_locking:
PASSWORD_LOCK_TIME: 3
FAILED_LOGIN_ATTEMPTS: 3
register: result
- assert:
that:
- result is changed
- include: assert_user.yml user_name={{ user_name_1 }}
- block:
- name: retrieve create request
command: "{{ mysql_command }} -L -N -s -e \"SHOW CREATE USER '{{ user_name_1 }}'@'localhost'\""
register: result
- assert:
that:
- "{{ 'PASSWORD_LOCK_TIME 3' in result.stdout }}"
- "{{ 'FAILED_LOGIN_ATTEMPTS 3' in result.stdout }}"
when: version_string is version('8.0.19', '>=') and version_string is version('10', '<')
- name: Create existing user with account locking in test mode
mysql_user:
<<: *mysql_params
name: '{{ user_name_1 }}'
password: '{{ user_password_1 }}'
account_locking:
PASSWORD_LOCK_TIME: 3
FAILED_LOGIN_ATTEMPTS: 3
check_mode: True
register: result
- assert:
that: result is not changed
- name: Create existing user with account locking
mysql_user:
<<: *mysql_params
name: '{{ user_name_1 }}'
password: '{{ user_password_1 }}'
account_locking:
PASSWORD_LOCK_TIME: 3
FAILED_LOGIN_ATTEMPTS: 3
register: result
- assert:
that: result is not changed
- name: Update existing user with account locking in test mode
mysql_user:
<<: *mysql_params
name: '{{ user_name_1 }}'
password: '{{ user_password_1 }}'
account_locking:
PASSWORD_LOCK_TIME: 3
FAILED_LOGIN_ATTEMPTS: 5
check_mode: True
register: result
- assert:
that: result is changed
- block:
- name: retrieve create request
command: "{{ mysql_command }} -L -N -s -e \"SHOW CREATE USER '{{ user_name_1 }}'@'localhost'\""
register: result
- assert:
that:
- "{{ 'PASSWORD_LOCK_TIME 3' in result.stdout }}"
- "{{ 'FAILED_LOGIN_ATTEMPTS 3' in result.stdout }}"
when: version_string is version('8.0.19', '>=') and version_string is version('10', '<')
- name: Update existing user with account locking
mysql_user:
<<: *mysql_params
name: '{{ user_name_1 }}'
password: '{{ user_password_1 }}'
account_locking:
PASSWORD_LOCK_TIME: 2
FAILED_LOGIN_ATTEMPTS: 5
register: result
- assert:
that: result is changed
- block:
- name: retrieve create request
command: "{{ mysql_command }} -L -N -s -e \"SHOW CREATE USER '{{ user_name_1 }}'@'localhost'\""
register: result
- assert:
that:
- "{{ 'PASSWORD_LOCK_TIME 2' in result.stdout }}"
- "{{ 'FAILED_LOGIN_ATTEMPTS 5' in result.stdout }}"
when: version_string is version('8.0.19', '>=') and version_string is version('10', '<')
- include: remove_user.yml user_name={{user_name_1}} user_password={{ user_password_1 }}
- include: assert_no_user.yml user_name={{user_name_1}}

View file

@ -36,6 +36,7 @@
login_port: '{{ mysql_primary_port }}' login_port: '{{ mysql_primary_port }}'
block: block:
- include: issue-49.yml
- include: issue-28.yml - include: issue-28.yml