Projet

Général

Profil

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

Paul Marillonnet, 26 février 2022 08:29

Télécharger (32,7 ko)

Voir les différences:

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

 src/authentic2/app_settings.py                |   4 +
 .../migrations/0031_profile_email.py          |  18 ++
 src/authentic2/custom_user/models.py          |   1 +
 src/authentic2_idp_oidc/app_settings.py       |   4 +
 src/authentic2_idp_oidc/apps.py               |   4 +-
 .../migrations/0015_auto_20220226_0824.py     |  42 +++
 src/authentic2_idp_oidc/models.py             |  10 +
 .../static/authentic2_idp_oidc/css/style.css  |  14 +
 .../authentic2_idp_oidc/authorization.html    |  33 ++-
 src/authentic2_idp_oidc/utils.py              |  41 ++-
 src/authentic2_idp_oidc/views.py              |  54 +++-
 tests/idp_oidc/conftest.py                    |   8 +
 tests/idp_oidc/test_user_profiles.py          | 262 ++++++++++++++++++
 13 files changed, 474 insertions(+), 21 deletions(-)
 create mode 100644 src/authentic2/custom_user/migrations/0031_profile_email.py
 create mode 100644 src/authentic2_idp_oidc/migrations/0015_auto_20220226_0824.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/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/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_20220226_0824.py
1
# Generated by Django 2.2.24 on 2022-02-26 07:24
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='oidccode',
34
            name='profile',
35
            field=models.ForeignKey(
36
                null=True,
37
                on_delete=django.db.models.deletion.CASCADE,
38
                to='custom_user.Profile',
39
                verbose_name='user selected profile',
40
            ),
41
        ),
42
    ]
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

  
132 138
    frontchannel_logout_uri = models.URLField(verbose_name=_('frontchannel logout URI'), blank=True)
