0001-idp_oidc-support-oauth2-resource-owner-password-cred.patch
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 ACCESS_TOKEN_DURATION(self): |
|
58 |
return self._setting('ACCESS_TOKEN_DURATION', 3600 * 8) |
|
59 | ||
60 |
@property |
|
61 |
def PASSWORD_GRANT_RATELIMIT(self): |
|
62 |
return self._setting('PASSWORD_GRANT_RATELIMIT', '100/m') |
|
63 | ||
56 | 64 |
app_settings = AppSettings('A2_IDP_OIDC_') |
57 | 65 |
app_settings.__name__ = __name__ |
58 | 66 |
sys.modules[__name__] = app_settings |
src/authentic2_idp_oidc/migrations/0001_initial.py | ||
---|---|---|
20 | 20 |
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), |
21 | 21 |
('uuid', models.CharField(default=authentic2_idp_oidc.models.generate_uuid, max_length=128, verbose_name='uuid')), |
22 | 22 |
('scopes', models.TextField(verbose_name='scopes')), |
23 |
('session_key', models.CharField(max_length=128, verbose_name='session key')), |
|
23 |
('session_key', models.CharField(blank=True, max_length=128, verbose_name='session key')),
|
|
24 | 24 |
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')), |
25 | 25 |
('expired', models.DateTimeField(verbose_name='expire')), |
26 | 26 |
], |
... | ... | |
40 | 40 |
('service_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='authentic2.Service')), |
41 | 41 |
('client_id', models.CharField(default=authentic2_idp_oidc.models.generate_uuid, unique=True, max_length=255, verbose_name='client id')), |
42 | 42 |
('client_secret', models.CharField(default=authentic2_idp_oidc.models.generate_uuid, max_length=255, verbose_name='client secret')), |
43 |
('authorization_flow', models.PositiveIntegerField(default=1, verbose_name='authorization flow', choices=[(1, 'authorization code'), (2, 'implicit/native')])),
|
|
43 |
('authorization_flow', models.PositiveIntegerField(choices=[(1, 'authorization code'), (2, 'implicit/native'), (3, 'resource owner password credentials')], default=1, verbose_name='authorization flow')),
|
|
44 | 44 |
('redirect_uris', models.TextField(verbose_name='redirect URIs', validators=[authentic2_idp_oidc.models.validate_https_url])), |
45 | 45 |
('sector_identifier_uri', models.URLField(verbose_name='sector identifier URI', blank=True)), |
46 | 46 |
('identifier_policy', models.PositiveIntegerField(default=2, verbose_name='identifier policy', choices=[(1, 'uuid'), (2, 'pairwise'), (3, 'email')])), |
src/authentic2_idp_oidc/migrations/0012_auto_20200122_2258.py | ||
---|---|---|
1 |
# -*- coding: utf-8 -*- |
|
2 |
# Generated by Django 1.11.20 on 2020-01-22 21:58 |
|
3 |
from __future__ import unicode_literals |
|
4 | ||
5 |
from django.db import migrations, models |
|
6 | ||
7 | ||
8 |
class Migration(migrations.Migration): |
|
9 | ||
10 |
dependencies = [ |
|
11 |
('authentic2_idp_oidc', '0011_auto_20180808_1546'), |
|
12 |
] |
|
13 | ||
14 |
operations = [ |
|
15 |
migrations.AddField( |
|
16 |
model_name='oidcclient', |
|
17 |
name='access_token_duration', |
|
18 |
field=models.DurationField(blank=True, default=None, null=True, verbose_name='time during which the access token is valid'), |
|
19 |
), |
|
20 |
migrations.AddField( |
|
21 |
model_name='oidcclient', |
|
22 |
name='scope', |
|
23 |
field=models.TextField(blank=True, default=b'', verbose_name='resource owner credentials grant scope'), |
|
24 |
), |
|
25 |
] |
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 |
... | ... | |
106 | 108 |
blank=True, |
107 | 109 |
null=True, |
108 | 110 |
default=None) |
111 |
access_token_duration = models.DurationField( |
|
112 |
verbose_name=_('time during which the access token is valid'), |
|
113 |
blank=True, |
|
114 |
null=True, |
|
115 |
default=None) |
|
109 | 116 |
authorization_mode = models.PositiveIntegerField( |
110 | 117 |
default=AUTHORIZATION_MODE_BY_SERVICE, |
111 | 118 |
choices=AUTHORIZATION_MODES, |
... | ... | |
129 | 136 |
verbose_name=_('identifier policy'), |
130 | 137 |
default=POLICY_PAIRWISE, |
131 | 138 |
choices=IDENTIFIER_POLICIES) |
139 |
scope = models.TextField( |
|
140 |
verbose_name=_('resource owner credentials grant scope'), |
|
141 |
help_text=_('Permitted or default scopes (for credentials grant)'), |
|
142 |
default='', |
|
143 |
blank=True) |
|
132 | 144 | |
133 | 145 |
@to_iter |
134 | 146 |
def get_idtoken_algorithms(): |
... | ... | |
198 | 210 |
return True |
199 | 211 |
return False |
200 | 212 | |
213 |
def scope_set(self): |
|
214 |
return utils.scope_set(self.scope) |
|
215 | ||
201 | 216 |
def __repr__(self): |
202 | 217 |
return ('<OIDCClient name:%r client_id:%r identifier_policy:%r>' % |
203 | 218 |
(self.name, self.client_id, self.get_identifier_policy_display())) |
... | ... | |
312 | 327 |
verbose_name=_('scopes')) |
313 | 328 |
session_key = models.CharField( |
314 | 329 |
verbose_name=_('session key'), |
315 |
max_length=128) |
|
330 |
max_length=128, |
|
331 |
blank=True) |
|
316 | 332 | |
317 | 333 |
# metadata |
318 | 334 |
created = models.DateTimeField( |
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 |
'sub': make_sub(client, user) |
|
185 | 184 |
} |
185 |
if 'openid' in scope_set: |
|
186 |
user_info['sub'] = make_sub(client, user) |
|
186 | 187 |
attributes = get_attributes({ |
187 | 188 |
'user': user, |
188 | 189 |
'request': request, |
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 |
... | ... | |
60 | 66 |
'frontchannel_logout_supported': True, |
61 | 67 |
'frontchannel_logout_session_supported': True, |
62 | 68 |
} |
63 |
return HttpResponse(json.dumps(metadata), content_type='application/json')
|
|
69 |
return JsonResponse(metadata)
|
|
64 | 70 | |
65 | 71 | |
66 | 72 |
@setting_enabled('ENABLE', settings=app_settings) |
... | ... | |
90 | 96 | |
91 | 97 | |
92 | 98 |
def idtoken_duration(client): |
93 |
if client.idtoken_duration: |
|
94 |
return client.idtoken_duration |
|
95 |
return datetime.timedelta(seconds=app_settings.IDTOKEN_DURATION) |
|
99 |
return client.idtoken_duration or datetime.timedelta(seconds=app_settings.IDTOKEN_DURATION) |
|
100 | ||
101 | ||
102 |
def access_token_duration(client): |
|
103 |
return client.access_token_duration or datetime.timedelta(seconds=app_settings.IDTOKEN_DURATION) |
|
104 | ||
105 | ||
106 |
def allowed_scopes(client): |
|
107 |
return client.scope_set() or app_settings.SCOPES or ['openid', 'email', 'profile'] |
|
108 | ||
109 | ||
110 |
def is_scopes_allowed(scopes, client): |
|
111 |
return scopes <= set(allowed_scopes(client)) |
|
96 | 112 | |
97 | 113 | |
98 | 114 |
@setting_enabled('ENABLE', settings=app_settings) |
... | ... | |
115 | 131 |
redirect_uri, client_id) |
116 | 132 |
return redirect(request, 'auth_homepage') |
117 | 133 | |
134 |
if client.authorization_flow == client.FLOW_RESOURCE_OWNER_CRED: |
|
135 |
messages.warning(request, _('Client is configured for resource owner password crendetial grant type')) |
|
136 |
return authorization_error(request, 'auth_homepage', |
|
137 |
'unauthorized_client', |
|
138 |
error_description='authz endpoint is configured ' |
|
139 |
'for resource owner password credential grant type') |
|
140 | ||
118 | 141 |
if not client.is_valid_redirect_uri(redirect_uri): |
119 | 142 |
messages.warning(request, _('Authorization request is invalid')) |
120 | 143 |
logger.warning(u'idp_oidc: authorization request error, unknown redirect_uri redirect_uri=%r client_id=%r', |
... | ... | |
171 | 194 |
error_description='openid scope is missing', |
172 | 195 |
state=state, |
173 | 196 |
fragment=fragment) |
174 |
allowed_scopes = app_settings.SCOPES or ['openid', 'email', 'profile'] |
|
175 |
if not (scopes <= set(allowed_scopes)):
|
|
197 | ||
198 |
if not is_scopes_allowed(scopes, client):
|
|
176 | 199 |
message = 'only "%s" scope(s) are supported, but "%s" requested' % ( |
177 |
', '.join(allowed_scopes), ', '.join(scopes)) |
|
200 |
', '.join(allowed_scopes(client)), ', '.join(scopes))
|
|
178 | 201 |
return authorization_error(request, redirect_uri, 'invalid_scope', |
179 | 202 |
error_description=message, |
180 | 203 |
state=state, |
... | ... | |
290 | 313 |
else: |
291 | 314 |
# FIXME: we should probably factorize this part with the token endpoint similar code |
292 | 315 |
need_access_token = 'token' in response_type.split() |
293 |
expires_in = 3600 * 8
|
|
316 |
expires_in = access_token_duration(client)
|
|
294 | 317 |
if need_access_token: |
295 | 318 |
access_token = models.OIDCAccessToken.objects.create( |
296 | 319 |
client=client, |
297 | 320 |
user=request.user, |
298 | 321 |
scopes=u' '.join(scopes), |
299 | 322 |
session_key=request.session.session_key, |
300 |
expired=start + datetime.timedelta(seconds=expires_in))
|
|
323 |
expired=start + expires_in)
|
|
301 | 324 |
acr = '0' |
302 | 325 |
if nonce is not None and last_auth.get('nonce') == nonce: |
303 | 326 |
acr = '1' |
... | ... | |
326 | 349 |
params.update({ |
327 | 350 |
'access_token': access_token.uuid, |
328 | 351 |
'token_type': 'Bearer', |
329 |
'expires_in': expires_in, |
|
352 |
'expires_in': expires_in.total_seconds(),
|
|
330 | 353 |
}) |
331 | 354 |
# query is transfered through the hashtag |
332 | 355 |
response = redirect(request, redirect_uri + '#%s' % urlencode(params), resolve=False) |
... | ... | |
365 | 388 |
return client |
366 | 389 | |
367 | 390 | |
368 |
def invalid_request(desc=None):
|
|
391 |
def error_response(error, error_description=None, status=400):
|
|
369 | 392 |
content = { |
370 |
'error': 'invalid_request',
|
|
393 |
'error': error,
|
|
371 | 394 |
} |
372 |
if desc:
|
|
373 |
content['desc'] = desc
|
|
374 |
return HttpResponseBadRequest(json.dumps(content), content_type='application/json')
|
|
395 |
if error_description:
|
|
396 |
content['error_description'] = error_description
|
|
397 |
return JsonResponse(content, status=status)
|
|
375 | 398 | |
376 | 399 | |
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') |
|
400 |
def invalid_request_response(error_description=None): |
|
401 |
return error_response('invalid_request', error_description=error_description) |
|
402 | ||
403 | ||
404 |
def access_denied_response(error_description=None): |
|
405 |
return error_response('access_denied', error_description=error_description) |
|
406 | ||
407 | ||
408 |
def unauthorized_client_response(error_description=None): |
|
409 |
return error_response('unauthorized_client', error_description=error_description) |
|
410 | ||
411 | ||
412 |
def invalid_client_response(error_description=None): |
|
413 |
return error_response('invalid_client', error_description=error_description) |
|
414 | ||
415 | ||
416 |
def credential_grant_ratelimit_key(group, request): |
|
417 |
client = authenticate_client(request, client=None) |
|
418 |
if client: |
|
419 |
return client.client_id |
|
420 |
# return remote address when no valid client credentials have been provided |
|
421 |
return request.META['REMOTE_ADDR'] |
|
422 | ||
423 | ||
424 |
def idtoken_from_user_credential(request): |
|
425 |
if request.META.get('CONTENT_TYPE') != 'application/x-www-form-urlencoded': |
|
426 |
return invalid_request_response( |
|
427 |
'wrong content type. request content type must be \'application/x-www-form-urlencoded\'') |
|
428 |
username = request.POST.get('username') |
|
429 |
scope = request.POST.get('scope') |
|
430 | ||
431 |
# scope is ignored, we used the configured scope |
|
432 | ||
433 |
if not all((username, request.POST.get('password'))): |
|
434 |
return invalid_request_response( |
|
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_response( |
|
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_response('client authentication failed') |
|
450 | ||
451 |
if client.authorization_flow != models.OIDCClient.FLOW_RESOURCE_OWNER_CRED: |
|
452 |
return unauthorized_client_response( |
|
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_response( |
|
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_response('invalid resource owner credentials') |
|
474 | ||
475 |
# limit requested scopes |
|
476 |
if scope is not None: |
|
477 |
scopes = utils.scope_set(scope) & client.scope_set() |
|
478 |
else: |
|
479 |
scopes = client.scope_set() |
|
480 | ||
481 |
exponential_backoff.success(*backoff_keys) |
|
482 |
start = now() |
|
483 |
# make access_token |
|
484 |
expires_in = access_token_duration(client) |
|
485 |
access_token = models.OIDCAccessToken.objects.create( |
|
486 |
client=client, |
|
487 |
user=user, |
|
488 |
scopes=' '.join(scopes), |
|
489 |
session_key='', |
|
490 |
expired=start + expires_in) |
|
491 |
# make id_token |
|
492 |
id_token = utils.create_user_info( |
|
493 |
request, |
|
494 |
client, |
|
495 |
user, |
|
496 |
scopes, |
|
497 |
id_token=True) |
|
498 |
id_token.update({ |
|
499 |
'iss': utils.get_issuer(request), |
|
500 |
'aud': client.client_id, |
|
501 |
'exp': timestamp_from_datetime(start + idtoken_duration(client)), |
|
502 |
'iat': timestamp_from_datetime(start), |
|
503 |
'auth_time': timestamp_from_datetime(start), |
|
504 |
'acr': '0', |
|
505 |
}) |
|
506 |
return JsonResponse({ |
|
507 |
'access_token': six.text_type(access_token.uuid), |
|
508 |
'token_type': 'Bearer', |
|
509 |
'expires_in': expires_in.total_seconds(), |
|
510 |
'id_token': utils.make_idtoken(client, id_token), |
|
511 |
}) |
|
512 | ||
513 | ||
514 |
def tokens_from_authz_code(request): |
|
385 | 515 |
code = request.POST.get('code') |
386 | 516 |
if code is None: |
387 |
return invalid_request('missing code') |
|
517 |
return invalid_request_response('missing code')
|
|
388 | 518 |
try: |
389 | 519 |
oidc_code = models.OIDCCode.objects.select_related().get(uuid=code) |
390 | 520 |
except models.OIDCCode.DoesNotExist: |
391 |
return invalid_request('invalid code') |
|
521 |
return invalid_request_response('invalid code')
|
|
392 | 522 |
if not oidc_code.is_valid(): |
393 |
return invalid_request('code has expired or user is disconnected') |
|
523 |
return invalid_request_response('code has expired or user is disconnected')
|
|
394 | 524 |
client = authenticate_client(request, client=oidc_code.client) |
395 | 525 |
if client is None: |
396 | 526 |
return HttpResponse('unauthenticated', status=401) |
... | ... | |
398 | 528 |
models.OIDCCode.objects.filter(uuid=code).delete() |
399 | 529 |
redirect_uri = request.POST.get('redirect_uri') |
400 | 530 |
if oidc_code.redirect_uri != redirect_uri: |
401 |
return invalid_request('invalid redirect_uri') |
|
402 |
expires_in = 3600 * 8
|
|
531 |
return invalid_request_response('invalid redirect_uri')
|
|
532 |
expires_in = access_token_duration(client)
|
|
403 | 533 |
access_token = models.OIDCAccessToken.objects.create( |
404 | 534 |
client=client, |
405 | 535 |
user=oidc_code.user, |
406 | 536 |
scopes=oidc_code.scopes, |
407 | 537 |
session_key=oidc_code.session_key, |
408 |
expired=oidc_code.created + datetime.timedelta(seconds=expires_in))
|
|
538 |
expired=oidc_code.created + expires_in)
|
|
409 | 539 |
start = now() |
410 | 540 |
acr = '0' |
411 | 541 |
if (oidc_code.nonce is not None |
... | ... | |
429 | 559 |
}) |
430 | 560 |
if oidc_code.nonce is not None: |
431 | 561 |
id_token['nonce'] = oidc_code.nonce |
432 |
response = HttpResponse(json.dumps({
|
|
562 |
return JsonResponse({
|
|
433 | 563 |
'access_token': six.text_type(access_token.uuid), |
434 | 564 |
'token_type': 'Bearer', |
435 |
'expires_in': expires_in, |
|
565 |
'expires_in': expires_in.total_seconds(),
|
|
436 | 566 |
'id_token': utils.make_idtoken(client, id_token), |
437 |
}), content_type='application/json') |
|
567 |
}) |
|
568 | ||
569 | ||
570 |
@setting_enabled('ENABLE', settings=app_settings) |
|
571 |
@csrf_exempt |
|
572 |
def token(request, *args, **kwargs): |
|
573 |
if request.method != 'POST': |
|
574 |
return HttpResponseNotAllowed(['POST']) |
|
575 |
grant_type = request.POST.get('grant_type') |
|
576 |
if grant_type == 'password': |
|
577 |
response = idtoken_from_user_credential(request) |
|
578 |
elif grant_type == 'authorization_code': |
|
579 |
response = tokens_from_authz_code(request) |
|
580 |
else: |
|
581 |
return invalid_request_response('grant_type must be either authorization_code or password') |
|
438 | 582 |
response['Cache-Control'] = 'no-store' |
439 | 583 |
response['Pragma'] = 'no-cache' |
440 | 584 |
return response |
... | ... | |
465 | 609 |
access_token.client, |
466 | 610 |
access_token.user, |
467 | 611 |
access_token.scope_set()) |
468 |
return HttpResponse(json.dumps(user_info), content_type='application/json')
|
|
612 |
return JsonResponse(user_info)
|
|
469 | 613 | |
470 | 614 | |
471 | 615 |
@setting_enabled('ENABLE', settings=app_settings) |
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 | |
... | ... | |
667 | 672 |
}, headers=client_authentication_headers(oidc_client), status=400) |
668 | 673 |
assert 'error' in response.json |
669 | 674 |
assert response.json['error'] == 'invalid_request' |
670 |
assert response.json['desc'] == 'code has expired or user is disconnected'
|
|
675 |
assert response.json['error_description'] == 'code has expired or user is disconnected'
|
|
671 | 676 | |
672 | 677 |
# invalid logout |
673 | 678 |
logout_url = make_url('oidc-logout', params={ |
... | ... | |
693 | 698 |
}, headers=client_authentication_headers(oidc_client), status=400) |
694 | 699 |
assert 'error' in response.json |
695 | 700 |
assert response.json['error'] == 'invalid_request' |
696 |
assert response.json['desc'] == 'code has expired or user is disconnected'
|
|
701 |
assert response.json['error_description'] == 'code has expired or user is disconnected'
|
|
697 | 702 | |
698 | 703 | |
699 | 704 |
def test_expired_manager(db, simple_user): |
... | ... | |
1161 | 1166 | |
1162 | 1167 |
response = app.get('/api/users/') |
1163 | 1168 |
assert len(response.json['results']) == count |
1169 | ||
1170 | ||
1171 |
def test_credentials_grant(app, oidc_client, admin, simple_user): |
|
1172 |
cache.clear() |
|
1173 |
oidc_client.authorization_flow = OIDCClient.FLOW_RESOURCE_OWNER_CRED |
|
1174 |
oidc_client.scope = 'openid' |
|
1175 |
oidc_client.save() |
|
1176 |
token_url = make_url('oidc-token') |
|
1177 |
if oidc_client.idtoken_algo == OIDCClient.ALGO_HMAC: |
|
1178 |
jwk = JWK(kty='oct', k=base64url(oidc_client.client_secret.encode('utf-8'))) |
|
1179 |
elif oidc_client.idtoken_algo == OIDCClient.ALGO_RSA: |
|
1180 |
jwk = get_first_rsa_sig_key() |
|
1181 | ||
1182 |
# 1. test in-request client credentials |
|
1183 |
params = { |
|
1184 |
'client_id': oidc_client.client_id, |
|
1185 |
'client_secret': oidc_client.client_secret, |
|
1186 |
'grant_type': 'password', |
|
1187 |
'username': simple_user.username, |
|
1188 |
'password': simple_user.username, |
|
1189 |
} |
|
1190 |
response = app.post(token_url, params=params) |
|
1191 |
assert 'id_token' in response.json |
|
1192 |
token = response.json['id_token'] |
|
1193 |
header, payload, signature = token.split('.') |
|
1194 |
jwt = JWT() |
|
1195 |
jwt.deserialize(token, key=jwk) |
|
1196 |
claims = json.loads(jwt.claims) |
|
1197 |
# xxx already verified by jwcrypto deserialization? |
|
1198 |
assert set(claims) == set(['acr', 'aud', 'auth_time', 'exp', 'iat', 'iss', 'sub']) |
|
1199 |
assert all(claims.values()) |
|
1200 | ||
1201 |
# 2. test basic authz |
|
1202 |
params.pop('client_id') |
|
1203 |
params.pop('client_secret') |
|
1204 | ||
1205 |
response = app.post(token_url, params=params, headers=client_authentication_headers(oidc_client)) |
|
1206 |
assert 'id_token' in response.json |
|
1207 |
token = response.json['id_token'] |
|
1208 |
header, payload, signature = token.split('.') |
|
1209 |
jwt = JWT() |
|
1210 |
jwt.deserialize(token, key=jwk) |
|
1211 |
claims = json.loads(jwt.claims) |
|
1212 |
# xxx already verified by jwcrypto deserialization? |
|
1213 |
assert set(claims) == set(['acr', 'aud', 'auth_time', 'exp', 'iat', 'iss', 'sub']) |
|
1214 |
assert all(claims.values()) |
|
1215 | ||
1216 | ||
1217 |
def test_credentials_grant_ratelimitation_invalid_client( |
|
1218 |
app, oidc_client, admin, simple_user, oidc_settings, freezer): |
|
1219 |
freezer.move_to('2020-01-01') |
|
1220 | ||
1221 |
cache.clear() |
|
1222 |
oidc_client.authorization_flow = OIDCClient.FLOW_RESOURCE_OWNER_CRED |
|
1223 |
oidc_client.save() |
|
1224 |
token_url = make_url('oidc-token') |
|
1225 |
params = { |
|
1226 |
'client_id': oidc_client.client_id, |
|
1227 |
'client_secret': 'notgood', |
|
1228 |
'grant_type': 'password', |
|
1229 |
'username': simple_user.username, |
|
1230 |
'password': simple_user.username, |
|
1231 |
} |
|
1232 |
for i in range(int(oidc_settings.A2_IDP_OIDC_PASSWORD_GRANT_RATELIMIT.split('/')[0])): |
|
1233 |
response = app.post(token_url, params=params, status=400) |
|
1234 |
assert response.json['error'] == 'invalid_client' |
|
1235 |
assert 'client authentication failed' in response.json['error_description'] |
|
1236 |
response = app.post(token_url, params=params, status=400) |
|
1237 |
assert response.json['error'] == 'invalid_request' |
|
1238 |
assert 'reached rate limitation' in response.json['error_description'] |
|
1239 | ||
1240 | ||
1241 |
def test_credentials_grant_ratelimitation_valid_client( |
|
1242 |
app, oidc_client, admin, simple_user, oidc_settings, freezer): |
|
1243 |
freezer.move_to('2020-01-01') |
|
1244 | ||
1245 |
cache.clear() |
|
1246 |
oidc_client.authorization_flow = OIDCClient.FLOW_RESOURCE_OWNER_CRED |
|
1247 |
oidc_client.save() |
|
1248 |
token_url = make_url('oidc-token') |
|
1249 |
params = { |
|
1250 |
'client_id': oidc_client.client_id, |
|
1251 |
'client_secret': oidc_client.client_secret, |
|
1252 |
'grant_type': 'password', |
|
1253 |
'username': simple_user.username, |
|
1254 |
'password': simple_user.username, |
|
1255 |
} |
|
1256 |
for i in range(int(oidc_settings.A2_IDP_OIDC_PASSWORD_GRANT_RATELIMIT.split('/')[0])): |
|
1257 |
app.post(token_url, params=params) |
|
1258 |
response = app.post(token_url, params=params, status=400) |
|
1259 |
assert response.json['error'] == 'invalid_request' |
|
1260 |
assert 'reached rate limitation' in response.json['error_description'] |
|
1261 | ||
1262 | ||
1263 |
def test_credentials_grant_retrytimout( |
|
1264 |
app, oidc_client, admin, simple_user, settings, freezer): |
|
1265 |
freezer.move_to('2020-01-01') |
|
1266 | ||
1267 |
cache.clear() |
|
1268 |
settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_DURATION = 2 |
|
1269 |
oidc_client.authorization_flow = OIDCClient.FLOW_RESOURCE_OWNER_CRED |
|
1270 |
oidc_client.save() |
|
1271 |
token_url = make_url('oidc-token') |
|
1272 |
params = { |
|
1273 |
'client_id': oidc_client.client_id, |
|
1274 |
'client_secret': oidc_client.client_secret, |
|
1275 |
'grant_type': 'password', |
|
1276 |
'username': simple_user.username, |
|
1277 |
'password': u'SurelyNotTheRightPassword', |
|
1278 |
} |
|
1279 |
attempts = 0 |
|
1280 |
while attempts < 100: |
|
1281 |
response = app.post(token_url, params=params, status=400) |
|
1282 |
attempts += 1 |
|
1283 |
if attempts >= 10: |
|
1284 |
assert response.json['error'] == 'invalid_request' |
|
1285 |
assert 'too many attempts with erroneous RO password' in response.json['error_description'] |
|
1286 | ||
1287 |
# freeze some time after backoff delay expiration |
|
1288 |
freezer.move_to(datetime.timedelta(days=2)) |
|
1289 | ||
1290 |
# obtain a successful login |
|
1291 |
params['password'] = simple_user.username |
|
1292 |
response = app.post(token_url, params=params, status=200) |
|
1293 |
assert 'id_token' in response.json |
|
1294 | ||
1295 | ||
1296 |
def test_credentials_grant_invalid_flow( |
|
1297 |
app, oidc_client, admin, simple_user, settings): |
|
1298 |
cache.clear() |
|
1299 |
params = { |
|
1300 |
'client_id': oidc_client.client_id, |
|
1301 |
'client_secret': oidc_client.client_secret, |
|
1302 |
'grant_type': 'password', |
|
1303 |
'username': simple_user.username, |
|
1304 |
'password': u'SurelyNotTheRightPassword', |
|
1305 |
} |
|
1306 |
token_url = make_url('oidc-token') |
|
1307 |
response = app.post(token_url, params=params, status=400) |
|
1308 |
assert response.json['error'] == 'unauthorized_client' |
|
1309 |
assert 'is not configured' in response.json['error_description'] |
|
1310 | ||
1311 | ||
1312 |
def test_credentials_grant_invalid_client( |
|
1313 |
app, oidc_client, admin, simple_user, settings): |
|
1314 |
cache.clear() |
|
1315 |
oidc_client.authorization_flow = OIDCClient.FLOW_RESOURCE_OWNER_CRED |
|
1316 |
oidc_client.save() |
|
1317 |
params = { |
|
1318 |
'client_id': oidc_client.client_id, |
|
1319 |
'client_secret': 'tryingthis', # Nope, wrong secret |
|
1320 |
'grant_type': 'password', |
|
1321 |
'username': simple_user.username, |
|
1322 |
'password': simple_user.username, |
|
1323 |
} |
|
1324 |
token_url = make_url('oidc-token') |
|
1325 |
response = app.post(token_url, params=params, status=400) |
|
1326 |
assert response.json['error'] == 'invalid_client' |
|
1327 |
assert response.json['error_description'] == 'client authentication failed' |
|
1328 | ||
1329 | ||
1330 |
def test_credentials_grant_unauthz_client( |
|
1331 |
app, oidc_client, admin, simple_user, settings): |
|
1332 |
cache.clear() |
|
1333 |
params = { |
|
1334 |
'client_id': oidc_client.client_id, |
|
1335 |
'client_secret': oidc_client.client_secret, |
|
1336 |
'grant_type': 'password', |
|
1337 |
'username': simple_user.username, |
|
1338 |
'password': simple_user.username, |
|
1339 |
} |
|
1340 |
token_url = make_url('oidc-token') |
|
1341 |
response = app.post(token_url, params=params, status=400) |
|
1342 |
assert response.json['error'] == 'unauthorized_client' |
|
1343 |
assert 'client is not configured for resource owner'in response.json['error_description'] |
|
1344 | ||
1345 | ||
1346 |
def test_credentials_grant_invalid_content_type( |
|
1347 |
app, oidc_client, admin, simple_user, settings): |
|
1348 |
cache.clear() |
|
1349 |
oidc_client.authorization_flow = OIDCClient.FLOW_RESOURCE_OWNER_CRED |
|
1350 |
oidc_client.save() |
|
1351 |
params = { |
|
1352 |
'client_id': oidc_client.client_id, |
|
1353 |
'client_secret': oidc_client.client_secret, |
|
1354 |
'grant_type': 'password', |
|
1355 |
'username': simple_user.username, |
|
1356 |
'password': simple_user.username, |
|
1357 |
} |
|
1358 |
token_url = make_url('oidc-token') |
|
1359 |
response = app.post( |
|
1360 |
token_url, params=params, |
|
1361 |
content_type='multipart/form-data', |
|
1362 |
status=400) |
|
1363 |
assert response.json['error'] == 'invalid_request' |
|
1364 |
assert 'wrong content type' in response.json['error_description'] |
|
1365 | ||
1366 | ||
1367 |
def test_resource_owner_password_credential_grant_wrong_endpoint(app, oidc_client, admin, simple_user, settings): |
|
1368 |
cache.clear() |
|
1369 |
oidc_client.authorization_flow = OIDCClient.FLOW_RESOURCE_OWNER_CRED |
|
1370 |
oidc_client.save() |
|
1371 |
redirect_uri = oidc_client.redirect_uris.split()[0] |
|
1372 |
params = { |
|
1373 |
'client_id': oidc_client.client_id, |
|
1374 |
'redirect_uri': redirect_uri, |
|
1375 |
'grant_type': 'password', |
|
1376 |
'username': simple_user.username, |
|
1377 |
'password': simple_user.username, |
|
1378 |
} |
|
1379 |
authorize_url = make_url('oidc-authorize', params=params) |
|
1380 |
response = app.get(authorize_url) |
|
1381 |
assert 'error=unauthorized_client' in response.location |
|
1164 |
- |