Projet

Général

Profil

0001-idp_oidc-make-user-info-depend-on-profile-choice-dur.patch

Paul Marillonnet, 04 mars 2022 07:52

Télécharger (36,3 ko)

Voir les différences:

Subject: [PATCH 1/2] idp_oidc: make user info depend on profile choice during
 authz (#58556)

 src/authentic2/admin.py                       |   2 +-
 src/authentic2/app_settings.py                |   4 +
 .../migrations/0031_profile_email.py          |  18 ++
 src/authentic2/custom_user/models.py          |   1 +
 src/authentic2_idp_oidc/admin.py              |  12 +-
 src/authentic2_idp_oidc/app_settings.py       |   4 +
 src/authentic2_idp_oidc/apps.py               |   4 +-
 .../migrations/0015_auto_20220304_0738.py     |  51 ++++
 src/authentic2_idp_oidc/models.py             |  16 +
 .../static/authentic2_idp_oidc/css/style.css  |  14 +
 .../authentic2_idp_oidc/authorization.html    |  33 +-
 src/authentic2_idp_oidc/utils.py              |  54 +++-
 src/authentic2_idp_oidc/views.py              |  54 +++-
 tests/idp_oidc/conftest.py                    |   8 +
 tests/idp_oidc/test_user_profiles.py          | 287 ++++++++++++++++++
 15 files changed, 539 insertions(+), 23 deletions(-)
 create mode 100644 src/authentic2/custom_user/migrations/0031_profile_email.py
 create mode 100644 src/authentic2_idp_oidc/migrations/0015_auto_20220304_0738.py
 create mode 100644 src/authentic2_idp_oidc/static/authentic2_idp_oidc/css/style.css
 create mode 100644 tests/idp_oidc/test_user_profiles.py
src/authentic2/admin.py
424 424

  
425 425

  
426 426
class ProfileAdmin(admin.ModelAdmin):
427
    list_display = ['profile_type', 'user', 'identifier']
427
    list_display = ['profile_type', 'user', 'identifier', 'email']
428 428

  
429 429

  
430 430
admin.site.register(Profile, ProfileAdmin)
src/authentic2/app_settings.py
333 333
        default=250,
334 334
        definition='Maximum number of mails to send per period',
335 335
    ),
336
    A2_USER_PROFILE_MANAGEMENT=Setting(
337
        default=False,
338
        definition='Activate user profiles for juridical entity management.',
339
    ),
336 340
)
337 341

  
338 342
app_settings = AppSettings(default_settings)
src/authentic2/custom_user/migrations/0031_profile_email.py
1
# Generated by Django 2.2.24 on 2022-02-15 11:39
2

  
3
from django.db import migrations, models
4

  
5

  
6
class Migration(migrations.Migration):
7

  
8
    dependencies = [
9
        ('custom_user', '0030_auto_20220222_1028'),
10
    ]
11

  
12
    operations = [
13
        migrations.AddField(
14
            model_name='profile',
15
            name='email',
16
            field=models.EmailField(blank=True, max_length=254, verbose_name='profile email address'),
17
        ),
18
    ]
src/authentic2/custom_user/models.py
520 520
        to=User, verbose_name=_('user'), related_name='subprofiles', on_delete=models.CASCADE
521 521
    )
522 522
    identifier = models.CharField(max_length=256, verbose_name=_('profile identifier'), default='')
523
    email = models.EmailField(blank=True, max_length=254, verbose_name=_('profile email address'))
523 524
    data = JSONField(verbose_name=_('profile data'), null=True, blank=True)
524 525

  
525 526
    class Meta:
src/authentic2_idp_oidc/admin.py
60 60

  
61 61

  
62 62
class OIDCClientAdmin(admin.ModelAdmin):
63
    list_display = ['name', 'slug', 'client_id', 'ou', 'identifier_policy', 'created', 'modified']
63
    list_display = [
64
        'name',
65
        'slug',
66
        'client_id',
67
        'ou',
68
        'identifier_policy',
69
        'created',
70
        'modified',
71
        'activate_user_profiles',
72
        'perform_sub_profile_substitution',
73
    ]
64 74
    list_filter = ['ou', 'identifier_policy']
65 75
    date_hierarchy = 'modified'
66 76
    readonly_fields = ['created', 'modified']
src/authentic2_idp_oidc/app_settings.py
79 79
            ],
80 80
        )
81 81

  
82
    @property
83
    def PROFILE_OVERRIDE_MAPPING(self):
84
        return self._setting('PROFILE_OVERRIDE_MAPPING', {'email': 'email'})
