0001-idp_oidc-support-oauth2-resource-owner-password-cred.patch
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 |
- |