fields: password field (#3201)

This commit is contained in:
Frédéric Péters 2014-10-03 16:53:35 +02:00
parent 66a72ab979
commit 989d5f8a64
4 changed files with 277 additions and 3 deletions

View File

@ -11,6 +11,9 @@ from wcs import publisher
from wcs.qommon.form import *
from wcs.qommon.http_request import HTTPRequest
import __builtin__
__builtin__.__dict__['ngettext'] = lambda x, y, z: x
def setup_module(module):
cleanup()
@ -27,7 +30,7 @@ def teardown_module(module):
class MockHtmlForm(object):
def __init__(self, widget):
widget = copy.copy(widget)
widget = copy.deepcopy(widget)
form = Form(method='post', use_tokens=False, enctype='application/x-www-form-urlencoded')
form.widgets.append(widget)
self.as_html = str(form.render())
@ -49,7 +52,6 @@ def mock_form_submission(req, widget, html_vars={}, click=None):
form.set_form_value(k, v)
if click is not None:
request = form.form.click(click)
print 'data:', request.data
req.form = parse_query(request.data, 'utf-8')
else:
req.form = form.get_parsed_query()
@ -121,3 +123,62 @@ def test_table_list_rows_required():
mock_form_submission(req, widget, click='test$add_element')
widget = TableListRowsWidget('test', columns=['a', 'b', 'c'], required=True)
assert not widget.has_error()
def test_passwordentry_widget_success():
widget = PasswordEntryWidget('test')
form = MockHtmlForm(widget)
assert 'name="test$pwd1"' in form.as_html
req.form = {}
assert widget.parse() is None
widget = PasswordEntryWidget('test', value={'plain': 'foo'}, formats=['plain'])
req.form = {}
assert widget.parse() == {'plain': 'foo'}
widget = PasswordEntryWidget('test', formats=['plain'])
req.form = {}
mock_form_submission(req, widget, {'test$pwd1': ''})
assert widget.parse() is None
widget = PasswordEntryWidget('test', formats=['plain'])
req.form = {}
mock_form_submission(req, widget, {'test$pwd1': 'foo', 'test$pwd2': 'foo'})
assert widget.parse() == {'plain': 'foo'}
def test_passwordentry_widget_errors():
# mismatch
widget = PasswordEntryWidget('test', formats=['plain'])
req.form = {}
mock_form_submission(req, widget, {'test$pwd1': 'foo', 'test$pwd2': 'bar'})
assert widget.parse() is None
assert widget.has_error() is True
# too short
widget = PasswordEntryWidget('test', formats=['plain'], min_length=4)
req.form = {}
mock_form_submission(req, widget, {'test$pwd1': 'foo', 'test$pwd2': 'foo'})
assert widget.parse() is None
assert widget.has_error() is True
# uppercases
widget = PasswordEntryWidget('test', formats=['plain'], count_uppercase=1)
req.form = {}
mock_form_submission(req, widget, {'test$pwd1': 'foo', 'test$pwd2': 'foo'})
assert widget.parse() is None
assert widget.has_error() is True
# digits
widget = PasswordEntryWidget('test', formats=['plain'], count_digit=1)
req.form = {}
mock_form_submission(req, widget, {'test$pwd1': 'foo', 'test$pwd2': 'foo'})
assert widget.parse() is None
assert widget.has_error() is True
# specials
widget = PasswordEntryWidget('test', formats=['plain'], count_special=1)
req.form = {}
mock_form_submission(req, widget, {'test$pwd1': 'foo', 'test$pwd2': 'foo'})
assert widget.parse() is None
assert widget.has_error() is True

View File

@ -1422,6 +1422,68 @@ class RankedItemsField(WidgetField):
register_field_class(RankedItemsField)
class PasswordField(WidgetField):
key = 'password'
description = N_('Password')
min_length = 0
max_length = 0
count_uppercase = 0
count_lowercase = 0
count_digit = 0
count_special = 0
confirmation_title = None
formats = ['sha1']
extra_attributes = ['formats', 'min_length', 'max_length',
'count_uppercase', 'count_lowercase', 'count_digit',
'count_special', 'confirmation_title']
widget_class = PasswordEntryWidget
def get_admin_attributes(self):
return WidgetField.get_admin_attributes(self) + self.extra_attributes
def fill_admin_form(self, form):
WidgetField.fill_admin_form(self, form)
formats = [('plain', _('Plain')),
('md5', _('MD5')),
('sha1', _('SHA1')),
]
form.add(CheckboxesWidget, 'formats', title=_('Storage formats'),
value=self.formats, elements=formats, inline=True)
form.add(IntWidget, 'min_length', title=_('Minimum length'),
value=self.min_length)
form.add(IntWidget, 'max_length', title=_('Maximum password length'),
value=self.max_length,
hint=_('0 for unlimited length'))
form.add(IntWidget, 'count_uppercase',
title=_('Minimum number of uppercase characters'),
value=self.count_uppercase)
form.add(IntWidget, 'count_lowercase',
title=_('Minimum number of lowercase characters'),
value=self.count_lowercase)
form.add(IntWidget, 'count_digit',
title=_('Minimum number of digits'),
value=self.count_digit)
form.add(IntWidget, 'count_special',
title=_('Minimum number of special characters'),
value=self.count_special)
form.add(StringWidget, 'confirmation_title', size=50,
title=_('Label for confirmation input'),
value=self.confirmation_title)
def get_view_value(self, value):
return '*'*8
def get_csv_value(self, value, hint=None):
return [self.get_view_value(value)]
def get_rst_view_value(self, value, indent=''):
return indent + self.get_view_value(value)
register_field_class(PasswordField)
def get_field_class_by_type(type):
for k in field_classes:
if k.key == type:

View File

@ -14,6 +14,7 @@
# You should have received a copy of the GNU General Public License
# along with this program; if not, see <http://www.gnu.org/licenses/>.
import base64
import cStringIO
import mimetypes
import os
@ -24,6 +25,8 @@ import time
import random
import datetime
import itertools
import hashlib
import json
from storage import atomic_write
@ -1729,3 +1732,144 @@ class ColourWidget(SingleSelectWidget):
SingleSelectWidget._parse(self, request)
if self.value is not None:
self.value = self.value.upper()
class PasswordEntryWidget(CompositeWidget):
min_length = 0
max_length = 0
count_uppercase = 0
count_lowercase = 0
count_digit = 0
count_special = 0
def __init__(self, name, value=None, **kwargs):
# hint will be displayed with pwd1 widget
hint = kwargs.pop('hint', None)
CompositeWidget.__init__(self, name, value, **kwargs)
self.min_length = kwargs.get('min_length', 0)
self.max_length = kwargs.get('max_length', 0)
self.count_uppercase = kwargs.get('count_uppercase', 0)
self.count_lowercase = kwargs.get('count_lowercase', 0)
self.count_digit = kwargs.get('count_digit', 0)
self.count_special = kwargs.get('count_special', 0)
confirmation_title = kwargs.get('confirmation_title') or _('Confirmation')
self.formats = kwargs.get('formats', ['sha1'])
if not self.attrs.get('readonly'):
self.add(PasswordWidget, name='pwd1', title='',
value='',
required=kwargs.get('required', False),
autocomplete='off',
hint=hint)
self.add(PasswordWidget, name='pwd2', title=confirmation_title,
required=kwargs.get('required', False),
autocomplete='off')
else:
encoded_value = base64.encodestring(json.dumps(value))
if value:
fake_value = '*' * 8
else:
fake_value = ''
self.add(HtmlWidget, 'hashed', title=htmltext(
'<input value="%s" readonly="readonly">'\
'<input type="hidden" name="%s$encoded" value="%s"></input>' % (
fake_value, self.name, encoded_value)))
def render_content(self):
if self.attrs.get('readonly'):
return CompositeWidget.render_content(self)
get_response().add_javascript(['jquery.js', 'jquery.passstrength.js'])
r = TemplateIO(html=True)
r += CompositeWidget.render_content(self)
r += htmltext('''<script>
$(function() {
$('input[id="form_%(form_id)s"]').passStrengthify({
levels: ["%(veryweak)s", "%(veryweak)s", "%(weak)s", "%(weak)s", "%(moderate)s", "%(good)s", "%(strong)s", "%(verystrong)s"],
minimum: %(min_length)s,
labels: {
passwordStrength: "%(password_strength)s",
tooShort: "%(tooshort)s"
}
});
});
</script>''') % {'form_id': self.get_widget('pwd1').get_name(),
'min_length': self.min_length,
'veryweak': _('Very weak'),
'weak': _('Weak'),
'moderate': _('Moderate'),
'good': _('Good'),
'strong': _('Strong'),
'verystrong': _('Very strong'),
'password_strength': _('Password strength:'),
'tooshort': _('Too short')}
return r.getvalue()
def _parse(self, request):
CompositeWidget._parse(self, request)
if request.form.get('%s$encoded' % self.name):
self.value = json.loads(base64.decodestring(
request.form.get('%s$encoded' % self.name)))
return
pwd1 = self.get('pwd1') or ''
pwd2 = self.get('pwd2') or ''
if not self.get_widget('pwd1'):
# we are in read-only mode, stop here.
return
set_errors = []
min_len = self.min_length
if len(pwd1) < min_len:
set_errors.append(_('Password is too short. It must be at least %d characters.') % min_len)
max_len = self.max_length
if max_len and len(pwd1) > max_len:
set_errors.append(_('Password is too long. It must be at most %d characters.') % max_len)
count = self.count_uppercase
if len(filter(lambda c: c.isupper(), pwd1)) < count:
set_errors.append(
ngettext('Password must contain an uppercase character.',
'Password must contain at least %(count)d uppercase characters.',
count) % {'count': count})
count = self.count_lowercase
if len(filter(lambda c: c.islower(), pwd1)) < count:
set_errors.append(
ngettext('Password must contain a lowercase character.',
'Password must contain at least %(count)d lowercase characters.',
count) % {'count': count})
count = self.count_digit
if len(filter(lambda c: c.isdigit(), pwd1)) < self.count_digit:
set_errors.append(
ngettext('Password must contain a digit.',
'Password must contain at least %(count)d digits.',
count) % {'count': count})
count = self.count_special
if len(filter(lambda c: not c.isalnum(), pwd1)) < count:
set_errors.append(
ngettext('Password must contain a special character.',
'Password must contain at least %(count)d special characters.',
count) % {'count': count})
if pwd1 != pwd2:
self.get_widget('pwd2').set_error(_('Passwords do not match'))
pwd1 = None
if set_errors:
self.get_widget('pwd1').set_error(' '.join(set_errors))
pwd1 = None
PASSWORD_FORMATS = {
'plain': lambda x: x,
'md5': lambda x: hashlib.md5(x).hexdigest(),
'sha1': lambda x: hashlib.sha1(x).hexdigest(),
}
if pwd1:
self.value = {}
for fmt in self.formats:
self.value[fmt] = PASSWORD_FORMATS[fmt](pwd1)
else:
self.value = None

View File

@ -40,7 +40,9 @@ SQL_TYPE_MAPPING = {
'table': 'text[][]',
'table-select': 'text[][]',
'tablerows': 'text[][]',
# mapping of dicts
'ranked-items': 'text[][]',
'password': 'text[][]',
}
def get_name_as_sql_identifier(name):
@ -528,7 +530,7 @@ class SqlMixin:
continue
value = data.get(field.id)
if value is not None:
if field.key == 'ranked-items':
if field.key in ('ranked-items', 'password'):
# turn {'poire': 2, 'abricot': 1, 'pomme': 3} into an array
value = [[x, str(y)] for x, y in value.items()]
elif sql_type == 'varchar':
@ -558,6 +560,11 @@ class SqlMixin:
for data, rank in value:
d[data] = int(rank)
value = d
elif field.key == 'password':
d = {}
for fmt, val in value:
d[fmt] = val
value = d
if sql_type == 'date':
value = value.timetuple()
elif sql_type == 'bytea':