authentic/src/authentic2_idp_oidc/views.py

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)