auth_oidc: verify and store id_token nonce (fixes #29009)
This commit is contained in:
parent
0e34001537
commit
b4110b3b3c
|
@ -341,7 +341,7 @@ def get_nonce(request):
|
||||||
return nonce
|
return nonce
|
||||||
|
|
||||||
|
|
||||||
def record_authentication_event(request, how):
|
def record_authentication_event(request, how, nonce=None):
|
||||||
'''Record an authentication event in the session and in the database, in
|
'''Record an authentication event in the session and in the database, in
|
||||||
later version the database persistence can be removed'''
|
later version the database persistence can be removed'''
|
||||||
from . import models
|
from . import models
|
||||||
|
@ -362,7 +362,7 @@ def record_authentication_event(request, how):
|
||||||
'who': unicode(request.user)[:80],
|
'who': unicode(request.user)[:80],
|
||||||
'how': how,
|
'how': how,
|
||||||
}
|
}
|
||||||
nonce = get_nonce(request)
|
nonce = nonce or get_nonce(request)
|
||||||
if nonce:
|
if nonce:
|
||||||
kwargs['nonce'] = nonce
|
kwargs['nonce'] = nonce
|
||||||
event['nonce'] = nonce
|
event['nonce'] = nonce
|
||||||
|
@ -388,7 +388,7 @@ def last_authentication_event(session):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def login(request, user, how, service_slug=None, **kwargs):
|
def login(request, user, how, service_slug=None, nonce=None, **kwargs):
|
||||||
'''Login a user model, record the authentication event and redirect to next
|
'''Login a user model, record the authentication event and redirect to next
|
||||||
URL or settings.LOGIN_REDIRECT_URL.'''
|
URL or settings.LOGIN_REDIRECT_URL.'''
|
||||||
from . import hooks
|
from . import hooks
|
||||||
|
@ -400,7 +400,7 @@ def login(request, user, how, service_slug=None, **kwargs):
|
||||||
if constants.LAST_LOGIN_SESSION_KEY not in request.session:
|
if constants.LAST_LOGIN_SESSION_KEY not in request.session:
|
||||||
request.session[constants.LAST_LOGIN_SESSION_KEY] = \
|
request.session[constants.LAST_LOGIN_SESSION_KEY] = \
|
||||||
localize(to_current_timezone(last_login), True)
|
localize(to_current_timezone(last_login), True)
|
||||||
record_authentication_event(request, how)
|
record_authentication_event(request, how, nonce=nonce)
|
||||||
hooks.call_hooks('event', name='login', user=user, how=how, service=service_slug)
|
hooks.call_hooks('event', name='login', user=user, how=how, service=service_slug)
|
||||||
return continue_to_next_url(request, **kwargs)
|
return continue_to_next_url(request, **kwargs)
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,6 @@ import requests
|
||||||
from jwcrypto.jwt import JWT
|
from jwcrypto.jwt import JWT
|
||||||
from jwcrypto.jwk import JWK
|
from jwcrypto.jwk import JWK
|
||||||
|
|
||||||
from django.core.exceptions import MultipleObjectsReturned
|
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.backends import ModelBackend
|
from django.contrib.auth.backends import ModelBackend
|
||||||
|
@ -20,7 +19,7 @@ from . import models, utils
|
||||||
|
|
||||||
|
|
||||||
class OIDCBackend(ModelBackend):
|
class OIDCBackend(ModelBackend):
|
||||||
def authenticate(self, access_token=None, id_token=None, **kwargs):
|
def authenticate(self, access_token=None, id_token=None, nonce=None, **kwargs):
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
if id_token is None:
|
if id_token is None:
|
||||||
return
|
return
|
||||||
|
@ -96,6 +95,12 @@ class OIDCBackend(ModelBackend):
|
||||||
duration)
|
duration)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
id_token_nonce = getattr(id_token, 'nonce', None)
|
||||||
|
if nonce and nonce != id_token_nonce:
|
||||||
|
logger.warning('auth_oidc: id_token nonce %r != expected nonce %r',
|
||||||
|
id_token_nonce, nonce)
|
||||||
|
return None
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
user = None
|
user = None
|
||||||
if provider.strategy == models.OIDCProvider.STRATEGY_FIND_UUID:
|
if provider.strategy == models.OIDCProvider.STRATEGY_FIND_UUID:
|
||||||
|
|
|
@ -92,6 +92,9 @@ class LoginCallback(View):
|
||||||
messages.warning(request, _('Login with OpenIDConnect failed, state lost.'))
|
messages.warning(request, _('Login with OpenIDConnect failed, state lost.'))
|
||||||
logger.warning('auth_oidc: state lost')
|
logger.warning('auth_oidc: state lost')
|
||||||
return redirect(request, settings.LOGIN_REDIRECT_URL)
|
return redirect(request, settings.LOGIN_REDIRECT_URL)
|
||||||
|
oidc_request = oidc_state.get('request')
|
||||||
|
assert isinstance(oidc_request, dict), 'state is not properly initialized'
|
||||||
|
nonce = oidc_request.get('nonce')
|
||||||
try:
|
try:
|
||||||
issuer = oidc_state.get('issuer')
|
issuer = oidc_state.get('issuer')
|
||||||
provider = get_provider_by_issuer(issuer)
|
provider = get_provider_by_issuer(issuer)
|
||||||
|
@ -176,7 +179,7 @@ class LoginCallback(View):
|
||||||
return self.continue_to_next_url()
|
return self.continue_to_next_url()
|
||||||
logger.info(u'got token response %s', result)
|
logger.info(u'got token response %s', result)
|
||||||
access_token = result.get('access_token')
|
access_token = result.get('access_token')
|
||||||
user = authenticate(access_token=access_token, id_token=result['id_token'])
|
user = authenticate(access_token=access_token, nonce=nonce, id_token=result['id_token'])
|
||||||
if user:
|
if user:
|
||||||
# remember last tokens for logout
|
# remember last tokens for logout
|
||||||
tokens = request.session.setdefault('auth_oidc', {}).setdefault('tokens', [])
|
tokens = request.session.setdefault('auth_oidc', {}).setdefault('tokens', [])
|
||||||
|
@ -185,7 +188,7 @@ class LoginCallback(View):
|
||||||
'provider_pk': provider.pk,
|
'provider_pk': provider.pk,
|
||||||
})
|
})
|
||||||
request.session.modified = True
|
request.session.modified = True
|
||||||
login(request, user, 'oidc')
|
login(request, user, 'oidc', nonce=nonce)
|
||||||
else:
|
else:
|
||||||
messages.warning(request, _('No user found'))
|
messages.warning(request, _('No user found'))
|
||||||
return self.continue_to_next_url()
|
return self.continue_to_next_url()
|
||||||
|
|
|
@ -21,7 +21,7 @@ from authentic2_auth_oidc.utils import (base64url_decode, parse_id_token, IDToke
|
||||||
has_providers)
|
has_providers)
|
||||||
from authentic2_auth_oidc.models import OIDCProvider, OIDCClaimMapping
|
from authentic2_auth_oidc.models import OIDCProvider, OIDCClaimMapping
|
||||||
from authentic2.models import AttributeValue
|
from authentic2.models import AttributeValue
|
||||||
from authentic2.utils import timestamp_from_datetime
|
from authentic2.utils import timestamp_from_datetime, last_authentication_event
|
||||||
from authentic2.a2_rbac.utils import get_default_ou
|
from authentic2.a2_rbac.utils import get_default_ou
|
||||||
from authentic2.crypto import base64url_encode
|
from authentic2.crypto import base64url_encode
|
||||||
|
|
||||||
|
@ -166,7 +166,7 @@ def code():
|
||||||
|
|
||||||
|
|
||||||
def oidc_provider_mock(oidc_provider, oidc_provider_jwkset, code, extra_id_token=None,
|
def oidc_provider_mock(oidc_provider, oidc_provider_jwkset, code, extra_id_token=None,
|
||||||
extra_user_info=None, sub='john.doe'):
|
extra_user_info=None, sub='john.doe', nonce=None):
|
||||||
token_endpoint = urlparse.urlparse(oidc_provider.token_endpoint)
|
token_endpoint = urlparse.urlparse(oidc_provider.token_endpoint)
|
||||||
userinfo_endpoint = urlparse.urlparse(oidc_provider.userinfo_endpoint)
|
userinfo_endpoint = urlparse.urlparse(oidc_provider.userinfo_endpoint)
|
||||||
token_revocation_endpoint = urlparse.urlparse(oidc_provider.token_revocation_endpoint)
|
token_revocation_endpoint = urlparse.urlparse(oidc_provider.token_revocation_endpoint)
|
||||||
|
@ -181,6 +181,8 @@ def oidc_provider_mock(oidc_provider, oidc_provider_jwkset, code, extra_id_token
|
||||||
'aud': str(oidc_provider.client_id),
|
'aud': str(oidc_provider.client_id),
|
||||||
'exp': timestamp_from_datetime(now() + datetime.timedelta(seconds=10)),
|
'exp': timestamp_from_datetime(now() + datetime.timedelta(seconds=10)),
|
||||||
}
|
}
|
||||||
|
if nonce:
|
||||||
|
id_token['nonce'] = nonce
|
||||||
if extra_id_token:
|
if extra_id_token:
|
||||||
id_token.update(extra_id_token)
|
id_token.update(extra_id_token)
|
||||||
|
|
||||||
|
@ -277,6 +279,8 @@ def test_sso(app, caplog, code, oidc_provider, oidc_provider_jwkset, login_url,
|
||||||
assert query['client_id'] == str(oidc_provider.client_id)
|
assert query['client_id'] == str(oidc_provider.client_id)
|
||||||
assert query['scope'] == 'openid'
|
assert query['scope'] == 'openid'
|
||||||
assert query['redirect_uri'] == 'http://testserver' + reverse('oidc-login-callback')
|
assert query['redirect_uri'] == 'http://testserver' + reverse('oidc-login-callback')
|
||||||
|
# get the nonce
|
||||||
|
nonce = app.session['auth_oidc'][query['state']]['request']['nonce']
|
||||||
|
|
||||||
if oidc_provider.claims_parameter_supported:
|
if oidc_provider.claims_parameter_supported:
|
||||||
claims = json.loads(query['claims'])
|
claims = json.loads(query['claims'])
|
||||||
|
@ -312,9 +316,12 @@ def test_sso(app, caplog, code, oidc_provider, oidc_provider_jwkset, login_url,
|
||||||
with oidc_provider_mock(oidc_provider, oidc_provider_jwkset, code,
|
with oidc_provider_mock(oidc_provider, oidc_provider_jwkset, code,
|
||||||
extra_id_token={'aud': 'zz'}):
|
extra_id_token={'aud': 'zz'}):
|
||||||
response = app.get(login_callback_url, params={'code': code, 'state': query['state']})
|
response = app.get(login_callback_url, params={'code': code, 'state': query['state']})
|
||||||
|
with utils.check_log(caplog, 'expected nonce'):
|
||||||
|
with oidc_provider_mock(oidc_provider, oidc_provider_jwkset, code):
|
||||||
|
response = app.get(login_callback_url, params={'code': code, 'state': query['state']})
|
||||||
assert not hooks.auth_oidc_backend_modify_user
|
assert not hooks.auth_oidc_backend_modify_user
|
||||||
with utils.check_log(caplog, 'created user'):
|
with utils.check_log(caplog, 'created user'):
|
||||||
with oidc_provider_mock(oidc_provider, oidc_provider_jwkset, code):
|
with oidc_provider_mock(oidc_provider, oidc_provider_jwkset, code, nonce=nonce):
|
||||||
response = app.get(login_callback_url, params={'code': code, 'state': query['state']})
|
response = app.get(login_callback_url, params={'code': code, 'state': query['state']})
|
||||||
assert len(hooks.auth_oidc_backend_modify_user) == 1
|
assert len(hooks.auth_oidc_backend_modify_user) == 1
|
||||||
assert set(hooks.auth_oidc_backend_modify_user[0]['kwargs']) >= set(['user', 'provider', 'user_info', 'id_token', 'access_token'])
|
assert set(hooks.auth_oidc_backend_modify_user[0]['kwargs']) >= set(['user', 'provider', 'user_info', 'id_token', 'access_token'])
|
||||||
|
@ -330,21 +337,22 @@ def test_sso(app, caplog, code, oidc_provider, oidc_provider_jwkset, login_url,
|
||||||
assert user.attributes.last_name == 'Doe'
|
assert user.attributes.last_name == 'Doe'
|
||||||
assert AttributeValue.objects.filter(content='John', verified=True).count() == 1
|
assert AttributeValue.objects.filter(content='John', verified=True).count() == 1
|
||||||
assert AttributeValue.objects.filter(content='Doe', verified=False).count() == 1
|
assert AttributeValue.objects.filter(content='Doe', verified=False).count() == 1
|
||||||
|
assert last_authentication_event(app.session)['nonce'] == nonce
|
||||||
|
|
||||||
with oidc_provider_mock(oidc_provider, oidc_provider_jwkset, code,
|
with oidc_provider_mock(oidc_provider, oidc_provider_jwkset, code,
|
||||||
extra_user_info={'family_name_verified': True}):
|
extra_user_info={'family_name_verified': True}, nonce=nonce):
|
||||||
response = app.get(login_callback_url, params={'code': code, 'state': query['state']})
|
response = app.get(login_callback_url, params={'code': code, 'state': query['state']})
|
||||||
assert AttributeValue.objects.filter(content='Doe', verified=False).count() == 0
|
assert AttributeValue.objects.filter(content='Doe', verified=False).count() == 0
|
||||||
assert AttributeValue.objects.filter(content='Doe', verified=True).count() == 1
|
assert AttributeValue.objects.filter(content='Doe', verified=True).count() == 1
|
||||||
|
|
||||||
with oidc_provider_mock(oidc_provider, oidc_provider_jwkset, code,
|
with oidc_provider_mock(oidc_provider, oidc_provider_jwkset, code,
|
||||||
extra_user_info={'ou': 'cassis'}):
|
extra_user_info={'ou': 'cassis'}, nonce=nonce):
|
||||||
response = app.get(login_callback_url, params={'code': code, 'state': query['state']})
|
response = app.get(login_callback_url, params={'code': code, 'state': query['state']})
|
||||||
assert User.objects.count() == 1
|
assert User.objects.count() == 1
|
||||||
user = User.objects.get()
|
user = User.objects.get()
|
||||||
assert user.ou == cassis
|
assert user.ou == cassis
|
||||||
|
|
||||||
with oidc_provider_mock(oidc_provider, oidc_provider_jwkset, code):
|
with oidc_provider_mock(oidc_provider, oidc_provider_jwkset, code, nonce=nonce):
|
||||||
response = app.get(login_callback_url, params={'code': code, 'state': query['state']})
|
response = app.get(login_callback_url, params={'code': code, 'state': query['state']})
|
||||||
assert User.objects.count() == 1
|
assert User.objects.count() == 1
|
||||||
user = User.objects.get()
|
user = User.objects.get()
|
||||||
|
@ -403,15 +411,16 @@ def test_strategy_find_uuid(app, caplog, code, oidc_provider, oidc_provider_jwks
|
||||||
response = response.click(oidc_provider.name)
|
response = response.click(oidc_provider.name)
|
||||||
location = urlparse.urlparse(response.location)
|
location = urlparse.urlparse(response.location)
|
||||||
query = check_simple_qs(urlparse.parse_qs(location.query))
|
query = check_simple_qs(urlparse.parse_qs(location.query))
|
||||||
|
nonce = app.session['auth_oidc'][query['state']]['request']['nonce']
|
||||||
|
|
||||||
# sub=john.doe, MUST not work
|
# sub=john.doe, MUST not work
|
||||||
with utils.check_log(caplog, 'cannot create user'):
|
with utils.check_log(caplog, 'cannot create user'):
|
||||||
with oidc_provider_mock(oidc_provider, oidc_provider_jwkset, code):
|
with oidc_provider_mock(oidc_provider, oidc_provider_jwkset, code, nonce=nonce):
|
||||||
response = app.get(login_callback_url, params={'code': code, 'state': query['state']})
|
response = app.get(login_callback_url, params={'code': code, 'state': query['state']})
|
||||||
|
|
||||||
# sub=simple_user.uuid MUST work
|
# sub=simple_user.uuid MUST work
|
||||||
with utils.check_log(caplog, 'found user using UUID'):
|
with utils.check_log(caplog, 'found user using UUID'):
|
||||||
with oidc_provider_mock(oidc_provider, oidc_provider_jwkset, code, sub=simple_user.uuid):
|
with oidc_provider_mock(oidc_provider, oidc_provider_jwkset, code, sub=simple_user.uuid, nonce=nonce):
|
||||||
response = app.get(login_callback_url, params={'code': code, 'state': query['state']})
|
response = app.get(login_callback_url, params={'code': code, 'state': query['state']})
|
||||||
|
|
||||||
assert urlparse.urlparse(response['Location']).path == '/'
|
assert urlparse.urlparse(response['Location']).path == '/'
|
||||||
|
@ -427,6 +436,6 @@ def test_strategy_find_uuid(app, caplog, code, oidc_provider, oidc_provider_jwks
|
||||||
|
|
||||||
response = app.get(reverse('account_management'))
|
response = app.get(reverse('account_management'))
|
||||||
with utils.check_log(caplog, 'revoked token from OIDC'):
|
with utils.check_log(caplog, 'revoked token from OIDC'):
|
||||||
with oidc_provider_mock(oidc_provider, oidc_provider_jwkset, code):
|
with oidc_provider_mock(oidc_provider, oidc_provider_jwkset, code, nonce=nonce):
|
||||||
response = response.click(href='logout')
|
response = response.click(href='logout')
|
||||||
assert 'https://idp.example.com/logout' in response.content
|
assert 'https://idp.example.com/logout' in response.content
|
||||||
|
|
Loading…
Reference in New Issue