85

  
82 86

  
83 87
app_settings = AppSettings('A2_IDP_OIDC_')
84 88
app_settings.__name__ = __name__
src/authentic2_idp_oidc/apps.py
88 88
        sub = smart_bytes(view.kwargs[lookup_url_kwarg])
89 89
        decrypted = utils.reverse_pairwise_sub(client, sub)
90 90
        if decrypted:
91
            view.kwargs[lookup_url_kwarg] = uuid.UUID(bytes=decrypted).hex
91
            view.kwargs[lookup_url_kwarg] = uuid.UUID(bytes=decrypted.split(b'#')[0]).hex
92 92

  
93 93
    def a2_hook_api_modify_serializer_after_validation(self, view, serializer):
94 94
        import uuid
......
111 111
        for u in serializer.validated_data['known_uuids']:
112 112
            decrypted = utils.reverse_pairwise_sub(client, smart_bytes(u))
113 113
            if decrypted:
114
                new_known_uuid = uuid.UUID(bytes=decrypted).hex
114
                new_known_uuid = uuid.UUID(bytes=decrypted.split(b'#')[0]).hex
115 115
                new_known_uuids.append(new_known_uuid)
116 116
                uuid_map[new_known_uuid] = u
117 117
            else:
src/authentic2_idp_oidc/migrations/0015_auto_20220304_0738.py
1
# Generated by Django 2.2.24 on 2022-03-04 06:38
2

  
3
import django.db.models.deletion
4
from django.db import migrations, models
5

  
6

  
7
class Migration(migrations.Migration):
8

  
9
    dependencies = [
10
        ('custom_user', '0031_profile_email'),
11
        ('authentic2_idp_oidc', '0014_auto_20201126_1812'),
12
    ]
13

  
14
    operations = [
15
        migrations.AddField(
16
            model_name='oidcauthorization',
17
            name='profile',
18
            field=models.ForeignKey(
19
                null=True,
20
                on_delete=django.db.models.deletion.CASCADE,
21
                to='custom_user.Profile',
22
                verbose_name='profile',
23
            ),
24
        ),
25
        migrations.AddField(
26
            model_name='oidcclient',
27
            name='activate_user_profiles',
28
            field=models.BooleanField(
29
                blank=True, default=False, verbose_name="activate users' juridical entity profiles management"
30
            ),
31
        ),
32
        migrations.AddField(
33
            model_name='oidcclient',
34
            name='perform_sub_profile_substitution',
35
            field=models.BooleanField(
36
                blank=True,
37
                default=False,
38
                verbose_name="make pseudonym subs depend on the user's chosen profile",
39
            ),
40
        ),
41
        migrations.AddField(
42
            model_name='oidccode',
43
            name='profile',
44
            field=models.ForeignKey(
45
                null=True,
46
                on_delete=django.db.models.deletion.CASCADE,
47
                to='custom_user.Profile',
48
                verbose_name='user selected profile',
49
            ),
50
        ),
51
    ]
src/authentic2_idp_oidc/models.py
27 27
from django.utils.translation import ugettext_lazy as _
28 28

  
29 29
from authentic2.a2_rbac.models import OrganizationalUnit
30
from authentic2.custom_user.models import Profile
30 31
from authentic2.models import Service
31 32

  
32 33
from . import app_settings, managers, utils
......
129 130
        default=ALGO_HMAC, choices=ALGO_CHOICES, verbose_name=_('IDToken signature algorithm')
130 131
    )
131 132
    has_api_access = models.BooleanField(verbose_name=_('has API access'), default=False)
133

  
134
    activate_user_profiles = models.BooleanField(
135
        verbose_name=_("activate users' juridical entity profiles management"), blank=True, default=False
136
    )
137

  
138
    perform_sub_profile_substitution = models.BooleanField(
139
        verbose_name=_("make pseudonym subs depend on the user's chosen profile"),
140
        blank=True,
141
        default=False,
142
    )
143

  
132 144
    frontchannel_logout_uri = models.URLField(verbose_name=_('frontchannel logout URI'), blank=True)
