Commit 87672409 by Linus Lewandowski

Rework flows.

parent 00bca46c
Pipeline #13929106 passed with stages
in 3 minutes 39 seconds
import json
from uuid import uuid4
from .openid_provider.flow import FlowMixin as AuthRequestMixin
from .serializable import Container
class BaseFlow:
class Fields:
id = None
next = None
bases = (
BaseFlow,
AuthRequestMixin,
)
class Flow(Container, *bases):
def __init__(self):
self.id = uuid4().hex
super().__init__()
class FlowMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
flows = request.session.setdefault('flows', {})
initial_flow_id = None
try:
initial_flow_id = request.GET['flow']
flow_data = flows[initial_flow_id]
except KeyError:
request.flow = None
else:
request.flow = Flow.unserialize(json.loads(flow_data))
resp = self.get_response(request)
# It's possible that session was flushed in get_response.
flows = request.session.setdefault('flows', {})
if initial_flow_id:
try:
del flows[initial_flow_id]
except KeyError:
pass
request.session.modified = True
if request.flow:
flows[request.flow.id] = json.dumps(request.flow.serialize())
request.session.modified = True
if 'Location' in resp:
if '?' in resp['Location']:
resp['Location'] += '&flow=' + request.flow.id
else:
resp['Location'] += '?flow=' + request.flow.id
return resp
def flow(request):
return {
'flow': request.flow
}
from django.conf import settings
from django.http import HttpResponse
from django.shortcuts import redirect
from django.urls import is_valid_path
def PrependUser(get_response):
def prepend_user(request):
urlconf = getattr(request, 'urlconf', None)
try:
account = request.user.default
except AttributeError:
account = request.user
if account:
prefix = '/u/{}'.format(request.user.pk)
else:
prefix = '/u/0'
if not request.path.startswith('/u/'):
if not is_valid_path(request.path_info, urlconf) and is_valid_path(prefix + request.path_info, urlconf):
if account:
resp = HttpResponse(status=307)
resp['Location'] = prefix + request.get_full_path()
else:
resp = HttpResponse(status=303)
resp['Location'] = settings.LOGIN_URL
return resp
return get_response(request)
return prepend_user
from urllib.parse import urlencode, urlsplit, urlunsplit
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.shortcuts import redirect
from cached_property import cached_property
from ..serializable import Container, Set
Account = get_user_model()
class AuthRequest(Container):
class Fields:
response_type = Set
response_mode = None
state = None
nonce = None
prompt = Set
scope = Set
id_hint = None
id_hint_valid = None
id_token_hint = None
client_id = None
redirect_uri = None
@cached_property
def client(self):
try:
return Account.objects.exclude(oauth_app=None).get(id=self.client_id)
except (Account.DoesNotExist, ValidationError):
return None
def respond(self, response):
if self.response_mode in {'query', 'fragment'}:
if self.state:
response['state'] = self.state
redirect_uri = urlsplit(self.redirect_uri)
if self.response_mode == 'query':
new_query = redirect_uri.query
if new_query:
new_query += '&'
new_query += urlencode(response)
redirect_uri = redirect_uri._replace(query=new_query)
elif self.response_mode == 'fragment':
new_fragment = redirect_uri.fragment
if new_fragment:
new_fragment += '&'
new_fragment += urlencode(response)
redirect_uri = redirect_uri._replace(fragment=new_fragment)
return redirect(urlunsplit(redirect_uri))
def deny(self, e):
# We're not going to be an open redirector.
raise e
# TODO Render a page with error description and, if there is a redirect_uri, a button to fire the following:
return self.respond({
'error': type(e).__name__,
'error_description': e.description,
})
from .auth_request import AuthRequest
class FlowMixin:
class Fields:
auth_request = AuthRequest
from .authorize import authorize
from django.shortcuts import redirect
from django.urls import reverse
from ..errors import *
def authorize(request):
auth_request = request.flow.auth_request
account_id = None
try:
account_id = request.resolver_match.kwargs['account_id']
except KeyError:
if not 'select_account' in auth_request.prompt:
if auth_request.id_hint:
account_id = auth_request.id_hint['sub']
if not account_id in [str(acc.pk) for acc in request.user.accounts]:
account_id = None
if not account_id:
if 'none' in auth_request.prompt:
request.flow = None
return auth_request.deny(interaction_required())
return redirect(reverse('extauth:select-account'))
return redirect(reverse('openid_provider:consent', args=[account_id]))
......@@ -29,17 +29,25 @@ def encode(myself, audience, payload, expires_in):
def decode(myself, issuers, payload):
# TODO support multiple issuers
issuer = issuers[0]
return jwt.decode(
payload,
issuer.public_jwks,
algorithms = ['RS256'],
audience = str(myself.id),
issuer = str(issuer.id),
options = dict(
verify_at_hash = False,
verify_aud = myself is not None,
),
)
try:
return jwt.decode(
payload,
issuer.public_jwks,
algorithms = ['RS256'],
audience = str(myself.id) if myself is not None else None,
issuer = str(issuer.id),
options = dict(
verify_at_hash = False,
verify_aud = myself is not None,
),
)
except jwt.JWTError as e:
try:
e.payload = jwt.get_unverified_claims(payload)
except jwt.JWTError:
pass
raise
class Myself:
......
......@@ -8,11 +8,13 @@ urlpatterns = [
url(r'^\.well-known/openid-configuration$', views.ConfigurationView.as_view()),
url(r'^oauth/authorize/$', views.AuthorizationView.as_view(), name='authorization'),
url(r'^oauth/select-account/$', views.SelectAccountView.as_view(), name='select-account'),
url(r'^oauth/end-session/$', views.EndSessionView.as_view(), name='end-session'),
url(r'^oauth/account-settings/$', views.AccountSettingsView.as_view(), name='account-settings'),
url(r'^u/(?P<user_id>[^/]+)/oauth/consent/$', views.ConsentView.as_view(), name='consent'),
url(r'^oauth/token/$', views.TokenView.as_view(), name='token'),
url(r'^oauth/userinfo/$', views.UserInfoView.as_view(), name='userinfo'),
url(r'^oauth/jwks/$', views.JWKSView.as_view(), name='jwks'),
url(r'^oauth/logout/$', views.LogoutView.as_view(), name='logout'),
url(r'^oauth/account-settings/$', views.AccountSettingsView.as_view(), name='account-settings'),
url(r'^oauth/continue/$', views.ContinueView.as_view(), name='continue'),
]
from .account_settings import AccountSettingsView
from .authorization import AuthorizationView
from .configuration import ConfigurationView
from .continue_ import ContinueView
from .consent import ConsentView
from .end_session import EndSessionView
from .jwks import JWKSView
from .logout import LogoutView
from .select_account import SelectAccountView
from .token import TokenView
from .userinfo import UserInfoView
......@@ -7,11 +7,7 @@ from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import View
from .auth_request import AuthRequest, badrequest_handler
class AccountSettingsRequest(AuthRequest):
redirect_uri_set = None
from .oauth_request import OAuthRequest, badrequest_handler
@method_decorator(csrf_exempt, name='dispatch')
......@@ -22,7 +18,7 @@ class AccountSettingsView(View):
id_token_hint = request.GET.get('id_token_hint')
if id_token_hint:
req = AccountSettingsRequest(request, dict(
req = OAuthRequest.parse(dict(
response_type = '',
response_mode = 'query',
id_token_hint = id_token_hint,
......
import json
import logging
from base64 import b64encode
from datetime import datetime
from urllib.parse import urlencode, urlsplit, urlunsplit
from uuid import uuid4
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.http import HttpResponseBadRequest
from django.shortcuts import redirect, render
from django.utils.translation import ugettext_lazy as _
from jose import jwt
log = logging.getLogger(__name__)
class BadRequest(Exception):
pass
def badrequest_handler(get_response):
def handler(request, *args, **kwargs):
try:
return get_response(request, *args, **kwargs)
except BadRequest as e:
return HttpResponseBadRequest(e.args[0])
return handler
def _load_data(request):
try:
id = request.POST['request']
except:
try:
id = request.GET['request']
except:
return
try:
return id, json.loads(request.session['auth_requests'][id])
except Exception as e:
log.warning(e)
RESPONSE_MODES = {
'fragment',
'query',
}
User = get_user_model()
class AuthRequest:
redirect_uri_set = 'redirect_uris'
def __init__(self, request, data = None):
self.http_request = request
self.id, self.data = _load_data(request) or (None, data)
if not self.data:
raise BadRequest()
self.response_type = set(self['response_type'].split(' ')) - {''}
self.response_mode = self['response_mode'] or ('fragment' if self.response_type != {'code'} else 'query')
if self.response_mode not in RESPONSE_MODES:
raise BadRequest(_("Invalid response_mode."))
if self.response_mode == 'query' and (self.response_type - {'code'}):
raise BadRequest(_("Query can be used only with response_type=code."))
self.state = self['state']
self.nonce = self['nonce']
if self.nonce == '' and (self.response_type - {'code'}):
raise BadRequest(_("Missing nonce."))
self.id_token_hint = self['id_token_hint']
if self.id_token_hint:
## TODO Verify the JWT securely
# Note: this is hard because it might have expired already, and we're going to rotate signing keys
# OTOH we're probably going to keep them for 24h after we've stopped signing with them, and older id_tokens should not be tolerared
self.id_hint = jwt.get_unverified_claims(self['id_token_hint'])
self.id_hint_valid = False
else:
self.id_hint = None
self.id_hint_valid = False
client_id = self['client_id']
if self.id_hint:
aud = self.id_hint['aud']
if isinstance(aud, list):
if len(aud) > 1:
raise BadRequest(_("Invalid id_token_hint."))
aud = aud[0]
if client_id:
if aud != client_id:
raise BadRequest(_("Invalid id_token_hint."))
else:
client_id = aud
if not client_id:
raise BadRequest(_("Missing client_id."))
try:
self.client = User.objects.get(id=client_id)
except (User.DoesNotExist, ValidationError):
raise BadRequest(_("Invalid client_id."))
if not self.client.oauth_app:
raise BadRequest(_("Invalid client_id."))
self.redirect_uri = self['redirect_uri']
if self.redirect_uri and (not self.redirect_uri_set or self.redirect_uri not in getattr(self.client, 'oauth_' + self.redirect_uri_set)):
raise BadRequest(_("Invalid redirect_uri."))
@property
def redirect_host(self):
return urlsplit(self.redirect_uri).hostname
def __getitem__(self, key):
if hasattr(self, key):
return getattr(self, key)
return self.data.get(key) or ''
def __contains__(self, key):
return self[key] != ''
def respond(self, response):
if self.response_mode in {'query', 'fragment'}:
if self.state:
response['state'] = self.state
redirect_uri = urlsplit(self.redirect_uri)
if self.response_mode == 'query':
new_query = redirect_uri.query
if new_query:
new_query += '&'
new_query += urlencode(response)
redirect_uri = redirect_uri._replace(query=new_query)
elif self.response_mode == 'fragment':
new_fragment = redirect_uri.fragment
if new_fragment:
new_fragment += '&'
new_fragment += urlencode(response)
redirect_uri = redirect_uri._replace(fragment=new_fragment)
return redirect(urlunsplit(redirect_uri))
def deny(self, e):
return self.respond({
'error': type(e).__name__,
'error_description': e.description,
})
import json
import logging
from uuid import uuid4
from django.contrib.auth.views import redirect_to_login
from django.http import HttpResponseBadRequest
from django.shortcuts import redirect
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.utils.translation import ugettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import View
from ..errors import *
from .auth_request import AuthRequest, BadRequest, badrequest_handler
from aiakos.flow import Flow
logger = logging.getLogger(__name__)
from ..errors import *
from ..flows import authorize
from .oauth_request import AuthorizationRequest, BadRequest, badrequest_handler
@method_decorator(csrf_exempt, name='dispatch')
@method_decorator(badrequest_handler, name='dispatch')
class AuthorizationView(View):
def _handle(self, request, auth_request):
if not auth_request['redirect_uri']:
raise BadRequest(_("Missing redirect_uri."))
try:
if auth_request['request']:
raise request_not_supported()
def _handle(self, request, data):
auth_request = AuthorizationRequest.parse(data)
if auth_request['request_uri']:
raise request_uri_not_supported()
if not auth_request.client_id:
raise BadRequest(_("Missing client_id."))
prompt = set(auth_request['prompt'].split(' ')) - {''}
if 'none' in prompt and len(prompt) > 1:
raise invalid_request()
for p in prompt:
if not p in {'none', 'select_account', 'consent'}:
raise server_error("Unimplemented prompt type.")
if 'max_age' in auth_request:
raise server_error("max_age is not implemented.")
if not auth_request.redirect_uri:
raise BadRequest(_("Missing redirect_uri."))
data = json.dumps(auth_request.data)
id = uuid4().hex
if auth_request.prompt - {'none', 'select_account', 'consent'}:
raise BadRequest(_("Unsupported prompt type."))
res = redirect(reverse('openid_provider:select-account') + '?request=' + id)
request.session.setdefault('auth_requests', {})[id] = data
request.session.modified = True
return res
request.flow = Flow()
request.flow.auth_request = auth_request
except OAuthError as e:
logger.debug("OAuth error", exc_info=True)
return auth_request.deny(e)
return authorize(request)
def get(self, request):
return self._handle(request, AuthRequest(request, request.GET))
return self._handle(request, request.GET)
def post(self, request):
return self._handle(request, AuthRequest(request, request.POST))
return self._handle(request, request.POST)
......@@ -20,7 +20,7 @@ class ConfigurationView(View):
authorization_endpoint = request.build_absolute_uri(reverse('openid_provider:authorization')),
token_endpoint = request.build_absolute_uri(reverse('openid_provider:token')),
userinfo_endpoint = request.build_absolute_uri(reverse('openid_provider:userinfo')),
end_session_endpoint = request.build_absolute_uri(reverse('openid_provider:logout')),
end_session_endpoint = request.build_absolute_uri(reverse('openid_provider:end-session')),
jwks_uri = request.build_absolute_uri(reverse('openid_provider:jwks')),
account_settings_endpoint = request.build_absolute_uri(reverse('openid_provider:account-settings')),
registration_endpoint = request.build_absolute_uri(reverse('client-list')),
......
......@@ -12,7 +12,6 @@ from ..errors import access_denied, consent_required
from ..models import UserConsent
from ..scopes import SCOPES
from ..tokens import *
from .auth_request import AuthRequest
User = get_user_model()
......@@ -26,7 +25,6 @@ class ConsentView(TemplateView):
context['user'] = self.user
context['client'] = self.auth_request.client
context['scope'] = {name: desc for name, desc in SCOPES.items() if name in self.req_untrusted_scope}
context['hidden_inputs'] = mark_safe('<input type="hidden" name="request" value="{}">'.format(self.auth_request.id))
return context
def dispatch(self, request, user_id):
......@@ -38,22 +36,19 @@ class ConsentView(TemplateView):
if not request.user.has_perm('openid_provider:impersonate', self.user):
raise PermissionDenied()
self.auth_request = AuthRequest(request)
self.auth_request = self.request.flow.auth_request
self.req_scope = set(self.auth_request['scope'].split(' '))
self.req_scope &= set(['openid']) | set(SCOPES.keys())
self.req_scope = self.auth_request.scope & (set(['openid']) | set(SCOPES.keys()))
self.req_untrusted_scope = self.req_scope - self.auth_request.client.oauth_app.trusted_scopes
self.prompt = self.auth_request['prompt'].split(' ')
return super().dispatch(request)
def get(self, request):
uc = None
try:
if 'consent' in self.prompt:
if 'consent' in self.auth_request.prompt:
raise consent_required()
if self.req_untrusted_scope:
......@@ -65,7 +60,7 @@ class ConsentView(TemplateView):
raise consent_required()
except consent_required:
if 'none' in self.prompt:
if 'none' in self.auth_request.prompt:
return self.auth_request.deny(interaction_required())
return super().get(request)
......@@ -114,4 +109,5 @@ class ConsentView(TemplateView):
id_token = makeIDToken(request=self.request, client=self.auth_request.client, user=self.user, scope=scope, nonce=self.auth_request.nonce, at=access_token, c=code)
response['id_token'] = id_token
self.request.flow = None
return self.auth_request.respond(response)
from django.http import Http404
from django.views.generic import TemplateView
class ContinueView(TemplateView):
template_name = 'openid_provider/continue.html'
def dispatch(self, request):
if not request.flow or not request.flow.auth_request:
request.flow = None
raise Http404
return super().dispatch(request)
def get(self, request):
if not request.flow.auth_request.id_hint_valid:
return super().get(request)
return self.go(request)
def post(self, request):
return self.go(request)
def go(self, request):
auth_request = request.flow.auth_request
request.flow = None
return auth_request.respond({})
from django.shortcuts import redirect
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import View
from aiakos.flow import Flow
from .oauth_request import EndSessionRequest, badrequest_handler
@method_decorator(csrf_exempt, name='dispatch')
@method_decorator(badrequest_handler, name='dispatch')
class EndSessionView(View):
def _handle(self, request, data):
request.flow = Flow()
request.flow.auth_request = EndSessionRequest.parse(data)
return redirect(reverse('openid_provider:logout'))
def get(self, request):
return self._handle(request, request.GET)
import logging
import json
from django.conf import settings
from django.contrib.auth import logout
from django.shortcuts import redirect
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import View
from django.urls import reverse
from django.views.generic import TemplateView
from .auth_request import AuthRequest, badrequest_handler
class LogoutView(TemplateView):
template_name = 'openid_provider/logout.html'
class LogoutRequest(AuthRequest):
redirect_uri_set = 'post_logout_redirect_uris'
def dispatch(self, request):
return super().dispatch(request)
def get(self, request):
if request.user.is_authenticated:
if not request.flow or not request.flow.auth_request or not request.flow.auth_request.id_hint_valid or request.flow.auth_request.id_hint