Projet

Général

Profil

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

Paul Marillonnet, 09 janvier 2020 18:22

Télécharger (19,6 ko)

Voir les différences:

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

 setup.py                                |   1 +
 src/authentic2_idp_oidc/app_settings.py |   4 +
 src/authentic2_idp_oidc/models.py       |   2 +
 src/authentic2_idp_oidc/utils.py        |   2 +-
 src/authentic2_idp_oidc/views.py        | 148 ++++++++++++++++--
 tests/test_idp_oidc.py                  | 196 +++++++++++++++++++++++-
 6 files changed, 340 insertions(+), 13 deletions(-)
setup.py
122 122
          'dnspython>=1.10',
123 123
          'Django-Select2>5,<6',
124 124
          'django-tables2>=1.0,<2.0',
125
          'django-ratelimit',
125 126
          'gadjo>=0.53',
126 127
          'django-import-export>=0.2.7,<=0.4.5',
127 128
          'djangorestframework>=3.3,<3.5',
src/authentic2_idp_oidc/app_settings.py
53 53
    def IDTOKEN_DURATION(self):
54 54
        return self._setting('IDTOKEN_DURATION', 30)
55 55

  
56
    @property
57
    def PASSWORD_GRANT_RATELIMIT(self):
58
        return self._setting('PASSWORD_GRANT_RATELIMIT', '100/m')
59

  
56 60
app_settings = AppSettings('A2_IDP_OIDC_')
57 61
app_settings.__name__ = __name__
58 62
sys.modules[__name__] = app_settings
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
21 22
import time
22 23

  
23
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed
24
from django.http import (HttpResponse, HttpResponseBadRequest,
25
        HttpResponseNotAllowed, JsonResponse)
24 26
from django.utils import six
25 27
from django.utils.timezone import now, utc
26 28
from django.utils.http import urlencode
......
28 30
from django.views.decorators.csrf import csrf_exempt
29 31
from django.core.urlresolvers import reverse
30 32
from django.contrib import messages
33
from django.contrib.auth import authenticate
31 34
from django.conf import settings
32 35
from django.utils.translation import ugettext as _
36
from ratelimit.utils import is_ratelimited
33 37

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

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

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

  
376 389

  
377
@setting_enabled('ENABLE', settings=app_settings)
378
@csrf_exempt
379
def token(request, *args, **kwargs):
380
    if request.method != 'POST':
381
        return HttpResponseNotAllowed(['POST'])
382
    grant_type = request.POST.get('grant_type')
383
    if grant_type != 'authorization_code':
384
        return invalid_request('grant_type is not authorization_code')
390
def access_denied(desc=None):
391
    content = {
392
        'error': 'access_denied',
393
    }
394
    if desc:
395
        content['desc'] = desc
396
    return HttpResponseBadRequest(json.dumps(content), content_type='application/json')
397

  
398

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

  
407

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

  
416

  
417
def credential_grant_ratelimit_key(group, request):
418
    client = authenticate_client(request, client=None)
419
    if client:
420
        return client.client_id
421
    # return remote address when no valid client credentials have been provided
422
    return request.META['REMOTE_ADDR']
423

  
424

  
425
def idtoken_from_user_credential(request):
426
        if request.META.get('CONTENT_TYPE') != 'application/x-www-form-urlencoded':
427
            return invalid_request(
428
                    'wrong content type \'%s\'.  request content type must be '
429
                    '\'application/x-www-form-urlencoded\'')
430
        username = request.POST.get('username')
431
        scope = request.POST.get('scope', '')
432

  
433
        if not all((username, request.POST.get('password'))):
434
            return invalid_request(
435
                    'request must bear both username and password as '
436
                    'parameters using the "application/x-www-form-urlencoded" '
437
                    'media type')
438

  
439
        if is_ratelimited(
440
                request, group='ro-cred-grant', increment=True,
441
                key=credential_grant_ratelimit_key,
442
                rate=app_settings.PASSWORD_GRANT_RATELIMIT):
