Projet

Général

Profil

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

Benjamin Dauvergne, 22 janvier 2020 23:49

Télécharger (19,9 ko)

Voir les différences:

Subject: [PATCH 1/2] 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                  | 204 +++++++++++++++++++++++-
 6 files changed, 348 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
25 25

  
26 26
import utils
27 27

  
28
from django.core.cache import cache
28 29
from django.core.urlresolvers import reverse
29 30
from django.core.files import File
30 31
from django.db import connection
31 32
from django.db.migrations.executor import MigrationExecutor
32 33
from django.utils.timezone import now
34
from django.test.client import RequestFactory
33 35
from django.contrib.auth import get_user_model
34 36
from django.utils.six.moves.urllib import parse as urlparse
37
from ratelimit.utils import is_ratelimited
35 38

  
36 39

  
37 40
User = get_user_model()
38 41

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

  
71 76

  
......
1161 1166

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

  
1170

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

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

  
1200
    # 2. test basic authz
1201
    params.pop('client_id')
1202
    params.pop('client_secret')
1203

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

  
1217

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

  
1251

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

  
1283

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

  
1306
    # freeze some time after backoff delay expiration
1307
    today = datetime.date.today()
1308
    dayafter = today + datetime.timedelta(days=2)
1309
    freezer.move_to(dayafter.strftime('%Y-%m-%d'))
1310

  
1311
    # obtain a successful login
1312
    params['password'] = simple_user.username
1313
    response = app.post(token_url, params=params, status=200)
1314
    assert 'id_token' in response.json
1315

  
1316

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

  
1333

  
1334
def test_resource_owner_password_credential_grant_unauthz_client(app, oidc_client, admin, simple_user, settings):
1335
    cache.clear()
1336
    params = {
1337
        'client_id': oidc_client.client_id,
1338
        'client_secret': oidc_client.client_secret,
1339
        'grant_type': 'password',
1340
        'username': simple_user.username,
1341
        'password': simple_user.username,
1342
    }
1343
    token_url = make_url('oidc-token')
1344
    response = app.post(token_url, params=params, status=400)
1345
    assert response.json['error'] == 'unauthorized_client'
1346
    assert 'client is not configured for resource owner'in response.json['desc']
1347

  
1348

  
1349
def test_resource_owner_password_credential_grant_invalid_content_type(app, oidc_client, admin, simple_user, settings):
1350
    cache.clear()
1351
    oidc_client.authorization_flow = OIDCClient.FLOW_RESOURCE_OWNER_CRED
1352
    oidc_client.save()
1353
    params = {
1354
        'client_id': oidc_client.client_id,
1355
        'client_secret': oidc_client.client_secret,
1356
        'grant_type': 'password',
1357
        'username': simple_user.username,
1358
        'password': simple_user.username,
1359
    }
1360
    token_url = make_url('oidc-token')
1361
    response = app.post(
1362
            token_url, params=params, content_type='multipart/form-data',
1363
            status=400)
1364
    assert response.json['error'] == 'invalid_request'
1365
    assert 'wrong content type' in response.json['desc']
1164
-