api: Drop generating a keypair and add special chars to naming

As agreed in the spec, we will both drop the generation support for a keypair
but we'll also accept @ (at) and . (dot) chars in the keyname, all of them in
the same API microversion.

Rebased the work from I5de15935e83823afa545a250cf84f6a7a37036b4

APIImpact

Implements: blueprint keypair-generation-removal
Co-Authored-By: Nicolas Parquet <nicolas.parquet@gandi.net>

Change-Id: I6a7c71fb4385348c87067543d0454f302907395e
This commit is contained in:
Sylvain Bauza 2022-07-08 17:23:01 +02:00
parent 09239fc2ea
commit a755e5d9f2
22 changed files with 344 additions and 60 deletions

View File

@ -44,12 +44,16 @@ Response
.. literalinclude:: ../../doc/api_samples/os-keypairs/v2.35/keypairs-list-resp.json
:language: javascript
Create Or Import Keypair
========================
Import (or create) Keypair
==========================
.. rest_method:: POST /os-keypairs
Generates or imports a keypair.
Imports (or generates) a keypair.
.. warning::
Generating a keypair is no longer possible starting from version 2.92.
Normal response codes: 200, 201
@ -65,7 +69,7 @@ Request
.. rest_parameters:: parameters.yaml
- keypair: keypair
- name: keypair_name
- name: keypair_name_in
- public_key: keypair_public_key_in
- type: keypair_type_in
- user_id: keypair_userid_in
@ -75,6 +79,11 @@ Request
.. literalinclude:: ../../doc/api_samples/os-keypairs/v2.10/keypairs-import-post-req.json
:language: javascript
**Example Import Keypair (v2.92): JSON request**
.. literalinclude:: ../../doc/api_samples/os-keypairs/v2.92/keypairs-import-post-req.json
:language: javascript
Response
--------
@ -93,6 +102,11 @@ Response
.. literalinclude:: ../../doc/api_samples/os-keypairs/v2.10/keypairs-import-post-resp.json
:language: javascript
**Example Import Keypair (v2.92): JSON response**
.. literalinclude:: ../../doc/api_samples/os-keypairs/v2.92/keypairs-import-post-resp.json
:language: javascript
Show Keypair Details
====================

View File

@ -4410,11 +4410,23 @@ keypair_links:
required: false
min_version: 2.35
keypair_name:
in: body
required: true
type: string
description: |
The name for the keypair.
keypair_name_in:
in: body
required: true
type: string
description: |
A name for the keypair which will be used to reference it later.
.. note::
Since microversion 2.92, allowed characters are ASCII letters
``[a-zA-Z]``, digits ``[0-9]`` and the following special
characters: ``[@._- ]``.
keypair_private_key:
description: |
If you do not provide a public key on create, a new keypair will
@ -4424,6 +4436,7 @@ keypair_private_key:
in: body
required: false
type: string
max_version: 2.91
keypair_public_key:
description: |
The keypair public key.
@ -4432,10 +4445,11 @@ keypair_public_key:
type: string
keypair_public_key_in:
description: |
The public ssh key to import. If you omit this value, a keypair is
generated for you.
The public ssh key to import.
Was optional before microversion 2.92 : if you were omitting this value, a
keypair was generated for you.
in: body
required: false
required: true
type: string
keypair_type:
in: body

View File

@ -0,0 +1,8 @@
{
"keypair": {
"name": "me.and.myself@this.nice.domain.com mooh.",
"type": "ssh",
"public_key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDx8nkQv/zgGgB4rMYmIf+6A4l6Rr+o/6lHBQdW5aYd44bd8JttDCE/F/pNRr0lRE+PiqSPO8nDPHw0010JeMH9gYgnnFlyY3/OcJ02RhIPyyxYpv9FhY+2YiUkpwFOcLImyrxEsYXpD/0d3ac30bNH6Sw9JD9UZHYcpSxsIbECHw== Generated-by-Nova",
"user_id": "fake"
}
}

View File