443
            return invalid_request(
444
                    'reached rate limitation, too many erroneous requests')
445

  
446
        client = authenticate_client(request, client=None)
447

  
448
        if not client:
449
            return invalid_client('client authentication failed')
450

  
451
        if client.authorization_flow != models.OIDCClient.FLOW_RESOURCE_OWNER_CRED:
452
            return unauthorized_client(
453
                'client is not configured for resource owner password '
454
                'credential grant')
455

  
456
        exponential_backoff = ExponentialRetryTimeout(
457
            key_prefix='idp-oidc-ro-cred-grant',
458
            duration=a2_app_settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_DURATION,
459
            factor=a2_app_settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_FACTOR)
460
        backoff_keys = (username, client.client_id)
461

  
462
        seconds_to_wait = exponential_backoff.seconds_to_wait(*backoff_keys)
463
        if seconds_to_wait > a2_app_settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_MAX_DURATION:
464
            seconds_to_wait = a2_app_settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_MAX_DURATION
465
        if seconds_to_wait:
466
            return invalid_request(
467
                'too many attempts with erroneous RO password, you must wait '
468
                '%s seconds to try again.' % int(math.ceil(seconds_to_wait)))
469

  
470
        user = authenticate(request, username=username, password=request.POST.get('password'))
471
        if not user:
472
            exponential_backoff.failure(*backoff_keys)
473
            return access_denied(
474
                    'invalid resource owner credentials')
475

  
476
        exponential_backoff.success(*backoff_keys)
477
        start = now()
478
        id_token = utils.create_user_info(
479
            request,
480
            client,
481
            user,
482
            scope,
483
            id_token=True)
484
        id_token.update({
485
            'iss': utils.get_issuer(request),
486
            'aud': client.client_id,
487
            'exp': timestamp_from_datetime(start + idtoken_duration(client)),
488
            'iat': timestamp_from_datetime(start),
489
            'auth_time': timestamp_from_datetime(start),
490
            'acr': '0',
491
        })
492
        return JsonResponse({'id_token': utils.make_idtoken(client, id_token)})
493

  
494

  
495
def tokens_from_authz_code(request):
385 496
    code = request.POST.get('code')
386 497
    if code is None:
387 498
        return invalid_request('missing code')
......
429 540
    })
430 541
    if oidc_code.nonce is not None:
431 542
        id_token['nonce'] = oidc_code.nonce
432
    response = HttpResponse(json.dumps({
543
    return JsonResponse({
433 544
        'access_token': six.text_type(access_token.uuid),
434 545
        'token_type': 'Bearer',
435 546
        'expires_in': expires_in,
436 547
        'id_token': utils.make_idtoken(client, id_token),
437
    }), content_type='application/json')
548
    })
549

  
550

  
551
@setting_enabled('ENABLE', settings=app_settings)
552
@csrf_exempt
553
def token(request, *args, **kwargs):
554
    if request.method != 'POST':
555
        return HttpResponseNotAllowed(['POST'])
556
    grant_type = request.POST.get('grant_type')
557
    if grant_type == 'password':
558
        response = idtoken_from_user_credential(request)
559
    elif grant_type == 'authorization_code':
560
        response= tokens_from_authz_code(request)
561
    else:
562
        return invalid_request(
563
                'grant_type must be either authorization_code or password')
438 564
    response['Cache-Control'] = 'no-store'
439 565
    response['Pragma'] = 'no-cache'
440 566
    return response