133 139
    frontchannel_timeout = models.PositiveIntegerField(
134 140
        verbose_name=_('frontchannel timeout'), null=True, blank=True
......
240 246
    client = GenericForeignKey('client_ct', 'client_id')
241 247
    user = models.ForeignKey(to=settings.AUTH_USER_MODEL, verbose_name=_('user'), on_delete=models.CASCADE)
242 248
    scopes = models.TextField(blank=False, verbose_name=_('scopes'))
249
    profile = models.ForeignKey(to=Profile, verbose_name=_('profile'), on_delete=models.CASCADE, null=True)
243 250

  
244 251
    # metadata
245 252
    created = models.DateTimeField(verbose_name=_('created'), auto_now_add=True)
......
291 298
    uuid = models.CharField(max_length=128, verbose_name=_('uuid'), default=generate_uuid)
292 299
    client = models.ForeignKey(to=OIDCClient, verbose_name=_('client'), on_delete=models.CASCADE)
293 300
    user = models.ForeignKey(to=settings.AUTH_USER_MODEL, verbose_name=_('user'), on_delete=models.CASCADE)
301
    profile = models.ForeignKey(
302
        to=Profile, verbose_name=_('user selected profile'), null=True, on_delete=models.CASCADE
303
    )
294 304
    scopes = models.TextField(verbose_name=_('scopes'))
295 305
    state = models.TextField(null=True, verbose_name=_('state'))
296 306
    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 "Additionally, 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
120 120
    return urllib.parse.urlparse(url).netloc.split(':')[0]
121 121

  
122 122

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

  
133 133

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

  
143 143

  
144
def make_pairwise_unreversible_sub(client, user):
144
def make_pairwise_unreversible_sub(client, user, profile=None):
145 145
    sector_identifier = client.get_sector_identifier()
146 146
    sub = sector_identifier + str(user.uuid) + settings.SECRET_KEY
147
    if profile:
148
        sub += str(profile.id)
147 149
    sub = base64.b64encode(hashlib.sha256(sub.encode('utf-8')).digest())
148 150
    return sub.decode('utf-8')
149 151

  
150 152

  
151
def make_pairwise_reversible_sub(client, user):
152
    return make_pairwise_reversible_sub_from_uuid(client, user.uuid)
153
def make_pairwise_reversible_sub(client, user, profile=None):
154
    return make_pairwise_reversible_sub_from_uuid(client, user.uuid, profile=profile)
153 155

  
154 156

  
155
def make_pairwise_reversible_sub_from_uuid(client, user_uuid):
157
def make_pairwise_reversible_sub_from_uuid(client, user_uuid, profile=None):
156 158
    try:
157 159
        identifier = uuid.UUID(user_uuid).bytes
158 160
    except ValueError:
159 161
        return None
160 162
    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')
163
    cipher_args = [
164
        settings.SECRET_KEY.encode('utf-8'),
165
        identifier,
166
        sector_identifier,
167
    ]
168
    if profile:
169
        cipher_args[1] += '#%s' % str(profile.id)
170
    return crypto.aes_base64url_deterministic_encrypt(*cipher_args).decode('utf-8')
164 171

  
165 172

  
166 173
def reverse_pairwise_sub(client, sub):
......
183 190
    return str(value)
184 191

  
185 192

  
186
def create_user_info(request, client, user, scope_set, id_token=False):
193
def create_user_info(request, client, user, scope_set, id_token=False, profile=None):
187 194
    '''Create user info dictionary'''
188 195
    user_info = {}
189 196
    if 'openid' in scope_set:
190
        user_info['sub'] = make_sub(client, user)
197
        user_info['sub'] = make_sub(client, user, profile=profile)
191 198
    attributes = get_attributes(
192 199
        {
193 200
            'user': user,
......
240 247
            ]:
241 248
                default_value = ''
242 249
            user_info[claim.name] = default_value
250
    if profile:
251
        for attr, userinfo_key in app_settings.PROFILE_OVERRIDE_MAPPING.items():
252
            if getattr(profile, attr, None) and userinfo_key in user_info:
253
                user_info[userinfo_key] = getattr(profile, attr)
254
        user_info['profile_identifier'] = profile.identifier
255
        user_info['profile_type'] = profile.profile_type.slug
243 256
    hooks.call_hooks('idp_oidc_modify_user_info', client, user, scope_set, user_info)
244 257
    return user_info
245 258

  
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 = authorized_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
    Profile.objects.create(
65
        user=user,
66
        profile_type=profile_type_delegate,
67
        identifier='Entity 1011',
68
        email='delegate@example1011.org',
69
    )
70
    user.clear_password = 'foobar'
71
    user.set_password('foobar')
72
    user.save()
73
    return user
74

  
75

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

  
85
    response = app.get(url)
86
    response.form.set('name', 'Delegate')
87
    response.form.set('slug', 'delegate')
88
    response = response.form.submit(name='_save').follow()
89
    assert ProfileType.objects.count() == 2
90

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

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

  
109

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

  
130

  
131
def test_login_profile_selection(app, oidc_client, profile_user, profile_settings):
132
    oidc_client.idtoken_algo = oidc_client.ALGO_HMAC
133
    oidc_client.activate_user_profiles = True
134
    oidc_client.save()
135
    redirect_uri = oidc_client.redirect_uris.split()[0]
136
    params = {
137
        'client_id': oidc_client.client_id,
138
        'scope': 'openid profile email',
139
        'redirect_uri': redirect_uri,
140
        'state': 'xxx',
141
        'nonce': 'yyy',
142
        'login_hint': 'backoffice john@example.com',
143
        'response_type': 'code',
144
    }
145
    assert profile_user.subprofiles.count() == 2
146

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

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

  
198
    # check subject identifier substitution:
199
    assert claims['sub'] != make_sub(oidc_client, profile_user)
200
    assert claims['sub'] == make_sub(oidc_client, profile_user, profile=profile_user.subprofiles.first())
201

  
202
    # check email substitution
203
    assert claims['email'] != profile_user.email
204
    assert claims['email'] == profile_user.subprofiles.first().email
205

  
206
    # check additional profile claims
207
    assert claims['profile_identifier'] == profile_user.subprofiles.first().identifier
208
    assert claims['profile_type'] == profile_user.subprofiles.first().profile_type.slug
209

  
210

  
211
def test_login_implicit(app, oidc_client, profile_user, profile_settings):
212
    oidc_client.idtoken_algo = oidc_client.ALGO_HMAC
213
    oidc_client.authorization_flow = oidc_client.FLOW_IMPLICIT
214
    oidc_client.activate_user_profiles = True
215
    oidc_client.save()
216
    redirect_uri = oidc_client.redirect_uris.split()[0]
217
    params = {
218
        'client_id': oidc_client.client_id,
219
        'scope': 'openid profile email',
220
        'redirect_uri': redirect_uri,
221
        'state': 'xxx',
222
        'nonce': 'yyy',
223
        'login_hint': 'backoffice john@example.com',
224
        'response_type': 'token id_token',
225
    }
226

  
227
    assert profile_user.subprofiles.count() == 2
228
    authorize_url = make_url('oidc-authorize', params=params)
229
    utils.login(app, profile_user)
230
    response = app.get(authorize_url)
231
    assert 'a2-oidc-authorization-form' in response.text
232
    assert 'profile-validation-' in response.text
233
    response.form.set('profile-validation', profile_user.subprofiles.first().id)
234
    response = response.form.submit('accept')
235
    location = urllib.parse.urlparse(response['Location'])
236
    assert location.fragment
237
    query = urllib.parse.parse_qs(location.fragment)
238
    assert OIDCAccessToken.objects.count() == 1
239
    access_token = OIDCAccessToken.objects.get()
240
    assert set(query.keys()) == {'access_token', 'token_type', 'expires_in', 'id_token', 'state'}
241
    assert query['access_token'] == [access_token.uuid]
242
    assert query['token_type'] == ['Bearer']
243
    assert query['state'] == ['xxx']
244
    access_token = query['access_token'][0]
245
    id_token = query['id_token'][0]
246
    k = base64.b64encode(oidc_client.client_secret.encode('utf-8'))
247
    key = JWK(kty='oct', k=force_text(k))
248
    algs = ['HS256']
249
    jwt = JWT(jwt=id_token, key=key, algs=algs)
250
    claims = json.loads(jwt.claims)
251

  
252
    # check subject identifier substitution:
253
    assert claims['sub'] != make_sub(oidc_client, profile_user)
254
    assert claims['sub'] == make_sub(oidc_client, profile_user, profile=profile_user.subprofiles.first())
255

  
256
    # check email substitution
257
    assert claims['email'] != profile_user.email
258
    assert claims['email'] == profile_user.subprofiles.first().email
259

  
260
    # check additional profile claims
261
    assert claims['profile_identifier'] == profile_user.subprofiles.first().identifier
262
    assert claims['profile_type'] == profile_user.subprofiles.first().profile_type.slug
0
-