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 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 |
- |