support avatar picture in user profile (#26022)

This commit is contained in:
Paul Marillonnet 2018-09-04 16:26:15 +02:00 committed by Frédéric Péters
parent e71b65b0cc
commit a5d652ce81
23 changed files with 300 additions and 19 deletions

View File

@ -25,7 +25,8 @@ Depends: ${misc:Depends}, ${python:Depends},
python-markdown (>= 2.1),
python-ldap (>= 2.4),
python-six (>= 1.0),
python-django-filters (>= 1)
python-django-filters (>= 1),
python-pil
Provides: ${python:Provides}
Recommends: python-ldap
Suggests: python-raven

3
debian/control vendored
View File

@ -27,7 +27,8 @@ Depends: ${misc:Depends}, ${python:Depends},
python-jwcrypto (>= 0.3.1),
python-cryptography (>= 1.3.4),
python-django-filters (>= 1),
python-django-filters (<< 2)
python-django-filters (<< 2),
python-pil
Provides: ${python:Provides}
Recommends: python-ldap
Suggests: python-raven

View File

@ -131,6 +131,7 @@ setup(name="authentic2",
'XStatic-jQuery',
'XStatic-jquery-ui<1.12',
'xstatic-select2',
'pillow',
],
zip_safe=False,
classifiers=[

View File

@ -145,6 +145,7 @@ default_settings = dict(
A2_OPENED_SESSION_COOKIE_NAME=Setting(default='A2_OPENED_SESSION', definition='Authentic session open'),
A2_OPENED_SESSION_COOKIE_DOMAIN=Setting(default=None),
A2_ATTRIBUTE_KINDS=Setting(default=(), definition='List of other attribute kinds'),
A2_ATTRIBUTE_KIND_PROFILE_IMAGE_MAX_SIZE=Setting(default=200, definition='Max width and height for a profile image'),
A2_VALIDATE_EMAIL=Setting(default=False, definition='Validate user email server by doing an RCPT command'),
A2_VALIDATE_EMAIL_DOMAIN=Setting(default=True, definition='Validate user email domain'),
A2_PASSWORD_POLICY_MIN_CLASSES=Setting(default=3, definition='Minimum number of characters classes to be present in passwords'),

View File

@ -1,6 +1,9 @@
import re
import string
import datetime
import io
import hashlib
import os
from itertools import chain
@ -9,14 +12,17 @@ from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
from django.utils.translation import ugettext_lazy as _, pgettext_lazy
from django.utils.functional import allow_lazy
from django.utils import html
from django.template.defaultfilters import capfirst
from django.core.files import File
from django.core.files.storage import default_storage
from rest_framework import serializers
from .decorators import to_iter
from .plugins import collect_from_plugins
from . import app_settings
from .forms import widgets
from .forms import widgets, fields
capfirst = allow_lazy(capfirst, unicode)
@ -100,6 +106,54 @@ class FrPostcodeDRFField(serializers.CharField):
default_validators = [validate_fr_postcode]
class ProfileImageFile(object):
def __init__(self, name):
self.name = name
@property
def url(self):
return default_storage.url(self.name)
def profile_image_serialize(uploadedfile):
if not uploadedfile:
return ''
if hasattr(uploadedfile, 'url'):
return uploadedfile.name
h_computation = hashlib.md5()
for chunk in uploadedfile.chunks():
h_computation.update(chunk)
hexdigest = h_computation.hexdigest()
stored_file = default_storage.save(
os.path.join('profile-image', hexdigest),
uploadedfile)
return stored_file
def profile_image_deserialize(name):
if name:
return ProfileImageFile(name)
return None
def profile_image_html_value(attribute, value):
if value:
fragment = u'<a href="%s"><img class="%s" src="%s"/></a>' % (
value.url, attribute.name, value.url)
return html.mark_safe(fragment)
return ''
def profile_attributes_ng_serialize(ctx, value):
if value and getattr(value, 'url', None):
request = ctx.get('request')
if request:
return request.build_absolute_uri(value.url)
else:
return value.url
return None
DEFAULT_ALLOW_BLANK = True
DEFAULT_MAX_LENGTH = 256
@ -160,6 +214,20 @@ DEFAULT_ATTRIBUTE_KINDS = [
'field_class': PhoneNumberField,
'rest_framework_field_class': PhoneNumberDRFField,
},
{
'label': _('profile image'),
'name': 'profile_image',
'field_class': fields.ProfileImageField,
'serialize': profile_image_serialize,
'deserialize': profile_image_deserialize,
'rest_framework_field_class': serializers.FileField,
'rest_framework_field_kwargs': {
'read_only': True,
'use_url': True,
},
'html_value': profile_image_html_value,
'attributes_ng_serialize': profile_attributes_ng_serialize,
},
]

View File

@ -49,7 +49,7 @@ def get_attribute_names(instance, ctx):
def get_dependencies(instance, ctx):
return ('user',)
return ('user', 'request')
def get_attributes(instance, ctx):
@ -68,8 +68,11 @@ def get_attributes(instance, ctx):
if user.ou:
for attr in ('uuid', 'slug', 'name'):
ctx['django_user_ou_' + attr] = getattr(user.ou, attr)
for av in AttributeValue.objects.with_owner(user):
ctx['django_user_' + str(av.attribute.name)] = av.to_python()
for av in AttributeValue.objects.with_owner(user).select_related('attribute'):
serialize = av.attribute.get_kind().get('attributes_ng_serialize', lambda a, b: b)
value = av.to_python()
serialized = serialize(ctx, value)
ctx['django_user_' + str(av.attribute.name)] = serialized
ctx['django_user_' + str(av.attribute.name) + ':verified'] = av.verified
ctx['django_user_groups'] = [group for group in user.groups.all()]
ctx['django_user_group_names'] = [unicode(group) for group in user.groups.all()]

View File

@ -1,8 +1,17 @@
from django.forms import CharField
from django.utils.translation import ugettext_lazy as _
import warnings
import io
from django.forms import CharField, FileField, ValidationError
from django.forms.fields import FILE_INPUT_CONTRADICTION
from django.utils.translation import ugettext_lazy as _
from django.core.files import File
from authentic2 import app_settings
from authentic2.passwords import password_help_text, validate_password
from authentic2.forms.widgets import PasswordInput, NewPasswordInput, CheckPasswordInput
from authentic2.forms.widgets import (PasswordInput, NewPasswordInput,
CheckPasswordInput, ProfileImageInput)
import PIL.Image
class PasswordField(CharField):
@ -33,3 +42,46 @@ class CheckPasswordField(CharField):
}
super(CheckPasswordField, self).__init__(*args, **kwargs)
class ProfileImageField(FileField):
widget = ProfileImageInput
def __init__(self, *args, **kwargs):
kwargs.setdefault(
'help_text',
_('Image must be JPG or PNG of size less '
'than {max_size}x{max_size} pixels').format(max_size=self.max_size))
super(ProfileImageField, self).__init__(*args, **kwargs)
@property
def max_size(self):
return app_settings.A2_ATTRIBUTE_KIND_PROFILE_IMAGE_MAX_SIZE
def clean(self, data, initial=None):
if data is FILE_INPUT_CONTRADICTION or data is False or data is None:
return super(ProfileImageField, self).clean(data, initial=initial)
# we have a file
try:
with warnings.catch_warnings():
image = PIL.Image.open(io.BytesIO(data.read()))
except (IOError, PIL.Image.DecompressionBombWarning):
raise ValidationError(_('The image is not valid'))
width, height = image.size
max_size = app_settings.A2_ATTRIBUTE_KIND_PROFILE_IMAGE_MAX_SIZE
if width > max_size or height > max_size:
raise ValidationError(_('The image is bigger than {max_size}x{max_size} pixels')
.format(max_size=self.max_size))
new_data = self.file_from_image(image, data.name)
return super(ProfileImageField, self).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)

