457 lines
20 KiB
Python
457 lines
20 KiB
Python
import logging
|
|
import datetime
|
|
import json
|
|
import base64
|
|
import time
|
|
|
|
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed
|
|
from django.utils.timezone import now, utc
|
|
from django.utils.http import urlencode
|
|
from django.shortcuts import render
|
|
from django.views.decorators.csrf import csrf_exempt
|
|
from django.core.urlresolvers import reverse
|
|
from django.contrib.auth import REDIRECT_FIELD_NAME
|
|
from django.contrib import messages
|
|
from django.conf import settings
|
|
from django.utils.translation import ugettext as _
|
|
|
|
from authentic2.decorators import setting_enabled
|
|
from authentic2.utils import (login_require, redirect, timestamp_from_datetime,
|
|
last_authentication_event, make_url)
|
|
from authentic2.views import logout as a2_logout
|
|
from authentic2 import hooks
|
|
|
|
from . import app_settings, models, utils
|
|
|
|
|
|
@setting_enabled('ENABLE', settings=app_settings)
|
|
def openid_configuration(request, *args, **kwargs):
|
|
metadata = {
|
|
'issuer': utils.get_issuer(request),
|
|
'authorization_endpoint': request.build_absolute_uri(reverse('oidc-authorize')),
|
|
'token_endpoint': request.build_absolute_uri(reverse('oidc-token')),
|
|
'jwks_uri': request.build_absolute_uri(reverse('oidc-certs')),
|
|
'end_session_endpoint': request.build_absolute_uri(reverse('oidc-logout')),
|
|
'response_types_supported': ['code', 'token', 'token id_token'],
|
|
'subject_types_supported': ['public', 'pairwise'],
|
|
'token_endpoint_auth_methods_supported': [
|
|
'clien_secret_post', 'client_secret_basic',
|
|
],
|
|
'id_token_signing_alg_values_supported': [
|
|
'RS256', 'HS256',
|
|
],
|
|
'userinfo_endpoint': request.build_absolute_uri(reverse('oidc-user-info')),
|
|
'frontchannel_logout_supported': True,
|
|
'frontchannel_logout_session_supported': True,
|
|
}
|
|
return HttpResponse(json.dumps(metadata), content_type='application/json')
|
|
|
|
|
|
@setting_enabled('ENABLE', settings=app_settings)
|
|
def certs(request, *args, **kwargs):
|
|
return HttpResponse(utils.get_jwkset().export(private_keys=False),
|
|
content_type='application/json')
|
|
|
|
|
|
def authorization_error(request, redirect_uri, error, error_description=None, error_uri=None,
|
|
state=None, fragment=False):
|
|
logger = logging.getLogger(__name__)
|
|
params = {
|
|
'error': error,
|
|
}
|
|
if error_description:
|
|
params['error_description'] = error_description
|
|
if error_uri:
|
|
params['error_uri'] = error_uri
|
|
if state is not None:
|
|
params['state'] = state
|
|
logger.warning(u'idp_oidc: authorization request error redirect_uri=%r error=%r error_description=%r',
|
|
redirect_uri, error, error_description, extra={'redirect_uri': redirect_uri})
|
|
if fragment:
|
|
return redirect(request, redirect_uri + '#%s' % urlencode(params), resolve=False)
|
|
else:
|
|
return redirect(request, redirect_uri, params=params, resolve=False)
|
|
|
|
|
|
def idtoken_duration(client):
|
|
if client.idtoken_duration:
|
|
return client.idtoken_duration
|
|
return datetime.timedelta(seconds=app_settings.IDTOKEN_DURATION)
|
|
|
|
|
|
@setting_enabled('ENABLE', settings=app_settings)
|
|
def authorize(request, *args, **kwargs):
|
|
logger = logging.getLogger(__name__)
|
|
start = now()
|
|
|
|
try:
|
|
client_id = request.GET['client_id']
|
|
redirect_uri = request.GET['redirect_uri']
|
|
except KeyError as k:
|
|
return HttpResponseBadRequest('invalid request: missing parameter %s' % k.args[0],
|
|
content_type='text/plain')
|
|
try:
|
|
client = models.OIDCClient.objects.get(client_id=client_id)
|
|
except models.OIDCClient.DoesNotExist:
|
|
return HttpResponseBadRequest('invalid request: unknown client_id', content_type='text/plain')
|
|
fragment = client.authorization_flow == client.FLOW_IMPLICIT
|
|
|
|
state = request.GET.get('state')
|
|
|
|
try:
|
|
response_type = request.GET['response_type']
|
|
scope = request.GET['scope']
|
|
except KeyError as k:
|
|
return authorization_error(request, redirect_uri, 'invalid_request',
|
|
state=state,
|
|
error_description='missing parameter %s' % k.args[0],
|
|
fragment=fragment)
|
|
|
|
prompt = set(filter(None, request.GET.get('prompt', '').split()))
|
|
nonce = request.GET.get('nonce')
|
|
scopes = utils.scope_set(scope)
|
|
|
|
max_age = request.GET.get('max_age')
|
|
if max_age:
|
|
try:
|
|
max_age = int(max_age)
|
|
if max_age < 0:
|
|
raise ValueError
|
|
except ValueError:
|
|
return authorization_error(request, redirect_uri, 'invalid_request',
|
|
error_description='max_age is not a positive integer',
|
|
state=state,
|
|
fragment=fragment)
|
|
|
|
if redirect_uri not in client.redirect_uris.split():
|
|
return authorization_error(request, redirect_uri, 'invalid_request',
|
|
error_description='unauthorized redirect_uri',
|
|
state=state,
|
|
fragment=fragment)
|
|
if client.authorization_flow == client.FLOW_AUTHORIZATION_CODE:
|
|
if response_type != 'code':
|
|
return authorization_error(request, redirect_uri, 'unsupported_response_type',
|
|
error_description='only code is supported',
|
|
state=state,
|
|
fragment=fragment)
|
|
elif client.authorization_flow == client.FLOW_IMPLICIT:
|
|
if not set(filter(None, response_type.split())) in (set(['id_token', 'token']),
|
|
set(['id_token'])):
|
|
return authorization_error(request, redirect_uri, 'unsupported_response_type',
|
|
error_description='only "id_token token" or "id_token" '
|
|
'are supported',
|
|
state=state,
|
|
fragment=fragment)
|
|
else:
|
|
raise NotImplementedError
|
|
if 'openid' not in scopes:
|
|
return authorization_error(request, redirect_uri, 'invalid_request',
|
|
error_description='openid scope is missing',
|
|
state=state,
|
|
fragment=fragment)
|
|
allowed_scopes = app_settings.SCOPES or ['openid', 'email', 'profile']
|
|
if not (scopes <= set(allowed_scopes)):
|
|
message = 'only "%s" scope(s) are supported, but "%s" requested' % (
|
|
', '.join(allowed_scopes), ', '.join(scopes))
|
|
return authorization_error(request, redirect_uri, 'invalid_scope',
|
|
error_description=message,
|
|
state=state,
|
|
fragment=fragment)
|
|
|
|
hooks.call_hooks('event', name='sso-request', idp='oidc', service=client)
|
|
# authentication canceled by user
|
|
if 'cancel' in request.GET:
|
|
logger.info(u'authentication canceled for service %s', client.name)
|
|
return authorization_error(request, redirect_uri, 'access_denied',
|
|
error_description='user did not authenticate',
|
|
state=state,
|
|
fragment=fragment)
|
|
|
|
if not request.user.is_authenticated() or 'login' in prompt:
|
|
if 'none' in prompt:
|
|
return authorization_error(request, redirect_uri, 'login_required',
|
|
error_description='login is required but prompt is none',
|
|
state=state,
|
|
fragment=fragment)
|
|
params = {}
|
|
if nonce is not None:
|
|
params['nonce'] = nonce
|
|
return login_require(request, params=params, service=client)
|
|
|
|
# if user not authorized, a ServiceAccessDenied exception
|
|
# is raised and handled by ServiceAccessMiddleware
|
|
client.authorize(request.user)
|
|
|
|
last_auth = last_authentication_event(request.session)
|
|
if max_age is not None and time.time() - last_auth['when'] >= max_age:
|
|
if 'none' in prompt:
|
|
return authorization_error(request, redirect_uri, 'login_required',
|
|
error_description='login is required but prompt is none',
|
|
state=state,
|
|
fragment=fragment)
|
|
params = {}
|
|
if nonce is not None:
|
|
params['nonce'] = nonce
|
|
return login_require(request, params=params, service=client)
|
|
|
|
if client.authorization_mode != client.AUTHORIZATION_MODE_NONE or 'consent' in prompt:
|
|
# authorization by user is mandatory, as per local configuration or per explicit request by
|
|
# the RP
|
|
if client.authorization_mode in (client.AUTHORIZATION_MODE_NONE,
|
|
client.AUTHORIZATION_MODE_BY_SERVICE):
|
|
auth_manager = client.authorizations
|
|
elif client.authorization_mode == client.AUTHORIZATION_MODE_BY_OU:
|
|
auth_manager = client.ou.oidc_authorizations
|
|
|
|
qs = auth_manager.filter(user=request.user)
|
|
|
|
if 'consent' in prompt:
|
|
# if consent is asked we delete existing authorizations
|
|
# it seems to be the safer option
|
|
qs.delete()
|
|
qs = auth_manager.none()
|
|
else:
|
|
qs = qs.filter(expired__gte=start)
|
|
authorized_scopes = set()
|
|
for authorization in qs:
|
|
authorized_scopes |= authorization.scope_set()
|
|
if (authorized_scopes & scopes) < scopes:
|
|
if 'none' in prompt:
|
|
return authorization_error(
|
|
request, redirect_uri, 'consent_required',
|
|
error_description='consent is required but prompt is none',
|
|
state=state,
|
|
fragment=fragment)
|
|
if request.method == 'POST':
|
|
if 'accept' in request.POST:
|
|
pk_to_deletes = []
|
|
for authorization in qs:
|
|
# clean obsolete authorizations
|
|
if authorization.scope_set() <= scopes:
|
|
pk_to_deletes.append(authorization.pk)
|
|
auth_manager.create(
|
|
user=request.user, scopes=u' '.join(sorted(scopes)),
|
|
expired=start + datetime.timedelta(days=365))
|
|
if pk_to_deletes:
|
|
auth_manager.filter(pk__in=pk_to_deletes).delete()
|
|
logger.info(u'authorized scopes %s for service %s', ' '.join(scopes),
|
|
client.name)
|
|
else:
|
|
logger.info(u'refused scopes %s for service %s', ' '.join(scopes),
|
|
client.name)
|
|
return authorization_error(request, redirect_uri, 'access_denied',
|
|
error_description='user denied access',
|
|
state=state,
|
|
fragment=fragment)
|
|
else:
|
|
return render(request, 'authentic2_idp_oidc/authorization.html',
|
|
{
|
|
'client': client,
|
|
'scopes': scopes - set(['openid']),
|
|
})
|
|
if response_type == 'code':
|
|
code = models.OIDCCode.objects.create(
|
|
client=client, user=request.user, scopes=u' '.join(scopes),
|
|
state=state, nonce=nonce, redirect_uri=redirect_uri,
|
|
expired=start + datetime.timedelta(seconds=30),
|
|
auth_time=datetime.datetime.fromtimestamp(last_auth['when'], utc),
|
|
session_key=request.session.session_key)
|
|
logger.info(u'sending code %s for scopes %s for service %s',
|
|
code.uuid, ' '.join(scopes),
|
|
client.name)
|
|
params = {
|
|
'code': unicode(code.uuid),
|
|
}
|
|
if state is not None:
|
|
params['state'] = state
|
|
response = redirect(request, redirect_uri, params=params, resolve=False)
|
|
else:
|
|
# FIXME: we should probably factorize this part with the token endpoint similar code
|
|
need_access_token = 'token' in response_type.split()
|
|
expires_in = 3600 * 8
|
|
if need_access_token:
|
|
access_token = models.OIDCAccessToken.objects.create(
|
|
client=client,
|
|
user=request.user,
|
|
scopes=u' '.join(scopes),
|
|
session_key=request.session.session_key,
|
|
expired=start + datetime.timedelta(seconds=expires_in))
|
|
acr = '0'
|
|
if nonce is not None and last_auth.get('nonce') == nonce:
|
|
acr = '1'
|
|
id_token = utils.create_user_info(client, request.user, scopes, id_token=True)
|
|
id_token.update({
|
|
'iss': utils.get_issuer(request),
|
|
'aud': client.client_id,
|
|
'exp': timestamp_from_datetime(start + idtoken_duration(client)),
|
|
'iat': timestamp_from_datetime(start),
|
|
'auth_time': last_auth['when'],
|
|
'acr': acr,
|
|
'sid': utils.get_session_id(request, client),
|
|
})
|
|
if nonce is not None:
|
|
id_token['nonce'] = nonce
|
|
params = {
|
|
'id_token': utils.make_idtoken(client, id_token),
|
|
}
|
|
if state is not None:
|
|
params['state'] = state
|
|
if need_access_token:
|
|
params.update({
|
|
'access_token': access_token.uuid,
|
|
'token_type': 'Bearer',
|
|
'expires_in': expires_in,
|
|
})
|
|
# query is transfered through the hashtag
|
|
response = redirect(request, redirect_uri + '#%s' % urlencode(params), resolve=False)
|
|
hooks.call_hooks('event', name='sso-success', idp='oidc', service=client, user=request.user)
|
|
utils.add_oidc_session(request, client)
|
|
return response
|
|
|
|
|
|
def authenticate_client(request, client=None):
|
|
'''Authenticate client on the token endpoint'''
|
|
|
|
if 'HTTP_AUTHORIZATION' in request.META:
|
|
authorization = request.META['HTTP_AUTHORIZATION'].split()
|
|
if authorization[0] != 'Basic' or len(authorization) != 2:
|
|
return None
|
|
try:
|
|
decoded = base64.b64decode(authorization[1])
|
|
except TypeError:
|
|
return None
|
|
parts = decoded.split(':')
|
|
if len(parts) != 2:
|
|
return None
|
|
client_id, client_secret = parts
|
|
elif 'client_id' in request.POST:
|
|
client_id = request.POST['client_id']
|
|
client_secret = request.POST.get('client_secret', '')
|
|
else:
|
|
return None
|
|
if not client:
|
|
try:
|
|
client = models.OIDCClient.objects.get(client_id=client_id)
|
|
except models.OIDCClient.DoesNotExist:
|
|
return None
|
|
if client.client_secret != client_secret:
|
|
return None
|
|
return client
|
|
|
|
|
|
def invalid_request(desc=None):
|
|
content = {
|
|
'error': 'invalid_request',
|
|
}
|
|
if desc:
|
|
content['desc'] = desc
|
|
return HttpResponseBadRequest(json.dumps(content), content_type='application/json')
|
|
|
|
|
|
@setting_enabled('ENABLE', settings=app_settings)
|
|
@csrf_exempt
|
|
def token(request, *args, **kwargs):
|
|
if request.method != 'POST':
|
|
return HttpResponseNotAllowed(['POST'])
|
|
grant_type = request.POST.get('grant_type')
|
|
if grant_type != 'authorization_code':
|
|
return invalid_request('grant_type is not authorization_code')
|
|
code = request.POST.get('code')
|
|
if code is None:
|
|
return invalid_request('missing code')
|
|
try:
|
|
oidc_code = models.OIDCCode.objects.select_related().get(uuid=code)
|
|
except models.OIDCCode.DoesNotExist:
|
|
return invalid_request('invalid code')
|
|
if not oidc_code.is_valid():
|
|
return invalid_request('code has expired or user is disconnected')
|
|
client = authenticate_client(request, client=oidc_code.client)
|
|
if client is None:
|
|
return HttpResponse('unauthenticated', status=401)
|
|
# delete immediately
|
|
models.OIDCCode.objects.filter(uuid=code).delete()
|
|
redirect_uri = request.POST.get('redirect_uri')
|
|
if oidc_code.redirect_uri != redirect_uri:
|
|
return invalid_request('invalid redirect_uri')
|
|
expires_in = 3600 * 8
|
|
access_token = models.OIDCAccessToken.objects.create(
|
|
client=client,
|
|
user=oidc_code.user,
|
|
scopes=oidc_code.scopes,
|
|
session_key=oidc_code.session_key,
|
|
expired=oidc_code.created + datetime.timedelta(seconds=expires_in))
|
|
start = now()
|
|
acr = '0'
|
|
if (oidc_code.nonce is not None and last_authentication_event(oidc_code.session).get('nonce') ==
|
|
oidc_code.nonce):
|
|
acr = '1'
|
|
# prefill id_token with user info
|
|
id_token = utils.create_user_info(client, oidc_code.user, oidc_code.scope_set(), id_token=True)
|
|
id_token.update({
|
|
'iss': utils.get_issuer(request),
|
|
'sub': utils.make_sub(client, oidc_code.user),
|
|
'aud': client.client_id,
|
|
'exp': timestamp_from_datetime(start + idtoken_duration(client)),
|
|
'iat': timestamp_from_datetime(start),
|
|
'auth_time': timestamp_from_datetime(oidc_code.auth_time),
|
|
'acr': acr,
|
|
})
|
|
if oidc_code.nonce is not None:
|
|
id_token['nonce'] = oidc_code.nonce
|
|
response = HttpResponse(json.dumps({
|
|
'access_token': unicode(access_token.uuid),
|
|
'token_type': 'Bearer',
|
|
'expires_in': expires_in,
|
|
'id_token': utils.make_idtoken(client, id_token),
|
|
}), content_type='application/json')
|
|
response['Cache-Control'] = 'no-store'
|
|
response['Pragma'] = 'no-cache'
|
|
return response
|
|
|
|
|
|
def authenticate_access_token(request):
|
|
if 'HTTP_AUTHORIZATION' not in request.META:
|
|
return None
|
|
authorization = request.META['HTTP_AUTHORIZATION'].split()
|
|
if authorization[0] != 'Bearer' or len(authorization) != 2:
|
|
return None
|
|
try:
|
|
access_token = models.OIDCAccessToken.objects.select_related().get(uuid=authorization[1])
|
|
except models.OIDCAccessToken.DoesNotExist:
|
|
return None
|
|
if not access_token.is_valid():
|
|
return None
|
|
return access_token
|
|
|
|
|
|
@setting_enabled('ENABLE', settings=app_settings)
|
|
@csrf_exempt
|
|
def user_info(request, *args, **kwargs):
|
|
access_token = authenticate_access_token(request)
|
|
if access_token is None:
|
|
return HttpResponse('unauthenticated', status=401)
|
|
user_info = utils.create_user_info(access_token.client, access_token.user,
|
|
access_token.scope_set())
|
|
return HttpResponse(json.dumps(user_info), content_type='application/json')
|
|
|
|
|
|
@setting_enabled('ENABLE', settings=app_settings)
|
|
def logout(request):
|
|
post_logout_redirect_uri = request.GET.get('post_logout_redirect_uri')
|
|
state = request.GET.get('state')
|
|
if post_logout_redirect_uri:
|
|
providers = models.OIDCClient.objects.filter(
|
|
post_logout_redirect_uris__contains=post_logout_redirect_uri)
|
|
for provider in providers:
|
|
if post_logout_redirect_uri in provider.post_logout_redirect_uris.split():
|
|
break
|
|
else:
|
|
messages.warning(request, _('Invalid post logout URI'))
|
|
return redirect(request, settings.LOGIN_REDIRECT_URL)
|
|
if state:
|
|
post_logout_redirect_uri = make_url(post_logout_redirect_uri, params={'state': state})
|
|
# FIXME: do something with id_token_hint
|
|
id_token_hint = request.GET.get('id_token_hint')
|
|
return a2_logout(request, next_url=post_logout_redirect_uri, do_local=False,
|
|
check_referer=False)
|