@ -0,0 +1,9 @@
{
"keypair": {
"fingerprint": "1e:2c:9b:56:79:4b:45:77:f9:ca:7a:98:2c:b0:d5:3c",
"name": "me.and.myself@this.nice.domain.com mooh.",
"type": "ssh",
"public_key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDx8nkQv/zgGgB4rMYmIf+6A4l6Rr+o/6lHBQdW5aYd44bd8JttDCE/F/pNRr0lRE+PiqSPO8nDPHw0010JeMH9gYgnnFlyY3/OcJ02RhIPyyxYpv9FhY+2YiUkpwFOcLImyrxEsYXpD/0d3ac30bNH6Sw9JD9UZHYcpSxsIbECHw== Generated-by-Nova",
"user_id": "fake"
}
}

View File

@ -19,7 +19,7 @@
}
],
"status": "CURRENT",
"version": "2.91",
"version": "2.92",
"min_version": "2.1",
"updated": "2013-07-23T11:33:21Z"
}

View File

@ -22,7 +22,7 @@
}
],
"status": "CURRENT",
"version": "2.91",
"version": "2.92",
"min_version": "2.1",
"updated": "2013-07-23T11:33:21Z"
}

View File

@ -249,6 +249,9 @@ REST_API_VERSION_HISTORY = """REST API Version History:
server responses regardless of policy configuration.
* 2.91 - Add support to unshelve instance to a specific host and
to pin/unpin AZ.
* 2.92 - Drop generation of keypair, add keypair name validation on
``POST /os-keypairs`` and allow including @ and dot (.) characters
in keypair name.
"""
# The minimum and maximum versions of the API supported
@ -257,7 +260,7 @@ REST_API_VERSION_HISTORY = """REST API Version History:
# Note(cyeoh): This only applies for the v2.1 API once microversions
# support is fully merged. It does not affect the V2 API.
_MIN_API_VERSION = '2.1'
_MAX_API_VERSION = '2.91'
_MAX_API_VERSION = '2.92'
DEFAULT_API_VERSION = _MIN_API_VERSION
# Almost all proxy APIs which are related to network, images and baremetal

View File

@ -43,15 +43,19 @@ class KeypairController(wsgi.Controller):
@wsgi.Controller.api_version("2.10")
@wsgi.response(201)
@wsgi.expected_errors((400, 403, 409))
@validation.schema(keypairs.create_v210)
@validation.schema(keypairs.create_v210, "2.10", "2.91")
@validation.schema(keypairs.create_v292, "2.92")
def create(self, req, body):
"""Create or import keypair.
Keypair generations are allowed until version 2.91.
Afterwards, only imports are allowed.
A policy check restricts users from creating keys for other users
params: keypair object with:
name (required) - string
public_key (optional) - string
public_key (optional or required if >=2.92) - string
type (optional) - string
user_id (optional) - string
"""
@ -114,6 +118,8 @@ class KeypairController(wsgi.Controller):
context, user_id, name, params['public_key'],
key_type_value)
else:
# public_key is a required field starting with 2.92 so this
# generation should only happen with older versions.
keypair, private_key = self.api.create_key_pair(
context, user_id, name, key_type_value)
keypair['private_key'] = private_key

View File

@ -1211,3 +1211,11 @@ responses is now visible to all users. Previously this was an admin-only field.
Add support to unshelve instance to a specific host.
Add support to pin a server to an availability zone or unpin a server from any availability zone.
.. _microversion 2.92:
2.92
----
The ``POST /os-keypairs`` API now forbids to generate a keypair and allows new
safe characters, specifically '@' and '.' (dot character).

View File

@ -23,7 +23,7 @@ create = {
'keypair': {
'type': 'object',
'properties': {
'name': parameter_types.name,
'name': parameter_types.keypair_name_special_chars,
'public_key': {'type': 'string'},
},
'required': ['name'],
@ -46,7 +46,7 @@ create_v22 = {
'keypair': {
'type': 'object',
'properties': {
'name': parameter_types.name,
'name': parameter_types.keypair_name_special_chars,
'type': {
'type': 'string',
'enum': ['ssh', 'x509']
@ -67,7 +67,7 @@ create_v210 = {
'keypair': {
'type': 'object',
'properties': {
'name': parameter_types.name,
'name': parameter_types.keypair_name_special_chars,
'type': {
'type': 'string',
'enum': ['ssh', 'x509']
@ -83,6 +83,11 @@ create_v210 = {
'additionalProperties': False,
}
create_v292 = copy.deepcopy(create_v210)
create_v292['properties']['keypair']['properties']['name'] = (parameter_types.
keypair_name_special_chars_292)
create_v292['properties']['keypair']['required'] = ['name', 'public_key']
index_query_schema_v20 = {
'type': 'object',
'properties': {},

View File

@ -290,7 +290,7 @@ fqdn = {
name = {
# NOTE: Nova v2.1 API contains some 'name' parameters such
# as keypair, server, flavor, aggregate and so on. They are
# as server, flavor, aggregate and so on. They are
# stored in the DB and Nova specific parameters.
# This definition is used for all their parameters.
'type': 'string', 'minLength': 1, 'maxLength': 255,
@ -304,6 +304,18 @@ az_name = {
}
keypair_name_special_chars = {'allOf': [name, {
'type': 'string', 'minLength': 1, 'maxLength': 255,
'format': 'keypair_name_20'
}]}
keypair_name_special_chars_292 = {'allOf': [name, {
'type': 'string', 'minLength': 1, 'maxLength': 255,
'format': 'keypair_name_292'
}]}
az_name_with_leading_trailing_spaces = {
'type': 'string', 'minLength': 1, 'maxLength': 255,
'format': 'az_name_with_leading_trailing_spaces'

View File

@ -17,6 +17,7 @@ Internal implementation of request Body validating middleware.
"""
import re
import string
import jsonschema
from jsonschema import exceptions as jsonschema_exc
@ -153,6 +154,28 @@ def _validate_az_name(instance):
raise exception.InvalidName(reason=regex.reason)
@jsonschema.FormatChecker.cls_checks('keypair_name_20',
exception.InvalidName)
def _validate_keypair_name_20(keypair_name):
safe_chars = "_- " + string.digits + string.ascii_letters
return _validate_keypair_name(keypair_name, safe_chars)
@jsonschema.FormatChecker.cls_checks('keypair_name_292',
exception.InvalidName)
def _validate_keypair_name_292(keypair_name):
safe_chars = "@._- " + string.digits + string.ascii_letters
return _validate_keypair_name(keypair_name, safe_chars)
def _validate_keypair_name(keypair_name, safe_chars):
clean_value = "".join(x for x in keypair_name if x in safe_chars)
if clean_value != keypair_name:
reason = _("Only expected characters: [%s]") % safe_chars
raise exception.InvalidName(reason=reason)
return True
def _soft_validate_additional_properties(validator,
additional_properties_value,
instance,

View File

@ -22,7 +22,6 @@ networking and storage of VMs, and compute hosts on which they run)."""
import collections
import functools
import re
import string
import typing as ty
from castellan import key_manager
@ -6694,19 +6693,7 @@ class KeypairAPI:
}
self.notifier.info(context, 'keypair.%s' % event_suffix, payload)
def _validate_new_key_pair(self, context, user_id, key_name, key_type):
safe_chars = "_- " + string.digits + string.ascii_letters
clean_value = "".join(x for x in key_name if x in safe_chars)
if clean_value != key_name:
raise exception.InvalidKeypair(
reason=_("Keypair name contains unsafe characters"))
try:
utils.check_string_length(key_name, min_length=1, max_length=255)
except exception.InvalidInput:
raise exception.InvalidKeypair(
reason=_('Keypair name must be string and between '
'1 and 255 characters long'))
def _check_key_pair_quotas(self, context, user_id, key_name, key_type):
try:
objects.Quotas.check_deltas(context, {'key_pairs': 1}, user_id)
local_limit.enforce_db_limit(context, local_limit.KEY_PAIRS,
@ -6720,7 +6707,7 @@ class KeypairAPI:
def import_key_pair(self, context, user_id, key_name, public_key,
key_type=keypair_obj.KEYPAIR_TYPE_SSH):
"""Import a key pair using an existing public key."""
self._validate_new_key_pair(context, user_id, key_name, key_type)
self._check_key_pair_quotas(context, user_id, key_name, key_type)
self._notify(context, 'import.start', key_name)
@ -6755,7 +6742,7 @@ class KeypairAPI:
def create_key_pair(self, context, user_id, key_name,
key_type=keypair_obj.KEYPAIR_TYPE_SSH):
"""Create a new key pair."""
self._validate_new_key_pair(context, user_id, key_name, key_type)
self._check_key_pair_quotas(context, user_id, key_name, key_type)
keypair = objects.KeyPair(context)
keypair.user_id = user_id

View File

@ -0,0 +1,8 @@
{
"keypair": {
"name": "%(keypair_name)s",
"type": "%(keypair_type)s",
"public_key": "%(public_key)s",
"user_id": "%(user_id)s"
}
}

View File

@ -0,0 +1,9 @@
{
"keypair": {
"fingerprint": "%(fingerprint)s",
"name": "%(keypair_name)s",
"type": "%(keypair_type)s",
"public_key": "%(public_key)s",
"user_id": "%(user_id)s"
}
}

View File

@ -0,0 +1,7 @@
{
"keypair": {
"name": "%(keypair_name)s",
"type": "%(keypair_type)s",
"user_id": "%(user_id)s"
}
}

View File

@ -319,3 +319,66 @@ class KeyPairsV235SampleJsonTest(api_sample_base.ApiSampleTestBaseV21):
% keypairs_user2[1])
subs = {'keypair_name': keypairs_user2[2]}
self._verify_response('keypairs-list-user2-resp', subs, response, 200)
class KeyPairsV292SampleJsonTest(api_sample_base.ApiSampleTestBaseV21):
ADMIN_API = True
sample_dir = 'os-keypairs'
microversion = '2.92'
expected_post_status_code = 201
scenarios = [('v2_92', {'api_major_version': 'v2.1'})]
def setUp(self):
super(KeyPairsV292SampleJsonTest, self).setUp()
self.api.microversion = self.microversion
# NOTE(sbauza): This method is stupidly needed for _verify_response().
# See the TODO(sdague) above.
def generalize_subs(self, subs, vanilla_regexes):
subs['keypair_name'] = '[0-9a-zA-Z-_.@ ]+'
return subs
def test_keypairs_post_no_longer_supported(self):
subs = {
'keypair_name': 'foo',
'keypair_type': keypair_obj.KEYPAIR_TYPE_SSH,
'user_id': 'fake'
}
response = self._do_post('os-keypairs', 'keypairs-post-req', subs)
self.assertEqual(400, response.status_code)
def test_keypairs_import_key_invalid_name(self):
public_key = fake_crypto.get_ssh_public_key()
subs = {
'keypair_name': '!nvalid=name|',
'keypair_type': keypair_obj.KEYPAIR_TYPE_SSH,
'user_id': 'fake',
'public_key': public_key,
}
response = self._do_post('os-keypairs', 'keypairs-import-post-req',
subs)
self.assertEqual(400, response.status_code)
def _test_keypairs_import_key_post(self, name=None):
if not name:
name = 'keypair-' + uuids.fake
public_key = fake_crypto.get_ssh_public_key()
params = {
'keypair_name': name,
'keypair_type': keypair_obj.KEYPAIR_TYPE_SSH,
'user_id': 'fake',
'public_key': public_key,
}
response = self._do_post('os-keypairs', 'keypairs-import-post-req',
params)
# NOTE(sbauza): We do some crazy regexp change in _verify_response() so
# we only need to pass the keypair name.
subs = {'keypair_name': name}
self._verify_response('keypairs-import-post-resp', subs, response,
self.expected_post_status_code)
def test_keypairs_import_key_post(self):
self._test_keypairs_import_key_post()
def test_keypairs_import_key_special_characters(self):
self._test_keypairs_import_key_post(name='my-key@ my.host')

View File

@ -16,7 +16,12 @@ from nova.tests.functional.notification_sample_tests \
class TestKeypairNotificationSample(
notification_sample_base.NotificationSampleTestBase):
api_major_version = 'v2.1'
microversion = 'latest'
def test_keypair_create_delete(self):
# Keypair generation is no longer supported with 2.92 microversion.
self.api.microversion = '2.91'
keypair_req = {
"keypair": {
"name": "my-key",

View File

@ -15,6 +15,7 @@
from nova import context
from nova import objects
from nova.tests.functional import integrated_helpers
from nova.tests.unit import fake_crypto
class RebuildWithKeypairTestCase(integrated_helpers._IntegratedTestBase):
@ -26,14 +27,19 @@ class RebuildWithKeypairTestCase(integrated_helpers._IntegratedTestBase):
microversion = 'latest'
def test_rebuild_with_keypair(self):
pub_key1 = fake_crypto.get_ssh_public_key()
keypair_req = {
'keypair': {
'name': 'test-key1',
'type': 'ssh',
'public_key': pub_key1,
},
}
keypair1 = self.api.post_keypair(keypair_req)
pub_key2 = fake_crypto.get_ssh_public_key()
keypair_req['keypair']['name'] = 'test-key2'
keypair_req['keypair']['public_key'] = pub_key2
keypair2 = self.api.post_keypair(keypair_req)
server = self._build_server(networks='none')

View File

@ -37,6 +37,8 @@ keypair_data = {
FAKE_UUID = 'b48316c5-71e8-45e4-9884-6c78055b9b13'
keypair_name_2_92_compatible = 'my-key@ my.host'
def fake_keypair(name):
return dict(test_keypair.fake_keypair,
@ -110,16 +112,22 @@ class KeypairsTestV21(test.TestCase):
self.assertGreater(len(res_dict['keypair']['private_key']), 0)
self._assert_keypair_type(res_dict)
def _test_keypair_create_bad_request_case(self,
body,
exception):
self.assertRaises(exception,
self.controller.create, self.req, body=body)
def _test_keypair_create_bad_request_case(
self, body, exception, error_msg=None
):
if error_msg:
self.assertRaisesRegex(exception, error_msg,
self.controller.create,
self.req, body=body)
else:
self.assertRaises(exception,
self.controller.create, self.req, body=body)
def test_keypair_create_with_empty_name(self):
body = {'keypair': {'name': ''}}
self._test_keypair_create_bad_request_case(body,
self.validation_error)
self.validation_error,
'is too short')
def test_keypair_create_with_name_too_long(self):
body = {
@ -128,7 +136,8 @@ class KeypairsTestV21(test.TestCase):
}
}
self._test_keypair_create_bad_request_case(body,
self.validation_error)
self.validation_error,
'is too long')
def test_keypair_create_with_name_leading_trailing_spaces(self):
body = {
@ -136,8 +145,10 @@ class KeypairsTestV21(test.TestCase):
'name': ' test '
}
}
expected_msg = 'Can not start or end with whitespace.'
self._test_keypair_create_bad_request_case(body,
self.validation_error)
self.validation_error,
expected_msg)
def test_keypair_create_with_name_leading_trailing_spaces_compat_mode(
self):
@ -152,8 +163,21 @@ class KeypairsTestV21(test.TestCase):
'name': 'test/keypair'
}
}
expected_msg = 'Only expected characters'
self._test_keypair_create_bad_request_case(body,
webob.exc.HTTPBadRequest)
self.validation_error,
expected_msg)
def test_keypair_create_with_special_characters(self):
body = {
'keypair': {
'name': keypair_name_2_92_compatible
}
}
expected_msg = 'Only expected characters'
self._test_keypair_create_bad_request_case(body,
self.validation_error,
expected_msg)
def test_keypair_import_bad_key(self):
body = {
@ -167,8 +191,10 @@ class KeypairsTestV21(test.TestCase):
def test_keypair_create_with_invalid_keypair_body(self):
body = {'alpha': {'name': 'create_test'}}
expected_msg = "'keypair' is a required property"
self._test_keypair_create_bad_request_case(body,
self.validation_error)
self.validation_error,
expected_msg)
def test_keypair_import(self):
body = {
@ -470,3 +496,82 @@ class KeypairsTestV275(test.TestCase):
version='2.75', use_admin_context=True)
self.assertRaises(exception.ValidationError, self.controller.delete,
req, 1)
class KeypairsTestV292(test.TestCase):
wsgi_api_version = '2.92'
wsgi_old_api_version = '2.91'
def setUp(self):
super(KeypairsTestV292, self).setUp()
self.controller = keypairs_v21.KeypairController()
self.req = fakes.HTTPRequest.blank('', version=self.wsgi_api_version)
self.old_req = fakes.HTTPRequest.blank(
'', version=self.wsgi_old_api_version)
def test_keypair_create_no_longer_supported(self):
body = {
'keypair': {
'name': keypair_name_2_92_compatible,
}
}
self.assertRaises(exception.ValidationError, self.controller.create,
self.req, body=body)
def test_keypair_create_works_with_old_version(self):
body = {
'keypair': {
'name': 'fake',
}
}
res_dict = self.controller.create(self.old_req, body=body)
self.assertEqual('fake', res_dict['keypair']['name'])
self.assertGreater(len(res_dict['keypair']['private_key']), 0)
def test_keypair_import_works_with_new_version(self):
body = {
'keypair': {
'name': 'fake',
'public_key': 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDBYIznA'
'x9D7118Q1VKGpXy2HDiKyUTM8XcUuhQpo0srqb9rboUp4'
'a9NmCwpWpeElDLuva707GOUnfaBAvHBwsRXyxHJjRaI6Y'
'Qj2oLJwqvaSaWUbyT1vtryRqy6J3TecN0WINY71f4uymi'
'MZP0wby4bKBcYnac8KiCIlvkEl0ETjkOGUq8OyWRmn7lj'
'j5SESEUdBP0JnuTFKddWTU/wD6wydeJaUhBTqOlHn0kX1'
'GyqoNTE1UEhcM5ZRWgfUZfTjVyDF2kGj3vJLCJtJ8LoGc'
'j7YaN4uPg1rBle+izwE/tLonRrds+cev8p6krSSrxWOwB'
'bHkXa6OciiJDvkRzJXzf',
}
}
res_dict = self.controller.create(self.req, body=body)
self.assertEqual('fake', res_dict['keypair']['name'])
self.assertNotIn('private_key', res_dict['keypair'])
def test_keypair_create_refuses_special_chars_with_old_version(self):
body = {
'keypair': {
'name': keypair_name_2_92_compatible,
}
}
self.assertRaises(exception.ValidationError, self.controller.create,
self.old_req, body=body)
def test_keypair_import_with_special_characters(self):
body = {
'keypair': {
'name': keypair_name_2_92_compatible,
'public_key': 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDBYIznA'
'x9D7118Q1VKGpXy2HDiKyUTM8XcUuhQpo0srqb9rboUp4'
'a9NmCwpWpeElDLuva707GOUnfaBAvHBwsRXyxHJjRaI6Y'
'Qj2oLJwqvaSaWUbyT1vtryRqy6J3TecN0WINY71f4uymi'
'MZP0wby4bKBcYnac8KiCIlvkEl0ETjkOGUq8OyWRmn7lj'
'j5SESEUdBP0JnuTFKddWTU/wD6wydeJaUhBTqOlHn0kX1'
'GyqoNTE1UEhcM5ZRWgfUZfTjVyDF2kGj3vJLCJtJ8LoGc'
'j7YaN4uPg1rBle+izwE/tLonRrds+cev8p6krSSrxWOwB'
'bHkXa6OciiJDvkRzJXzf',
}
}
res_dict = self.controller.create(self.req, body=body)
self.assertEqual(keypair_name_2_92_compatible,
res_dict['keypair']['name'])

View File

@ -123,24 +123,6 @@ class CreateImportSharedTestMixIn(object):
name, *args)
self.assertIn(expected_message, str(exc))
def assertInvalidKeypair(self, expected_message, name):
msg = 'Keypair data is invalid: %s' % expected_message
self.assertKeypairRaises(exception.InvalidKeypair, msg, name)
def test_name_too_short(self):
msg = ('Keypair name must be string and between 1 '
'and 255 characters long')
self.assertInvalidKeypair(msg, '')
def test_name_too_long(self):
msg = ('Keypair name must be string and between 1 '
'and 255 characters long')
self.assertInvalidKeypair(msg, 'x' * 256)
def test_invalid_chars(self):
msg = "Keypair name contains unsafe characters"
self.assertInvalidKeypair(msg, '* BAD CHARACTERS! *')
def test_already_exists(self):
def db_key_pair_create_duplicate(context, keypair):
raise exception.KeyPairExists(key_name=keypair.get('name', ''))

View File

@ -0,0 +1,10 @@
---
features:
- |
The 2.92 microversion makes the following changes:
* Make public_key a mandatory parameter for keypair creation. This means
that by this microversion, Nova will stop to support automatic keypair
generations. Only imports will be possible.
* Allow 2 new special characters: '@' and '.' (dot),
in addition to the existing constraints of ``[a-z][A-Z][0-9][_- ]``