diff --git a/changelogs/fragments/100-postgresql_user_scram_sha_256_support.yml b/changelogs/fragments/100-postgresql_user_scram_sha_256_support.yml new file mode 100644 index 0000000000..657e90b148 --- /dev/null +++ b/changelogs/fragments/100-postgresql_user_scram_sha_256_support.yml @@ -0,0 +1,2 @@ +minor_changes: +- postgresql_user - add scram-sha-256 support (https://github.com/ansible/ansible/issues/49878). diff --git a/plugins/module_utils/saslprep.py b/plugins/module_utils/saslprep.py new file mode 100644 index 0000000000..297882878f --- /dev/null +++ b/plugins/module_utils/saslprep.py @@ -0,0 +1,176 @@ +# -*- coding: utf-8 -*- + +# This code is part of Ansible, but is an independent component. +# This particular file snippet, and this file snippet only, is BSD licensed. +# Modules you write using this snippet, which is embedded dynamically by Ansible +# still belong to the author of the module, and may assign their own license +# to the complete work. + +# Copyright: (c) 2020, Andrew Klychkov (@Andersson007) + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +from stringprep import ( + in_table_a1, + in_table_b1, + in_table_c3, + in_table_c4, + in_table_c5, + in_table_c6, + in_table_c7, + in_table_c8, + in_table_c9, + in_table_c12, + in_table_c21_c22, + in_table_d1, + in_table_d2, +) +from unicodedata import normalize + +from ansible.module_utils.six import text_type + + +def is_unicode_str(string): + return True if isinstance(string, text_type) else False + + +def mapping_profile(string): + """RFC4013 Mapping profile implementation.""" + # Regarding RFC4013, + # This profile specifies: + # - non-ASCII space characters [StringPrep, C.1.2] that can be + # mapped to SPACE (U+0020), and + # - the "commonly mapped to nothing" characters [StringPrep, B.1] + # that can be mapped to nothing. + + tmp = [] + for c in string: + # If not the "commonly mapped to nothing" + if not in_table_b1(c): + if in_table_c12(c): + # map non-ASCII space characters + # (that can be mapped) to Unicode space + tmp.append(u' ') + else: + tmp.append(c) + + return u"".join(tmp) + + +def is_ral_string(string): + """RFC3454 Check bidirectional category of the string""" + # Regarding RFC3454, + # Table D.1 lists the characters that belong + # to Unicode bidirectional categories "R" and "AL". + # If a string contains any RandALCat character, a RandALCat + # character MUST be the first character of the string, and a + # RandALCat character MUST be the last character of the string. + if in_table_d1(string[0]): + if not in_table_d1(string[-1]): + raise ValueError('RFC3454: incorrect bidirectional RandALCat string.') + return True + return False + + +def prohibited_output_profile(string): + """RFC4013 Prohibited output profile implementation.""" + # Implements: + # RFC4013, 2.3. Prohibited Output. + # This profile specifies the following characters as prohibited input: + # - Non-ASCII space characters [StringPrep, C.1.2] + # - ASCII control characters [StringPrep, C.2.1] + # - Non-ASCII control characters [StringPrep, C.2.2] + # - Private Use characters [StringPrep, C.3] + # - Non-character code points [StringPrep, C.4] + # - Surrogate code points [StringPrep, C.5] + # - Inappropriate for plain text characters [StringPrep, C.6] + # - Inappropriate for canonical representation characters [StringPrep, C.7] + # - Change display properties or deprecated characters [StringPrep, C.8] + # - Tagging characters [StringPrep, C.9] + # RFC4013, 2.4. Bidirectional Characters. + # RFC4013, 2.5. Unassigned Code Points. + + # Determine how to handle bidirectional characters (RFC3454): + if is_ral_string(string): + # If a string contains any RandALCat characters, + # The string MUST NOT contain any LCat character: + is_prohibited_bidi_ch = in_table_d2 + bidi_table = 'D.2' + else: + # Forbid RandALCat characters in LCat string: + is_prohibited_bidi_ch = in_table_d1 + bidi_table = 'D.1' + + RFC = 'RFC4013' + for c in string: + # RFC4013 2.3. Prohibited Output: + if in_table_c12(c): + raise ValueError('%s: prohibited non-ASCII space characters ' + 'that cannot be replaced (C.1.2).' % RFC) + if in_table_c21_c22(c): + raise ValueError('%s: prohibited control characters (C.2.1).' % RFC) + if in_table_c3(c): + raise ValueError('%s: prohibited private Use characters (C.3).' % RFC) + if in_table_c4(c): + raise ValueError('%s: prohibited non-character code points (C.4).' % RFC) + if in_table_c5(c): + raise ValueError('%s: prohibited surrogate code points (C.5).' % RFC) + if in_table_c6(c): + raise ValueError('%s: prohibited inappropriate for plain text ' + 'characters (C.6).' % RFC) + if in_table_c7(c): + raise ValueError('%s: prohibited inappropriate for canonical ' + 'representation characters (C.7).' % RFC) + if in_table_c8(c): + raise ValueError('%s: prohibited change display properties / ' + 'deprecated characters (C.8).' % RFC) + if in_table_c9(c): + raise ValueError('%s: prohibited tagging characters (C.9).' % RFC) + + # RFC4013, 2.4. Bidirectional Characters: + if is_prohibited_bidi_ch(c): + raise ValueError('%s: prohibited bidi characters (%s).' % (RFC, bidi_table)) + + # RFC4013, 2.5. Unassigned Code Points: + if in_table_a1(c): + raise ValueError('%s: prohibited unassigned code points (A.1).' % RFC) + + +def saslprep(string): + """RFC4013 implementation. + Implements "SASLprep" profile (RFC4013) of the "stringprep" algorithm (RFC3454) + to prepare Unicode strings representing user names and passwords for comparison. + Regarding the RFC4013, the "SASLprep" profile is intended to be used by + Simple Authentication and Security Layer (SASL) mechanisms + (such as PLAIN, CRAM-MD5, and DIGEST-MD5), as well as other protocols + exchanging simple user names and/or passwords. + + Args: + string (unicode string): Unicode string to validate and prepare. + + Returns: + Prepared unicode string. + """ + # RFC4013: "The algorithm assumes all strings are + # comprised of characters from the Unicode [Unicode] character set." + # Validate the string is a Unicode string + # (text_type is the string type if PY3 and unicode otherwise): + if not is_unicode_str(string): + raise TypeError('input must be of type %s, not %s' % (text_type, type(string))) + + # RFC4013: 2.1. Mapping. + string = mapping_profile(string) + + # RFC4013: 2.2. Normalization. + # "This profile specifies using Unicode normalization form KC." + string = normalize('NFKC', string) + if not string: + return u'' + + # RFC4013: 2.3. Prohibited Output. + # RFC4013: 2.4. Bidirectional Characters. + # RFC4013: 2.5. Unassigned Code Points. + prohibited_output_profile(string) + + return string diff --git a/plugins/modules/database/postgresql/postgresql_user.py b/plugins/modules/database/postgresql/postgresql_user.py index 5065452223..a12aedf5b5 100644 --- a/plugins/modules/database/postgresql/postgresql_user.py +++ b/plugins/modules/database/postgresql/postgresql_user.py @@ -48,7 +48,7 @@ options: - Unhashed password will automatically be hashed when saved into the database if C(encrypted) parameter is set, otherwise it will be save in plain text format. - - When passing a hashed password it must be generated with the format + - When passing an MD5-hashed password it must be generated with the format C('str["md5"] + md5[ password + username ]'), resulting in a total of 35 characters. An easy way to do this is C(echo "md5$(echo -n 'verysecretpasswordJOE' | md5sum | awk '{print $1}')"). @@ -157,6 +157,8 @@ notes: Use NOLOGIN role_attr_flags to change this behaviour. - If you specify PUBLIC as the user (role), then the privilege changes will apply to all users (roles). You may not specify password or role_attr_flags when the PUBLIC user is specified. +- SCRAM-SHA-256-hashed passwords (SASL Authentication) require PostgreSQL version 10 or newer. + On the previous versions the whole hashed string will be used as a password. seealso: - module: postgresql_privs - module: postgresql_membership @@ -164,6 +166,9 @@ seealso: - name: PostgreSQL database roles description: Complete reference of the PostgreSQL database roles documentation. link: https://www.postgresql.org/docs/current/user-manag.html +- name: PostgreSQL SASL Authentication + description: Complete reference of the PostgreSQL SASL Authentication. + link: https://www.postgresql.org/docs/current/sasl-authentication.html author: - Ansible Core Team extends_documentation_fragment: @@ -232,6 +237,15 @@ EXAMPLES = r''' groups: - user_ro - user_rw + +# Create user with a cleartext password if it does not exist or update its password. +# The password will be encrypted with SCRAM algorithm (available since PostgreSQL 10) +- name: Create appclient user with SCRAM-hashed password + postgresql_user: + name: appclient + password: "secret123" + environment: + PGOPTIONS: "-c password_encryption=scram-sha-256" ''' RETURN = r''' @@ -245,7 +259,9 @@ queries: import itertools import re import traceback -from hashlib import md5 +from hashlib import md5, sha256 +import hmac +from base64 import b64decode try: import psycopg2 @@ -267,13 +283,24 @@ from ansible_collections.community.general.plugins.module_utils.postgres import PgMembership, postgres_common_argument_spec, ) -from ansible.module_utils._text import to_bytes, to_native +from ansible.module_utils._text import to_bytes, to_native, to_text from ansible.module_utils.six import iteritems +import ansible_collections.community.general.plugins.module_utils.saslprep as saslprep + +try: + # pbkdf2_hmac is missing on python 2.6, we can safely assume, + # that postresql 10 capable instance have at least python 2.7 installed + from hashlib import pbkdf2_hmac + pbkdf2_found = True +except ImportError: + pbkdf2_found = False FLAGS = ('SUPERUSER', 'CREATEROLE', 'CREATEDB', 'INHERIT', 'LOGIN', 'REPLICATION') FLAGS_BY_VERSION = {'BYPASSRLS': 90500} +SCRAM_SHA256_REGEX = r'^SCRAM-SHA-256\$(\d+):([A-Za-z0-9+\/=]+)\$([A-Za-z0-9+\/=]+):([A-Za-z0-9+\/=]+)$' + VALID_PRIVS = dict(table=frozenset(('SELECT', 'INSERT', 'UPDATE', 'DELETE', 'TRUNCATE', 'REFERENCES', 'TRIGGER', 'ALL')), database=frozenset( ('CREATE', 'CONNECT', 'TEMPORARY', 'TEMP', 'ALL')), @@ -350,6 +377,39 @@ def user_should_we_change_password(current_role_attrs, user, password, encrypted if password == '': if current_role_attrs['rolpassword'] is not None: pwchanging = True + + # SCRAM hashes are represented as a special object, containing hash data: + # `SCRAM-SHA-256$:$:` + # for reference, see https://www.postgresql.org/docs/current/catalog-pg-authid.html + elif current_role_attrs['rolpassword'] is not None \ + and pbkdf2_found \ + and re.match(SCRAM_SHA256_REGEX, current_role_attrs['rolpassword']): + + r = re.match(SCRAM_SHA256_REGEX, current_role_attrs['rolpassword']) + try: + # extract SCRAM params from rolpassword + it = int(r.group(1)) + salt = b64decode(r.group(2)) + server_key = b64decode(r.group(4)) + # we'll never need `storedKey` as it is only used for server auth in SCRAM + # storedKey = b64decode(r.group(3)) + + # from RFC5802 https://tools.ietf.org/html/rfc5802#section-3 + # SaltedPassword := Hi(Normalize(password), salt, i) + # ServerKey := HMAC(SaltedPassword, "Server Key") + normalized_password = saslprep.saslprep(to_text(password)) + salted_password = pbkdf2_hmac('sha256', to_bytes(normalized_password), salt, it) + + server_key_verifier = hmac.new(salted_password, digestmod=sha256) + server_key_verifier.update(b'Server Key') + + if server_key_verifier.digest() != server_key: + pwchanging = True + except Exception: + # We assume the password is not scram encrypted + # or we cannot check it properly, e.g. due to missing dependencies + pwchanging = True + # 32: MD5 hashes are represented as a sequence of 32 hexadecimal digits # 3: The size of the 'md5' prefix # When the provided password looks like a MD5-hash, value of diff --git a/tests/integration/targets/postgresql_user/tasks/test_password.yml b/tests/integration/targets/postgresql_user/tasks/test_password.yml index be033a5569..0f1edcffcf 100644 --- a/tests/integration/targets/postgresql_user/tasks/test_password.yml +++ b/tests/integration/targets/postgresql_user/tasks/test_password.yml @@ -3,10 +3,12 @@ become_user: "{{ pg_user }}" become: yes register: result - postgresql_parameters: ¶meters + postgresql_query_parameters: &query_parameters db: postgres - name: "{{ db_user1 }}" login_user: "{{ pg_user }}" + postgresql_parameters: ¶meters + <<: *query_parameters + name: "{{ db_user1 }}" block: - name: 'Check that PGOPTIONS environment variable is effective (1/2)' @@ -300,6 +302,97 @@ when: encrypted == 'no' + # start of block scram-sha-256 + # scram-sha-256 password encryption type is supported since PostgreSQL 10 + - when: postgres_version_resp.stdout is version('10', '>=') + block: + + - name: 'Using cleartext password with scram-sha-256: resetting password' + <<: *task_parameters + postgresql_user: + <<: *parameters + password: "" + encrypted: "{{ encrypted }}" + environment: + PGCLIENTENCODING: 'UTF8' + + - name: 'Using cleartext password with scram-sha-256: check that password is changed when using cleartext password' + <<: *task_parameters + postgresql_user: + <<: *parameters + password: "{{ db_password1 }}" + encrypted: "{{ encrypted }}" + environment: + PGCLIENTENCODING: 'UTF8' + # ansible postgresql_user module interface does not (yet) support forcing password_encryption + # type value, we'll have to hack it in env variable to force correct encryption + PGOPTIONS: "-c password_encryption=scram-sha-256" + + - <<: *changed + + - name: 'Using cleartext password with scram-sha-256: ensure password is properly encrypted' + <<: *task_parameters + postgresql_query: + <<: *query_parameters + query: select * from pg_authid where rolname=%s and rolpassword like %s + positional_args: + - '{{ db_user1 }}' + - 'SCRAM-SHA-256$%' + + - assert: + that: + - result.rowcount == 1 + + - name: 'Using cleartext password with scram-sha-256: check that password is not changed when using the same password' + <<: *task_parameters + postgresql_user: + <<: *parameters + password: "{{ db_password1 }}" + encrypted: "{{ encrypted }}" + environment: + PGCLIENTENCODING: 'UTF8' + PGOPTIONS: "-c password_encryption=scram-sha-256" + + - <<: *not_changed + + - name: 'Using cleartext password with scram-sha-256: check that password is changed when using another cleartext password' + <<: *task_parameters + postgresql_user: + <<: *parameters + password: "changed{{ db_password1 }}" + encrypted: "{{ encrypted }}" + environment: + PGCLIENTENCODING: 'UTF8' + PGOPTIONS: "-c password_encryption=scram-sha-256" + + - <<: *changed + + - name: 'Using cleartext password with scram-sha-256: check that password is changed when clearing the password' + <<: *task_parameters + postgresql_user: + <<: *parameters + password: '' + encrypted: "{{ encrypted }}" + environment: + PGCLIENTENCODING: 'UTF8' + PGOPTIONS: "-c password_encryption=scram-sha-256" + + - <<: *changed + + - name: 'Using cleartext password with scram-sha-256: check that password is not changed when clearing the password again' + <<: *task_parameters + postgresql_user: + <<: *parameters + password: '' + encrypted: "{{ encrypted }}" + environment: + PGCLIENTENCODING: 'UTF8' + PGOPTIONS: "-c password_encryption=scram-sha-256" + + - <<: *not_changed + + # end of block scram-sha-256 + - name: Remove user <<: *task_parameters postgresql_user: diff --git a/tests/unit/module_utils/test_saslprep.py b/tests/unit/module_utils/test_saslprep.py new file mode 100644 index 0000000000..4829f55b83 --- /dev/null +++ b/tests/unit/module_utils/test_saslprep.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# Copyright: (c) 2019, Andrey Tuzhilin +# Copyright: (c) 2020, Andrew Klychkov (@Andersson007) + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import pytest + +from ansible_collections.community.general.plugins.module_utils.saslprep import saslprep + + +VALID = [ + (u'', u''), + (u'\u00A0', u' '), + (u'a', u'a'), + (u'й', u'й'), + (u'\u30DE\u30C8\u30EA\u30C3\u30AF\u30B9', u'\u30DE\u30C8\u30EA\u30C3\u30AF\u30B9'), + (u'The\u00ADM\u00AAtr\u2168', u'TheMatrIX'), + (u'I\u00ADX', u'IX'), + (u'user', u'user'), + (u'USER', u'USER'), + (u'\u00AA', u'a'), + (u'\u2168', u'IX'), + (u'\u05BE\u00A0\u05BE', u'\u05BE\u0020\u05BE'), +] + +INVALID = [ + (None, TypeError), + (b'', TypeError), + (u'\u0221', ValueError), + (u'\u0007', ValueError), + (u'\u0627\u0031', ValueError), + (u'\uE0001', ValueError), + (u'\uE0020', ValueError), + (u'\uFFF9', ValueError), + (u'\uFDD0', ValueError), + (u'\u0000', ValueError), + (u'\u06DD', ValueError), + (u'\uFFFFD', ValueError), + (u'\uD800', ValueError), + (u'\u200E', ValueError), + (u'\u05BE\u00AA\u05BE', ValueError), +] + + +@pytest.mark.parametrize('source,target', VALID) +def test_saslprep_conversions(source, target): + assert saslprep(source) == target + + +@pytest.mark.parametrize('source,exception', INVALID) +def test_saslprep_exceptions(source, exception): + with pytest.raises(exception) as ex: + saslprep(source)