Projet

Général

Profil

0001-idp_oidc-support-oauth2-resource-owner-password-cred.patch

Paul Marillonnet, 23 décembre 2019 14:56

Télécharger (18,9 ko)

Voir les différences:

Subject: [PATCH] idp_oidc: support oauth2 resource owner password credential
 grant (#35205)

 src/authentic2_idp_oidc/models.py |   2 +
 src/authentic2_idp_oidc/utils.py  |   2 +-
 src/authentic2_idp_oidc/views.py  | 215 ++++++++++++++++++++++--------
 tests/conftest.py                 |   7 +
 tests/test_idp_oidc.py            | 130 +++++++++++++++++-
 5 files changed, 299 insertions(+), 57 deletions(-)
src/authentic2_idp_oidc/models.py
78 78
    ]
79 79
    FLOW_AUTHORIZATION_CODE = 1
80 80
    FLOW_IMPLICIT = 2
81
    FLOW_RESOURCE_OWNER_CRED = 3
81 82
    FLOW_CHOICES = [
82 83
        (FLOW_AUTHORIZATION_CODE, _('authorization code')),
83 84
        (FLOW_IMPLICIT, _('implicit/native')),
85
        (FLOW_RESOURCE_OWNER_CRED, _('resource owner password credentials')),
84 86
    ]
85 87

  
86 88
    AUTHORIZATION_MODE_BY_SERVICE = 1
src/authentic2_idp_oidc/utils.py
179 179

  
180 180

  
181 181
def create_user_info(request, client, user, scope_set, id_token=False):
182
    '''Create user info dictionnary'''
182
    '''Create user info dictionary'''
183 183
    user_info = {
184 184
        'sub': make_sub(client, user)
185 185
    }
src/authentic2_idp_oidc/views.py
15 15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16 16

  
17 17
import logging
18
import math
18 19
import datetime
19 20
import json
20 21
import base64
......
28 29
from django.views.decorators.csrf import csrf_exempt
29 30
from django.core.urlresolvers import reverse
30 31
from django.contrib import messages
32
from django.contrib.auth import authenticate
31 33
from django.conf import settings
32 34
from django.utils.translation import ugettext as _
33 35

  
36
from authentic2 import app_settings as a2_app_settings
34 37
from authentic2.decorators import setting_enabled
38
from authentic2.exponential_retry_timeout import ExponentialRetryTimeout
35 39
from authentic2.utils import (login_require, redirect, timestamp_from_datetime,
36 40
                              last_authentication_event, make_url)
37 41
from authentic2.views import logout as a2_logout
......
115 119
                       redirect_uri, client_id)
116 120
        return redirect(request, 'auth_homepage')
117 121

  
122
    if client.authorization_flow == client.FLOW_RESOURCE_OWNER_CRED:
123
        return authorization_error(request, 'auth_homepage',
124
                                   'unauthorized_client',
125
                                   error_description='authz endpoint is not '
126
                                   'part of resource owner password credential '
127
                                   'grant type')
128

  
118 129
    if not client.is_valid_redirect_uri(redirect_uri):
119 130
        messages.warning(request, _('Authorization request is invalid'))