View File

@ -11,7 +11,9 @@ import json
import re
import uuid
from django.forms.widgets import DateTimeInput, DateInput, TimeInput
import django
from django.forms.widgets import DateTimeInput, DateInput, TimeInput, \
ClearableFileInput
from django.forms.widgets import PasswordInput as BasePasswordInput
from django.utils.formats import get_language, get_format
from django.utils.safestring import mark_safe
@ -246,3 +248,13 @@ class CheckPasswordInput(PasswordInput):
json.dumps(_id),
)
return output
class ProfileImageInput(ClearableFileInput):
if django.VERSION < (1, 9):
template_with_initial = (
'%(initial_text)s: <a href="%(initial_url)s"><img src="%(initial_url)s"/></a> '
'%(clear_template)s<br />%(input_text)s: %(input)s'
)
else:
template_name = "authentic2/profile_image_input.html"

View File

@ -22,6 +22,8 @@ SECRET_KEY = 'please-change-me-with-a-very-long-random-string'
DEBUG = False
DEBUG_DB = False
MEDIA = 'media'
MEDIA_ROOT = 'media'
MEDIA_URL = '/media/'
# See https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts
ALLOWED_HOSTS = []

View File

@ -12,7 +12,8 @@
{% endblock %}
{% block content %}
<form method="post">
<form enctype="multipart/form-data" method="post">
{% csrf_token %}
{{ form.as_p }}
{% if form.instance and form.instance.id %}

