authentic/src/authentic2_idp_oidc/views.py

665 lines
27 KiB
Python

# authentic2 - versatile identity manager
# Copyright (C) 2010-2019 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import logging
import math
import datetime
import base64
import time
from django.http import (HttpResponse, HttpResponseNotAllowed, JsonResponse)
from django.urls import reverse
from django.utils import six
from django.utils.encoding import force_text
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.contrib import messages
from django.contrib.auth import authenticate
from django.conf import settings
from django.utils.translation import ugettext as _
from ratelimit.utils import is_ratelimited
from authentic2 import app_settings as a2_app_settings
from authentic2.compat.misc import Base64Error
from authentic2.decorators import setting_enabled
from authentic2.exponential_retry_timeout import ExponentialRetryTimeout
from authentic2.utils import (login_require, redirect,
last_authentication_event, make_url)
from authentic2.views import logout as a2_logout
from authentic2 import hooks
from django_rbac.utils import get_ou_model
from . import app_settings, models, utils
logger = logging.getLogger(__name__)
@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', 'ES256',
],
'userinfo_endpoint': request.build_absolute_uri(reverse('oidc-user-info')),
'frontchannel_logout_supported': True,
'frontchannel_logout_session_supported': True,
}
return JsonResponse(metadata)
@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):
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):
return client.idtoken_duration or datetime.timedelta(seconds=app_settings.IDTOKEN_DURATION)
def access_token_duration(client):
return client.access_token_duration or datetime.timedelta(seconds=app_settings.IDTOKEN_DURATION)
def allowed_scopes(client):
return client.scope_set() or app_settings.SCOPES or ['openid', 'email', 'profile']
def is_scopes_allowed(scopes, client):
return scopes <= set(allowed_scopes(client))
def log_invalid_request(request, debug_info):
logger.warning('idp_oidc: authorization request error, %s', debug_info)
error_message = _('Authorization request is invalid')
if settings.DEBUG:
error_message += ' (%s)' % debug_info
messages.warning(request, error_message)
@setting_enabled('ENABLE', settings=app_settings)
def authorize(request, *args, **kwargs):
start = now()
try:
client_id = request.GET['client_id']
redirect_uri = request.GET['redirect_uri']
except KeyError as k:
log_invalid_request(request, 'missing %s' % k.args[0])
return redirect(request, 'auth_homepage')
try:
client = models.OIDCClient.objects.get(client_id=client_id)
except models.OIDCClient.DoesNotExist:
log_invalid_request(request, 'unknown client_id redirect_uri=%r client_id=%r' % (redirect_uri, client_id))
return redirect(request, 'auth_homepage')
if client.authorization_flow == client.FLOW_RESOURCE_OWNER_CRED:
messages.warning(request, _('Client is configured for resource owner password credentials grant type'))
return authorization_error(request, 'auth_homepage',
'unauthorized_client',
error_description='authz endpoint is configured '
'for resource owner password credential grant type')
try:
client.validate_redirect_uri(redirect_uri)
except ValueError as e:
log_invalid_request(request, 'invalid redirect_uri redirect_uri=%r client_id=%r (%s)' % (redirect_uri, client_id, e))
return redirect(request, 'auth_homepage')
fragment = client.authorization_flow == client.FLOW_IMPLICIT
state = request.GET.get('state')
login_hint = set(request.GET.get('login_hint', u'').split())
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 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)
if not is_scopes_allowed(scopes, client):
message = 'only "%s" scope(s) are supported, but "%s" requested' % (
', '.join(allowed_scopes(client)), ', '.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, login_hint=login_hint)
# if user not authorized, a ServiceAccessDenied exception
# is raised and handled by ServiceAccessMiddleware
client.authorize(request.user)
last_auth = last_authentication_event(request=request)
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, login_hint=login_hint)
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:
if 'do_not_ask_again' 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()
request.journal.record(
'user.service.sso.authorization',
service=client,
scopes=list(sorted(scopes)))
logger.info(u'authorized scopes %s saved for service %s', ' '.join(scopes),
client.name)
else:
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': six.text_type(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 = access_token_duration(client)
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 + expires_in)
acr = '0'
if nonce is not None and last_auth.get('nonce') == nonce:
acr = '1'
id_token = utils.create_user_info(request,
client,
request.user,
scopes,
id_token=True)
exp = start + idtoken_duration(client)
id_token.update({
'iss': utils.get_issuer(request),
'aud': client.client_id,
'exp': int(exp.timestamp()),
'iat': int(start.timestamp()),
'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': int(expires_in.total_seconds()),
})
# query is transfered through the hashtag
response = redirect(request, redirect_uri + '#%s' % urlencode(params), resolve=False)
request.journal.record(
'user.service.sso',
service=client,
how=last_auth and last_auth.get('how'))
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 = force_text(base64.b64decode(authorization[1]))
except Base64Error:
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 error_response(error, error_description=None, status=400):
content = {
'error': error,
}
if error_description:
content['error_description'] = error_description
return JsonResponse(content, status=status)
def invalid_request_response(error_description=None):
return error_response('invalid_request', error_description=error_description)
def access_denied_response(error_description=None):
return error_response('access_denied', error_description=error_description)
def unauthorized_client_response(error_description=None):
return error_response('unauthorized_client', error_description=error_description)
def invalid_client_response(error_description=None):
return error_response('invalid_client', error_description=error_description)
def credential_grant_ratelimit_key(group, request):
client = authenticate_client(request, client=None)
if client:
return client.client_id
# return remote address when no valid client credentials have been provided
return request.META['REMOTE_ADDR']
def idtoken_from_user_credential(request):
if request.META.get('CONTENT_TYPE') != 'application/x-www-form-urlencoded':
return invalid_request_response(
'wrong content type. request content type must be \'application/x-www-form-urlencoded\'')
username = request.POST.get('username')
scope = request.POST.get('scope')
OrganizationalUnit = get_ou_model()
# scope is ignored, we used the configured scope
if not all((username, request.POST.get('password'))):
return invalid_request_response(
'request must bear both username and password as '
'parameters using the "application/x-www-form-urlencoded" '
'media type')
if is_ratelimited(
request, group='ro-cred-grant', increment=True,
key=credential_grant_ratelimit_key,
rate=app_settings.PASSWORD_GRANT_RATELIMIT):
return invalid_request_response(
'reached rate limitation, too many erroneous requests')
client = authenticate_client(request, client=None)
if not client:
return invalid_client_response('client authentication failed')
if client.authorization_flow != models.OIDCClient.FLOW_RESOURCE_OWNER_CRED:
return unauthorized_client_response(
'client is not configured for resource owner password '
'credential grant')
exponential_backoff = ExponentialRetryTimeout(
key_prefix='idp-oidc-ro-cred-grant',
duration=a2_app_settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_DURATION,
factor=a2_app_settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_FACTOR)
backoff_keys = (username, client.client_id)
seconds_to_wait = exponential_backoff.seconds_to_wait(*backoff_keys)
if seconds_to_wait > a2_app_settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_MAX_DURATION:
seconds_to_wait = a2_app_settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_MAX_DURATION
if seconds_to_wait:
return invalid_request_response(
'too many attempts with erroneous RO password, you must wait '
'%s seconds to try again.' % int(math.ceil(seconds_to_wait)))
ou = None
if 'ou_slug' in request.POST:
try:
ou = OrganizationalUnit.objects.get(slug=request.POST.get('ou_slug'))
except OrganizationalUnit.DoesNotExist:
return invalid_request_response(
'ou_slug parameter does not match a valid organization unit')
user = authenticate(request, username=username, password=request.POST.get('password'), ou=ou)
if not user:
exponential_backoff.failure(*backoff_keys)
return access_denied_response('invalid resource owner credentials')
# limit requested scopes
if scope is not None:
scopes = utils.scope_set(scope) & client.scope_set()
else:
scopes = client.scope_set()
exponential_backoff.success(*backoff_keys)
start = now()
# make access_token
expires_in = access_token_duration(client)
access_token = models.OIDCAccessToken.objects.create(
client=client,
user=user,
scopes=' '.join(scopes),
session_key='',
expired=start + expires_in)
# make id_token
id_token = utils.create_user_info(
request,
client,
user,
scopes,
id_token=True)
exp = start + idtoken_duration(client)
id_token.update({
'iss': utils.get_issuer(request),
'aud': client.client_id,
'exp': int(exp.timestamp()),
'iat': int(start.timestamp()),
'auth_time': int(start.timestamp()),
'acr': '0',
})
return JsonResponse({
'access_token': six.text_type(access_token.uuid),
'token_type': 'Bearer',
'expires_in': int(expires_in.total_seconds()),
'id_token': utils.make_idtoken(client, id_token),
})
def tokens_from_authz_code(request):
code = request.POST.get('code')
if code is None:
return invalid_request_response('missing code')
try:
oidc_code = models.OIDCCode.objects.select_related().get(uuid=code)
except models.OIDCCode.DoesNotExist:
return invalid_request_response('invalid code')
if not oidc_code.is_valid():
return invalid_request_response('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_response('invalid redirect_uri')
expires_in = access_token_duration(client)
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 + expires_in)
start = now()
acr = '0'
if (oidc_code.nonce is not None
and last_authentication_event(session=oidc_code.session).get('nonce') == oidc_code.nonce):
acr = '1'
# prefill id_token with user info
id_token = utils.create_user_info(
request,
client,
oidc_code.user,
oidc_code.scope_set(),
id_token=True)
exp = start + idtoken_duration(client)
id_token.update({
'iss': utils.get_issuer(request),
'sub': utils.make_sub(client, oidc_code.user),
'aud': client.client_id,
'exp': int(exp.timestamp()),
'iat': int(start.timestamp()),
'auth_time': int(oidc_code.auth_time.timestamp()),
'acr': acr,
})
if oidc_code.nonce is not None:
id_token['nonce'] = oidc_code.nonce
return JsonResponse({
'access_token': six.text_type(access_token.uuid),
'token_type': 'Bearer',
'expires_in': int(expires_in.total_seconds()),
'id_token': utils.make_idtoken(client, id_token),
})
@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 == 'password':
response = idtoken_from_user_credential(request)
elif grant_type == 'authorization_code':
response = tokens_from_authz_code(request)
else:
return invalid_request_response('grant_type must be either authorization_code or password')
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(request,
access_token.client,
access_token.user,
access_token.scope_set())
return JsonResponse(user_info)
@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)