120 131
        logger.warning(u'idp_oidc: authorization request error, unknown redirect_uri redirect_uri=%r client_id=%r',
......
374 385
    return HttpResponseBadRequest(json.dumps(content), content_type='application/json')
375 386

  
376 387

  
388
def access_denied(desc=None):
389
    content = {
390
        'error': 'access_denied',
391
    }
392
    if desc:
393
        content['desc'] = desc
394
    return HttpResponseBadRequest(json.dumps(content), content_type='application/json')
395

  
396

  
397
def unauthorized_client(desc=None):
398
    content = {
399
        'error': 'unauthorized_client',
400
    }
401
    if desc:
402
        content['desc'] = desc
403
    return HttpResponseBadRequest(json.dumps(content), content_type='application/json')
404

  
405

  
406
def invalid_client(desc=None):
407
    content = {
408
        'error': 'invalid_client',
409
    }
410
    if desc:
411
        content['desc'] = desc
412
    return HttpResponseBadRequest(json.dumps(content), content_type='application/json')
413

  
414

  
377 415
@setting_enabled('ENABLE', settings=app_settings)
378 416
@csrf_exempt
379 417
def token(request, *args, **kwargs):
380 418
    if request.method != 'POST':
381 419
        return HttpResponseNotAllowed(['POST'])
382 420
    grant_type = request.POST.get('grant_type')
383
    if grant_type != 'authorization_code':
384
        return invalid_request('grant_type is not authorization_code')
385
    code = request.POST.get('code')
386
    if code is None:
387
        return invalid_request('missing code')
388
    try:
389
        oidc_code = models.OIDCCode.objects.select_related().get(uuid=code)
390
    except models.OIDCCode.DoesNotExist:
391
        return invalid_request('invalid code')
392
    if not oidc_code.is_valid():
393
        return invalid_request('code has expired or user is disconnected')
394
    client = authenticate_client(request, client=oidc_code.client)
395
    if client is None:
396
        return HttpResponse('unauthenticated', status=401)
397
    # delete immediately
398
    models.OIDCCode.objects.filter(uuid=code).delete()
399
    redirect_uri = request.POST.get('redirect_uri')
400
    if oidc_code.redirect_uri != redirect_uri:
401
        return invalid_request('invalid redirect_uri')
402
    expires_in = 3600 * 8
403
    access_token = models.OIDCAccessToken.objects.create(
404
        client=client,
405
        user=oidc_code.user,
406
        scopes=oidc_code.scopes,
407
        session_key=oidc_code.session_key,
408
        expired=oidc_code.created + datetime.timedelta(seconds=expires_in))
409
    start = now()
410
    acr = '0'
411
    if (oidc_code.nonce is not None
412
            and last_authentication_event(session=oidc_code.session).get('nonce') == oidc_code.nonce):
413
        acr = '1'
414
    # prefill id_token with user info
415
    id_token = utils.create_user_info(
416
        request,
417
        client,
418
        oidc_code.user,
419
        oidc_code.scope_set(),
420
        id_token=True)
421
    id_token.update({
422
        'iss': utils.get_issuer(request),
423
        'sub': utils.make_sub(client, oidc_code.user),
424
        'aud': client.client_id,
425
        'exp': timestamp_from_datetime(start + idtoken_duration(client)),
426
        'iat': timestamp_from_datetime(start),
427
        'auth_time': timestamp_from_datetime(oidc_code.auth_time),
428
        'acr': acr,
429
    })
430
    if oidc_code.nonce is not None:
431
        id_token['nonce'] = oidc_code.nonce
432
    response = HttpResponse(json.dumps({
433
        'access_token': six.text_type(access_token.uuid),
434
        'token_type': 'Bearer',
435
        'expires_in': expires_in,
436
        'id_token': utils.make_idtoken(client, id_token),
437
    }), content_type='application/json')
421
    if grant_type == 'authorization_code':
422
        code = request.POST.get('code')
423
        if code is None:
424
            return invalid_request('missing code')
425
        try:
426
            oidc_code = models.OIDCCode.objects.select_related().get(uuid=code)
427
        except models.OIDCCode.DoesNotExist:
428
            return invalid_request('invalid code')
429
        if not oidc_code.is_valid():
430
            return invalid_request('code has expired or user is disconnected')
431
        client = authenticate_client(request, client=oidc_code.client)
432
        if client is None:
433
            return HttpResponse('unauthenticated', status=401)
434
        # delete immediately
435
        models.OIDCCode.objects.filter(uuid=code).delete()
436
        redirect_uri = request.POST.get('redirect_uri')
437
        if oidc_code.redirect_uri != redirect_uri:
438
            return invalid_request('invalid redirect_uri')
439
        expires_in = 3600 * 8
440
        access_token = models.OIDCAccessToken.objects.create(
441
            client=client,
442
            user=oidc_code.user,
443
            scopes=oidc_code.scopes,
444
            session_key=oidc_code.session_key,
445
            expired=oidc_code.created + datetime.timedelta(seconds=expires_in))
446
        start = now()
447
        acr = '0'
448
        if (oidc_code.nonce is not None
449
                and last_authentication_event(session=oidc_code.session).get('nonce') == oidc_code.nonce):
450
            acr = '1'
451
        # prefill id_token with user info
452
        id_token = utils.create_user_info(
453
            request,
454
            client,
455
            oidc_code.user,
456
            oidc_code.scope_set(),
457
            id_token=True)
458
        id_token.update({
459
            'iss': utils.get_issuer(request),
460
            'sub': utils.make_sub(client, oidc_code.user),
461
            'aud': client.client_id,
462
            'exp': timestamp_from_datetime(start + idtoken_duration(client)),
463
            'iat': timestamp_from_datetime(start),
464
            'auth_time': timestamp_from_datetime(oidc_code.auth_time),
465
            'acr': acr,
466
        })
467
        if oidc_code.nonce is not None:
468
            id_token['nonce'] = oidc_code.nonce
469
        response = HttpResponse(json.dumps({
470
            'access_token': six.text_type(access_token.uuid),
471
            'token_type': 'Bearer',
472
            'expires_in': expires_in,
473
            'id_token': utils.make_idtoken(client, id_token),
474
        }), content_type='application/json')
475
    elif grant_type == 'password':
476
        if request.META.get('CONTENT_TYPE') != 'application/x-www-form-urlencoded':
477
            return invalid_request(
478
                    'wrong content type \'%s\'.  request content type must be '
479
                    '\'application/x-www-form-urlencoded\'')
480
        username = request.POST.get('username')
481
        scope = request.POST.get('scope', '')
482

  
483
        if not all((username, request.POST.get('password'))):
484
            return invalid_request(
485
                    'request must bear both username and password as '
486
                    'parameters using the "application/x-www-form-urlencoded" '
487
                    'media type')
488

  
489
        client = authenticate_client(request, client=None)
490

  
491
        if not client:
492
            return invalid_client(
493
                'client authentication failed')
494

  
495
        if client.authorization_flow != models.OIDCClient.FLOW_RESOURCE_OWNER_CRED:
496
            return unauthorized_client(
497
                'client is not configured for resource owner password '
498
                'credential grant')
499

  
500
        exponential_backoff = ExponentialRetryTimeout(
501
            key_prefix='idp-oidc-ro-cred-grant',
502
            duration=a2_app_settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_DURATION,
503
            factor=a2_app_settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_FACTOR)
504
        backoff_keys = (username, client.client_id, request.META['REMOTE_ADDR'])
505

  
506
        seconds_to_wait = exponential_backoff.seconds_to_wait(*backoff_keys)
507
        if seconds_to_wait > a2_app_settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_MAX_DURATION:
508
            seconds_to_wait = a2_app_settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_MAX_DURATION
509
        if seconds_to_wait:
510
            return invalid_request(
511
                'too many attempts with erroneous RO password, you must wait '
512
                '%s seconds to try again.' % int(math.ceil(seconds_to_wait)))
513

  
514
        user = authenticate(request, username=username, password=request.POST.get('password'))
515
        if not user:
516
            exponential_backoff.failure(*backoff_keys)
517
            return access_denied(
518
                    'invalid resource owner credentials')
519

  
520
        exponential_backoff.success(*backoff_keys)
521
        start = now()
522
        id_token = utils.create_user_info(
523
            request,
524
            client,
525
            user,
526
            scope,
527
            id_token=True)
528
        id_token.update({
529
            'iss': utils.get_issuer(request),
530
            'aud': client.client_id,
531
            'exp': timestamp_from_datetime(start + idtoken_duration(client)),
532
            'iat': timestamp_from_datetime(start),
533
            'auth_time': timestamp_from_datetime(start),
534
            'acr': '0', # XXX Corner cases where the authn context class ref is required?
535
        })
536

  
537
        response = HttpResponse(json.dumps({
538
            'id_token': utils.make_idtoken(client, id_token),
539
        }), content_type='application/json')
540
    else:
541
        return invalid_request(
542
                'grant_type must be either authorization_code or password')
438 543
    response['Cache-Control'] = 'no-store'
439 544
    response['Pragma'] = 'no-cache'
440 545
    return response
tests/conftest.py
89 89
                       email='user@example.net', ou=get_default_ou())
