mirror of
https://github.com/ansible-collections/community.general.git
synced 2024-09-14 20:13:21 +02:00
postgresql_user: add scram-sha-256 password support (#100)
* postgresql_user: add support for scram-sha-256 passwords * postgresql_user: add support for scram-sha-256 passwords * add changelog fragment * fix
This commit is contained in:
parent
dee5de23d7
commit
bb459cb014
5 changed files with 391 additions and 5 deletions
|
@ -0,0 +1,2 @@
|
|||
minor_changes:
|
||||
- postgresql_user - add scram-sha-256 support (https://github.com/ansible/ansible/issues/49878).
|
176
plugins/module_utils/saslprep.py
Normal file
176
plugins/module_utils/saslprep.py
Normal file
|
@ -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) <aaklychkov@mail.ru>
|
||||
|
||||
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
|
|
@ -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$<iteration count>:<salt>$<StoredKey>:<ServerKey>`
|
||||
# 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
|
||||
|
|
|
@ -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:
|
||||
|
|
55
tests/unit/module_utils/test_saslprep.py
Normal file
55
tests/unit/module_utils/test_saslprep.py
Normal file
|
@ -0,0 +1,55 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Copyright: (c) 2019, Andrey Tuzhilin <andrei.tuzhilin@gmail.com>
|
||||
# Copyright: (c) 2020, Andrew Klychkov (@Andersson007) <aaklychkov@mail.ru>
|
||||
|
||||
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)
|
Loading…
Reference in a new issue