View File

@ -0,0 +1,5 @@
{% if widget.is_initial %}{{ widget.initial_text }}: <a href="{{ widget.value.url }}"><img src="{{ widget.value.url }}"/></a>{% if not widget.required %}
<input type="checkbox" name="{{ widget.checkbox_name }}" id="{{ widget.checkbox_id }}" />
<label for="{{ widget.checkbox_id }}">{{ widget.clear_checkbox_label }}</label>{% endif %}<br />
{{ widget.input_text }}:{% endif %}
<input type="{{ widget.type }}" name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %} />

View File

@ -15,7 +15,8 @@
<h2>{{ view.title }}</h2>
<form method="post">
<form enctype="multipart/form-data" method="post">
{% csrf_token %}
{{ form.as_p }}
<button class="submit-button">{% trans 'Submit' %}</button>

View File

@ -2,6 +2,7 @@ from django.conf.urls import url, include
from django.conf import settings
from django.contrib import admin
from django.contrib.staticfiles.views import serve
from django.views.static import serve as media_serve
from . import app_settings, plugins, views
@ -44,6 +45,10 @@ if settings.DEBUG:
urlpatterns += [
url(r'^static/(?P<path>.*)$', serve)
]
urlpatterns += [
url(r'^media/(?P<path>.*)$', media_serve, {
'document_root': settings.MEDIA_ROOT})
]
if settings.DEBUG and 'debug_toolbar' in settings.INSTALLED_APPS:
import debug_toolbar

View File

@ -444,11 +444,13 @@ class ProfileView(cbv.TemplateNamesMixin, TemplateView):
if attribute:
if not attribute.user_visible:
continue
html_value = attribute.get_kind().get('html_value', lambda a, b: b)
qs = models.AttributeValue.objects.with_owner(request.user)
qs = qs.filter(attribute=attribute)
qs = qs.select_related()
value = [at_value.to_python() for at_value in qs]
value = filter(None, value)
value = [html_value(attribute, at_value) for at_value in value]
if not title:
title = unicode(attribute)
else:

View File

@ -161,14 +161,17 @@ def normalize_claim_values(values):
return values_list
def create_user_info(client, user, scope_set, id_token=False):
def create_user_info(request, client, user, scope_set, id_token=False):
'''Create user info dictionnary'''
user_info = {
'sub': make_sub(client, user)
}
attributes = get_attributes({
'user': user, 'request': None, 'service': client,
'__wanted_attributes': client.get_wanted_attributes()})
'user': user,
'request': request,
'service': client,
'__wanted_attributes': client.get_wanted_attributes(),
})
for claim in client.oidcclaim_set.filter(name__isnull=False):
if not set(claim.get_scopes()).intersection(scope_set):
continue

View File

