Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

login tracking [Temporarily frozen] #62

Draft
wants to merge 27 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
be0244e
Introduce account locking functionality
Jorge-Rodriguez Nov 17, 2020
9ffdeb0
Fix assertions
Jorge-Rodriguez Nov 17, 2020
5646003
Fix parsing of versions with dashes
Jorge-Rodriguez Nov 17, 2020
46d7f41
Make dictionary comprehension 2.6 compatible
Jorge-Rodriguez Nov 17, 2020
da9d82e
Fix version added
Jorge-Rodriguez Nov 17, 2020
0532ebd
Fix parsing of versions with dashes
Jorge-Rodriguez Nov 17, 2020
2f26a66
Add account locking warnings on user add
Jorge-Rodriguez Nov 17, 2020
6d795ea
Fix assertions for versions that do not support account locking
Jorge-Rodriguez Nov 17, 2020
aaf7020
Fix version added
Jorge-Rodriguez Nov 17, 2020
9730eeb
Log account locking warnings only when locking is requested
Jorge-Rodriguez Nov 17, 2020
c2d5b97
Add changelog fragment
Jorge-Rodriguez Nov 21, 2020
800d9a5
Update changelog
Jorge-Rodriguez Nov 29, 2020
9a303ac
Update module documentation
Jorge-Rodriguez Nov 29, 2020
de75697
Check `account_locking` values
Jorge-Rodriguez Nov 29, 2020
58d21f6
Remove reference to unused `msg`
Jorge-Rodriguez Nov 29, 2020
0502359
Fix docstring
Jorge-Rodriguez Nov 30, 2020
f41a9ce
Fix key error
Jorge-Rodriguez Nov 30, 2020
64aae9b
Supply empty string to re.match if value is not defined
Jorge-Rodriguez Nov 30, 2020
bb45038
Fix password lock time check
Jorge-Rodriguez Nov 30, 2020
8f337ed
Fix function signature
Jorge-Rodriguez Feb 7, 2021
2fed868
Fix PEP8 error
Jorge-Rodriguez Feb 7, 2021
e9fd1db
Fix function call
Jorge-Rodriguez Feb 7, 2021
1146f66
Fix argument tuple
Jorge-Rodriguez Feb 7, 2021
d7c931f
Fix test conditionals
Jorge-Rodriguez Feb 7, 2021
7bcf810
Fix changelog typo
Jorge-Rodriguez Feb 11, 2021
d3d68e4
Include the password update tests in the play.
Jorge-Rodriguez Feb 11, 2021
f43676c
Fix command quotes
Jorge-Rodriguez Feb 11, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelogs/fragments/49-mysql_user_login_tracking.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
minor_changes:
- mysql_user - add the ``account_locking`` options to support login attempt tracking and account locking feature (https://github.com/ansible-collections/community.mysql/issues/49).
146 changes: 133 additions & 13 deletions plugins/modules/mysql_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,27 @@
- Used when I(state=present), ignored otherwise.
type: dict
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)."
Jorge-Rodriguez marked this conversation as resolved.
Show resolved Hide resolved
- 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
suboptions:
FAILED_LOGIN_ATTEMPTS:
description:
- Number of failed login attempts before the user account is locked.
- Permitted values are in the range from 0 to 32767.
- A value of 0 disables the option.
type: int
PASSWORD_LOCK_TIME:
description:
- Number of days the account stays locked after the FAILED_LOGIN_ATTEMPTS threshold is exceeded.
- Permitted values are in the range from 0 to 32767, or the string ``UNBOUNDED``
- A value of 0 disables the option.
- A value of ``UNBOUNDED`` permanently locks the account until it's administratively unlocked.
version_added: '1.2.0'

notes:
- "MySQL server installs with default I(login_user) of C(root) and no password.
Expand All @@ -139,6 +160,7 @@
- Jonathan Mainguy (@Jmainguy)
- Benjamin Malynovytch (@bmalynovytch)
- Lukasz Tomaszkiewicz (@tomaszkiewicz)
- Jorge Rodriguez (@Jorge-Rodriguez)
extends_documentation_fragment:
- community.mysql.mysql

Expand Down Expand Up @@ -188,6 +210,22 @@
'db1.*': '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.
# Setting this privilege in this manner is supported for backwards compatibility only.
# Use 'tls_requires' instead.
Expand Down Expand Up @@ -217,7 +255,14 @@
name: bob
tls_requires:

- name: Ensure no user named 'sally'@'localhost' exists, also passing in the auth credentials
- name: Create user with enabled loging tracking.
community.mysql.mysql_user:
name: bob
account_locking:
PASSWORD_LOCK_TIME: 2
FAILED_LOGIN_ATTEMPTS: 5

- name: Ensure no user named 'sally'@'localhost' exists, also passing in the auth credentials.
community.mysql.mysql_user:
login_user: root
login_password: 123456
Expand Down Expand Up @@ -386,6 +431,57 @@
return LooseVersion(version_str) < LooseVersion('8')