tests/test_idp_oidc.py
30 30
from django.db import connection
31 31
from django.db.migrations.executor import MigrationExecutor
32 32
from django.utils.timezone import now
33
from django.test.client import RequestFactory
33 34
from django.contrib.auth import get_user_model
34 35
from django.utils.six.moves.urllib import parse as urlparse
36
from ratelimit.utils import is_ratelimited
35 37

  
36 38

  
37 39
User = get_user_model()
38 40

  
39 41
from authentic2.models import Attribute, AuthorizedRole
40 42
from authentic2_idp_oidc.models import OIDCClient, OIDCAuthorization, OIDCCode, OIDCAccessToken, OIDCClaim
41
from authentic2_idp_oidc.utils import make_sub
43
from authentic2_idp_oidc.utils import (make_sub, get_first_rsa_sig_key,
44
        base64url)
42 45
from authentic2.a2_rbac.utils import get_default_ou
43 46
from authentic2.utils import make_url
44 47
from authentic2_auth_oidc.utils import parse_timestamp
......
66 69
@pytest.fixture
67 70
def oidc_settings(settings):
68 71
    settings.A2_IDP_OIDC_JWKSET = JWKSET
72
    settings.A2_IDP_OIDC_PASSWORD_GRANT_RATELIMIT = '100/m'
69 73
    return settings
70 74

  
71 75

  
......
1161 1165

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

  
1169

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

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

  
1198
    # 2. test basic authz
1199
    params.pop('client_id')
1200
    params.pop('client_secret')
1201

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

  
1215

  
1216
def test_resource_owner_password_credential_grant_ratelimitation_invalid_client(app, oidc_client, admin, simple_user, oidc_settings):
1217
    oidc_client.authorization_flow = OIDCClient.FLOW_RESOURCE_OWNER_CRED
1218
    oidc_client.save()
1219
    token_url = make_url('oidc-token')
1220
    params = {
1221
        'client_id': oidc_client.client_id,
1222
        'client_secret': 'notgood',
1223
        'grant_type': 'password',
1224
        'username': simple_user.username,
1225
        'password': simple_user.username,
1226
    }
1227
    attempts = 0
1228
    dummy_post = RequestFactory().post('/dummy')
1229
    while attempts < 1000:
1230
        before = now()
1231
        attempts += 1
1232
        ratelimited = is_ratelimited(
1233
                request=dummy_post, group='test-ro-cred-grant', increment=True,
1234
                key=lambda x, y: '127.0.0.1',
1235
                rate=oidc_settings.A2_IDP_OIDC_PASSWORD_GRANT_RATELIMIT)
1236
        response = app.post(token_url, params=params, status=400)
1237
        if not ratelimited:
1238
            assert response.json['error'] == 'invalid_client'
1239
            assert 'client authentication failed' in response.json['desc']
1240
            continue
1241
        else:
1242
            assert response.json['error'] == 'invalid_request'
1243
            assert 'reached rate limitation' in response.json['desc']
1244
            break
1245
    if not ratelimited:
1246
        assert 0
1247

  
1248

  
1249
def test_resource_owner_password_credential_grant_ratelimitation_valid_client(app, oidc_client, admin, simple_user, oidc_settings):
1250
    oidc_client.authorization_flow = OIDCClient.FLOW_RESOURCE_OWNER_CRED
1251
    oidc_client.save()
1252
    token_url = make_url('oidc-token')
1253
    params = {
1254
        'client_id': oidc_client.client_id,
1255
        'client_secret': oidc_client.client_secret,
1256
        'grant_type': 'password',
1257
        'username': simple_user.username,
1258
        'password': simple_user.username,
1259
    }
1260
    attempts = 0
1261
    dummy_post = RequestFactory().post('/dummy')
1262
    while attempts < 1000:
1263
        before = now()
1264
        attempts += 1
1265
        ratelimited = is_ratelimited(
1266
                request=dummy_post, group='test-ro-cred-grant', increment=True,
1267
                key=lambda x, y: oidc_client.client_id,
1268
                rate=oidc_settings.A2_IDP_OIDC_PASSWORD_GRANT_RATELIMIT)
1269
        if ratelimited:
1270
            response = app.post(token_url, params=params, status=400)
1271
            assert response.json['error'] == 'invalid_request'