@ -279,7 +279,11 @@ def authorize(request, *args, **kwargs):
acr = '0'
if nonce is not None and last_auth.get('nonce') == nonce:
acr = '1'
id_token = utils.create_user_info(client, request.user, scopes, id_token=True)
id_token = utils.create_user_info(request,
client,
request.user,
scopes,
id_token=True)
id_token.update({
'iss': utils.get_issuer(request),
'aud': client.client_id,
@ -386,7 +390,12 @@ def token(request, *args, **kwargs):
oidc_code.nonce):
acr = '1'
# prefill id_token with user info
id_token = utils.create_user_info(client, oidc_code.user, oidc_code.scope_set(), id_token=True)
id_token = utils.create_user_info(
request,
client,
oidc_code.user,
oidc_code.scope_set(),
id_token=True)
id_token.update({
'iss': utils.get_issuer(request),
'sub': utils.make_sub(client, oidc_code.user),
@ -430,7 +439,9 @@ def user_info(request, *args, **kwargs):
access_token = authenticate_access_token(request)
if access_token is None:
return HttpResponse('unauthenticated', status=401)
user_info = utils.create_user_info(access_token.client, access_token.user,
user_info = utils.create_user_info(request,
access_token.client,
access_token.user,
access_token.scope_set())
return HttpResponse(json.dumps(user_info), content_type='application/json')

BIN
tests/200x200.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 B

BIN
tests/201x201.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 B

View File

@ -339,3 +339,8 @@ def french_translation():
activate('fr')
yield
deactivate()
@pytest.fixture
def media(settings, tmpdir):
settings.MEDIA_ROOT = str(tmpdir.mkdir('media'))

View File

@ -5,6 +5,7 @@ from authentic2.custom_user.models import User
from authentic2.models import Attribute
from utils import get_link_from_mail
from webtest import Upload
def test_string(db, app, admin, mailoutbox):
@ -369,3 +370,66 @@ def test_birthdate_api(db, app, admin, mailoutbox, freezer):
app.post_json('/api/users/', params=payload)
assert qs.get().attributes.birthdate == datetime.date(1900, 1, 1)
qs.delete()
def test_profile_image(db, app, admin, mailoutbox, media):
Attribute.objects.create(name='cityscape_image', label='cityscape', kind='profile_image',
asked_on_registration=True, required=False,
user_visible=True, user_editable=True)
def john():
return User.objects.get(first_name='John')
response = app.get('/accounts/register/')
form = response.form
form.set('email', 'john.doe@example.com')
response = form.submit().follow()
assert 'john.doe@example.com' in response
url = get_link_from_mail(mailoutbox[0])
response = app.get(url)
# verify empty file is refused
form = response.form
form.set('first_name', 'John')
form.set('last_name', 'Doe')
form.set('cityscape_image', Upload('/dev/null'))
form.set('password1', '12345abcdA')
form.set('password2', '12345abcdA')
response = form.submit()
assert response.pyquery.find('.form-field-error #id_cityscape_image')
# verify 201x201 image is refused
form = response.form
form.set('cityscape_image', Upload('tests/201x201.jpg'))
form.set('password1', '12345abcdA')
form.set('password2', '12345abcdA')
response = form.submit()
assert response.pyquery.find('.form-field-error #id_cityscape_image')
# verify 200x200 image is accepted
form = response.form
form.set('cityscape_image', Upload('tests/200x200.jpg'))
form.set('password1', '12345abcdA')
form.set('password2', '12345abcdA')
response = form.submit()
assert john().attributes.cityscape_image
# verify API serves absolute URL for profile images
app.authorization = ('Basic', (admin.username, admin.username))
response = app.get('/api/users/%s/' % john().uuid)
assert response.json['cityscape_image'] == 'http://testserver/media/%s' % john().attributes.cityscape_image.name
app.authorization = None
# verify we can clear the image
response = app.get('/accounts/edit/')
form = response.form
form.set('edit-profile-first_name', 'John')
form.set('edit-profile-last_name', 'Doe')
form.set('edit-profile-cityscape_image-clear', True)
response = form.submit()
assert john().attributes.cityscape_image == None
# verify API serves absolute URL for profile images
app.authorization = ('Basic', (admin.username, admin.username))
response = app.get('/api/users/%s/' % john().uuid)
assert response.json['cityscape_image'] is None

View File

@ -11,6 +11,7 @@ from jwcrypto.jwk import JWKSet, JWK
import utils
from django.core.urlresolvers import reverse
from django.core.files import File
from django.db import connection
from django.db.migrations.executor import MigrationExecutor
from django.utils.timezone import now
@ -19,6 +20,7 @@ from django.contrib.auth import get_user_model
User = get_user_model()
from authentic2.models import Attribute
from authentic2_idp_oidc.models import OIDCClient, OIDCAuthorization, OIDCCode, OIDCAccessToken, OIDCClaim
from authentic2_idp_oidc.utils import make_sub
from authentic2.a2_rbac.utils import get_default_ou
@ -98,7 +100,16 @@ OIDC_CLIENT_PARAMS = [
@pytest.fixture(params=OIDC_CLIENT_PARAMS)
def oidc_client(request, superuser, app):
def oidc_client(request, superuser, app, simple_user, media):
Attribute.objects.create(
name='cityscape_image',
label='cityscape',
kind='profile_image',
asked_on_registration=True,
required=False,
user_visible=True,
user_editable=True)
url = reverse('admin:authentic2_idp_oidc_oidcclient_add')
assert OIDCClient.objects.count() == 0
response = utils.login(app, superuser, path=url)
@ -256,11 +267,19 @@ def test_authorization_code_sso(login_first, oidc_settings, oidc_client, simple_
# when adding extra attributes
OIDCClaim.objects.create(client=oidc_client, name='ou', value='django_user_ou_name', scopes='profile')
OIDCClaim.objects.create(client=oidc_client, name='roles', value='a2_role_names', scopes='profile, role')
OIDCClaim.objects.create(client=oidc_client,
name='cityscape_image',
value='django_user_cityscape_image',
scopes='profile')
simple_user.roles.add(get_role_model().objects.create(
name='Whatever', slug='whatever', ou=get_default_ou()))
response = app.get(user_info_url, headers=bearer_authentication_headers(access_token))
assert response.json['ou'] == simple_user.ou.name
assert response.json['roles'][0] == 'Whatever'
assert response.json.get('cityscape_image') is None
simple_user.attributes.cityscape_image = File(open('tests/200x200.jpg'))
response = app.get(user_info_url, headers=bearer_authentication_headers(access_token))
assert response.json['cityscape_image'].startswith('http://testserver/media/profile-image/')
# check against a user without username
simple_user.username = None

View File

@ -1,3 +1,4 @@
import re
import datetime
import base64
import unittest
@ -9,6 +10,7 @@ from django.test import Client
from django.test.utils import override_settings
from django.contrib.auth import get_user_model, REDIRECT_FIELD_NAME
from django.core.urlresolvers import reverse
from django.core.files import File
from django.utils.translation import gettext as _
from authentic2.saml import models as saml_models
@ -85,6 +87,10 @@ class SamlBaseTestCase(Authentic2TestCase):
self.code_attribute = Attribute.objects.create(kind='string', name='code', label='Code')
self.mobile_attribute = Attribute.objects.create(kind='string', name='mobile',
label='Mobile')
self.avatar_attribute = Attribute.objects.create(
kind='profile_image',
name='avatar',
label='Avatar')
self.user = get_user_model().objects.create(
email=self.email,
username=self.username,
@ -92,6 +98,7 @@ class SamlBaseTestCase(Authentic2TestCase):
last_name=self.last_name)
self.code_attribute.set_value(self.user, '1234', verified=True)
self.mobile_attribute.set_value(self.user, '5678', verified=True)
self.avatar_attribute.set_value(self.user, File(open('tests/200x200.jpg')))
self.user.set_password(self.password)
self.user.save()
self.default_ou = OrganizationalUnit.objects.get()
@ -154,6 +161,11 @@ class SamlBaseTestCase(Authentic2TestCase):
name='verified_attributes',
friendly_name='Verified attributes',
attribute_name='@verified_attributes@')
self.liberty_provider.attributes.create(
name_format='basic',
name='avatar',
friendly_name='Avatar',
attribute_name='django_user_avatar')
self.role_authorized = Role.objects.create(name='PC Delta', slug='pc-delta')
self.liberty_provider.unauthorized_url = 'https://whatever.com/loser/'
self.liberty_provider.save()
@ -406,6 +418,13 @@ class SamlSSOTestCase(SamlBaseTestCase):
("/saml:Assertion/saml:AttributeStatement/saml:Attribute[@Name='mobile']/"
"saml:AttributeValue", '5678'),
("/saml:Assertion/saml:AttributeStatement/saml:Attribute[@Name='avatar']/"
"@NameFormat", lasso.SAML2_ATTRIBUTE_NAME_FORMAT_BASIC),
("/saml:Assertion/saml:AttributeStatement/saml:Attribute[@Name='avatar']/"
"@FriendlyName", 'Avatar'),
("/saml:Assertion/saml:AttributeStatement/saml:Attribute[@Name='avatar']/"
"saml:AttributeValue", re.compile('^http://testserver/media/profile-image/.*$')),
("/saml:Assertion/saml:AttributeStatement/saml:Attribute[@Name='verified_attributes']/"
"@NameFormat", lasso.SAML2_ATTRIBUTE_NAME_FORMAT_BASIC),
("/saml:Assertion/saml:AttributeStatement/saml:Attribute[@Name='verified_attributes']/"

View File

@ -121,6 +121,11 @@ class Authentic2TestCase(TestCase):
self.assertEqual(set(values), content)
elif isinstance(content, list):
self.assertEqual(values, content)
elif hasattr(content, 'pattern'):
for value in values:
self.assertRegexpMatches(
value, content,
msg='xpath %s does not match regexp %s' % (xpath, content.pattern))
else:
raise NotImplementedError('comparing xpath result to type %s: %r is not '
'implemented' % (type(content), content))