90 90

  
91 91

  
92
@pytest.fixture
93
def cleartext_pw_user(db, ou1):
94
    return create_user(username='user', first_name=u'Jôhn', last_name=u'Dôe',
95
                       email='user@example.net', ou=get_default_ou(),
96
                       password='auie1234!')
97

  
98

  
92 99
@pytest.fixture
93 100
def superuser(db):
94 101
    return create_user(username='superuser',
tests/test_idp_oidc.py
38 38

  
39 39
from authentic2.models import Attribute, AuthorizedRole
40 40
from authentic2_idp_oidc.models import OIDCClient, OIDCAuthorization, OIDCCode, OIDCAccessToken, OIDCClaim
41
from authentic2_idp_oidc.utils import make_sub
41
from authentic2_idp_oidc.utils import (make_sub, get_first_rsa_sig_key,
42
        base64url)
42 43
from authentic2.a2_rbac.utils import get_default_ou
43 44
from authentic2.utils import make_url
44 45
from authentic2_auth_oidc.utils import parse_timestamp
......
1161 1162

  
1162 1163
    response = app.get('/api/users/')
1163 1164
    assert len(response.json['results']) == count
1165

  
1166

  
1167
def test_resource_owner_password_credential_grant(app, oidc_client, admin, cleartext_pw_user, role_random):
1168
    oidc_client.authorization_flow = OIDCClient.FLOW_RESOURCE_OWNER_CRED
1169
    oidc_client.save()
1170
    token_url = make_url('oidc-token')
1171
    if oidc_client.idtoken_algo == OIDCClient.ALGO_HMAC:
1172
        jwk = JWK(kty='oct', k=base64url(oidc_client.client_secret.encode('utf-8')))
1173
    elif oidc_client.idtoken_algo == OIDCClient.ALGO_RSA:
1174
        jwk = get_first_rsa_sig_key()
1175

  
1176
    # 1. test in-request client credentials
1177
    params = {
1178
        'client_id': oidc_client.client_id,
1179
        'client_secret': oidc_client.client_secret,
1180
        'grant_type': 'password',
1181
        'username': cleartext_pw_user.username,
1182
        'password': u'auie1234!',
1183
    }
1184
    response = app.post(token_url, params=params)
1185
    assert 'id_token' in response.json
1186
    token = response.json['id_token']
1187
    header, payload, signature = token.split('.')
1188
    jwt = JWT()
1189
    jwt.deserialize(token, key=jwk)
1190
    claims = json.loads(jwt.claims)
1191
    # xxx already verified by jwcrypto deserialization?
1192
    assert all(claims.get(key) for key in ('acr', 'aud', 'auth_time', 'exp',
1193
            'iat', 'iss', 'sub'))
1194

  
1195
    # 2. test basic authz
1196
    params.pop('client_id')
1197
    params.pop('client_secret')
1198

  
1199
    response = app.post(
1200
            token_url, params=params,
1201
            headers=client_authentication_headers(oidc_client))
1202
    assert 'id_token' in response.json
1203
    token = response.json['id_token']
1204
    header, payload, signature = token.split('.')
1205
    jwt = JWT()
1206
    jwt.deserialize(token, key=jwk)
1207
    claims = json.loads(jwt.claims)
1208
    # xxx already verified by jwcrypto deserialization?
1209
    assert all(claims.get(key) for key in ('acr', 'aud', 'auth_time', 'exp',
1210
            'iat', 'iss', 'sub'))
1211

  
1212

  
1213
def test_resource_owner_password_credential_grant_throttling(app, oidc_client, admin, cleartext_pw_user, role_random, settings, freezer):
1214
    settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_DURATION = 2
1215
    oidc_client.authorization_flow = OIDCClient.FLOW_RESOURCE_OWNER_CRED
1216
    oidc_client.save()
1217
    token_url = make_url('oidc-token')
1218
    params = {
1219
        'client_id': oidc_client.client_id,
1220
        'client_secret': oidc_client.client_secret,
1221
        'grant_type': 'password',
1222
        'username': cleartext_pw_user.username,
1223
        'password': u'SurelyNotTheRightPassword',
1224
    }
1225
    attempts = 0
1226
    while attempts < 100:
1227
        before = now()
1228
        response = app.post(token_url, params=params, status=400)
1229
        attempts += 1
1230
        if attempts >= 10:
1231
            assert response.json['error'] == 'invalid_request'
1232
            assert 'too many attempts with erroneous RO password' in response.json['desc']
1233
            # XXX parse backoff value announced in request.body
1234

  
1235
    # freeze some time after backoff delay expiration
1236
    today = datetime.date.today()
1237
    dayafter = today + datetime.timedelta(days=2)
1238
    freezer.move_to(dayafter.strftime('%Y-%m-%d'))
1239

  
1240
    # obtain a successful login
1241
    params['password'] = u'auie1234!'
1242
    response = app.post(token_url, params=params, status=200)
1243
    assert 'id_token' in response.json
1244

  
1245

  
1246
def test_resource_owner_password_credential_grant_invalid_client(app, oidc_client, admin, cleartext_pw_user, role_random, settings):
1247
    oidc_client.authorization_flow = OIDCClient.FLOW_RESOURCE_OWNER_CRED
1248
    oidc_client.save()
1249
    params = {
1250
        'client_id': oidc_client.client_id,
1251
        'client_secret': 'tryingthis', # Nope, wrong secret
1252
        'grant_type': 'password',
1253
        'username': cleartext_pw_user.username,
1254
        'password': u'auie1234!',
1255
    }
1256
    token_url = make_url('oidc-token')
1257
    response = app.post(token_url, params=params, status=400)
1258
    assert response.json['error'] == 'invalid_client'
1259
    assert response.json['desc'] == 'client authentication failed'
1260

  
1261

  
1262
def test_resource_owner_password_credential_grant_unauthz_client(app, oidc_client, admin, cleartext_pw_user, role_random, settings):
1263
    params = {
1264
        'client_id': oidc_client.client_id,
1265
        'client_secret': oidc_client.client_secret,
1266
        'grant_type': 'password',
1267
        'username': cleartext_pw_user.username,
1268
        'password': u'auie1234!',
1269
    }
1270
    token_url = make_url('oidc-token')
1271
    response = app.post(token_url, params=params, status=400)
1272
    assert response.json['error'] == 'unauthorized_client'
1273
    assert 'client is not configured for resource owner'in response.json['desc']
1274

  
1275

  
1276
def test_resource_owner_password_credential_grant_invalid_content_type(app, oidc_client, admin, cleartext_pw_user, role_random, settings):
1277
    oidc_client.authorization_flow = OIDCClient.FLOW_RESOURCE_OWNER_CRED
1278
    oidc_client.save()
1279
    params = {
1280
        'client_id': oidc_client.client_id,
1281
        'client_secret': oidc_client.client_secret,
1282
        'grant_type': 'password',
1283
        'username': cleartext_pw_user.username,
1284
        'password': u'auie1234!',
1285
    }
1286
    token_url = make_url('oidc-token')
1287
    response = app.post(
1288
            token_url, params=params, content_type='multipart/form-data',
1289
            status=400)
1290
    assert response.json['error'] == 'invalid_request'
1291
    assert 'wrong content type' in response.json['desc']
1164
-