246 lines
8.4 KiB
Python
246 lines
8.4 KiB
Python
# authentic2 - versatile identity manager
|
|
# Copyright (C) 2010-2019 Entr'ouvert
|
|
#
|
|
# This program is free software: you can redistribute it and/or modify it
|
|
# under the terms of the GNU Affero General Public License as published
|
|
# by the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU Affero General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU Affero General Public License
|
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
import io
|
|
import warnings
|
|
|
|
import phonenumbers
|
|
import PIL.Image
|
|
from django import forms
|
|
from django.conf import settings
|
|
from django.core import validators
|
|
from django.core.files import File
|
|
from django.forms import CharField, EmailField, FileField, ModelChoiceField, MultiValueField, ValidationError
|
|
from django.forms.fields import FILE_INPUT_CONTRADICTION
|
|
from django.utils.translation import gettext_lazy as _
|
|
|
|
from authentic2 import app_settings
|
|
from authentic2.a2_rbac.models import Role
|
|
from authentic2.forms.widgets import (
|
|
CheckPasswordInput,
|
|
EmailInput,
|
|
NewPasswordInput,
|
|
PasswordInput,
|
|
PhoneWidget,
|
|
ProfileImageInput,
|
|
)
|
|
from authentic2.manager.utils import label_from_role
|
|
from authentic2.passwords import get_password_checker, get_password_strength
|
|
from authentic2.validators import email_validator
|
|
|
|
|
|
class PasswordField(CharField):
|
|
widget = PasswordInput
|
|
|
|
|
|
class NewPasswordField(CharField):
|
|
widget = NewPasswordInput
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self.min_strength = None
|
|
|
|
def _get_min_strength(self):
|
|
return self._min_strength
|
|
|
|
def _set_min_strength(self, value):
|
|
self._min_strength = value
|
|
self.widget.min_strength = value
|
|
|
|
min_strength = property(_get_min_strength, _set_min_strength)
|
|
|
|
def validate(self, value):
|
|
super().validate(value)
|
|
if value == '':
|
|
return
|
|
|
|
min_strength = self.min_strength
|
|
if min_strength is not None:
|
|
if get_password_strength(value).strength < min_strength:
|
|
raise ValidationError(_('This password is not strong enough.'))
|
|
|
|
min_length = app_settings.A2_PASSWORD_POLICY_MIN_LENGTH
|
|
if min_length > len(value):
|
|
raise ValidationError(_('Password must be at least %s characters.') % min_length)
|
|
else:
|
|
password_checker = get_password_checker()
|
|
errors = [not check.result for check in password_checker(value)]
|
|
if any(errors):
|
|
raise ValidationError(_('This password is not accepted.'))
|
|
|
|
|
|
class CheckPasswordField(CharField):
|
|
widget = CheckPasswordInput
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
kwargs[
|
|
'help_text'
|
|
] = '''
|
|
<span class="a2-password-check-equality-default">%(default)s</span>
|
|
<span class="a2-password-check-equality-matched">%(match)s</span>
|
|
<span class="a2-password-check-equality-unmatched">%(nomatch)s</span>
|
|
''' % {
|
|
'default': _('Both passwords must match.'),
|
|
'match': _('Passwords match.'),
|
|
'nomatch': _('Passwords do not match.'),
|
|
}
|
|
super().__init__(*args, **kwargs)
|
|
|
|
|
|
class ProfileImageField(FileField):
|
|
widget = ProfileImageInput
|
|
|
|
@property
|
|
def image_size(self):
|
|
return app_settings.A2_ATTRIBUTE_KIND_PROFILE_IMAGE_SIZE
|
|
|
|
def clean(self, data, initial=None):
|
|
if data is FILE_INPUT_CONTRADICTION or data is False or data is None:
|
|
return super().clean(data, initial=initial)
|
|
# we have a file
|
|
try:
|
|
with warnings.catch_warnings():
|
|
image = PIL.Image.open(io.BytesIO(data.read()))
|
|
except (OSError, PIL.Image.DecompressionBombWarning):
|
|
raise ValidationError(_('The image is not valid'))
|
|
image = self.normalize_image(image)
|
|
new_data = self.file_from_image(image, data.name)
|
|
return super().clean(new_data, initial=initial)
|
|
|
|
def file_from_image(self, image, name=None):
|
|
output = io.BytesIO()
|
|
if image.mode != 'RGB':
|
|
image = image.convert('RGB')
|
|
image.save(output, format='JPEG', quality=99, optimize=1)
|
|
output.seek(0)
|
|
return File(output, name=name)
|
|
|
|
def normalize_image(self, image):
|
|
width = height = self.image_size
|
|
if abs((1.0 * width / height) - (1.0 * image.size[0] / image.size[1])) > 0.1:
|
|
# aspect ratio change, crop the image first
|
|
box = [0, 0, image.size[0], int(image.size[0] * (1.0 * height / width))]
|
|
|
|
if box[2] > image.size[0]:
|
|
box = [int(t * (1.0 * image.size[0] / box[2])) for t in box]
|
|
if box[3] > image.size[1]:
|
|
box = [int(t * (1.0 * image.size[1] / box[3])) for t in box]
|
|
|
|
if image.size[0] > image.size[1]: # landscape
|
|
box[0] = (image.size[0] - box[2]) / 2 # keep the middle
|
|
box[2] += box[0]
|
|
else:
|
|
box[1] = (image.size[1] - box[3]) / 4 # keep mostly the top
|
|
box[3] += box[1]
|
|
|
|
image = image.crop(box)
|
|
try:
|
|
resampling_algorithm = PIL.Image.Resampling.LANCZOS
|
|
except AttributeError:
|
|
# can be removed when Pillow < 9.1.0 is not supported anymore
|
|
resampling_algorithm = PIL.Image.LANCZOS
|
|
return image.resize([width, height], resampling_algorithm)
|
|
|
|
|
|
class ValidatedEmailField(EmailField):
|
|
default_validators = [email_validator]
|
|
widget = EmailInput
|
|
|
|
|
|
class RoleChoiceField(ModelChoiceField):
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self.queryset = Role.objects.exclude(slug__startswith='_')
|
|
|
|
def label_from_instance(self, obj):
|
|
return label_from_role(obj)
|
|
|
|
|
|
class ListValidator:
|
|
def __init__(self, item_validator):
|
|
self.item_validator = item_validator
|
|
|
|
def __call__(self, value):
|
|
for i, item in enumerate(value):
|
|
try:
|
|
self.item_validator(item)
|
|
except ValidationError as e:
|
|
raise ValidationError(_('Item {0} is invalid: {1}').format(i, e.args[0]))
|
|
|
|
|
|
class CommaSeparatedInput(forms.TextInput):
|
|
def format_value(self, value):
|
|
if not value:
|
|
return ''
|
|
if not isinstance(value, str):
|
|
return ', '.join(value)
|
|
return value
|
|
|
|
|
|
class CommaSeparatedCharField(forms.Field):
|
|
widget = CommaSeparatedInput
|
|
|
|
def __init__(self, dedup=True, max_length=None, min_length=None, *args, **kwargs):
|
|
self.dedup = dedup
|
|
self.max_length = max_length
|
|
self.min_length = min_length
|
|
item_validators = kwargs.pop('item_validators', [])
|
|
super().__init__(*args, **kwargs)
|
|
for item_validator in item_validators:
|
|
self.validators.append(ListValidator(item_validator))
|
|
|
|
def to_python(self, value):
|
|
if value in validators.EMPTY_VALUES:
|
|
return []
|
|
|
|
value = [item.strip() for item in value.split(',') if item.strip()]
|
|
if self.dedup:
|
|
value = list(set(value))
|
|
|
|
return value
|
|
|
|
def clean(self, value):
|
|
value = self.to_python(value)
|
|
self.validate(value)
|
|
self.run_validators(value)
|
|
return value
|
|
|
|
|
|
class PhoneField(MultiValueField):
|
|
widget = PhoneWidget
|
|
|
|
def __init__(self, **kwargs):
|
|
fields = (
|
|
CharField(max_length=8, initial=settings.DEFAULT_COUNTRY_CODE),
|
|
CharField(max_length=16, required=False),
|
|
)
|
|
super().__init__(error_messages=None, fields=fields, require_all_fields=False, **kwargs)
|
|
|
|
def compress(self, data_list):
|
|
from authentic2.attribute_kinds import clean_number
|
|
|
|
if data_list and data_list[1]:
|
|
country_code = data_list[0]
|
|
data_list[0] = '+%s' % data_list[0]
|
|
data_list[1] = clean_number(data_list[1])
|
|
dial = settings.PHONE_COUNTRY_CODES.get(country_code, settings.DEFAULT_COUNTRY_CODE).get('lang')
|
|
try:
|
|
pn = phonenumbers.parse(''.join(data_list), dial)
|
|
except phonenumbers.NumberParseException:
|
|
raise ValidationError(_('Invalid phone number.'))
|
|
return phonenumbers.format_number(pn, phonenumbers.PhoneNumberFormat.E164)
|
|
return ''
|