def validate_account_locking(cursor, account_locking, module):
cursor.execute("SELECT VERSION()")
result = cursor.fetchone()
version_str = result[0]

Check warning on line 437 in plugins/modules/mysql_user.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/mysql_user.py#L434-L437

Added lines #L434 - L437 were not covered by tests
version = version_str.split('-')[0].split('.')

Check warning on line 439 in plugins/modules/mysql_user.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/mysql_user.py#L439

Added line #L439 was not covered by tests
locking = {}

if 'mariadb' in version_str.lower():
module.warn("MariaDB does not support this manner of account locking. Use the MAX_PASSWORD_ERRORS server variable instead.")

Check warning on line 443 in plugins/modules/mysql_user.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/mysql_user.py#L442-L443

Added lines #L442 - L443 were not covered by tests
module.warn("Account locking settings are being ignored.")
else:
if int(version[0]) * 1000 + int(version[2]) < 8019:
module.warn("MySQL is too old to support this manner of account locking.")

Check warning on line 447 in plugins/modules/mysql_user.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/mysql_user.py#L446-L447

Added lines #L446 - L447 were not covered by tests
module.warn("Account locking settings are being ignored.")
else:
if account_locking is not None:

Check warning on line 450 in plugins/modules/mysql_user.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/mysql_user.py#L450

Added line #L450 was not covered by tests
locking = {
"FAILED_LOGIN_ATTEMPTS": str(account_locking.get("FAILED_LOGIN_ATTEMPTS", 0)),
"PASSWORD_LOCK_TIME": str(account_locking.get("PASSWORD_LOCK_TIME", 0))
}
if any([int(value) < 0 or int(value) > 32767 for value in locking.values() if re.match("[-+]?\\d+$", value)]):

Check warning on line 455 in plugins/modules/mysql_user.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/mysql_user.py#L455

Added line #L455 was not covered by tests
module.fail_json(msg="Account locking values are out of the valid range (0-32767)")
if ("PASSWORD_LOCK_TIME" in locking.keys()
and not re.match("[-+]?\\d+$", locking.get("PASSWORD_LOCK_TIME"))
and locking.get("PASSWORD_LOCK_TIME") != "UNBOUNDED"):
module.fail_json(msg="PASSWORD_LOCK_TIME must be an integer between 0 and 32767 or 'UNBOUNDED'")

Check warning on line 460 in plugins/modules/mysql_user.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/mysql_user.py#L459-L460

Added lines #L459 - L460 were not covered by tests
return locking


def get_account_locking(cursor, user, host):
cursor.execute("SELECT VERSION()")
result = cursor.fetchone()
version_str = result[0]

Check warning on line 467 in plugins/modules/mysql_user.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/mysql_user.py#L464-L467

Added lines #L464 - L467 were not covered by tests
version = version_str.split('-')[0].split('.')

Check warning on line 469 in plugins/modules/mysql_user.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/mysql_user.py#L469

Added line #L469 was not covered by tests
locking = {}

if 'mariadb' in version_str.lower() or int(version[0]) * 1000 + int(version[2]) < 8019:

Check warning on line 472 in plugins/modules/mysql_user.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/mysql_user.py#L472

Added line #L472 was not covered by tests
return locking

cursor.execute("SHOW CREATE USER %s@%s", (user, host))

Check warning on line 475 in plugins/modules/mysql_user.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/mysql_user.py#L474-L475

Added lines #L474 - L475 were not covered by tests
result = cursor.fetchone()

for setting in ('FAILED_LOGIN_ATTEMPTS', 'PASSWORD_LOCK_TIME'):

Check warning on line 478 in plugins/modules/mysql_user.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/mysql_user.py#L478

Added line #L478 was not covered by tests
match = re.search("%s (\\d+|UNBOUNDED)" % setting, result[0])
if match:
locking[setting] = match.groups()[0]

Check warning on line 481 in plugins/modules/mysql_user.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/mysql_user.py#L480-L481

Added lines #L480 - L481 were not covered by tests
return locking


def get_mode(cursor):
cursor.execute('SELECT @@GLOBAL.sql_mode')
result = cursor.fetchone()
Expand Down Expand Up @@ -426,7 +522,7 @@
return None


def mogrify_requires(query, params, tls_requires):
def mogrify_requires(query, params, tls_requires, account_locking):
if tls_requires:
if isinstance(tls_requires, dict):
k, v = zip(*tls_requires.items())
Expand All @@ -435,10 +531,17 @@
else:
requires_query = tls_requires
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):

Check warning on line 537 in plugins/modules/mysql_user.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/mysql_user.py#L537

Added line #L537 was not covered by tests
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)))

Check warning on line 544 in plugins/modules/mysql_user.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/mysql_user.py#L543-L544

Added lines #L543 - L544 were not covered by tests
return query, params


Expand Down Expand Up @@ -477,11 +580,13 @@

def user_add(cursor, user, host, host_all, password, encrypted,
plugin, plugin_hash_string, plugin_auth_string, new_priv,
tls_requires, check_mode):
tls_requires, account_locking, check_mode, module):
# we cannot create users without a proper hostname
if host_all:
return False

