From 267f2883d3a88ae29628104028902b0a584b9a67 Mon Sep 17 00:00:00 2001 From: Paul Marillonnet Date: Tue, 4 Sep 2018 16:26:15 +0200 Subject: [PATCH] support avatar picture in user profile (#26022) --- debian-wheezy/control | 3 +- debian/control | 3 +- setup.py | 1 + src/authentic2/app_settings.py | 1 + src/authentic2/attribute_kinds.py | 70 +++++++++++++++++- .../attributes_ng/sources/django_user.py | 9 ++- src/authentic2/forms/fields.py | 56 +++++++++++++- src/authentic2/forms/widgets.py | 14 +++- src/authentic2/settings.py | 2 + .../templates/authentic2/accounts_edit.html | 3 +- .../authentic2/profile_image_input.html | 5 ++ .../registration/registration_form.html | 3 +- src/authentic2/urls.py | 5 ++ src/authentic2/views.py | 2 + src/authentic2_idp_oidc/utils.py | 9 ++- src/authentic2_idp_oidc/views.py | 17 ++++- tests/200x200.jpg | Bin 0 -> 317 bytes tests/201x201.jpg | Bin 0 -> 330 bytes tests/conftest.py | 5 ++ tests/test_attribute_kinds.py | 64 ++++++++++++++++ tests/test_idp_oidc.py | 21 +++++- tests/test_idp_saml2.py | 19 +++++ tests/utils.py | 5 ++ 23 files changed, 299 insertions(+), 18 deletions(-) create mode 100644 src/authentic2/templates/authentic2/profile_image_input.html create mode 100644 tests/200x200.jpg create mode 100644 tests/201x201.jpg diff --git a/debian-wheezy/control b/debian-wheezy/control index b8039228..1c469f3a 100644 --- a/debian-wheezy/control +++ b/debian-wheezy/control @@ -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 diff --git a/debian/control b/debian/control index 91613441..1564608b 100644 --- a/debian/control +++ b/debian/control @@ -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 diff --git a/setup.py b/setup.py index ec933942..27bb3c64 100755 --- a/setup.py +++ b/setup.py @@ -131,6 +131,7 @@ setup(name="authentic2", 'XStatic-jQuery', 'XStatic-jquery-ui<1.12', 'xstatic-select2', + 'pillow', ], zip_safe=False, classifiers=[ diff --git a/src/authentic2/app_settings.py b/src/authentic2/app_settings.py index 19154118..fdb64d7e 100644 --- a/src/authentic2/app_settings.py +++ b/src/authentic2/app_settings.py @@ -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'), diff --git a/src/authentic2/attribute_kinds.py b/src/authentic2/attribute_kinds.py index d142eba3..eed9b621 100644 --- a/src/authentic2/attribute_kinds.py +++ b/src/authentic2/attribute_kinds.py @@ -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'' % ( + 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, + }, ] diff --git a/src/authentic2/attributes_ng/sources/django_user.py b/src/authentic2/attributes_ng/sources/django_user.py index 4a075c54..2c65b218 100644 --- a/src/authentic2/attributes_ng/sources/django_user.py +++ b/src/authentic2/attributes_ng/sources/django_user.py @@ -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()] diff --git a/src/authentic2/forms/fields.py b/src/authentic2/forms/fields.py index 0ca312cc..3be157a6 100644 --- a/src/authentic2/forms/fields.py +++ b/src/authentic2/forms/fields.py @@ -1,8 +1,17 @@ -from django.forms import CharField +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) diff --git a/src/authentic2/forms/widgets.py b/src/authentic2/forms/widgets.py index c3d1dda2..36fcf7fa 100644 --- a/src/authentic2/forms/widgets.py +++ b/src/authentic2/forms/widgets.py @@ -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: ' + '%(clear_template)s
%(input_text)s: %(input)s' + ) + else: + template_name = "authentic2/profile_image_input.html" diff --git a/src/authentic2/settings.py b/src/authentic2/settings.py index c045ce2a..8c9289a6 100644 --- a/src/authentic2/settings.py +++ b/src/authentic2/settings.py @@ -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 = [] diff --git a/src/authentic2/templates/authentic2/accounts_edit.html b/src/authentic2/templates/authentic2/accounts_edit.html index c7587b14..97a03324 100644 --- a/src/authentic2/templates/authentic2/accounts_edit.html +++ b/src/authentic2/templates/authentic2/accounts_edit.html @@ -12,7 +12,8 @@ {% endblock %} {% block content %} -
+ + {% csrf_token %} {{ form.as_p }} {% if form.instance and form.instance.id %} diff --git a/src/authentic2/templates/authentic2/profile_image_input.html b/src/authentic2/templates/authentic2/profile_image_input.html new file mode 100644 index 00000000..186e7167 --- /dev/null +++ b/src/authentic2/templates/authentic2/profile_image_input.html @@ -0,0 +1,5 @@ +{% if widget.is_initial %}{{ widget.initial_text }}: {% if not widget.required %} + +{% endif %}
+{{ widget.input_text }}:{% endif %} + diff --git a/src/authentic2/templates/registration/registration_form.html b/src/authentic2/templates/registration/registration_form.html index 292cf023..68252c9b 100644 --- a/src/authentic2/templates/registration/registration_form.html +++ b/src/authentic2/templates/registration/registration_form.html @@ -15,7 +15,8 @@

{{ view.title }}

- + + {% csrf_token %} {{ form.as_p }} diff --git a/src/authentic2/urls.py b/src/authentic2/urls.py index 35b13139..b35b1559 100644 --- a/src/authentic2/urls.py +++ b/src/authentic2/urls.py @@ -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.*)$', serve) ] + urlpatterns += [ + url(r'^media/(?P.*)$', media_serve, { + 'document_root': settings.MEDIA_ROOT}) + ] if settings.DEBUG and 'debug_toolbar' in settings.INSTALLED_APPS: import debug_toolbar diff --git a/src/authentic2/views.py b/src/authentic2/views.py index c9d8d9a7..13c96d0f 100644 --- a/src/authentic2/views.py +++ b/src/authentic2/views.py @@ -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: diff --git a/src/authentic2_idp_oidc/utils.py b/src/authentic2_idp_oidc/utils.py index bae504d8..c3d03a9f 100644 --- a/src/authentic2_idp_oidc/utils.py +++ b/src/authentic2_idp_oidc/utils.py @@ -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 diff --git a/src/authentic2_idp_oidc/views.py b/src/authentic2_idp_oidc/views.py index e7a51558..86092054 100644 --- a/src/authentic2_idp_oidc/views.py +++ b/src/authentic2_idp_oidc/views.py @@ -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') diff --git a/tests/200x200.jpg b/tests/200x200.jpg new file mode 100644 index 0000000000000000000000000000000000000000..946c079344b3fc869f0b624c73cc8ff2c7610b5b GIT binary patch literal 317 zcmd6h!41MN3`M_biR>h8oEGNcfK&;lU_Pecz@Z})xUh*yIMEGwU$XwUCEK>x8+x8j z6NmsRg1vA=v#HeD6k69-*tx9}_Fh=T#}s|&gGiUVKB;E)aTo_3i&wiLFlNj|8Ft71 byC1(uY45ZgTw_mg{pEn`Wj*<;l?@88swq)C$wm>h_ zc>)nYS+EaIsCI=?t4!_cf_HB3*o&}dme@rJZD4Lv({`#!CH6zFL-uNS1dkarQGz{i c{O-puoZAP@2e;T8zWg%ccHK_?H;wjM0of@P+yDRo literal 0 HcmV?d00001 diff --git a/tests/conftest.py b/tests/conftest.py index efa66a49..b5b5e78c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -339,3 +339,8 @@ def french_translation(): activate('fr') yield deactivate() + + +@pytest.fixture +def media(settings, tmpdir): + settings.MEDIA_ROOT = str(tmpdir.mkdir('media')) diff --git a/tests/test_attribute_kinds.py b/tests/test_attribute_kinds.py index b821bc6d..bc7e6307 100644 --- a/tests/test_attribute_kinds.py +++ b/tests/test_attribute_kinds.py @@ -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 diff --git a/tests/test_idp_oidc.py b/tests/test_idp_oidc.py index 6a14525b..8ec90a9b 100644 --- a/tests/test_idp_oidc.py +++ b/tests/test_idp_oidc.py @@ -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 diff --git a/tests/test_idp_saml2.py b/tests/test_idp_saml2.py index 4575121c..3e0748f2 100644 --- a/tests/test_idp_saml2.py +++ b/tests/test_idp_saml2.py @@ -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']/" diff --git a/tests/utils.py b/tests/utils.py index d95a685d..c926f929 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -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)) -- 2.18.0