133 145
    frontchannel_timeout = models.PositiveIntegerField(
134 146
        verbose_name=_('frontchannel timeout'), null=True, blank=True
......
250 262
    client = GenericForeignKey('client_ct', 'client_id')
251 263
    user = models.ForeignKey(to=settings.AUTH_USER_MODEL, verbose_name=_('user'), on_delete=models.CASCADE)
252 264
    scopes = models.TextField(blank=False, verbose_name=_('scopes'))
265
    profile = models.ForeignKey(to=Profile, verbose_name=_('profile'), on_delete=models.CASCADE, null=True)
253 266

  
254 267
    # metadata
255 268
    created = models.DateTimeField(verbose_name=_('created'), auto_now_add=True)
......
301 314
    uuid = models.CharField(max_length=128, verbose_name=_('uuid'), default=generate_uuid)
302 315
    client = models.ForeignKey(to=OIDCClient, verbose_name=_('client'), on_delete=models.CASCADE)
303 316
    user = models.ForeignKey(to=settings.AUTH_USER_MODEL, verbose_name=_('user'), on_delete=models.CASCADE)
317
    profile = models.ForeignKey(
318
        to=Profile, verbose_name=_('user selected profile'), null=True, on_delete=models.CASCADE
319
    )
304 320
    scopes = models.TextField(verbose_name=_('scopes'))
305 321
    state = models.TextField(null=True, verbose_name=_('state'))
306 322
    nonce = models.TextField(null=True, verbose_name=_('nonce'))
src/authentic2_idp_oidc/static/authentic2_idp_oidc/css/style.css
1
#profile-validation {
2
	background: #f5f5f7;
3
	padding-top: .3em;
4
	margin-bottom: .3em;
5
	border-radius: 4px;
6
	color: black;
7
}
8

  
9
#profile-validation > .profile {
10
	padding: .5em;
11
	margin-top: .2em;
12
	margin-bottom: .2em;
13
	background: #d9d9d9;
14
}
src/authentic2_idp_oidc/templates/authentic2_idp_oidc/authorization.html
1 1
{% extends "authentic2/base-page.html" %}
2
{% load i18n %}
2
{% load i18n static %}
3

  
4
{% block extra-top-head %}
5
 {{ block.super }}
6
<link rel="stylesheet" type="text/css" href="{% static 'authentic2_idp_oidc/css/style.css' %}"></link>
7
{% endblock %}
8

  
3 9
{% block content %}
4 10
<h2>{% trans "Authentication access check" %}</h2>
5 11
<form method="post" id="a2-oidc-authorization-form">
6 12
    <p>{% blocktrans with client_name=client.name %}Do you want to be authenticated on service {{ client_name }} ?{% endblocktrans %}</p>
7
    {% if scopes %}
13
    {% if needs_scope_validation %}
8 14
      <p>{% trans "The following informations will be sent to the service:" %}</p>
9 15
      <ul>
10 16
        {% for scope in scopes %}
......
16 22
        {% endfor %}
17 23
      </ul>
18 24
    {% endif %}
25
    {% if needs_profile_validation %}
26
     <div id="profile-validation">
27
     <p>{% trans "You may authenticate as owner of the following juridical entity management profile, which may change the aforementioned information." %}</p>
28
      <div class="profile" id="profile-validation-none">
29
        <input type="radio" id="_none" name="profile-validation" value="" checked>
30
        <label for="_none">{% trans "Keep my default user profile." %}</label>
31
      </div>
32
      {% for profile in user.subprofiles.all %}
33
        <div class="profile" id="profile-validation-{{ profile.id }}">
34
          <input type="radio" id="{{ profile.id }}" name="profile-validation" value="{{ profile.id }}">
35
          <label for="{{ profile.id }}">
36
          {% with identifier=profile.identifier type_name=profile.profile_type.name %}
37
            {% trans "Unknown entity" as fallback_identifier %}
38
            {% blocktrans %}Profile of type {{ type_name }}: {% endblocktrans %}
39
            {% firstof identifier fallback_identifier %}<br />
40
          {% endwith %}
41
          {% if profile.email %}
42
            {% blocktrans with email=profile.email %}Email address: {{ email }}{% endblocktrans %}<br />
43
          {% endif %}</label>
44
        </div>
45
      {% endfor %}
46
     </div>
47
    {% endif %}
19 48
    {% csrf_token %}
20 49
    <p class="a2-oidc-authorization-form--do-not-ask-again">
21 50
      <label for="id_do_not_ask_again"><input id="id_do_not_ask_again" type="checkbox" name="do_not_ask_again" value="on"/><span>{% trans "Do not ask again" %}</span></label>
src/authentic2_idp_oidc/utils.py
41 41
    return base64.urlsafe_b64encode(content).strip(b'=')
42 42

  
43 43

  
44
def flatten_dict(data):
45
    for key, value in list(data.items()):
46
        if isinstance(value, dict):
47
            flatten_dict(value)
48
            for key2, value2 in value.items():