locking = validate_account_locking(cursor, account_locking, module)

if check_mode:
return True

Expand Down Expand Up @@ -511,7 +616,7 @@
else:
query_with_args = "CREATE USER %s@%s", (user, host)

query_with_args_and_tls_requires = query_with_args + (tls_requires,)
query_with_args_and_tls_requires = query_with_args + (tls_requires, locking)

Check warning on line 619 in plugins/modules/mysql_user.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/mysql_user.py#L619

Added line #L619 was not covered by tests
cursor.execute(*mogrify(*query_with_args_and_tls_requires))

if new_priv is not None:
Expand All @@ -532,14 +637,16 @@

def user_mod(cursor, user, host, host_all, password, encrypted,
plugin, plugin_hash_string, plugin_auth_string, new_priv,
append_privs, tls_requires, module):
append_privs, tls_requires, account_locking, module):

Check warning on line 640 in plugins/modules/mysql_user.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/mysql_user.py#L640

Added line #L640 was not covered by tests
changed = False
msg = "User unchanged"
grant_option = False

# Determine what user management method server uses
old_user_mgmt = use_old_user_mgmt(cursor)

locking = validate_account_locking(cursor, account_locking, module)

if host_all:
hostnames = user_get_hostnames(cursor, user)
else:
Expand Down Expand Up @@ -706,14 +813,25 @@

if tls_requires is not None:
query = " ".join((pre_query, "%s@%s"))
query_with_args = mogrify_requires(query, (user, host), tls_requires)
query_with_args = mogrify_requires(query, (user, host), tls_requires, locking)
else:
query = " ".join((pre_query, "%s@%s REQUIRE NONE"))
query_with_args = query, (user, host)

cursor.execute(*query_with_args)
changed = True

# Handle Account locking
locking = validate_account_locking(cursor, account_locking, module)

Check warning on line 825 in plugins/modules/mysql_user.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/mysql_user.py#L824-L825

Added lines #L824 - L825 were not covered by tests
current_locking = get_account_locking(cursor, user, host)
clear_locking = dict((x, y) for x, y in locking.items() if y != '0')
if current_locking != clear_locking:

Check warning on line 828 in plugins/modules/mysql_user.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/mysql_user.py#L828

Added line #L828 was not covered by tests
msg = "Account locking updated"
if module.check_mode:
return (True, msg)
cursor.execute(*mogrify_account_locking("ALTER USER %s@%s", (user, host), locking))

Check warning on line 832 in plugins/modules/mysql_user.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/mysql_user.py#L830-L832

Added lines #L830 - L832 were not covered by tests
changed = True

Check warning on line 834 in plugins/modules/mysql_user.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/mysql_user.py#L834

Added line #L834 was not covered by tests
return (changed, msg)


Expand Down Expand Up @@ -1031,6 +1149,7 @@
state=dict(type='str', default='present', choices=['absent', 'present']),
priv=dict(type='raw'),
tls_requires=dict(type='dict'),
account_locking=dict(type='dict', default={}),
append_privs=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),
Expand All @@ -1054,6 +1173,7 @@
state = module.params["state"]
priv = module.params["priv"]
tls_requires = sanitize_requires(module.params["tls_requires"])
account_locking = module.params['account_locking']

Check warning on line 1176 in plugins/modules/mysql_user.py

View check run for this annotation

Codecov / codecov/patch

plugins/modules/mysql_user.py#L1176

Added line #L1176 was not covered by tests
check_implicit_admin = module.params["check_implicit_admin"]
connect_timeout = module.params["connect_timeout"]
config_file = module.params["config_file"]
Expand Down Expand Up @@ -1112,12 +1232,12 @@
try:
if update_password == "always":
changed, msg = user_mod(cursor, user, host, host_all, password, encrypted,
plugin, plugin_hash_string, plugin_auth_string,
priv, append_privs, tls_requires, module)
plugin, plugin_hash_string, plugin_auth_string, priv,
append_privs, tls_requires, account_locking, module)
else:
changed, msg = user_mod(cursor, user, host, host_all, None, encrypted,
plugin, plugin_hash_string, plugin_auth_string,
priv, append_privs, tls_requires, module)
plugin, plugin_hash_string, plugin_auth_string, priv,
append_privs, tls_requires, account_locking, module)

except (SQLParseError, InvalidPrivsError, mysql_driver.Error) as e:
module.fail_json(msg=to_native(e))
Expand All @@ -1126,8 +1246,8 @@
module.fail_json(msg="host_all parameter cannot be used when adding a user")
try:
changed = user_add(cursor, user, host, host_all, password, encrypted,
plugin, plugin_hash_string, plugin_auth_string,
priv, tls_requires, module.check_mode)
plugin, plugin_hash_string, plugin_auth_string, priv,
tls_requires, account_locking, module.check_mode, module)
if changed:
msg = "User added"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,4 @@
- name: assert output message mysql user was created
assert:
that:
- "result.changed == true"
- result is changed
Loading