Merge "Guaranteed password complexity using OS::Heat::RandomString"

This commit is contained in:
Jenkins 2014-07-02 22:33:35 +00:00 committed by Gerrit Code Review
commit 41aa555865
2 changed files with 298 additions and 12 deletions

View File

@ -16,10 +16,13 @@ import string
from six.moves import xrange
from heat.common import exception
from heat.engine import attributes
from heat.engine import constraints
from heat.engine import properties
from heat.engine import resource
from heat.engine import support
from heat.openstack.common.gettextutils import _
class RandomString(resource.Resource):
@ -29,9 +32,23 @@ class RandomString(resource.Resource):
This is useful for configuring passwords and secrets on services.
'''
PROPERTIES = (
LENGTH, SEQUENCE, SALT,
LENGTH, SEQUENCE, CHARACTER_CLASSES, CHARACTER_SEQUENCES,
SALT,
) = (
'length', 'sequence', 'salt',
'length', 'sequence', 'character_classes', 'character_sequences',
'salt',
)
_CHARACTER_CLASSES_KEYS = (
CHARACTER_CLASSES_CLASS, CHARACTER_CLASSES_MIN,
) = (
'class', 'min',
)
_CHARACTER_SEQUENCES = (
CHARACTER_SEQUENCES_SEQUENCE, CHARACTER_SEQUENCES_MIN,
) = (
'sequence', 'min',
)
ATTRIBUTES = (
@ -52,13 +69,74 @@ class RandomString(resource.Resource):
SEQUENCE: properties.Schema(
properties.Schema.STRING,
_('Sequence of characters to build the random string from.'),
default='lettersdigits',
constraints=[
constraints.AllowedValues(['lettersdigits', 'letters',
'lowercase', 'uppercase',
'digits', 'hexdigits',
'octdigits']),
]
],
support_status=support.SupportStatus(
support.DEPRECATED,
_('Use property %s.') % CHARACTER_CLASSES
)
),
CHARACTER_CLASSES: properties.Schema(
properties.Schema.LIST,
_('A list of character class and their constraints to generate '
'the random string from.'),
schema=properties.Schema(
properties.Schema.MAP,
schema={
CHARACTER_CLASSES_CLASS: properties.Schema(
properties.Schema.STRING,
(_('A character class and its corresponding %(min)s '
'constraint to generate the random string from.')
% {'min': CHARACTER_CLASSES_MIN}),
constraints=[
constraints.AllowedValues(
['lettersdigits', 'letters', 'lowercase',
'uppercase', 'digits', 'hexdigits',
'octdigits']),
],
default='lettersdigits'),
CHARACTER_CLASSES_MIN: properties.Schema(
properties.Schema.INTEGER,
_('The minimum number of characters from this '
'character class that will be in the generated '
'string.'),
default=1,
constraints=[
constraints.Range(1, 512),
]
)
}
)
),
CHARACTER_SEQUENCES: properties.Schema(
properties.Schema.LIST,
_('A list of character sequences and their constraints to '
'generate the random string from.'),
schema=properties.Schema(
properties.Schema.MAP,
schema={
CHARACTER_SEQUENCES_SEQUENCE: properties.Schema(
properties.Schema.STRING,
_('A character sequence and its corresponding %(min)s '
'constraint to generate the random string '
'from.') % {'min': CHARACTER_SEQUENCES_MIN},
required=True),
CHARACTER_SEQUENCES_MIN: properties.Schema(
properties.Schema.INTEGER,
_('The minimum number of characters from this '
'sequence that will be in the generated '
'string.'),
default=1,
constraints=[
constraints.Range(1, 512),
]
)
}
)
),
SALT: properties.Schema(
properties.Schema.STRING,
@ -87,14 +165,106 @@ class RandomString(resource.Resource):
}
@staticmethod
def _generate_random_string(sequence, length):
def _deprecated_random_string(sequence, length):
rand = random.SystemRandom()
return ''.join(rand.choice(sequence) for x in xrange(length))
def handle_create(self):
def _generate_random_string(self, char_sequences, char_classes, length):
random_string = ""
# Add the minimum number of chars from each char sequence & char class
if char_sequences:
for char_seq in char_sequences:
seq = char_seq[self.CHARACTER_SEQUENCES_SEQUENCE]
seq_min = char_seq[self.CHARACTER_SEQUENCES_MIN]
for _ in xrange(seq_min):
random_string += random.choice(seq)
if char_classes:
for char_class in char_classes:
cclass_class = char_class[self.CHARACTER_CLASSES_CLASS]
cclass_seq = self._sequences[cclass_class]
cclass_min = char_class[self.CHARACTER_CLASSES_MIN]
for _ in xrange(cclass_min):
random_string += random.choice(cclass_seq)
def random_class_char():
cclass_dict = random.choice(char_classes)
cclass_class = cclass_dict[self.CHARACTER_CLASSES_CLASS]
cclass_seq = self._sequences[cclass_class]
return random.choice(cclass_seq)
def random_seq_char():
seq_dict = random.choice(char_sequences)
seq = seq_dict[self.CHARACTER_SEQUENCES_SEQUENCE]
return random.choice(seq)
# Fill up rest with random chars from provided sequences & classes
if char_sequences and char_classes:
weighted_choices = ([True] * len(char_classes) +
[False] * len(char_sequences))
while len(random_string) < length:
if random.choice(weighted_choices):
random_string += random_class_char()
else:
random_string += random_seq_char()
elif char_sequences:
while len(random_string) < length:
random_string += random_seq_char()
else:
while len(random_string) < length:
random_string += random_class_char()
# Randomize string
random_string = ''.join(random.sample(random_string,
len(random_string)))
return random_string
def validate(self):
sequence = self.properties.get(self.SEQUENCE)
char_sequences = self.properties.get(self.CHARACTER_SEQUENCES)
char_classes = self.properties.get(self.CHARACTER_CLASSES)
if sequence and (char_sequences or char_classes):
msg = (_("Cannot use deprecated '%(seq)s' property along with "
"'%(char_seqs)s' or '%(char_classes)s' properties")
% {'seq': self.SEQUENCE,
'char_seqs': self.CHARACTER_SEQUENCES,
'char_classes': self.CHARACTER_CLASSES})
raise exception.StackValidationFailed(message=msg)
def char_min(char_dicts, min_prop):
if char_dicts:
return sum(char_dict[min_prop] for char_dict in char_dicts)
return 0
length = self.properties.get(self.LENGTH)
sequence = self._sequences[self.properties.get(self.SEQUENCE)]
random_string = self._generate_random_string(sequence, length)
min_length = (char_min(char_sequences, self.CHARACTER_SEQUENCES_MIN) +
char_min(char_classes, self.CHARACTER_CLASSES_MIN))
if min_length > length:
msg = _("Length property cannot be smaller than combined "
"character class and character sequence minimums")
raise exception.StackValidationFailed(message=msg)
def handle_create(self):
char_sequences = self.properties.get(self.CHARACTER_SEQUENCES)
char_classes = self.properties.get(self.CHARACTER_CLASSES)
length = self.properties.get(self.LENGTH)
if char_sequences or char_classes:
random_string = self._generate_random_string(char_sequences,
char_classes,
length)
else:
sequence = self.properties.get(self.SEQUENCE)
if not sequence: # Deprecated property not provided, use a default
sequence = "lettersdigits"
char_seq = self._sequences[sequence]
random_string = self._deprecated_random_string(char_seq, length)
self.data_set('value', random_string, redact=True)
self.resource_id_set(random_string)

View File

@ -11,6 +11,9 @@
# License for the specific language governing permissions and limitations
# under the License.
import re
import six
from testtools.matchers import HasLength
from testtools.matchers import MatchesRegex
@ -38,6 +41,46 @@ Resources:
Properties:
length: 100
sequence: octdigits
secret4:
Type: OS::Heat::RandomString
Properties:
length: 32
character_classes:
- class: digits
min: 1
- class: uppercase
min: 1
- class: lowercase
min: 20
character_sequences:
- sequence: (),[]{}
min: 1
- sequence: $_
min: 2
- sequence: '@'
min: 5
secret5:
Type: OS::Heat::RandomString
Properties:
length: 25
character_classes:
- class: digits
min: 1
- class: uppercase
min: 1
- class: lowercase
min: 20
secret6:
Type: OS::Heat::RandomString
Properties:
length: 10
character_sequences:
- sequence: (),[]{}
min: 1
- sequence: $_
min: 2
- sequence: '@'
min: 5
'''
def setUp(self):
@ -62,22 +105,95 @@ Resources:
stack = self.create_stack(self.template_random_string)
secret1 = stack['secret1']
def assert_min(pattern, string, minimum):
self.assertTrue(len(re.findall(pattern, string)) >= minimum)
random_string = secret1.FnGetAtt('value')
self.assertThat(random_string, MatchesRegex('[a-zA-Z0-9]{32}'))
assert_min('[a-zA-Z0-9]', random_string, 32)
self.assertRaises(exception.InvalidTemplateAttribute,
secret1.FnGetAtt, 'foo')
self.assertEqual(random_string, secret1.FnGetRefId())
secret2 = stack['secret2']
random_string = secret2.FnGetAtt('value')
self.assertThat(random_string, MatchesRegex('[a-zA-Z0-9]{10}'))
assert_min('[a-zA-Z0-9]', random_string, 10)
self.assertEqual(random_string, secret2.FnGetRefId())
secret3 = stack['secret3']
random_string = secret3.FnGetAtt('value')
self.assertThat(random_string, MatchesRegex('[0-7]{100}'))
assert_min('[0-7]', random_string, 100)
self.assertEqual(random_string, secret3.FnGetRefId())
secret4 = stack['secret4']
random_string = secret4.FnGetAtt('value')
self.assertEqual(len(random_string), 32)
assert_min('[0-9]', random_string, 1)
assert_min('[A-Z]', random_string, 1)
assert_min('[a-z]', random_string, 20)
assert_min('[(),\[\]{}]', random_string, 1)
assert_min('[$_]', random_string, 2)
assert_min('@', random_string, 5)
self.assertEqual(random_string, secret4.FnGetRefId())
secret5 = stack['secret5']
random_string = secret5.FnGetAtt('value')
self.assertEqual(len(random_string), 25)
assert_min('[0-9]', random_string, 1)
assert_min('[A-Z]', random_string, 1)
assert_min('[a-z]', random_string, 20)
self.assertEqual(random_string, secret5.FnGetRefId())
secret6 = stack['secret6']
random_string = secret6.FnGetAtt('value')
self.assertEqual(len(random_string), 10)
assert_min('[(),\[\]{}]', random_string, 1)
assert_min('[$_]', random_string, 2)
assert_min('@', random_string, 5)
self.assertEqual(random_string, secret6.FnGetRefId())
def test_invalid_property_combination(self):
template_random_string = '''
HeatTemplateFormatVersion: '2012-12-12'
Resources:
secret:
Type: OS::Heat::RandomString
Properties:
length: 32
sequence: octdigits
character_classes:
- class: digits
min: 1
character_sequences:
- sequence: (),[]{}
min: 1
'''
exc = self.assertRaises(exception.StackValidationFailed,
self.create_stack, template_random_string)
self.assertEqual("Cannot use deprecated 'sequence' property along "
"with 'character_sequences' or 'character_classes' "
"properties", six.text_type(exc))
def test_invalid_length(self):
template_random_string = '''
HeatTemplateFormatVersion: '2012-12-12'
Resources:
secret:
Type: OS::Heat::RandomString
Properties:
length: 5
character_classes:
- class: digits
min: 5
character_sequences:
- sequence: (),[]{}
min: 1
'''
exc = self.assertRaises(exception.StackValidationFailed,
self.create_stack, template_random_string)
self.assertEqual("Length property cannot be smaller than combined "
"character class and character sequence minimums",
six.text_type(exc))
class TestGenerateRandomString(HeatTestCase):
@ -103,7 +219,7 @@ class TestGenerateRandomString(HeatTestCase):
# doesn't generate a matching pattern by chance
for i in range(1, 32):
sequence = RandomString._sequences[self.seq]
r = RandomString._generate_random_string(sequence, self.length)
r = RandomString._deprecated_random_string(sequence, self.length)
self.assertThat(r, HasLength(self.length))
regex = '%s{%s}' % (self.pattern, self.length)