49
                data['%s_%s' % (key, key2)] = value2
50
            del data[key]
51

  
52

  
44 53
def get_jwkset():
45 54
    try:
46 55
        jwkset = json.dumps(app_settings.JWKSET)
......
120 129
    return urllib.parse.urlparse(url).netloc.split(':')[0]
121 130

  
122 131

  
123
def make_sub(client, user):
132
def make_sub(client, user, profile=None):
124 133
    if client.identifier_policy in (client.POLICY_PAIRWISE, client.POLICY_PAIRWISE_REVERSIBLE):
125
        return make_pairwise_sub(client, user)
134
        return make_pairwise_sub(client, user, profile=profile)
126 135
    elif client.identifier_policy == client.POLICY_UUID:
127 136
        return force_text(user.uuid)
128 137
    elif client.identifier_policy == client.POLICY_EMAIL:
......
131 140
        raise NotImplementedError
132 141

  
133 142

  
134
def make_pairwise_sub(client, user):
143
def make_pairwise_sub(client, user, profile=None):
135 144
    '''Make a pairwise sub'''
136 145
    if client.identifier_policy == client.POLICY_PAIRWISE:
137
        return make_pairwise_unreversible_sub(client, user)
146
        return make_pairwise_unreversible_sub(client, user, profile=profile)
138 147
    elif client.identifier_policy == client.POLICY_PAIRWISE_REVERSIBLE:
139
        return make_pairwise_reversible_sub(client, user)
148
        return make_pairwise_reversible_sub(client, user, profile=profile)
140 149
    else:
141 150
        raise NotImplementedError('unknown pairwise client.identifier_policy %s' % client.identifier_policy)
142 151

  
143 152

  
144
def make_pairwise_unreversible_sub(client, user):
153
def make_pairwise_unreversible_sub(client, user, profile=None):
145 154
    sector_identifier = client.get_sector_identifier()
146 155
    sub = sector_identifier + str(user.uuid) + settings.SECRET_KEY
156
    if profile and client.perform_sub_profile_substitution:
157
        sub += str(profile.id)
147 158
    sub = base64.b64encode(hashlib.sha256(sub.encode('utf-8')).digest())
148 159
    return sub.decode('utf-8')
149 160

  
150 161

  
151
def make_pairwise_reversible_sub(client, user):
152
    return make_pairwise_reversible_sub_from_uuid(client, user.uuid)
162
def make_pairwise_reversible_sub(client, user, profile=None):
163
    return make_pairwise_reversible_sub_from_uuid(client, user.uuid, profile=profile)
153 164

  
154 165

  
155
def make_pairwise_reversible_sub_from_uuid(client, user_uuid):
166
def make_pairwise_reversible_sub_from_uuid(client, user_uuid, profile=None):
156 167
    try:
157 168
        identifier = uuid.UUID(user_uuid).bytes
158 169
    except ValueError:
159 170
        return None
160 171
    sector_identifier = client.get_sector_identifier()
161
    return crypto.aes_base64url_deterministic_encrypt(
162
        settings.SECRET_KEY.encode('utf-8'), identifier, sector_identifier
163
    ).decode('utf-8')
172
    cipher_args = [
173
        settings.SECRET_KEY.encode('utf-8'),
174
        identifier,
175
        sector_identifier,
176
    ]
177
    if profile and client.perform_sub_profile_substitution:
178
        cipher_args[1] += '#%s' % str(profile.id)
179
    return crypto.aes_base64url_deterministic_encrypt(*cipher_args).decode('utf-8')
164 180

  
165 181

  
166 182
def reverse_pairwise_sub(client, sub):
......
183 199
    return str(value)
184 200

  
185 201

  
186
def create_user_info(request, client, user, scope_set, id_token=False):
202
def create_user_info(request, client, user, scope_set, id_token=False, profile=None):
187 203
    '''Create user info dictionary'''
188 204
    user_info = {}
189 205
    if 'openid' in scope_set:
190
        user_info['sub'] = make_sub(client, user)
206
        user_info['sub'] = make_sub(client, user, profile=profile)