1272
            assert 'reached rate limitation' in response.json['desc']
1273
            break
1274
        else:
1275
            response = app.post(token_url, params=params)
1276
    if not ratelimited:
1277
        assert 0
1278

  
1279

  
1280
def test_resource_owner_password_credential_grant_retrytimout(app, oidc_client, admin, simple_user, settings, freezer):
1281
    settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_DURATION = 2
1282
    oidc_client.authorization_flow = OIDCClient.FLOW_RESOURCE_OWNER_CRED
1283
    oidc_client.save()
1284
    token_url = make_url('oidc-token')
1285
    params = {
1286
        'client_id': oidc_client.client_id,
1287
        'client_secret': oidc_client.client_secret,
1288
        'grant_type': 'password',
1289
        'username': simple_user.username,
1290
        'password': u'SurelyNotTheRightPassword',
1291
    }
1292
    attempts = 0
1293
    while attempts < 100:
1294
        before = now()
1295
        response = app.post(token_url, params=params, status=400)
1296
        attempts += 1
1297
        if attempts >= 10:
1298
            assert response.json['error'] == 'invalid_request'
1299
            assert 'too many attempts with erroneous RO password' in response.json['desc']
1300

  
1301
    # freeze some time after backoff delay expiration
1302
    today = datetime.date.today()
1303
    dayafter = today + datetime.timedelta(days=2)
1304
    freezer.move_to(dayafter.strftime('%Y-%m-%d'))
1305

  
1306
    # obtain a successful login
1307
    params['password'] = simple_user.username
1308
    response = app.post(token_url, params=params, status=200)
1309
    assert 'id_token' in response.json
1310

  
1311

  
1312
def test_resource_owner_password_credential_grant_invalid_client(app, oidc_client, admin, simple_user, settings):
1313
    oidc_client.authorization_flow = OIDCClient.FLOW_RESOURCE_OWNER_CRED
1314
    oidc_client.save()
1315
    params = {
1316
        'client_id': oidc_client.client_id,
1317
        'client_secret': 'tryingthis', # Nope, wrong secret
1318
        'grant_type': 'password',
1319
        'username': simple_user.username,
1320
        'password': simple_user.username,
1321
    }
1322
    token_url = make_url('oidc-token')
1323
    response = app.post(token_url, params=params, status=400)
1324
    assert response.json['error'] == 'invalid_client'
1325
    assert response.json['desc'] == 'client authentication failed'
1326

  
1327

  
1328
def test_resource_owner_password_credential_grant_unauthz_client(app, oidc_client, admin, simple_user, settings):
1329
    params = {
1330
        'client_id': oidc_client.client_id,
1331
        'client_secret': oidc_client.client_secret,
1332
        'grant_type': 'password',
1333
        'username': simple_user.username,
1334
        'password': simple_user.username,
1335
    }
1336
    token_url = make_url('oidc-token')
1337
    response = app.post(token_url, params=params, status=400)
1338
    assert response.json['error'] == 'unauthorized_client'
1339
    assert 'client is not configured for resource owner'in response.json['desc']
1340

  
1341

  
1342
def test_resource_owner_password_credential_grant_invalid_content_type(app, oidc_client, admin, simple_user, settings):
1343
    oidc_client.authorization_flow = OIDCClient.FLOW_RESOURCE_OWNER_CRED
1344
    oidc_client.save()
1345
    params = {
1346
        'client_id': oidc_client.client_id,
1347
        'client_secret': oidc_client.client_secret,
1348
        'grant_type': 'password',
1349
        'username': simple_user.username,
1350
        'password': simple_user.username,
1351
    }
1352
    token_url = make_url('oidc-token')
1353
    response = app.post(
1354
            token_url, params=params, content_type='multipart/form-data',
1355
            status=400)
1356
    assert response.json['error'] == 'invalid_request'
1357
    assert 'wrong content type' in response.json['desc']
1164
-