1
0
Fork 0
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:
Andrew Klychkov 2020-04-22 14:45:14 +03:00 committed by GitHub
parent dee5de23d7
commit bb459cb014
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 391 additions and 5 deletions

View file

@ -0,0 +1,2 @@
minor_changes:
- postgresql_user - add scram-sha-256 support (https://github.com/ansible/ansible/issues/49878).

View 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

View file

@ -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

View file

@ -3,10 +3,12 @@
become_user: "{{ pg_user }}"
become: yes
register: result
postgresql_parameters: &parameters
postgresql_query_parameters: &query_parameters
db: postgres
name: "{{ db_user1 }}"
login_user: "{{ pg_user }}"
postgresql_parameters: &parameters
<<: *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:

View 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)