191 207
    attributes = get_attributes(
192 208
        {
193 209
            'user': user,
......
240 256
            ]:
241 257
                default_value = ''
242 258
            user_info[claim.name] = default_value
259
    if profile:
260
        for attr, userinfo_key in app_settings.PROFILE_OVERRIDE_MAPPING.items():
261
            if getattr(profile, attr, None) and userinfo_key in user_info:
262
                user_info[userinfo_key] = getattr(profile, attr)
263
        user_info['profile_identifier'] = profile.identifier
264
        user_info['profile_type'] = profile.profile_type.slug
265
        if isinstance(profile.data, dict):
266
            flat_data = profile.data.copy()
267
            flatten_dict(flat_data)
268
            user_info.update(flat_data)
243 269
    hooks.call_hooks('idp_oidc_modify_user_info', client, user, scope_set, user_info)
244 270
    return user_info
245 271

  
src/authentic2_idp_oidc/views.py
38 38
from authentic2 import app_settings as a2_app_settings
39 39
from authentic2 import hooks
40 40
from authentic2.a2_rbac.models import OrganizationalUnit
41
from authentic2.custom_user.models import Profile
41 42
from authentic2.decorators import setting_enabled
42 43
from authentic2.exponential_retry_timeout import ExponentialRetryTimeout
43 44
from authentic2.utils.misc import last_authentication_event, login_require, make_url, redirect
......
359 360

  
360 361
    iat = now()  # iat = issued at
361 362

  
363
    needs_profile_validation = False
364
    needs_scope_validation = False
365
    profile = None
362 366
    if client.authorization_mode != client.AUTHORIZATION_MODE_NONE or 'consent' in prompt:
363 367
        # authorization by user is mandatory, as per local configuration or per explicit request by
364 368
        # the RP
......
379 383
        else:
380 384
            qs = qs.filter(expired__gte=iat)
381 385
        authorized_scopes = set()
386
        authorized_profile = None
382 387
        for authorization in qs:
383 388
            authorized_scopes |= authorization.scope_set()
389
            # load first authorized profile
390
            if not authorized_profile and authorization.profile:
391
                authorized_profile = authorization.profile
392
        if (
393
            request.user.subprofiles.count()
394
            and not authorized_profile
395
            and client.activate_user_profiles
396
            and a2_app_settings.A2_USER_PROFILE_MANAGEMENT
397
        ):
398
            needs_profile_validation = True
399
        else:
400
            profile = authorized_profile
384 401
        if (authorized_scopes & scopes) < scopes:
402
            needs_scope_validation = True
403
        if needs_scope_validation or needs_profile_validation:
385 404
            if 'none' in prompt:
386 405
                raise ConsentRequired(_('Consent is required but prompt parameter is "none"'))
387 406
            if request.method == 'POST':
407
                if request.POST.get('profile-validation', ''):
408
                    try:
409
                        profile = Profile.objects.get(
410
                            user=request.user,
411
                            id=request.POST['profile-validation'],
412
                        )
413
                    except Profile.DoesNotExist:
414
                        pass
388 415
                if 'accept' in request.POST:
389 416
                    if 'do_not_ask_again' in request.POST:
390 417
                        pk_to_deletes = []
......
394 421
                                pk_to_deletes.append(authorization.pk)
395 422
                        auth_manager.create(
396 423
                            user=request.user,
424
                            profile=profile,
397 425
                            scopes=' '.join(sorted(scopes)),
398 426
                            expired=iat + datetime.timedelta(days=365),
399 427
                        )
......
417 445
                    request,
418 446
                    'authentic2_idp_oidc/authorization.html',
419 447
                    {
448
                        'needs_profile_validation': needs_profile_validation,
449
                        'needs_scope_validation': needs_scope_validation,
420 450
                        'client': client,
421 451
                        'scopes': scopes - {'openid'},
422 452
                    },
......
425 455
        code = models.OIDCCode.objects.create(
426 456
            client=client,
427 457
            user=request.user,
458
            profile=profile,
428 459
            scopes=' '.join(scopes),
429 460
            state=state,
430 461
            nonce=nonce,
......
444 475
        response = redirect(request, redirect_uri, params=params, resolve=False)
445 476
    else:
446 477
        need_access_token = 'token' in response_type.split()
478
        if 'profile-validation' in request.POST:
479
            try:
480
                profile = Profile.objects.get(
481
                    id=request.POST.get('profile-validation', None),
482
                    user=request.user,
483
                )
484
            except Profile.DoesNotExist:
485
                pass
447 486
        if need_access_token:
448 487
            if client.access_token_duration is None:
449 488
                expires_in = datetime.timedelta(seconds=request.session.get_expiry_age())
......
461 500
        acr = '0'
462 501
        if nonce is not None and last_auth.get('nonce') == nonce:
463 502
            acr = '1'
464
        id_token = utils.create_user_info(request, client, request.user, scopes, id_token=True)
503
        id_token = utils.create_user_info(
504
            request, client, request.user, scopes, id_token=True, profile=profile
505
        )
465 506
        exp = iat + idtoken_duration(client)
466 507
        id_token.update(
467 508
            {
......
721 762
    ):
722 763
        acr = '1'
723 764
    # prefill id_token with user info
724
    id_token = utils.create_user_info(request, client, oidc_code.user, oidc_code.scope_set(), id_token=True)
765
    id_token = utils.create_user_info(
766
        request,
767
        client,
768
        oidc_code.user,
769
        oidc_code.scope_set(),
770
        id_token=True,
771
        profile=oidc_code.profile,
772
    )
725 773
    exp = start + idtoken_duration(client)
726 774
    id_token.update(
727 775
        {
728 776
            'iss': utils.get_issuer(request),
729
            'sub': utils.make_sub(client, oidc_code.user),
777
            'sub': utils.make_sub(client, oidc_code.user, profile=oidc_code.profile),
730 778
            'aud': client.client_id,
731 779
            'exp': int(exp.timestamp()),
732 780
            'iat': int(start.timestamp()),
tests/idp_oidc/conftest.py
75 75
    return settings
76 76

  
77 77

  
78
@pytest.fixture
79
def profile_settings(settings, jwkset):
80
    settings.A2_IDP_OIDC_JWKSET = jwkset
81
    settings.A2_IDP_OIDC_PASSWORD_GRANT_RATELIMIT = '100/m'
82
    settings.A2_USER_PROFILE_MANAGEMENT = True
83
    return settings
84

  
85

  
78 86
def make_client(app, superuser, params=None):
79 87
    Attribute.objects.create(
80 88
        name='cityscape_image',
tests/idp_oidc/test_user_profiles.py
1
# authentic2 - versatile identity manager
2
# Copyright (C) 2010-2022 Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16

  
17
import base64
18
import json
19
import urllib.parse
20

  
21
import pytest
22
from django.contrib.auth import get_user_model
23
from django.urls import reverse
24
from django.utils.encoding import force_text
25
from django.utils.timezone import now
26
from jwcrypto.jwk import JWK
27
from jwcrypto.jwt import JWT
28

  
29
from authentic2.custom_user.models import Profile, ProfileType
30
from authentic2.utils.misc import make_url
31
from authentic2_idp_oidc.models import OIDCAccessToken, OIDCCode
32
from authentic2_idp_oidc.utils import make_sub
33

  
34
from .. import utils
35
from .conftest import client_authentication_headers
36

  
37
User = get_user_model()
38

  
39
pytestmark = pytest.mark.django_db
40

  
41

  
42
@pytest.fixture
43
def profile_user():
44
    user = User.objects.create(
45
        first_name='Foo',
46
        last_name='Bar',
47
        username='foobar',
48
        email='foobar@example.org',
49
    )
50
    profile_type_manager = ProfileType.objects.create(
51
        name='One Manager Type',
52
        slug='one-manager-type',
53
    )
54
    profile_type_delegate = ProfileType.objects.create(
55
        name='One Delegate Type',
56
        slug='one-delegate-type',
57
    )
58
    Profile.objects.create(
59
        user=user,
60
        profile_type=profile_type_manager,
61
        identifier='Entity 789',
62
        email='manager@example789.org',
63
    )
64
    data = {
65
        'entity_name': 'Foobar',
66
        'entity_data': {'au': 'ie', 'ts': 'rn'},
67
    }
68
    Profile.objects.create(
69
        user=user,
70
        profile_type=profile_type_delegate,
71
        identifier='Entity 1011',
72
        email='delegate@example1011.org',
73
        data=data,
74
    )
75
    user.clear_password = 'foobar'
76
    user.set_password('foobar')
77
    user.save()
78
    return user
79

  
80

  
81
def test_admin_base_models(app, superuser, simple_user, profile_settings):
82
    url = reverse('admin:custom_user_profiletype_add')
83
    assert ProfileType.objects.count() == 0
84
    response = utils.login(app, superuser, path=url)
85
    response.form.set('name', 'Manager')
86
    response.form.set('slug', 'manager')
87
    response = response.form.submit(name='_save').follow()
88
    assert ProfileType.objects.count() == 1
89

  
90
    response = app.get(url)
91
    response.form.set('name', 'Delegate')
92
    response.form.set('slug', 'delegate')
93
    response = response.form.submit(name='_save').follow()
94
    assert ProfileType.objects.count() == 2
95

  
96
    url = reverse('admin:custom_user_profile_add')
97
    assert Profile.objects.count() == 0
98
    response = app.get(url)
99
    response.form.set('user', simple_user.id)
100
    response.form.set('profile_type', ProfileType.objects.first().pk)
101
    response.form.set('email', 'john.doe@example.org')
102
    response.form.set('identifier', 'Entity 0123')
103
    response = response.form.submit(name='_save').follow()
104
    assert Profile.objects.count() == 1
105

  
106
    response = app.get(url)
107
    response.form.set('user', simple_user.id)
108
    response.form.set('profile_type', ProfileType.objects.last().pk)
109
    response.form.set('email', 'john.doe@anotherexample.org')
110
    response.form.set('identifier', 'Entity 5678')
111
    response = response.form.submit(name='_save').follow()
112
    assert Profile.objects.count() == 2
113

  
114

  
115
def test_login_profiles_absent(app, oidc_client, simple_user, profile_settings):
116
    redirect_uri = oidc_client.redirect_uris.split()[0]
117
    oidc_client.activate_user_profiles = True
118
    oidc_client.save()
119
    params = {
120
        'client_id': oidc_client.client_id,
121
        'scope': 'openid profile email',
122
        'redirect_uri': redirect_uri,
123
        'state': 'xxx',
124
        'nonce': 'yyy',
125
        'login_hint': 'backoffice john@example.com',
126
        'response_type': 'code',
127
    }
128
    authorize_url = make_url('oidc-authorize', params=params)
129
    utils.login(app, simple_user)
130
    response = app.get(authorize_url)
131
    assert 'a2-oidc-authorization-form' in response.text
132
    # not interface changes for users without a profile
133
    assert not 'profile-validation-' in response.text
134

  
135

  
136
@pytest.mark.parametrize('sub_profile_substitution', [True, False])
137
def test_login_profile_selection(app, oidc_client, profile_user, profile_settings, sub_profile_substitution):
138
    oidc_client.idtoken_algo = oidc_client.ALGO_HMAC
139
    oidc_client.activate_user_profiles = True
140
    oidc_client.perform_sub_profile_substitution = sub_profile_substitution
141
    oidc_client.save()
142
    redirect_uri = oidc_client.redirect_uris.split()[0]
143
    params = {
144
        'client_id': oidc_client.client_id,
145
        'scope': 'openid profile email',
146
        'redirect_uri': redirect_uri,
147
        'state': 'xxx',
148
        'nonce': 'yyy',
149
        'login_hint': 'backoffice john@example.com',
150
        'response_type': 'code',
151
    }
152
    assert profile_user.subprofiles.count() == 2
153

  
154
    authorize_url = make_url('oidc-authorize', params=params)
155
    utils.login(app, profile_user)
156
    response = app.get(authorize_url)
157
    assert 'a2-oidc-authorization-form' in response.text
158
    assert 'profile-validation-' in response.text
159
    response.form.set('profile-validation', profile_user.subprofiles.first().id)
160
    response = response.form.submit('accept')
161
    assert OIDCCode.objects.count() == 1
162
    code = OIDCCode.objects.get()
163
    assert code.client == oidc_client
164
    assert code.user == profile_user
165
    assert code.profile == profile_user.subprofiles.first()
166
    assert code.scope_set() == set('openid profile email'.split())
167
    assert code.state == 'xxx'
168
    assert code.nonce == 'yyy'
169
    assert code.redirect_uri == redirect_uri
170
    assert code.session_key == app.session.session_key
171
    assert code.auth_time <= now()
172
    assert code.expired >= now()
173
    assert response['Location'].startswith(redirect_uri)
174
    location = urllib.parse.urlparse(response['Location'])
175
    query = urllib.parse.parse_qs(location.query)
176
    assert set(query.keys()) == {'code', 'state'}
177
    assert query['code'] == [code.uuid]
178
    code = query['code'][0]
179
    assert query['state'] == ['xxx']
180

  
181
    token_url = make_url('oidc-token')
182
    response = app.post(
183
        token_url,
184
        params={
185
            'grant_type': 'authorization_code',
186
            'code': code,
187
            'redirect_uri': oidc_client.redirect_uris.split()[0],
188
        },
189
        headers=client_authentication_headers(oidc_client),
190
    )
191
    assert 'error' not in response.json
192
    assert 'access_token' in response.json
193
    assert 'expires_in' in response.json
194
    assert 'id_token' in response.json
195
    assert response.json['token_type'] == 'Bearer'
196
    access_token = response.json['access_token']
197
    assert access_token
198
    id_token = response.json['id_token']
199
    k = base64.b64encode(oidc_client.client_secret.encode('utf-8'))
200
    key = JWK(kty='oct', k=force_text(k))
201
    algs = ['HS256']
202
    jwt = JWT(jwt=id_token, key=key, algs=algs)
203
    claims = json.loads(jwt.claims)
204

  
205
    # check subject identifier substitution:
206
    if sub_profile_substitution:
207
        assert claims['sub'] != make_sub(oidc_client, profile_user)
208
        assert claims['sub'] == make_sub(oidc_client, profile_user, profile=profile_user.subprofiles.first())
209
    else:
210
        assert claims['sub'] == make_sub(oidc_client, profile_user)
211

  
212
    # check email substitution
213
    assert claims['email'] != profile_user.email
214
    assert claims['email'] == profile_user.subprofiles.first().email
215

  
216
    # check additional profile claims
217
    assert claims['profile_identifier'] == profile_user.subprofiles.first().identifier
218
    assert claims['profile_type'] == profile_user.subprofiles.first().profile_type.slug
219

  
220
    # check profile data dict flatten into oidc claims
221
    assert claims['entity_name'] == 'Foobar'
222
    assert claims['entity_data_au'] == 'ie'
223
    assert claims['entity_data_ts'] == 'rn'
224

  
225

  
226
@pytest.mark.parametrize('sub_profile_substitution', [True, False])
227
def test_login_implicit(app, oidc_client, profile_user, profile_settings, sub_profile_substitution):
228
    oidc_client.idtoken_algo = oidc_client.ALGO_HMAC
229
    oidc_client.authorization_flow = oidc_client.FLOW_IMPLICIT
230
    oidc_client.activate_user_profiles = True
231
    oidc_client.perform_sub_profile_substitution = sub_profile_substitution
232
    oidc_client.save()
233
    redirect_uri = oidc_client.redirect_uris.split()[0]
234
    params = {
235
        'client_id': oidc_client.client_id,
236
        'scope': 'openid profile email',
237
        'redirect_uri': redirect_uri,
238
        'state': 'xxx',
239
        'nonce': 'yyy',
240
        'login_hint': 'backoffice john@example.com',
241
        'response_type': 'token id_token',
242
    }
243

  
244
    assert profile_user.subprofiles.count() == 2
245
    authorize_url = make_url('oidc-authorize', params=params)
246
    utils.login(app, profile_user)
247
    response = app.get(authorize_url)
248
    assert 'a2-oidc-authorization-form' in response.text
249
    assert 'profile-validation-' in response.text
250
    response.form.set('profile-validation', profile_user.subprofiles.first().id)
251
    response = response.form.submit('accept')
252
    location = urllib.parse.urlparse(response['Location'])
253
    assert location.fragment
254
    query = urllib.parse.parse_qs(location.fragment)
255
    assert OIDCAccessToken.objects.count() == 1
256
    access_token = OIDCAccessToken.objects.get()
257
    assert set(query.keys()) == {'access_token', 'token_type', 'expires_in', 'id_token', 'state'}
258
    assert query['access_token'] == [access_token.uuid]
259
    assert query['token_type'] == ['Bearer']
260
    assert query['state'] == ['xxx']
261
    access_token = query['access_token'][0]
262
    id_token = query['id_token'][0]
263
    k = base64.b64encode(oidc_client.client_secret.encode('utf-8'))
264
    key = JWK(kty='oct', k=force_text(k))
265
    algs = ['HS256']
266
    jwt = JWT(jwt=id_token, key=key, algs=algs)
267
    claims = json.loads(jwt.claims)
268

  
269
    # check subject identifier substitution:
270
    if sub_profile_substitution:
271
        assert claims['sub'] != make_sub(oidc_client, profile_user)
272
        assert claims['sub'] == make_sub(oidc_client, profile_user, profile=profile_user.subprofiles.first())
273
    else:
274
        assert claims['sub'] == make_sub(oidc_client, profile_user)
275

  
276
    # check email substitution
277
    assert claims['email'] != profile_user.email
278
    assert claims['email'] == profile_user.subprofiles.first().email
279

  
280
    # check additional profile claims
281
    assert claims['profile_identifier'] == profile_user.subprofiles.first().identifier
282
    assert claims['profile_type'] == profile_user.subprofiles.first().profile_type.slug
283

  
284
    # check profile data dict flatten into oidc claims
285
    assert claims['entity_name'] == 'Foobar'
286
    assert claims['entity_data_au'] == 'ie'
287
    assert claims['entity_data_ts'] == 'rn'
0
-