From 66df05f6cd30821b00cbb6b09dc6c28ae3404b77 Mon Sep 17 00:00:00 2001 From: Patrick Uiterwijk Date: May 09 2016 15:15:24 +0000 Subject: [PATCH 1/4] Implement WebFinger (RFC7033) Ticket: #216 Signed-off-by: Patrick Uiterwijk Reviewed-by: Rob Crittenden --- diff --git a/ipsilon/root.py b/ipsilon/root.py index d701616..5978265 100644 --- a/ipsilon/root.py +++ b/ipsilon/root.py @@ -1,6 +1,7 @@ -# Copyright (C) 2013 Ipsilon project Contributors, for license see COPYING +# Copyright (C) 2013,2016 Ipsilon project Contributors, for license see COPYING from ipsilon.util.page import Page +from ipsilon.util.webfinger import WebFinger from ipsilon.util import errors from ipsilon.login.common import Login from ipsilon.login.common import Logout @@ -33,6 +34,9 @@ class Root(Page): cherrypy.config['error_page.404'] = errors.Error_404(self._site) cherrypy.config['error_page.500'] = errors.Errors(self._site) + # set up WebFinger endpoint + self.webfinger = WebFinger(self._site) + # now set up the default login plugins self.login = Login(self._site) self.logout = Logout(self._site) diff --git a/ipsilon/util/webfinger.py b/ipsilon/util/webfinger.py new file mode 100644 index 0000000..f551f2d --- /dev/null +++ b/ipsilon/util/webfinger.py @@ -0,0 +1,77 @@ +# Copyright (C) 2016 Ipsilon project Contributors, for license see COPYING + +import cherrypy +from ipsilon.util.page import Page +from ipsilon.util.log import Log +from ipsilon.util.endpoint import allow_iframe + +import json + + +class WebFinger(Page, Log): + + def __init__(self, site): + super(WebFinger, self).__init__(site) + self.supported_rels = {} + + @allow_iframe + def root(self, *args, **kwargs): + cherrypy.response.headers.update({ + 'Content-Type': 'application/jrd+json', + 'Access-Control-Allow-Origin': '*' + }) + + if 'resource' not in kwargs: + raise cherrypy.HTTPError(400, 'Missing resource parameter') + + resource = kwargs['resource'] + self.debug('WebFinger request for %s' % resource) + + response = {'subject': resource, + 'links': [], + 'properties': {}} + found = False + + if 'rel' in kwargs: + rels = kwargs['rel'] + if isinstance(rels, basestring): + rels = [rels] + else: + rels = self.supported_rels.keys() + + for rel in rels: + if rel in self.supported_rels: + func = self.supported_rels[rel] + rel_resp = func(resource) + self.debug('Rel %s returned %s' % (rel, rel_resp)) + if 'links' in rel_resp: + if len(rel_resp['links']) > 0: + found = True + response['links'].extend(rel_resp['links']) + if 'properties' in rel_resp: + if len(rel_resp['properties']) > 0: + found = True + response['properties'].update(rel_resp['properties']) + + if not found: + # None of the plugins had any info, we don't know this resource + raise cherrypy.HTTPError(404, 'No info about resource found') + + response['subject'] = resource + + return json.dumps(response) + + def register_rel(self, rel, function): + if rel in self.supported_rels: + raise KeyError('Rel %s already registered' % rel) + + self.debug('WebFinger rel %s registered as %s' + % (rel, function)) + self.supported_rels[rel] = function + + def unregister_rel(self, rel): + if rel not in self.supported_rels: + raise KeyError('Rel %s not registered' % rel) + + self.debug('WebFinger rel %s unregistered' % rel) + del self.supported_rels[rel] diff --git a/templates/install/idp.conf b/templates/install/idp.conf index 7c21774..83cf2a6 100644 --- a/templates/install/idp.conf +++ b/templates/install/idp.conf @@ -1,6 +1,8 @@ Alias /${instance}/ui ${staticdir}/ui Alias /.well-known ${wellknowndir} Alias /${instance}/cache /var/cache/ipsilon +Redirect /${instance}/.well-known/webfinger /${instance}/webfinger + WSGIScriptAlias /${instance} ${ipsilondir}/ipsilon WSGIDaemonProcess ${instance} user=${sysuser} group=${sysuser} home=${datadir} display-name=ipsilon-${instance} ${wsgi_socket} From f913b7c46ead81e75445a7b3c2097d4867925c83 Mon Sep 17 00:00:00 2001 From: Patrick Uiterwijk Date: May 09 2016 15:15:27 +0000 Subject: [PATCH 2/4] Implement OpenID Connect core This patch implements: - RFC6749 (OAuth 2.0 Authorization Framework) - OpenID Connect Core 1.0 errata 1 - OpenID Connect Dynamic Client Registration 1.0 errata 1 - OpenID Connect Discovery 1.0 errata 1 - OAuth 2.0 Multiple Response Type Encoding - OAuth 2.0 Form Post Response Mode - RFC6750 (OAuth 2.0 Bearer Token Usage) - RFC7662 (OAuth 2.0 Token Introspection) Ticket: #93 Signed-off-by: Patrick Uiterwijk Reviewed-by: Rob Crittenden --- diff --git a/ipsilon/login/common.py b/ipsilon/login/common.py index 4f8b13d..8bb3631 100644 --- a/ipsilon/login/common.py +++ b/ipsilon/login/common.py @@ -9,6 +9,7 @@ from ipsilon.info.common import Info from ipsilon.util.cookies import SecureCookie from ipsilon.util.log import Log import cherrypy +import time USERNAME_COOKIE = 'ipsilon_default_username' @@ -118,10 +119,9 @@ class LoginHelper(Log): self.debug("User %s attributes: %s" % (username, repr(userdata))) if auth_type: - if userdata: - userdata.update({'_auth_type': auth_type}) - else: - userdata = {'_auth_type': auth_type} + userdata.update({'_auth_type': auth_type}) + + userdata.update({'_auth_time': int(time.time())}) # create session login including all the userdata just gathered session.login(username, userdata) diff --git a/ipsilon/providers/openidc/__init__.py b/ipsilon/providers/openidc/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/ipsilon/providers/openidc/__init__.py diff --git a/ipsilon/providers/openidc/auth.py b/ipsilon/providers/openidc/auth.py new file mode 100644 index 0000000..fc2af0a --- /dev/null +++ b/ipsilon/providers/openidc/auth.py @@ -0,0 +1,1199 @@ +# Copyright (C) 2016 Ipsilon project Contributors, for license see COPYING + +from ipsilon.util.log import Log +from ipsilon.providers.common import ProviderPageBase +from ipsilon.providers.common import InvalidRequest +from ipsilon.util.policy import Policy +from ipsilon.util.trans import Transaction +from ipsilon.util.security import (generate_random_secure_string, + constant_time_string_comparison) +from ipsilon.util.user import UserSession + +from jwcrypto.jwt import JWT +from jwcrypto.jwk import JWK, JWKSet +from jwcrypto.jws import default_allowed_algs as jws_default_allowed_algs + +import base64 +import cherrypy +import hashlib +import requests +import time +import json +from urlparse import urlparse + +URLROOT = 'openidc' + + +def get_url_hostpart(url): + try: + o = urlparse(url) + return o.hostname + except: # pylint: disable=bare-except + return url + + +class AuthenticateRequest(ProviderPageBase): + + def __init__(self, site, provider, *args, **kwargs): + super(AuthenticateRequest, self).__init__(site, provider, + *args, **kwargs) + self.trans = None + + def _preop(self, *args, **kwargs): + try: + # generate a new id or get current one + self.trans = Transaction('openidc', **kwargs) + if (self.trans.cookie and + self.trans.cookie.value != self.trans.provider): + self.debug('Invalid transaction, %s != %s' % ( + self.trans.cookie.value, self.trans.provider)) + except Exception, e: # pylint: disable=broad-except + self.debug('Transaction initialization failed: %s' % repr(e)) + raise cherrypy.HTTPError(400, 'Invalid transaction id') + + def pre_GET(self, *args, **kwargs): + self._preop(*args, **kwargs) + + def pre_POST(self, *args, **kwargs): + self._preop(*args, **kwargs) + + # get attributes, and apply policy mapping and filtering + def _source_attributes(self, session): + policy = Policy(self.cfg.default_attribute_mapping, + self.cfg.default_allowed_attributes) + userattrs = session.get_user_attrs() + mappedattrs, _ = policy.map_attributes(userattrs) + attributes = policy.filter_attributes(mappedattrs) + self.debug('Filterd attributes: %s' % repr(attributes)) + return attributes + + def _respond(self, request, contents): + url = request['redirect_uri'] + response_mode = request.get('response_mode', None) + response_type = request.get('response_type', []) + if 'none' in response_type: + response_mode = 'none' + self.debug('none response_type, using none response_mode') + elif 'id_token' in response_type or 'token' in response_type: + if response_mode in [None, 'query']: + # If no response_mode or query response_mode is selected, + # fall back to the default for id_token or token requests, + # which is fragment encoding. The query override is because + # the specifications specify that with id_token or token, + # query MUST NOT be used. + response_mode = 'fragment' + self.debug('id_token requesed, fragment response_mode forced') + elif not response_mode: + # We still have no response_mode, fall back to query + # This also happens in case we were unable to parse the request, + # and as such were unable to get the response_mode the client + # preferred + response_mode = 'query' + self.debug('Using default query response mode') + + # If the client sent a state, we need to pass that back + if 'state' in request: + contents['state'] = request['state'] + + # Build a response-string, which is sent with either query, form + # or fragment responses + if response_mode in ['query', 'fragment']: + data = ['%s=%s' % (key, contents[key]) for key in contents.keys()] + + separator = '?' + if response_mode == 'fragment': + separator = '#' + if separator not in url: + url += separator + else: + url += '&' + + url += '&'.join(data) + + if response_mode in ['query', 'fragment', 'none']: + raise cherrypy.HTTPRedirect(url) + elif response_mode == 'form_post': + context = { + "title": "Continue", + "redirect_url": url, + "response_info": contents + } + return self._template(URLROOT + '/form_response.html', **context) + else: + raise InvalidRequest('Invalid response_mode requested') + + def _respond_error(self, request, error, message): + if request.get('redirect_uri') is None: + raise InvalidRequest('Request is missing redirct_uri') + + return self._respond(request, {'error': error, + 'error_description': message}) + + +class APIError(cherrypy.HTTPError, Log): + + def __init__(self, code, error, description=None): + super(APIError, self).__init__(code, error) + self.debug('OpenIDC API error: %s, desc: %s' + % (error, description)) + response = {'error': error} + if description: + response['error_description'] = description + self._error_response = json.dumps(response) + cherrypy.response.headers.update({ + 'Content-Type': 'application/json' + }) + + def get_error_page(self, *args, **kwargs): + return self._error_response + + +class APIRequest(ProviderPageBase): + # Bearer token (RFC 6750) and Client Auth + authenticate_client = False + authenticate_token = False + requires_client_auth = False + requires_valid_token = False + required_scope = None + + def __init__(self, site, provider, *args, **kwargs): + super(APIRequest, self).__init__(site, provider) + self.api_client_id = None + self.api_valid_token = False + self.api_username = None + self.api_scopes = [] + self.api_client_authenticated = False + self.api_client_id = None + self.api_token = None + self.api_client = None + + def pre_GET(self, *args, **kwargs): + # Note that we explicitly do NOT support URI Query parameter posting + # of bearer tokens (RFC6750, section 2.3 marks this as MAY) + self._preop() + + def pre_POST(self, *args, **kwargs): + self._preop(*args, **kwargs) + + def _preop(self, *args, **kwargs): + cherrypy.response.headers.update({ + 'Content-Type': 'application/json' + }) + + self.api_client_id = None + self.api_valid_token = False + self.api_username = None + self.api_scopes = [] + self.api_client_authenticated = False + self.api_client_id = None + self.api_token = None + self.api_client = None + + if self.authenticate_client: + self._authenticate_client(kwargs) + if self.requires_client_auth and not self.api_client_id: + raise APIError(400, 'invalid_client', + 'client authentication required') + + if self.authenticate_token: + self._authenticate_token(kwargs) + + def _respond(self, response): + return json.dumps(response) + + def _respond_error(self, error, message): + return self._respond({'error': error, + 'error_description': message}) + + def _handle_client_authentication(self, auth_method, client_id, + client_secret): + self.debug('Trying client auth for %s with method %s' + % (client_id, auth_method)) + if not client_id: + self.error('Client authentication without client_id') + raise APIError(400, 'invalid_client', + 'client authentication error') + + client = self.cfg.datastore.getClient(client_id) + if not client: + self.error('Client authentication with invalid client ID') + raise APIError(400, 'invalid_client', + 'client authentication error') + + if client['client_secret_expires_at'] != 0 and \ + client['client_secret_expires_at'] <= int(time.time()): + self.error('Client authentication with expired secret') + raise APIError(400, 'invalid_client', + 'client authentication error') + + if client['token_endpoint_auth_method'] != auth_method: + self.error('Client authentication with invalid auth method: %s' + % auth_method) + raise APIError(400, 'invalid_client', + 'client authentication error') + + if not constant_time_string_comparison(client['client_secret'], + client_secret): + self.error('Client authentication with invalid secret: %s' + % client_secret) + raise APIError(400, 'invalid_client', + 'client authentication error') + + self.api_client_authenticated = True + self.api_client_id = client_id + self.api_client = client + + def _authenticate_client(self, post_args): + request = cherrypy.serving.request + self.debug('Trying to authenticate client') + if 'authorization' in request.headers: + self.debug('Authorization header found') + hdr = request.headers['authorization'] + if hdr.startswith('Basic '): + self.debug('Authorization header is basic') + hdr = hdr[len('Basic '):] + try: + client_id, client_secret = \ + base64.b64decode(hdr).split(':', 1) + except Exception as e: # pylint: disable=broad-except + self.error('Invalid request received: %s' % repr(e)) + self._respond_error('invalid_request', + 'invalid auth header') + self.debug('Client ID: %s' % client_id) + self._handle_client_authentication('client_secret_basic', + client_id, + client_secret) + else: + self.error('Invalid authorization header presented') + response = cherrypy.serving.response + response.headers['WWW-Authenticate'] = 'Bearer realm="Ipsilon"' + raise cherrypy.HTTPError(401, "Unauthorized") + elif 'client_id' in post_args: + self.debug('Client id found in post args: %s' + % post_args['client_id']) + self._handle_client_authentication('client_secret_post', + post_args['client_id'], + post_args.get('client_secret', + '')) + else: + self.error('No authorization presented') + response = cherrypy.serving.response + response.headers['WWW-Authenticate'] = 'Bearer realm="Ipsilon"' + raise cherrypy.HTTPError(401, "Unauthorized") + # FIXME: Perhaps add client_secret_jwt and private_key_jwt as per + # OpenID Connect Core section 9 + + def _handle_token_authentication(self, token): + token = self.cfg.datastore.lookupToken(token, 'Bearer') + if not token: + self.error('Unknown token provided') + raise APIError(400, 'invalid_token') + + if self.api_client_id: + if token['client_id'] != self.api_client_id: + self.error('Authenticated client is not token owner: %s != %s' + % (token['client_id'], self.api_client_id)) + raise APIError(400, 'invalid_request') + else: + self.api_client = self.cfg.datastore.getClient(token['client_id']) + if not self.api_client: + self.error('Token authentication with invalid client ID') + raise APIError(400, 'invalid_client', + 'client authentication error') + + if (self.required_scope is not None and + self.required_scope not in token['scope']): + self.error('Required %s not in token scopes %s' + % (self.required_scope, token['scope'])) + raise APIError(403, 'insufficient_scope') + + self.api_client_id = token['client_id'] + self.api_valid_token = True + self.api_username = token['username'] + self.api_scopes = token['scope'] + self.api_token = token + + def _authenticate_token(self, post_args): + request = cherrypy.serving.request + if 'authorization' in request.headers: + hdr = request.headers['authorization'] + if hdr.startswith('Bearer '): + token = hdr[len('Bearer '):] + self._handle_token_authentication(token) + else: + raise APIError(400, 'invalid_request') + elif 'access_token' in post_args: + # Bearer token + token = post_args['access_token'] + self._handle_token_authentication(token) + + def require_scope(self, scope): + if scope not in self.api_scopes: + raise APIError(403, 'insufficient_scope') + + +class Authorization(AuthenticateRequest): + + def start_authz(self, arguments): + request_data = { + 'scope': [], + 'response_type': [], + 'client_id': None, + 'redirect_uri': None, + 'state': None, + 'response_mode': None, + 'nonce': None, + 'display': None, + 'prompt': [], + 'max_age': None, + 'ui_locales': None, + 'id_token_hint': None, + 'login_hint': None, + 'acr_values': None, + 'claims': '{}' + } + + # Get the request + # Step 1: get the get query arguments + for data in request_data.keys(): + if arguments.get(data, None): + request_data[data] = arguments[data] + + # This is a workaround for python not understanding the splits we + # do later + if request_data['prompt'] == []: + request_data['prompt'] = None + + for required_arg in ['scope', + 'response_type', + 'client_id']: + if request_data[required_arg] is None or \ + len(request_data[required_arg]) == 0: + return self._respond_error(request_data, + 'invalid_request', + 'missing required argument %s' % + required_arg) + + client = self.cfg.datastore.getClient(request_data['client_id']) + if not client: + return self._respond_error(request_data, + 'unauthorized_client', + 'Unknown client ID') + + request_data['response_type'] = request_data.get('response_type', + '').split(' ') + for rtype in request_data['response_type']: + if rtype not in ['id_token', 'token', 'code']: + return self._respond_error(request_data, + 'unsupported_response_type', + 'response type %s is not supported' + % rtype) + + if request_data['response_type'] != ['code'] and \ + not request_data['nonce']: + return self._respond_error(request_data, + 'invalid_request', + 'nonce missing in non-code flow') + + # Step 2: get any provided request or request_uri + if 'request' in arguments or 'request_uri' in arguments: + # This is a JWT-encoded request + if 'request' in arguments and 'request_uri' in arguments: + return self._respond_error(request_data, + 'invalid_request', + 'both request and request_uri ' + + 'provided') + + if 'request' in arguments: + jwt_object = arguments['request'] + else: + try: + # FIXME: MAY cache this at client registration time and + # cache permanently until client registration is changed. + jwt_object = requests.get(arguments['request_uri']).text + except Exception as ex: # pylint: disable=broad-except + self.debug('Unable to get request: %s' % ex) + return self._respond_error(request_data, + 'invalid_request', + 'unable to parse request_uri') + + jwt_request = None + try: + # FIXME: Implement decryption + decoded = JWT(jwt=jwt_object) + if 'request_object_signing_alg' in client: + # Client told us we need to check signature + if decoded.token.jose_header['alg'] != \ + client['request_object_signing_alg']: + raise Exception('Invalid algorithm used: %s' + % decoded.token.jose_header['alg']) + + if client['request_object_signing_alg'] == 'none': + jwt_request = json.loads( + decoded.token.objects['payload']) + else: + keyset = None + if 'jkws' in client: + keys = json.loads(client['jkws']) + else: + keys = requests.get(client['jwks_uri']).json() + keyset = JWKSet() + for key in keys['keys']: + keyset.add(JWK(**key)) + key = keyset.get_key(decoded.token.jose_header['kid']) + decoded = JWT(jwt=jwt_object, key=key) + jwt_request = json.loads(decoded.claims) + + except Exception as ex: # pylint: disable=broad-except + self.debug('Unable to parse request: %s' % ex) + return self._respond_error(request_data, + 'invalid_request', + 'unable to parse request') + + if 'response_type' in jwt_request: + jwt_request['response_type'] = \ + jwt_request['response_type'].split(' ') + if jwt_request['response_type'] != \ + request_data['response_type']: + return self._respond_error(request_data, + 'invalid_request', + 'response_type does not match') + + if 'client_id' in jwt_request: + if jwt_request['client_id'] != request_data['client_id']: + return self._respond_error(request_data, + 'invalid_request', + 'client_id does not match') + + for data in request_data.keys(): + if data in jwt_request: + request_data[data] = jwt_request[data] + + # Split these options since they are space-separated lists + for to_split in ['prompt', + 'ui_locales', + 'acr_values', + 'scope']: + if request_data[to_split] is not None: + # We know better than pylint in this regard + # pylint: disable=no-member + request_data[to_split] = request_data[to_split].split(' ') + else: + request_data[to_split] = [] + + # Start checking the request + if request_data['redirect_uri'] is None: + if len(client['redirect_uris']) != 1: + return self._respond_error(request_data, + 'invalid_request', + 'missing redirect_uri') + else: + request_data['redirect_uri'] = client['redirect_uris'][0] + + for scope in request_data['scope']: + if scope not in self.cfg.supported_scopes: + return self._respond_error(request_data, + 'invalid_scope', + 'unknown scope %s requested' % + scope) + + for response_type in request_data['response_type']: + if response_type not in ['code', 'id_token', 'token']: + return self._respond_error(request_data, + 'unsupported_response_type', + 'response_type %s is unknown' + % response_type) + + if request_data['redirect_uri'] not in client['redirect_uris']: + raise InvalidRequest('Invalid redirect_uri') + + # Build the "claims" values from scopes + try: + request_data['claims'] = json.loads(request_data['claims']) + except Exception, ex: # pylint: disable=broad-except + return self._respond_error(request_data, + 'invalid_request', + 'claims malformed: %s' % ex) + if 'userinfo' not in request_data['claims']: + request_data['claims']['userinfo'] = {} + if 'id_token' not in request_data['claims']: + request_data['claims']['id_token'] = {} + + scopes_to_claim = { + 'profile': [ + 'name', 'family_name', 'given_name', 'middle_name', 'nickname', + 'preferred_username', 'profile', 'picture', 'website', + 'gender', 'birthdate', 'zoneinfo', 'locale', 'updated_at' + ], + 'email': ['email', 'email_verified'], + 'address': ['address'], + 'phone': ['phone_number', 'phone_number_verified'] + } + for scope in scopes_to_claim: + if scope in request_data['scope']: + for claim in scopes_to_claim[scope]: + if claim not in request_data['claims']: + # pylint: disable=invalid-sequence-index + request_data['claims']['userinfo'][claim] = None + + # Store data so we can continue with the request + us = UserSession() + user = us.get_user() + + returl = '%s/%s/Continue?%s' % ( + self.basepath, URLROOT, self.trans.get_GET_arg()) + data = {'login_target': client.get('client_name', None), + 'login_return': returl, + 'openidc_stage': 'continue', + 'openidc_request': json.dumps(request_data)} + + if request_data['login_hint']: + data['login_username'] = request_data['login_hint'] + + if not data['login_target']: + data['login_target'] = get_url_hostpart( + request_data['redirect_uri']) + + # Decide what to do with the request + if request_data['max_age'] is None: + request_data['max_age'] = client.get('default_max_age', None) + + needs_auth = True + if not user.is_anonymous: + if request_data['max_age'] is None: + needs_auth = False + else: + auth_time = us.get_user_attrs()['_auth_time'] + needs_auth = ((int(auth_time) + + int(request_data['max_age'])) <= + int(time.time())) + + if needs_auth or 'login' in request_data['prompt']: + if 'none' in request_data['prompt']: + # We were asked not to provide a UI. Answer with false. + return self._respond_error(request_data, + 'login_required', + 'user interface required') + + # Either the user wasn't logged in, or we were explicitly + # asked to re-auth them. Let's do so! + us.logout(user) + + # Let the user go to auth + self.trans.store(data) + redirect = '%s/login?%s' % (self.basepath, + self.trans.get_GET_arg()) + self.debug('Redirecting: %s' % redirect) + raise cherrypy.HTTPRedirect(redirect) + + self.trans.store(data) + # The user was already signed on, and no request to re-assert its + # identity. Let's forward directly to /Continue/ + self.debug('Redirecting: %s' % returl) + raise cherrypy.HTTPRedirect(returl) + + def GET(self, *args, **kwargs): + return self.start_authz(kwargs) + + def POST(self, *args, **kwargs): + return self.start_authz(kwargs) + + +class Continue(AuthenticateRequest): + + def _respond_success(self, request, client, user, userinfo): + # Answer the current request with a successful response. + response = {} + + if client['subject_type'] == 'public': + userinfo['sub'] = user.name + else: + h = hashlib.sha256() + if 'sector_identifier_uri' in client: + domain = get_url_hostpart( + client['sector_identifier_uri']) + else: + # We are guaranteed that we either have a sector_identifier_uri + # or that the hostpart of all redirect_uris are equal + domain = get_url_hostpart(client['redirect_uris'][0]) + h.update(domain) + h.update(user.name) + h.update(self.cfg.idp_subject_salt) + userinfo['sub'] = h.hexdigest() + + claims_userinfo = {} + for requested_claim in request['claims']['userinfo']: + if requested_claim in userinfo: + claims_userinfo[requested_claim] = userinfo[requested_claim] + claims_userinfo['sub'] = userinfo['sub'] + + userinfocode = None + if 'openid' in request['scope']: + userinfocode = self.cfg.datastore.storeUserInfo(claims_userinfo) + + if 'token' in request['response_type']: + # Asked to return token in authz response + # Flows: Hybrid and Implicit + token = self.cfg.datastore.issueToken( + request['client_id'], + user.name, + request['scope'], + False, + userinfocode) + del token['token_id'] + response['access_token'] = token['access_token'] + response['token_type'] = 'Bearer' + response['expires_in'] = token['expires_in'] + + if 'code' in request['response_type']: + # Asked to return authorization code + # Flows: Authorization code and Hybrid + response['code'] = self.cfg.datastore.issueAuthorizationCode( + request['client_id'], + user.name, + request['scope'], + userinfo, + request['redirect_uri'], + userinfocode) + + if 'openid' in request['scope']: + id_token = {} + + # Build the id_token + for requested_claim in request['claims']['id_token']: + if requested_claim in userinfo: + id_token[requested_claim] = userinfo[requested_claim] + + id_token['sub'] = userinfo['sub'] + id_token['iss'] = self.cfg.endpoint_url + id_token['aud'] = request['client_id'] + id_token['exp'] = int(time.time()) + 600 + id_token['iat'] = int(time.time()) + id_token['auth_time'] = userinfo['_auth_time'] + if 'nonce' in request: + id_token['nonce'] = request['nonce'] + id_token['acr'] = '0' + id_token['amr'] = json.dumps([]) + id_token['azp'] = request['client_id'] + + if 'code' in response: + # Add c_hash + id_token['c_hash'] = self._calc_hash(response['code']) + + if 'access_token' in response: + # Add at_hash + id_token['at_hash'] = self._calc_hash(response['access_token']) + + sig = JWT(header={'alg': 'RS256', + 'kid': self.cfg.idp_sig_key_id}, + claims=id_token) + # FIXME: Maybe add other algorithms in the future + sig.make_signed_token(self.cfg.keyset.get_key( + self.cfg.idp_sig_key_id)) + # FIXME: Maybe encrypt in the future + signed_id_token = sig.serialize(compact=True) + + if 'code' in response: + self.cfg.datastore.storeAuthorizationIDToken(response['code'], + signed_id_token) + + if 'id_token' in request['response_type']: + response['id_token'] = signed_id_token + + return self._respond(request, response) + + def _calc_hash(self, msg): + h = hashlib.sha256(msg.encode()).digest() + return base64.urlsafe_b64encode(h[:16]).rstrip(b'=').decode() + + def _perform_continue(self, *args, **kwargs): + us = UserSession() + user = us.get_user() + + if user.is_anonymous: + raise InvalidRequest('User not authenticated at continue') + + transdata = self.trans.retrieve() + stage = transdata.get('openidc_stage', None) + request_data = transdata.get('openidc_request', None) + if stage not in ['continue', 'consent'] or request_data is None: + raise InvalidRequest('Invalid stage or no request') + + # Since we have openidc_stage continue or consent, request is sane + try: + request_data = json.loads(request_data) + except: + raise InvalidRequest('Unable to re-parse stored request') + + client = self.cfg.datastore.getClient(request_data['client_id']) + if not client: + return self._respond_error(request_data, + 'unauthorized_client', + 'Unknown client ID') + + userattrs = self._source_attributes(us) + if client['ipsilon_internal']['trusted']: + # No consent needed, approve + self.debug('Client trusted, no consent needed') + return self._respond_success(request_data, + client, + user, + userattrs) + + if 'none' in request_data['prompt']: + # We were asked to not show any UI + return self._respond_error(request_data, + 'consent_required', + 'user interface required') + + # Now ask consent + if 'form_filled' in kwargs and stage == 'consent': + # The user has been shown the form, let's process his choice + if 'decided_allow' in kwargs: + # User allowed the request + return self._respond_success(request_data, + client, + user, + userattrs) + + else: + # User denied consent + self.debug('User denied consent') + return self._respond_error(request_data, + 'access_denied', + 'user denied consent') + else: + # The user was not shown the form yet, let's + data = {'openidc_stage': 'consent', + 'openidc_request': json.dumps(request_data)} + self.trans.store(data) + + userattrs = self._source_attributes(us) + claim_requests = {} + for claimtype in request_data['claims']: + for claim in request_data['claims'][claimtype]: + if claim in userattrs: + essential = False + if isinstance( + request_data['claims'][claimtype][claim], + dict): + essential = \ + request_data['claims'][claimtype][claim].get( + 'essential', False) + + claim_requests[claim] = { + 'display_name': self.cfg.mapping.display_name( + claim), + 'value': userattrs[claim], + 'essential': essential + } + + scopes = {} + # Add extension data + for n, e in self.cfg.extensions.available().items(): + data = e.get_display_data(request_data['scope']) + self.debug('%s returned %s' % (n, repr(data))) + if len(data) > 0: + scopes[e.get_display_name()] = data + + client_params = { + 'name': client.get('client_name', None), + 'logo': client.get('logo_uri', None), + 'homepage': client.get('client_uri', None), + 'policy': client.get('policy_uri', None), + 'tos': client.get('tos_uri', None) + } + + if not client_params['name']: + client_params['name'] = get_url_hostpart( + request_data['redirect_uri']) + + context = { + "title": 'Consent', + "action": '%s/%s/Continue' % (self.basepath, URLROOT), + "client": client_params, + "claim_requests": claim_requests, + "scopes": scopes, + "username": user.name, + } + context.update(dict((self.trans.get_POST_tuple(),))) + return self._template(URLROOT + '/consent_form.html', **context) + + def GET(self, *args, **kwargs): + # We do not pass kwargs in the case of GET, since there + # will be no arguments passed to this endpoint by GET + # that we need to process + return self._perform_continue(*args) + + def POST(self, *args, **kwargs): + return self._perform_continue(*args, form_filled=True, **kwargs) + + +class Registration(APIRequest): + + def POST(self, *args, **kwargs): + try: + client_metadata = json.loads(cherrypy.request.rfile.read()) + except: + raise APIError(400, 'invalid_client_metadata', + 'unable to parse metadata') + self.debug('Received client registration request: %s' + % client_metadata) + + # Fill in defaults for optional arguments + client_metadata['response_types'] = client_metadata.get( + 'response_types', ['code']) + client_metadata['grant_types'] = client_metadata.get( + 'grant_types', ['authorization_code']) + client_metadata['application_type'] = client_metadata.get( + 'application_type', 'web') + client_metadata['contacts'] = client_metadata.get('contacts', []) + client_metadata['subject_type'] = client_metadata.get('subject_type', + 'pairwise') + client_metadata['id_token_signed_response_alg'] = client_metadata.get( + 'id_token_signed_response_alg', 'RS256') + client_metadata['token_endpoint_auth_method'] = client_metadata.get( + 'token_endpoint_auth_method', 'client_secret_basic') + client_metadata['require_auth_time'] = client_metadata.get( + 'require_auth_time', False) + + # Check the client metadata received + if 'redirect_uris' not in client_metadata: + raise APIError(400, 'invalid_client_metadata', + 'missing redirect_uris') + + if client_metadata['application_type'] not in ['web', 'native']: + raise APIError(400, 'invalid_client_metadata', + 'application_type invalid') + + for redirect_uri in client_metadata['redirect_uris']: + if '#' in redirect_uri: + raise APIError(400, 'invalid_redirect_uri', + 'redirect_uri contains fragment') + + if client_metadata['application_type'] == 'web': + # In this case, it must be https:// and not https://localhost + if (not redirect_uri.startswith('https://') or + redirect_uri.startswith('https://localhost')): + raise APIError(400, 'invalid_redirect_uri', + 'redirect_uri %s not valid' % redirect_uri) + + elif client_metadata['application_type'] == 'native': + # In this case, it must be http://localhost, or something + # that is not http:// or https:// + if (redirect_uri.startswith('https://') or + (redirect_uri.startswith('http://') and + not redirect_uri.startswith('http://localhost'))): + raise APIError(400, 'invalid_redirect_uri', + 'redirect_uri %s not valid' % redirect_uri) + + if 'initiate_login_uri' in client_metadata: + if not client_metadata['initiate_login_uri'].startswith( + 'https://'): + raise APIError(400, 'invalid_client_metadata', + 'initiate_login_uri must be https') + + if 'sector_identifier_uri' not in client_metadata: + hostname = None + for redir_uri in client_metadata['redirect_uris']: + cur_host = get_url_hostpart(redir_uri) + if not cur_host: + raise APIError(400, 'invalid_client_metadata', + 'Unable to parse hostname from ' + + 'redirect_uri %s' % redir_uri) + if hostname is not None and cur_host != hostname: + raise APIError(400, 'invalid_client_metadata', + 'Multiple redirect_uri hostnames without ' + + 'sector_identifier_uri') + hostname = cur_host + else: + if not client_metadata['sector_identifier_uri'].startswith( + 'https://'): + raise APIError(400, 'invalid_client_metadata', + 'sector_identifier_uri must be https') + + try: + resp = requests.get(client_metadata['sector_identifier_uri']) + resp = resp.json() + for redirect_uri in client_metadata['redirect_uris']: + if redirect_uri not in resp: + raise APIError(400, 'invalid_client_metadata', + 'redirect_uri %s not in ' + + 'sector_identifier_uri document' % + redirect_uri) + except Exception as ex: + self.debug('Unable to process sector_identifier_uri: %s' % + ex) + raise APIError(400, 'invalid_client_metadata', + 'unable to process sector_identifier_uri') + + if 'code' in client_metadata['response_types']: + if 'authorization_code' not in client_metadata['grant_types']: + raise APIError(400, 'invalid_client_metadata', + 'authorization_code missing with code') + + if ('token' in client_metadata['response_types'] or + 'id_token' in client_metadata['response_types']): + if 'implicit' not in client_metadata['grant_types']: + raise APIError(400, 'invalid_client_metadata', + 'implicit missing with token or id_token') + + if 'jwks' in client_metadata and 'jwks_uri' in client_metadata: + raise APIError(400, 'invalid_client_metadata', + 'both jwks and jwks_uri provided') + + # If all checks pass, generate client ID and secret + client_metadata['client_secret'] = \ + generate_random_secure_string() + client_metadata['client_secret_expires_at'] = 0 # FIXME: Expire? + client_metadata['client_id_issued_at'] = int(time.time()) + + # Store some internal data + client_metadata['ipsilon_internal'] = { + 'trusted': False + } + + # Store and add reg uri + client_id = self.cfg.datastore.registerDynamicClient(client_metadata) + client_metadata['client_id'] = client_id + + # Clear internal data from returned values + del client_metadata['ipsilon_internal'] + + # FIXME: Offer this once we add a ClientConfiguration endpoint + # client_metadata['registration_access_token'] = \ + # generate_random_secure_string() + # client_metadata['registration_client_uri'] = '%s%s' % ( + # self.cfg.endpoint_url, 'ClientConfiguration') + + return self._respond(client_metadata) + + +class Token(APIRequest): + authenticate_client = True + + def POST(self, *args, **kwargs): + grant_type = kwargs.get('grant_type', None) + + if grant_type == 'authorization_code': + # Handle authz code + code = kwargs.get('code', None) + redirect_uri = kwargs.get('redirect_uri', None) + + token = self.cfg.datastore.lookupToken(code, 'Authorization') + if not token: + self.error('Unknown authz token provided') + return self._respond_error('invalid_request', + 'invalid token') + + if token['client_id'] != self.api_client_id: + self.error('Authz code owner does not match authenticated ' + + 'client: %s != %s' % (token['client_id'], + self.api_client_id)) + return self._respond_error('invalid_request', + 'invalid token for client ID') + + if token['redirect_uri'] != redirect_uri: + self.error('Token redirect URI does not match request: ' + + '%s != %s' % (token['redirect_uri'], redirect_uri)) + return self._respond_error('invalid_request', + 'redirect_uri does not match') + + new_token = self.cfg.datastore.exchangeAuthorizationCode(code) + if not new_token: + self.error('Was unable to exchange for token') + return self._respond_error('invalid_grant', + 'Could not refresh') + new_token['token_type'] = 'Bearer' + + return self._respond(new_token) + + elif grant_type == 'refresh_token': + # Handle token refresh + refresh_token = kwargs.get('refresh_token', None) + + refresh_result = self.cfg.datastore.refreshToken( + refresh_token, + self.api_client_id) + + if not refresh_result: + return self._respond_error('invalid_grant', + 'Something went wrong refreshing') + + return self._respond({ + 'access_token': refresh_result['access_token'], + 'token_type': 'Bearer', + 'refresh_token': refresh_result['refresh_token'], + 'expires_in': refresh_result['expires_in']}) + + else: + return self._respond_error('unsupported_grant_type', + 'unknown grant_type') + + +class TokenInfo(APIRequest): + # RFC7662 (Token introspection) + authenticate_client = True + requires_client_auth = True + + def POST(self, *args, **kwargs): + token = kwargs.get('token', None) + + token = self.cfg.datastore.lookupToken(token, None, True) + if not token: + # Per spec, if this token is not valid, but the request itself is, + # we return with an "empty" response + return self._respond({ + 'active': False + }) + + # FIXME: At this moment, we only have Bearer tokens + token_type = 'Bearer' + + return self._respond({ + 'active': int(time.time()) <= int(token['expires_at']), + 'scope': ' '.join(token['scope']), + 'client_id': token['client_id'], + 'username': token['username'], + 'token_type': token_type, + 'exp': token['expires_at'], + 'iat': token['issued_at'], + 'sub': token['username'], + 'aud': token['client_id'], + 'iss': self.cfg.endpoint_url, + }) + + +class UserInfo(APIRequest): + authenticate_token = True + requires_valid_token = True + required_scope = 'openid' + + def _get_userinfo(self, *args, **kwargs): + info = self.cfg.datastore.getUserInfo(self.api_token['userinfocode']) + if not info: + return self._respond_error('invalid_request', + 'No userinfo for token') + + if 'userinfo_signed_response_alg' in self.api_client: + cherrypy.response.headers.update({ + 'Content-Type': 'application/jwt' + }) + + sig = JWT(header={'alg': 'RS256', + 'kid': self.cfg.idp_sig_key_id}, + claims=info) + # FIXME: Maybe add other algorithms in the future + sig.make_signed_token(self.cfg.keyset.get_key( + self.cfg.idp_sig_key_id)) + # FIXME: Maybe encrypt in the future + info = sig.serialize(compact=True) + + if isinstance(info, dict): + info = json.dumps(info) + + return info + + def GET(self, *args, **kwargs): + return self._get_userinfo(*kwargs, **kwargs) + + def POST(self, *args, **kwargs): + return self._get_userinfo(*kwargs, **kwargs) + + +class OpenIDC(ProviderPageBase): + + def __init__(self, *args, **kwargs): + super(OpenIDC, self).__init__(*args, **kwargs) + self.Authorization = Authorization(*args, **kwargs) + self.Continue = Continue(*args, **kwargs) + self.Token = Token(*args, **kwargs) + self.TokenInfo = TokenInfo(*args, **kwargs) + self.Registration = Registration(*args, **kwargs) + self.UserInfo = UserInfo(*args, **kwargs) + + def wellknown_openid_configuration(self): + cherrypy.response.headers.update({ + 'Content-Type': 'application/json' + }) + + configuration = { + 'issuer': self.cfg.endpoint_url, + 'authorization_endpoint': '%s%s' % (self.cfg.endpoint_url, + 'Authorization'), + 'token_endpoint': '%s%s' % (self.cfg.endpoint_url, + 'Token'), + 'userinfo_endpoint': '%s%s' % (self.cfg.endpoint_url, + 'UserInfo'), + 'jwks_uri': '%s%s' % (self.cfg.endpoint_url, + 'Jwks'), + 'registration_endpoint': '%s%s' % (self.cfg.endpoint_url, + 'Registration'), + 'scopes_supported': self.cfg.supported_scopes, + 'response_types_supported': ['code', 'id_token' 'token', + 'token id_token'], + 'response_modes_supported': ['query', 'fragment', 'form_post', + 'none'], + 'grant_types_supported': ['authorization_code', 'implicit', + 'refresh_token'], + 'acr_values_supported': ['0'], + 'subject_types_supported': ['pairwise', 'public'], + # FIXME: At some point, we might want to support all the algorithms + # that jwcrypto has support for + 'id_token_signing_alg_values_supported': ['RS256'], + 'id_token_encryption_alg_values_supported': [], + 'id_token_encryption_enc_values_supported': [], + 'userinfo_signing_alg_values_supported': ['RS256'], + 'userinfo_encryption_alg_values_supported': [], + 'userinfo_encryption_enc_values_supported': [], + 'request_object_signing_alg_values_supported': + jws_default_allowed_algs + ['none'], + 'request_object_encryption_alg_values_supported': [], + 'request_object_encryption_enc_values_supported': [], + 'token_endpoint_auth_methods_supported': [ + 'client_secret_basic', + 'client_secret_post' + ], + 'token_endpoint_auth_signing_alg_values_supported': ['RS256'], + 'display_values_supported': ['page', 'popup'], + 'claim_types_supported': ['normal'], + 'claims_supported': [ + 'sub', 'name', 'given_name', 'family_name', 'middle_name', + 'nickname', 'preferred_username', 'profile', 'picture', + 'website', 'email', 'email_verified', 'gender', 'birthdate', + 'zoneinfo', 'locale', 'phone_number', 'phone_number_verified', + 'address', 'updated_at' + ], + 'service_documentation': self.cfg.documentation_url, + # 'claims_locales_supported': [], + 'ui_locales_supported': ['en'], + 'claims_parameter_supported': True, + 'request_parameter_supported': True, + 'request_uri_parameter_supported': True, + 'require_request_uri_registration': False, + 'op_policy_uri': self.cfg.policy_url, + 'op_tos_uri': self.cfg.tos_url, + } + + return json.dumps(configuration) + wellknown_openid_configuration.public_function = True + + def Jwks(self): + cherrypy.response.headers.update({ + 'Content-Type': 'application/json' + }) + + # Sent to jwcrypto as https://github.com/latchset/jwcrypto/pull/20 + keys = [] + for key in self.cfg.keyset: + keys.append(json.loads(key.export_public())) + return json.dumps({'keys': keys}) + Jwks.public_function = True + + def __call__(self, *args, **kwargs): + # We need to have this because it's impossible to have a function + # called .well-known + if len(args) == 2 and args == ('.well-known', 'openid-configuration'): + args = ('wellknown_openid_configuration', ) + + return super(OpenIDC, self).__call__(*args, **kwargs) diff --git a/ipsilon/providers/openidc/plugins/__init__.py b/ipsilon/providers/openidc/plugins/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/ipsilon/providers/openidc/plugins/__init__.py diff --git a/ipsilon/providers/openidc/plugins/common.py b/ipsilon/providers/openidc/plugins/common.py new file mode 100644 index 0000000..55f01c4 --- /dev/null +++ b/ipsilon/providers/openidc/plugins/common.py @@ -0,0 +1,69 @@ +# Copyright (C) 2016 Ipsilon project Contributors, for license see COPYING + +from __future__ import absolute_import + +from ipsilon.util.plugin import PluginLoader +from ipsilon.util.log import Log + + +class OpenidCExtensionBase(Log): + + def __init__(self, provider, name, display_name, scopes): + self.name = name + self.display_name = display_name + # A mapping of scope to display string for supported scopes + self.scopes = scopes + self.enabled = False + self.provider = None + + def get_scopes(self): + if not self.enabled: + return [] + + return self.scopes.keys() + + def get_display_name(self): + return self.display_name + + def get_display_data(self, scopes): + if not self.enabled: + return {} + + display_data = {} + for scope in scopes: + if scope in self.scopes: + display_data[scope] = self.scopes[scope] + return display_data + + def enable(self, provider): + self.enabled = True + self.provider = provider + + def disable(self): + self.enabled = False + self.provider = None + + +FACILITY = 'openidc_extensions' + + +class LoadExtensions(Log): + + def __init__(self): + self.plugins = PluginLoader(LoadExtensions, + FACILITY, 'OpenidCExtension', False) + self.plugins.get_plugin_data() + + available = self.plugins.available.keys() + self.debug('Available Extensions: %s' % str(available)) + + def enable(self, enabled, provider): + for item in enabled: + if item not in self.plugins.available: + self.debug('<%s> not available' % item) + continue + self.debug('Enable OpenId Connect extension: %s' % item) + self.plugins.available[item].enable(provider) + + def available(self): + return self.plugins.available diff --git a/ipsilon/providers/openidc/plugins/ipsilon.py b/ipsilon/providers/openidc/plugins/ipsilon.py new file mode 100644 index 0000000..377380d --- /dev/null +++ b/ipsilon/providers/openidc/plugins/ipsilon.py @@ -0,0 +1,20 @@ +# Copyright (C) 2016 Ipsilon project Contributors, for license see COPYING + +from __future__ import absolute_import + +from ipsilon.providers.openidc.plugins.common import OpenidCExtensionBase + + +class OpenidCExtension(OpenidCExtensionBase): + + def __init__(self, provider, *pargs): + name = 'ipsilon' + display_name = 'Ipsilon Token API' + scopes = { + 'ipsilon_token': 'Ipsilon token verification' + } + + super(OpenidCExtension, self).__init__(provider, + name, + display_name, + scopes) diff --git a/ipsilon/providers/openidc/store.py b/ipsilon/providers/openidc/store.py new file mode 100644 index 0000000..491a271 --- /dev/null +++ b/ipsilon/providers/openidc/store.py @@ -0,0 +1,297 @@ +# Copyright (C) 2016 Ipsilon project Contributors, for license see COPYING + +from ipsilon.util.security import (generate_random_secure_string, + constant_time_string_comparison) +from ipsilon.util.data import Store, UNIQUE_DATA_TABLE + +import json +import time + + +class OpenIDCStore(Store): + def __init__(self, database_url): + Store.__init__(self, database_url=database_url) + + def registerDynamicClient(self, client): + data = {} + + for key in client: + data[key] = json.dumps(client[key]) + + client_id = self.new_unique_data('client', data) + + # Prepend client ID with D- to indicate that this is a dynamic client + return 'D-%s' % client_id + + def registerStaticClient(self, client): + # TODO: Implement static client + + client_id = None + + # Prepend client ID with S- to indicate that this is a static client + return 'S-%s' % client_id + + def getClient(self, client_id): + if client_id.startswith('D-'): + # This is a dynamically registered client + client_id = client_id[2:] + data = self.get_unique_data('client', client_id) + elif client_id.startswith('S-'): + # This is a statically configured client + client_id = client_id[2:] + # TODO: Get the configured client data + return None + else: + # No idea what this is + self.debug('Invalid client ID request: %s' % client_id) + return None + + if len(data) < 1: + return None + + datum = data[client_id] + + for key in datum: + datum[key] = json.loads(datum[key]) + + return datum + + def lookupToken(self, token, expected_type, return_expired=False): + if '_' not in token: + return None + + checkfield = 'security_check' + if expected_type == 'Refresh' and token.startswith('R_'): + checkfield = 'refresh_security_check' + token = token[len('R_'):] + + token_id, security_check = token.split('_', 1) + + data = self.get_unique_data('token', token_id) + + if len(data) < 1: + return None + + datum = data[token_id] + + if not constant_time_string_comparison(security_check, + datum[checkfield]): + return None + + if not return_expired and \ + datum['expires_at'] <= int(time.time()): + return None + + if expected_type and expected_type != 'Refresh' and \ + datum['type'] != expected_type: + return None + + datum['scope'] = json.loads(datum['scope']) + datum['token_id'] = token_id + + return datum + + def storeAuthorizationIDToken(self, authz_code, signed_id_token): + token = self.lookupToken(authz_code, 'Authorization') + if not token: + return None + token['id_token'] = signed_id_token + self.update_token(token) + + def update_token(self, token): + token_id = token['token_id'] + del token['token_id'] + token['scope'] = json.dumps(token['scope']) + + self.save_unique_data('token', {token_id: token}) + + def refreshToken(self, refresh_token, client_id): + token = self.lookupToken(refresh_token, 'Refresh', True) + + if not token: + return None + + if not constant_time_string_comparison(token['client_id'], + client_id): + return None + + if token['type'] != 'Bearer': + # Only Bearer tokens are supported + return None + + if not token['refreshable']: + return None + + if token['refreshable_until'] and \ + token['refreshable_until'] >= int(time.time()): + return None + + token_security_check = generate_random_secure_string() + refresh_security_check = generate_random_secure_string(128) + expires_in = 3600 + # TODO: Figure out values for this + refreshable_until = None + + token['security_check'] = token_security_check + token['refresh_security_check'] = refresh_security_check + token['expires_at'] = int(time.time()) + expires_in + token['refreshable_until'] = refreshable_until + + self.update_token(token) + + token = '%s_%s' % (token['token_id'], token_security_check) + refresh_token = 'R_%s_%s' % (token['token_id'], refresh_security_check) + + return { + 'access_token': token, + 'refresh_token': refresh_token, + 'expires_in': expires_in + } + + def issueToken(self, client_id, username, scope, issue_refresh, + userinfocode): + token_security_check = generate_random_secure_string() + + expires_in = 3600 + + token = { + 'type': 'Bearer', + 'security_check': token_security_check, + 'client_id': client_id, + 'username': username, + 'scope': json.dumps(scope), + 'expires_at': int(time.time()) + expires_in, + 'issued_at': int(time.time()), + 'refreshable': False, + 'userinfocode': userinfocode + } + + if issue_refresh: + token['refreshable'] = True + # TODO: Figure out time for this + token['refreshable_until'] = None + token['refresh_security_check'] = \ + generate_random_secure_string(128) + + token_id = self.new_unique_data('token', token) + + # The refresh token also has a prefix of R_ to make it distinguishable + if issue_refresh: + refresh_token = 'R_%s_%s' % (token_id, + token['refresh_security_check']) + else: + refresh_token = None + + # The returned token is the token ID with appended to it the security + # check value. + # The token ID is used to lookup the token in the database, and the + # security check value is used to make the string slightly more + # random + token = '%s_%s' % (token_id, token_security_check) + + return { + "token_id": token_id, + 'access_token': token, + 'refresh_token': refresh_token, + 'expires_in': expires_in, + } + + def invalidateToken(self, token): + self.del_unique_data('token', token) + + def storeUserInfo(self, userinfo): + to_store = {} + for key in userinfo: + to_store[key] = json.dumps(userinfo[key]) + + return self.new_unique_data('userinfo', to_store) + + def getUserInfo(self, userinfocode): + data = self.get_unique_data('userinfo', userinfocode) + if len(data) < 1: + return None + + data = data[userinfocode] + + userinfo = {} + for key in data: + userinfo[key] = json.loads(data[key]) + return userinfo + + def exchangeAuthorizationCode(self, authz_code): + token = self.lookupToken(authz_code, 'Authorization') + if not token: + return None + + if 'issued_token' in token: + # This authorization code was already used before... We don't know + # whether this is a malfunctional client or if the authorization + # code got stolen, so let's just revoke the old key and refuse this + # request. + self.invalidateToken(token['issued_token']) + return None + + new_token = self.issueToken(token['client_id'], token['username'], + token['scope'], True, + token['userinfocode']) + if not new_token: + return None + + if 'id_token' in token: + new_token['id_token'] = token['id_token'] + + token['issued_token'] = new_token['token_id'] + del new_token['token_id'] + + self.update_token(token) + + return new_token + + def issueAuthorizationCode(self, client_id, username, scope, userinfo, + redirect_uri, userinfocode): + token_security_check = generate_random_secure_string() + + expires_in = 600 + + token = { + 'type': 'Authorization', + 'security_check': token_security_check, + 'client_id': client_id, + 'username': username, + 'scope': json.dumps(scope), + 'expires_at': int(time.time()) + expires_in, + 'userinfocode': userinfocode, + 'redirect_uri': redirect_uri + } + + token_id = self.new_unique_data('token', token) + + # The returned token is the token ID with appended to it the security + # check value. + # The token ID is used to lookup the token in the database, and the + # security check value is used to make the string slightly more + # random + token = '%s_%s' % (token_id, token_security_check) + + return token + + def _cleanup(self): + # TODO: Clean up any tokens with expiry <= time.time() + return 0 + + def _initialize_schema(self): + q = self._query(self._db, 'client', UNIQUE_DATA_TABLE, + trans=False) + q.create() + q._con.close() # pylint: disable=protected-access + q = self._query(self._db, 'token', UNIQUE_DATA_TABLE, + trans=False) + q.create() + q._con.close() # pylint: disable=protected-access + q = self._query(self._db, 'userinfo', UNIQUE_DATA_TABLE, + trans=False) + q.create() + q._con.close() # pylint: disable=protected-access + + def _upgrade_schema(self, old_version): + raise NotImplementedError() diff --git a/ipsilon/providers/openidcp.py b/ipsilon/providers/openidcp.py new file mode 100644 index 0000000..2d1b59a --- /dev/null +++ b/ipsilon/providers/openidcp.py @@ -0,0 +1,254 @@ +# Copyright (C) 2016 Ipsilon project Contributors, for license see COPYING + +from __future__ import absolute_import + +from ipsilon.providers.common import ProviderBase, ProviderInstaller +from ipsilon.providers.openidc.plugins.common import LoadExtensions +from ipsilon.providers.openidc.store import OpenIDCStore +from ipsilon.providers.openidc.auth import OpenIDC +from ipsilon.util.plugin import PluginObject +from ipsilon.util import config as pconfig +from ipsilon.info.common import InfoMapping + +import json +from jwcrypto.jwk import JWK, JWKSet +import os +import time +import uuid + + +class IdpProvider(ProviderBase): + + def __init__(self, *pargs): + super(IdpProvider, self).__init__('openidc', 'openidc', *pargs) + self.mapping = InfoMapping() + self.keyset = None + self.page = None + self.datastore = None + self.server = None + self.basepath = None + self.extensions = LoadExtensions() + self.description = """ +Provides OpenID Connect authentication infrastructure. """ + + self.new_config( + self.name, + pconfig.String( + 'database url', + 'Database URL for OpenID Connect storage', + 'openidc.sqlite'), + pconfig.Choice( + 'enabled extensions', + 'Choose the extensions to enable', + self.extensions.available().keys()), + pconfig.String( + 'endpoint url', + 'The Absolute URL of the OpenID Connect provider', + 'http://localhost:8080/idp/openidc/'), + pconfig.String( + 'documentation url', + 'The Absolute URL of the OpenID Connect documentation', + 'https://ipsilonproject.org/doc/openidc/'), + pconfig.String( + 'policy url', + 'The Absolute URL of the OpenID Connect policy', + 'http://www.example.com/'), + pconfig.String( + 'tos url', + 'The Absolute URL of the OpenID Connect terms of service', + 'http://www.example.com/'), + pconfig.String( + 'idp key file', + 'The file where the OpenIDC keyset is stored.', + 'openidc.key'), + pconfig.String( + 'idp sig key id', + 'The key to use for signing.', + ''), + pconfig.String( + 'idp subject salt', + 'The salt used for pairwise subjects.', + None), + pconfig.MappingList( + 'default attribute mapping', + 'Defines how to map attributes', + [['*', '*']]), + pconfig.ComplexList( + 'default allowed attributes', + 'Defines a list of allowed attributes, applied after mapping', + ['*']), + ) + + @property + def endpoint_url(self): + url = self.get_config_value('endpoint url') + if url.endswith('/'): + return url + else: + return url+'/' + + @property + def documentation_url(self): + url = self.get_config_value('documentation url') + if url.endswith('/'): + return url + else: + return url+'/' + + @property + def policy_url(self): + url = self.get_config_value('policy url') + if url.endswith('/'): + return url + else: + return url+'/' + + @property + def tos_url(self): + url = self.get_config_value('tos url') + if url.endswith('/'): + return url + else: + return url+'/' + + @property + def enabled_extensions(self): + return self.get_config_value('enabled extensions') + + @property + def idp_key_file(self): + return self.get_config_value('idp key file') + + @property + def idp_sig_key_id(self): + return self.get_config_value('idp sig key id') + + @property + def idp_subject_salt(self): + return self.get_config_value('idp subject salt') + + @property + def default_attribute_mapping(self): + return self.get_config_value('default attribute mapping') + + @property + def default_allowed_attributes(self): + return self.get_config_value('default allowed attributes') + + @property + def supported_scopes(self): + supported = ['openid'] + # Default scopes used in OpenID Connect claims + supported.extend(['profile', 'email', 'address', 'phone']) + for _, ext in self.extensions.available().items(): + supported.extend(ext.get_scopes()) + return supported + + def get_tree(self, site): + self.page = OpenIDC(site, self) + # self.admin = AdminPage(site, self) + + return self.page + + def used_datastores(self): + return [self.datastore] + + def init_idp(self): + self.keyset = JWKSet() + with open(self.idp_key_file, 'r') as keyfile: + loaded_keys = json.loads(keyfile.read()) + for key in loaded_keys['keys']: + self.keyset.add(JWK(**key)) + + self.datastore = OpenIDCStore(self.get_config_value('database url')) + + def openid_connect_issuer_wf_rel(self, resource): + link = { + 'rel': 'http://openid.net/specs/connect/1.0/issuer', + 'href': self.endpoint_url + } + return {'links': [link]} + + def on_enable(self): + super(IdpProvider, self).on_enable() + self.init_idp() + self.extensions.enable(self._config['enabled extensions'].get_value(), + self) + self._root.webfinger.register_rel( + 'http://openid.net/specs/connect/1.0/issuer', + self.openid_connect_issuer_wf_rel + ) + + def on_disable(self): + super(IdpProvider, self).on_enable() + self._root.webfinger.unregister_rel( + 'http://openid.net/specs/connect/1.0/issuer' + ) + + +class Installer(ProviderInstaller): + + def __init__(self, *pargs): + super(Installer, self).__init__() + self.name = 'openidc' + self.pargs = pargs + + def install_args(self, group): + group.add_argument('--openidc', choices=['yes', 'no'], default='yes', + help='Configure OpenID Connect Provider') + group.add_argument('--openidc-dburi', + help='OpenID Connect database URI') + group.add_argument('--openidc-subject-salt', default=None, + help='Salt to use for pairwise subject subjects') + group.add_argument('--openidc-extensions', default='', + help='List of OpenID Connect Extensions to enable') + + def configure(self, opts, changes): + if opts['openidc'] != 'yes': + return + + path = os.path.join(opts['data_dir'], 'openidc') + if not os.path.exists(path): + os.makedirs(path, 0700) + + keyfile = os.path.join(path, 'openidc.key') + keyid = int(time.time()) + keyset = JWKSet() + # We generate one RSA2048 signing key + rsasig = JWK(generate='RSA', size=2048, use='sig', + kid='%s-sig' % keyid) + keyset.add(rsasig) + # We generate one RSA2048 encryption key + rsasig = JWK(generate='RSA', size=2048, use='enc', + kid='%s-enc' % keyid) + keyset.add(rsasig) + + with open(keyfile, 'w') as m: + m.write(keyset.export()) + + proto = 'https' + url = '%s://%s/%s/openidc/' % ( + proto, opts['hostname'], opts['instance']) + + subject_salt = uuid.uuid4().hex + if opts['openidc_subject_salt']: + subject_salt = opts['openidc_subject_salt'] + + # Add configuration data to database + po = PluginObject(*self.pargs) + po.name = 'openidc' + po.wipe_data() + po.wipe_config_values() + config = {'endpoint url': url, + 'database url': opts['openidc_dburi'] or + opts['database_url'] % { + 'datadir': opts['data_dir'], 'dbname': 'openidc'}, + 'enabled extensions': opts['openidc_extensions'], + 'idp key file': keyfile, + 'idp sig key id': '%s-sig' % keyid, + 'idp subject salt': subject_salt} + po.save_plugin_config(config) + + # Update global config to add login plugin + po.is_enabled = True + po.save_enabled_state() diff --git a/ipsilon/util/security.py b/ipsilon/util/security.py new file mode 100644 index 0000000..5f5c38f --- /dev/null +++ b/ipsilon/util/security.py @@ -0,0 +1,13 @@ +# Copyright (C) 2016 Ipsilon project Contributors, for license see COPYING + +import base64 +from cryptography.hazmat.primitives.constant_time import bytes_eq +import os + + +def generate_random_secure_string(size=32): + return base64.urlsafe_b64encode(os.urandom(size))[:size] + + +def constant_time_string_comparison(stra, strb): + return bytes_eq(str(stra), str(strb)) diff --git a/quickrun.py b/quickrun.py index 27022cc..3189ba4 100755 --- a/quickrun.py +++ b/quickrun.py @@ -12,6 +12,8 @@ from datetime import timedelta from ipsilon.tools.certs import Certificate from ipsilon.providers.saml2idp import IdpMetadataGenerator +from jwcrypto.jwk import JWK, JWKSet + logger = None @@ -32,9 +34,13 @@ ADMIN_TEMPLATE=''' CREATE TABLE login_config (name TEXT,option TEXT,value TEXT); INSERT INTO login_config VALUES('global', 'enabled', 'testauth'); CREATE TABLE provider_config (name TEXT,option TEXT,value TEXT); -INSERT INTO provider_config VALUES('global', 'enabled', 'saml2'); +INSERT INTO provider_config VALUES('global', 'enabled', 'saml2,openidc'); INSERT INTO provider_config VALUES('saml2', 'idp storage path', '${workdir}/saml2'); +INSERT INTO provider_config VALUES('openidc', 'idp key file', + '${workdir}/openidc.key'); +INSERT INTO provider_config VALUES('openidc', 'idp sig key id', + 'quickstart'); ''' USERS_TEMPLATE=''' @@ -97,6 +103,16 @@ def init(workdir): timedelta(validity)) meta.output(os.path.join(workdir, 'saml2', 'metadata.xml')) + # Also initalize OpenID Connect + keyfile = os.path.join(workdir, 'openidc.key') + keyset = JWKSet() + # We generate one RSA2048 signing key + rsasig = JWK(generate='RSA', size=2048, use='sig', kid='quickstart') + keyset.add(rsasig) + with open(keyfile, 'w') as m: + m.write(keyset.export()) + + if __name__ == '__main__': args = parse_args() diff --git a/templates/install/idp.conf b/templates/install/idp.conf index 83cf2a6..7e41456 100644 --- a/templates/install/idp.conf +++ b/templates/install/idp.conf @@ -5,6 +5,8 @@ Redirect /${instance}/.well-known/webfinger /${instance}/webfinger WSGIScriptAlias /${instance} ${ipsilondir}/ipsilon WSGIDaemonProcess ${instance} user=${sysuser} group=${sysuser} home=${datadir} display-name=ipsilon-${instance} +# This header is required to be passed for OIDC client_secret_basic +WSGIPassAuthorization On ${wsgi_socket} diff --git a/templates/openidc/consent_form.html b/templates/openidc/consent_form.html new file mode 100644 index 0000000..9879d58 --- /dev/null +++ b/templates/openidc/consent_form.html @@ -0,0 +1,58 @@ +{% extends "master.html" %} +{% block main %} + +
+

The OpenID Connect client +{%- if client['homepage'] %} + {{ client['name'] }} +{% else %} + {{ client['name'] }} +{%- endif %} + is asking + to authorize access for {{ username }}.

+

Please review the authorization details

+ +{%- if client['policy'] %} +

Client privacy policy

+{% endif %} +{%- if client['tos'] %} +

Client terms of service

+{%- endif %} +
+ + + +{% endblock %} diff --git a/templates/openidc/form_response.html b/templates/openidc/form_response.html new file mode 100644 index 0000000..b70dfb9 --- /dev/null +++ b/templates/openidc/form_response.html @@ -0,0 +1,23 @@ +{% extends "master.html" %} +{% block main %} +
+

If you are not redirected automatically, please click Continue.

+
+ + + + +{% endblock %} diff --git a/tests/pgdb.py b/tests/pgdb.py index 235e47e..b6b5481 100755 --- a/tests/pgdb.py +++ b/tests/pgdb.py @@ -29,6 +29,7 @@ idp_a = {'hostname': '${ADDRESS}:${PORT}', 'system_user': '${TEST_USER}', 'instance': '${NAME}', 'openid': 'False', + 'openidc': 'False', 'testauth': 'yes', 'pam': 'no', 'gssapi': 'no', From 416dbc2c4e796a50a2726b8155d0dddb297e61cd Mon Sep 17 00:00:00 2001 From: Patrick Uiterwijk Date: May 09 2016 15:15:34 +0000 Subject: [PATCH 3/4] Add OpenID Connect support to ipsilon-client-install Ticket: #93 Signed-off-by: Patrick Uiterwijk Reviewed-by: Rob Crittenden --- diff --git a/README.md b/README.md index be751ac..fa85224 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ Prerequisites: The default configuration for the client will install a configuration in Apache that will authenticate via the IdP any attempt to connect to the location named -'/saml2protected', a test file is returned at that location. +'/protected', a test file is returned at that location. In order to successfully install a client 2 steps are necessary: diff --git a/doc/saml-integration.rst b/doc/saml-integration.rst index ec3404d..b23ba14 100644 --- a/doc/saml-integration.rst +++ b/doc/saml-integration.rst @@ -212,7 +212,7 @@ auth beneath the base URI. Accessing this URI will trigger the authentication flow described above. The browser will then return to this URI upon successful authentication. This should typically be set to the "Log In" URI - of your web application. It defaults to ``/saml2protected``, but it can be + of your web application. It defaults to ``/protected``, but it can be set with the ``--saml-auth`` option. endpoint diff --git a/ipsilon/install/ipsilon-client-install b/ipsilon/install/ipsilon-client-install index 44c3df3..1f064d7 100755 --- a/ipsilon/install/ipsilon-client-install +++ b/ipsilon/install/ipsilon-client-install @@ -24,9 +24,10 @@ import base64 HTTPDCONFD = '/etc/httpd/conf.d' SAML2_TEMPLATE = '/usr/share/ipsilon/templates/install/saml2/sp.conf' -SAML2_CONFFILE = '/etc/httpd/conf.d/ipsilon-saml.conf' -SAML2_HTTPDIR = '/etc/httpd/saml2' -SAML2_PROTECTED = '/saml2protected' +OPENIDC_TEMPLATE = '/usr/share/ipsilon/templates/install/openidc/rp.conf' +CONFFILE = '/etc/httpd/conf.d/ipsilon-%s.conf' +HTTPDIR = '/etc/httpd/%s' +PROTECTED = '/protected' #Installation arguments args = dict() @@ -73,7 +74,7 @@ def saml2(): path = None if not args['saml_no_httpd']: - path = os.path.join(SAML2_HTTPDIR, args['hostname']) + path = os.path.join(HTTPDIR % 'saml2', args['hostname']) if os.path.exists(path): raise Exception('Service Provider is already configured') os.makedirs(path, 0750) @@ -153,7 +154,7 @@ def saml2(): logger.error("Failed to read SP Image file!\n" + "Error: [%s]" % e) - sp_link = 'https://%s%s' % (args['hostname'], args['saml_auth']) + sp_link = 'https://%s%s' % (args['hostname'], args['auth_location']) # Register the SP try: @@ -173,15 +174,15 @@ def saml2(): saml_protect = 'auth' saml_auth='' - if args['saml_base'] != args['saml_auth']: + if args['saml_base'] != args['auth_location']: saml_protect = 'info' saml_auth = '\n' \ ' MellonEnable "auth"\n' \ ' Header append Cache-Control "no-cache"\n' \ - '\n' % args['saml_auth'] + '
\n' % args['auth_location'] psp = '# ' - if args['saml_auth'] == SAML2_PROTECTED: + if args['auth_location'] == PROTECTED: # default location, enable the default page psp = '' @@ -213,13 +214,13 @@ def saml2(): 'sp_hostname': args['hostname'], 'sp_port': port_str, 'sp': psp} - files.write_from_template(SAML2_CONFFILE, SAML2_TEMPLATE, samlopts) + files.write_from_template(CONFFILE % 'saml', SAML2_TEMPLATE, samlopts) - files.fix_user_dirs(SAML2_HTTPDIR, args['httpd_user']) + files.fix_user_dirs(HTTPDIR % 'saml2', args['httpd_user']) logger.info('SAML Service Provider configured.') logger.info('You should be able to restart the HTTPD server and' + - ' then access it at %s%s' % (url, args['saml_auth'])) + ' then access it at %s%s' % (url, args['auth_location'])) else: logger.info('SAML Service Provider configuration ready.') logger.info('Use the certificate, key and metadata.xml files to' + @@ -264,33 +265,200 @@ def saml2_register_sp(url, user, password, sp_name, sp_metadata, raise Exception('%s' % message) -def install(): - if args['saml']: - saml2() - - def saml2_uninstall(): - path = os.path.join(SAML2_HTTPDIR, args['hostname']) + path = os.path.join(HTTPDIR % 'saml2', args['hostname']) if os.path.exists(path): try: shutil.rmtree(path) except Exception, e: # pylint: disable=broad-except log_exception(e) - if os.path.exists(SAML2_CONFFILE): + if os.path.exists(CONFFILE % 'saml'): try: - os.remove(SAML2_CONFFILE) + os.remove(CONFFILE % 'saml') except Exception, e: # pylint: disable=broad-except log_exception(e) -def uninstall(): - logger.info('Uninstalling Service Provider') - #FXIME: ask confirmation - saml2_uninstall() - logger.info('Uninstalled SAML2 data') +def saml2_add_arguments(parser): + parser.add_argument('--saml', action='store_true', + help="Whether to install a saml2 SP") + parser.add_argument('--saml-idp-url', default=None, + help="A URL of the IDP to register the SP with") + parser.add_argument('--saml-idp-metadata', default=None, + help="A URL pointing at the IDP Metadata (FILE or HTTP)") + parser.add_argument('--saml-no-httpd', action='store_true', default=False, + help="Do not configure httpd") + parser.add_argument('--saml-base', default='/', + help="Where saml2 authdata is available") + parser.add_argument('--saml-sp', default='/saml2', + help="Where saml communication happens") + parser.add_argument('--saml-sp-logout', default=None, + help="Single Logout URL") + parser.add_argument('--saml-sp-post', default=None, + help="Post response URL") + parser.add_argument('--saml-sp-paos', default=None, + help="PAOS response URL, used for ECP") + parser.add_argument('--no-saml-soap-logout', action='store_true', + default=False, + help="Disable Single Logout over SOAP") + parser.add_argument('--saml-secure-setup', action='store_true', + default=True, help="Turn on all security checks") + parser.add_argument('--saml-nameid', default='unspecified', + choices=SAML2_NAMEID_MAP.keys(), + help="SAML NameID format to use") + parser.add_argument('--saml-sp-name', default=None, + help="The SP name to register with the IdP") + parser.add_argument('--saml-sp-description', default=None, + help="The description of the SP to display on the " + + "portal") + parser.add_argument('--saml-sp-visible', action='store_false', + default=True, + help="The SP is visible in the portal") + parser.add_argument('--saml-sp-image', default=None, + help="Image to display for this SP on the portal") + parser.add_argument('--debug', action='store_true', default=False, + help="Turn on script debugging") + parser.add_argument('--config-profile', default=None, + help=argparse.SUPPRESS) + parser.add_argument('--saml-auth', default=None, + help="Backwards compatibility. Use --auth-location.") + + +def saml2_verify_arguments(args): + if args['saml_auth']: + logger.warn('--saml-auth is deprecated. Please use --auth-location') + args['auth_location'] = args['saml_auth'] + + # Validate that all path options begin with '/' + path_args = ['saml_base', 'auth_location', 'saml_sp', 'saml_sp_logout', + 'saml_sp_post', 'saml_sp_paos'] + for path_arg in path_args: + if args[path_arg] is not None and not args[path_arg].startswith('/'): + raise ValueError('--%s must begin with a / character.' % + path_arg.replace('_', '-')) + + # The saml_sp setting must be a subpath of saml_base since it is + # used as the MellonEndpointPath. + if not args['saml_sp'].startswith(args['saml_base']): + raise ValueError('--saml-sp must be a subpath of --saml-base.') + + # The samle_auth setting must be a subpath of saml_base otherwise + # the IdP cannot be identified by mod_auth_mellon. + if not args['auth_location'].startswith(args['saml_base']): + raise ValueError('--auth-location must be a subpath of --saml-base.') + + # The saml_sp_logout, saml_sp_post and saml_sp_paos settings must + # be subpaths of saml_sp (the mellon endpoint). + path_args = {'saml_sp_logout': 'logout', + 'saml_sp_post': 'postResponse', + 'saml_sp_paos': 'paosResponse'} + for path_arg, default_path in path_args.items(): + if args[path_arg] is None: + args[path_arg] = '%s/%s' % (args['saml_sp'].rstrip('/'), + default_path) + + elif not args[path_arg].startswith(args['saml_sp']): + raise ValueError('--%s must be a subpath of --saml-sp' % + path_arg.replace('_', '-')) + + # If saml_idp_url if being used, we require saml_sp_name to + # use when registering the SP. + if args['saml_idp_url'] and not args['saml_sp_name']: + raise ValueError('--saml-sp-name must be specified when using' + + '--saml-idp-url') + + +# OpenID Connect +def openidc(): + logger.info('Installing OpenID Connect Relying Party') + + discovery_url = '%s/openidc/wellknown_openid_configuration' % \ + args['openidc_idp_url'] + try: + r = requests.get(discovery_url) + r.raise_for_status() + discovered_info = r.json() + except Exception, e: # pylint: disable=broad-except + logger.error("Failed to retrieve IdP configuration!\n" + + "Error: [%s]" % repr(e)) + raise + + if not 'registration_endpoint' in discovered_info: + raise ValueError('This IdP does not provide automatic registration') + + proto = 'https' + port_str = '' + if args['port']: + port_str = ':%s' % args['port'] + url = '%s://%s%s%s' % (proto, args['hostname'], port_str, + args['auth_location']) + redirect_uri = '%s/redirect_uri' % url + + # Generate client metadata + client_info = {} + client_info['redirect_uris'] = [redirect_uri] + client_info['response_types'] = ['code'] + client_info['grant_types'] = ['authorization_code'] + client_info['application_type'] = 'web' + client_info['client_name'] = 'Ipsilon Client %s' % url + client_info['client_uri'] = url + client_info['subject_type'] = args['openidc_subject_type'] + + # Submit client info + logger.info('Registering RP with the IdP') + try: + r = requests.post(discovered_info['registration_endpoint'], + json=client_info) + r.raise_for_status() + registration_response = r.json() + except Exception, e: # pylint: disable=broad-except + logger.error("Failed to register with the IdP!\n" + + "Error: [%s]" % repr(e)) + raise + validate_server = 'On' + if args['openidc_skip_ssl_validation']: + validate_server = 'Off' + + # Generate config + openidcopts = {'redirect_uri': redirect_uri, + 'crypto_passphrase': base64.b64encode(os.urandom(32))[:32], + 'idp_metadata_url': discovery_url, + 'client_id': registration_response['client_id'], + 'client_secret': registration_response['client_secret'], + 'validate_server': validate_server, + 'response_type': args['openidc_response_type'], + 'auth_location': args['auth_location']} + files.write_from_template(CONFFILE % 'openidc', OPENIDC_TEMPLATE, + openidcopts) + + logger.info('OpenID Connect Relying Party configured') + logger.info('You should be able to restart the HTTPD server and' + + ' then access it at %s%s' % (url, args['auth_location'])) + + +def openidc_verify_arguments(args): + if not args['openidc_idp_url']: + raise ValueError('OpenIDC IdP URL needs to be provided') + + +def openidc_add_arguments(parser): + parser.add_argument('--openidc', action='store_true', default=False, + help='Whether to install an OpenID Connect RP') + parser.add_argument('--openidc-idp-url', default=None, + help='A URL of the IdP to register the RP with') + parser.add_argument('--openidc-response-type', default='code', + help='Which response type to use, determines the flow') + parser.add_argument('--openidc-subject-type', default='pairwise', + help='Which subject type to request: pairwise or ' + + 'public') + parser.add_argument('--openidc-skip-ssl-validation', action='store_true', + help='Whether to skip validating the IdP SSL cert') + + +# Global def log_exception(e): if 'debug' in args and args['debug']: logger.exception(e) @@ -353,51 +521,14 @@ def parse_args(): "used to create a SP (- to read from stdin)") parser.add_argument('--httpd-user', default='apache', help="Web server account used to read certs") - parser.add_argument('--saml', action='store_true', default=True, - help="Whether to install a saml2 SP") - parser.add_argument('--saml-idp-url', default=None, - help="A URL of the IDP to register the SP with") - parser.add_argument('--saml-idp-metadata', default=None, - help="A URL pointing at the IDP Metadata (FILE or HTTP)") - parser.add_argument('--saml-no-httpd', action='store_true', default=False, - help="Do not configure httpd") - parser.add_argument('--saml-base', default='/', - help="Where saml2 authdata is available") - parser.add_argument('--saml-auth', default=SAML2_PROTECTED, - help="Where saml2 authentication is enforced") - parser.add_argument('--saml-sp', default='/saml2', - help="Where saml communication happens") - parser.add_argument('--saml-sp-logout', default=None, - help="Single Logout URL") - parser.add_argument('--saml-sp-post', default=None, - help="Post response URL") - parser.add_argument('--saml-sp-paos', default=None, - help="PAOS response URL, used for ECP") - parser.add_argument('--no-saml-soap-logout', action='store_true', - default=False, - help="Disable Single Logout over SOAP") - parser.add_argument('--saml-secure-setup', action='store_true', - default=True, help="Turn on all security checks") - parser.add_argument('--saml-nameid', default='unspecified', - choices=SAML2_NAMEID_MAP.keys(), - help="SAML NameID format to use") - parser.add_argument('--saml-sp-name', default=None, - help="The SP name to register with the IdP") - parser.add_argument('--saml-sp-description', default=None, - help="The description of the SP to display on the " + - "portal") - parser.add_argument('--saml-sp-visible', action='store_false', - default=True, - help="The SP is visible in the portal") - parser.add_argument('--saml-sp-image', default=None, - help="Image to display for this SP on the portal") - parser.add_argument('--debug', action='store_true', default=False, - help="Turn on script debugging") - parser.add_argument('--config-profile', default=None, - help=argparse.SUPPRESS) - parser.add_argument('--uninstall', action='store_true', + parser.add_argument('--auth-location', default=PROTECTED, + help="Where authentication is enforced") + parser.add_argument('--uninstall', action='store_true', default=False, help="Uninstall the server and all data") + openidc_add_arguments(parser) + saml2_add_arguments(parser) + args = vars(parser.parse_args()) if args['config_profile']: @@ -409,64 +540,51 @@ def parse_args(): if args['port'] and not args['port'].isdigit(): raise ValueError('Port number: %s is not an integer.' % args['port']) - # Validate that all path options begin with '/' - path_args = ['saml_base', 'saml_auth', 'saml_sp', 'saml_sp_logout', - 'saml_sp_post', 'saml_sp_paos'] - for path_arg in path_args: - if args[path_arg] is not None and not args[path_arg].startswith('/'): - raise ValueError('--%s must begin with a / character.' % - path_arg.replace('_', '-')) + # Exactly one on this list needs to be specified or we do nothing + sp_list = ['saml', 'openidc'] + service_type = None + for sp in sp_list: + if args[sp]: + if service_type: + raise ValueError('Multiple service types selected') + service_type = sp - # The saml_sp setting must be a subpath of saml_base since it is - # used as the MellonEndpointPath. - if not args['saml_sp'].startswith(args['saml_base']): - raise ValueError('--saml-sp must be a subpath of --saml-base.') + if not service_type: + # Since this was our default previously, let's be backwards compatible + # and default to SAML2 + args['saml'] = True + service_type = 'saml' - # The samle_auth setting must be a subpath of saml_base otherwise - # the IdP cannot be identified by mod_auth_mellon. - if not args['saml_auth'].startswith(args['saml_base']): - raise ValueError('--saml-auth must be a subpath of --saml-base.') + if service_type == 'saml': + saml2_verify_arguments(args) - # The saml_sp_logout, saml_sp_post and saml_sp_paos settings must - # be subpaths of saml_sp (the mellon endpoint). - path_args = {'saml_sp_logout': 'logout', - 'saml_sp_post': 'postResponse', - 'saml_sp_paos': 'paosResponse'} - for path_arg, default_path in path_args.items(): - if args[path_arg] is None: - args[path_arg] = '%s/%s' % (args['saml_sp'].rstrip('/'), - default_path) + elif service_type == 'openidc': + openidc_verify_arguments(args) - elif not args[path_arg].startswith(args['saml_sp']): - raise ValueError('--%s must be a subpath of --saml-sp' % - path_arg.replace('_', '-')) - - # If saml_idp_url if being used, we require saml_sp_name to - # use when registering the SP. - if args['saml_idp_url'] and not args['saml_sp_name']: - raise ValueError('--saml-sp-name must be specified when using' + - '--saml-idp-url') - - # At least one on this list needs to be specified or we do nothing - sp_list = ['saml'] - present = False - for sp in sp_list: - if args[sp]: - present = True - if not present and not args['uninstall']: - raise ValueError('Nothing to install, please select a Service type.') + return service_type if __name__ == '__main__': out = 0 openlogs() try: - parse_args() + service_type = parse_args() if 'uninstall' in args and args['uninstall'] is True: - uninstall() + logger.info('Uninstalling Service Provider') + #FXIME: ask confirmation + + if service_type == 'saml': + saml2_uninstall() + elif service_type == 'openidc': + openidc_uninstall() + + logger.info('Uninstalled Service Provider') else: - install() + if service_type == 'saml': + saml2() + elif service_type == 'openidc': + openidc() except Exception, e: # pylint: disable=broad-except log_exception(e) if 'uninstall' in args and args['uninstall'] is True: diff --git a/man/ipsilon-client-install.1 b/man/ipsilon-client-install.1 index 8259283..e1df9aa 100644 --- a/man/ipsilon-client-install.1 +++ b/man/ipsilon-client-install.1 @@ -47,7 +47,7 @@ Do not configure httpd. The default is False. Where saml2 authdata is available (default: /) .TP \fB\-\-saml\-auth\fR \fISAML_AUTH\fR -Where saml2 authentication is enforced. The default is /saml2protected. This only applies when configuring Apache. +Where saml2 authentication is enforced. The default is /protected. This only applies when configuring Apache. .TP \fB\-\-saml\-sp\fR \fISAML_SP\fR Where saml communication happens. The default is /saml2. diff --git a/templates/install/openidc/rp.conf b/templates/install/openidc/rp.conf new file mode 100644 index 0000000..077ca09 --- /dev/null +++ b/templates/install/openidc/rp.conf @@ -0,0 +1,12 @@ +OIDCRedirectURI "${redirect_uri}" +OIDCCryptoPassphrase "${crypto_passphrase}" +OIDCProviderMetadataURL "${idp_metadata_url}" +OIDCClientID "${client_id}" +OIDCClientSecret "${client_secret}" +OIDCSSLValidateServer ${validate_server} +OIDCResponseType "${response_type}" + + + AuthType openid-connect + Require valid-user + diff --git a/templates/install/saml2/sp.conf b/templates/install/saml2/sp.conf index 2bb5134..3ea4ca5 100644 --- a/templates/install/saml2/sp.conf +++ b/templates/install/saml2/sp.conf @@ -26,7 +26,7 @@ ${saml_auth} -${sp}Alias /saml2protected /usr/share/ipsilon/ui/saml2sp +${sp}Alias /protected /usr/share/ipsilon/ui/saml2sp ${sp} ${sp} ${sp} diff --git a/tests/attrs.py b/tests/attrs.py index 76cc0c5..929ef66 100755 --- a/tests/attrs.py +++ b/tests/attrs.py @@ -33,8 +33,8 @@ idp_a = {'hostname': '${ADDRESS}:${PORT}', sp_g = {'HTTPDCONFD': '${TESTDIR}/${NAME}/conf.d', 'SAML2_TEMPLATE': '${TESTDIR}/templates/install/saml2/sp.conf', - 'SAML2_CONFFILE': '${TESTDIR}/${NAME}/conf.d/ipsilon-saml.conf', - 'SAML2_HTTPDIR': '${TESTDIR}/${NAME}/saml2'} + 'CONFFILE': '${TESTDIR}/${NAME}/conf.d/ipsilon-%s.conf', + 'HTTPDIR': '${TESTDIR}/${NAME}/%s'} sp_a = {'hostname': '${ADDRESS}', diff --git a/tests/fconf.py b/tests/fconf.py index 1b68a19..305a6cf 100755 --- a/tests/fconf.py +++ b/tests/fconf.py @@ -61,8 +61,8 @@ saml2 idp nameid salt = ${IDPSALT} sp_g = {'HTTPDCONFD': '${TESTDIR}/${NAME}/conf.d', 'SAML2_TEMPLATE': '${TESTDIR}/templates/install/saml2/sp.conf', - 'SAML2_CONFFILE': '${TESTDIR}/${NAME}/conf.d/ipsilon-saml.conf', - 'SAML2_HTTPDIR': '${TESTDIR}/${NAME}/saml2'} + 'CONFFILE': '${TESTDIR}/${NAME}/conf.d/ipsilon-%s.conf', + 'HTTPDIR': '${TESTDIR}/${NAME}/%s'} sp_a = {'hostname': '${ADDRESS}', diff --git a/tests/ldap.py b/tests/ldap.py index db3dcc2..9942dfc 100755 --- a/tests/ldap.py +++ b/tests/ldap.py @@ -36,8 +36,8 @@ idp_a = {'hostname': '${ADDRESS}:${PORT}', sp_g = {'HTTPDCONFD': '${TESTDIR}/${NAME}/conf.d', 'SAML2_TEMPLATE': '${TESTDIR}/templates/install/saml2/sp.conf', - 'SAML2_CONFFILE': '${TESTDIR}/${NAME}/conf.d/ipsilon-saml.conf', - 'SAML2_HTTPDIR': '${TESTDIR}/${NAME}/saml2'} + 'CONFFILE': '${TESTDIR}/${NAME}/conf.d/ipsilon-%s.conf', + 'HTTPDIR': '${TESTDIR}/${NAME}/%s'} sp_a = {'hostname': '${ADDRESS}', diff --git a/tests/ldapdown.py b/tests/ldapdown.py index 52b0f3b..4522f45 100755 --- a/tests/ldapdown.py +++ b/tests/ldapdown.py @@ -38,8 +38,8 @@ idp_a = {'hostname': '${ADDRESS}:${PORT}', sp_g = {'HTTPDCONFD': '${TESTDIR}/${NAME}/conf.d', 'SAML2_TEMPLATE': '${TESTDIR}/templates/install/saml2/sp.conf', - 'SAML2_CONFFILE': '${TESTDIR}/${NAME}/conf.d/ipsilon-saml.conf', - 'SAML2_HTTPDIR': '${TESTDIR}/${NAME}/saml2'} + 'CONFFILE': '${TESTDIR}/${NAME}/conf.d/ipsilon-%s.conf', + 'HTTPDIR': '${TESTDIR}/${NAME}/%s'} sp_a = {'hostname': '${ADDRESS}', diff --git a/tests/pgdb.py b/tests/pgdb.py index b6b5481..27a2b23 100755 --- a/tests/pgdb.py +++ b/tests/pgdb.py @@ -39,8 +39,8 @@ idp_a = {'hostname': '${ADDRESS}:${PORT}', sp_g = {'HTTPDCONFD': '${TESTDIR}/${NAME}/conf.d', 'SAML2_TEMPLATE': '${TESTDIR}/templates/install/saml2/sp.conf', - 'SAML2_CONFFILE': '${TESTDIR}/${NAME}/conf.d/ipsilon-saml.conf', - 'SAML2_HTTPDIR': '${TESTDIR}/${NAME}/saml2'} + 'CONFFILE': '${TESTDIR}/${NAME}/conf.d/ipsilon-%s.conf', + 'HTTPDIR': '${TESTDIR}/${NAME}/%s'} sp_a = {'hostname': '${ADDRESS}', diff --git a/tests/test1.py b/tests/test1.py index 3519706..5858dca 100755 --- a/tests/test1.py +++ b/tests/test1.py @@ -32,8 +32,8 @@ idp_a = {'hostname': '${ADDRESS}:${PORT}', sp_g = {'HTTPDCONFD': '${TESTDIR}/${NAME}/conf.d', 'SAML2_TEMPLATE': '${TESTDIR}/templates/install/saml2/sp.conf', - 'SAML2_CONFFILE': '${TESTDIR}/${NAME}/conf.d/ipsilon-saml.conf', - 'SAML2_HTTPDIR': '${TESTDIR}/${NAME}/saml2'} + 'CONFFILE': '${TESTDIR}/${NAME}/conf.d/ipsilon-%s.conf', + 'HTTPDIR': '${TESTDIR}/${NAME}/%s'} sp_a = {'hostname': '${ADDRESS}', @@ -43,8 +43,8 @@ sp_a = {'hostname': '${ADDRESS}', sp2_g = {'HTTPDCONFD': '${TESTDIR}/${NAME}/conf.d', 'SAML2_TEMPLATE': '${TESTDIR}/templates/install/saml2/sp.conf', - 'SAML2_CONFFILE': '${TESTDIR}/${NAME}/conf.d/ipsilon-saml.conf', - 'SAML2_HTTPDIR': '${TESTDIR}/${NAME}/saml2'} + 'CONFFILE': '${TESTDIR}/${NAME}/conf.d/ipsilon-%s.conf', + 'HTTPDIR': '${TESTDIR}/${NAME}/%s'} sp2_a = {'hostname': '${ADDRESS}', 'saml_idp_url': 'https://127.0.0.10:45080/idp1', diff --git a/tests/testgssapi.py b/tests/testgssapi.py index 24b978b..3e3abea 100755 --- a/tests/testgssapi.py +++ b/tests/testgssapi.py @@ -34,8 +34,8 @@ idp_a = {'hostname': '${ADDRESS}:${PORT}', sp_g = {'HTTPDCONFD': '${TESTDIR}/${NAME}/conf.d', 'SAML2_TEMPLATE': '${TESTDIR}/templates/install/saml2/sp.conf', - 'SAML2_CONFFILE': '${TESTDIR}/${NAME}/conf.d/ipsilon-saml.conf', - 'SAML2_HTTPDIR': '${TESTDIR}/${NAME}/saml2'} + 'CONFFILE': '${TESTDIR}/${NAME}/conf.d/ipsilon-%s.conf', + 'HTTPDIR': '${TESTDIR}/${NAME}/%s'} sp_a = {'hostname': '${ADDRESS}', @@ -46,8 +46,8 @@ sp_a = {'hostname': '${ADDRESS}', sp2_g = {'HTTPDCONFD': '${TESTDIR}/${NAME}/conf.d', 'SAML2_TEMPLATE': '${TESTDIR}/templates/install/saml2/sp.conf', - 'SAML2_CONFFILE': '${TESTDIR}/${NAME}/conf.d/ipsilon-saml.conf', - 'SAML2_HTTPDIR': '${TESTDIR}/${NAME}/saml2'} + 'CONFFILE': '${TESTDIR}/${NAME}/conf.d/ipsilon-%s.conf', + 'HTTPDIR': '${TESTDIR}/${NAME}/%s'} sp2_a = {'hostname': '${ADDRESS}', 'saml_idp_url': 'https://idp.ipsilon.dev:45080/idp1', diff --git a/tests/testlogout.py b/tests/testlogout.py index 63115d3..f956bd2 100755 --- a/tests/testlogout.py +++ b/tests/testlogout.py @@ -33,8 +33,8 @@ idp_a = {'hostname': '${ADDRESS}:${PORT}', sp_g = {'HTTPDCONFD': '${TESTDIR}/${NAME}/conf.d', 'SAML2_TEMPLATE': '${TESTDIR}/templates/install/saml2/sp.conf', - 'SAML2_CONFFILE': '${TESTDIR}/${NAME}/conf.d/ipsilon-saml.conf', - 'SAML2_HTTPDIR': '${TESTDIR}/${NAME}/saml2'} + 'CONFFILE': '${TESTDIR}/${NAME}/conf.d/ipsilon-%s.conf', + 'HTTPDIR': '${TESTDIR}/${NAME}/%s'} sp_a = {'hostname': '${ADDRESS}', diff --git a/tests/testmapping.py b/tests/testmapping.py index 7c450a8..1fb38e0 100755 --- a/tests/testmapping.py +++ b/tests/testmapping.py @@ -36,8 +36,8 @@ idp_a = {'hostname': '${ADDRESS}:${PORT}', sp_g = {'HTTPDCONFD': '${TESTDIR}/${NAME}/conf.d', 'SAML2_TEMPLATE': '${TESTDIR}/templates/install/saml2/sp.conf', - 'SAML2_CONFFILE': '${TESTDIR}/${NAME}/conf.d/ipsilon-saml.conf', - 'SAML2_HTTPDIR': '${TESTDIR}/${NAME}/saml2'} + 'CONFFILE': '${TESTDIR}/${NAME}/conf.d/ipsilon-%s.conf', + 'HTTPDIR': '${TESTDIR}/${NAME}/%s'} sp_a = {'hostname': '${ADDRESS}', diff --git a/tests/testnameid.py b/tests/testnameid.py index d2d15d5..22beed5 100755 --- a/tests/testnameid.py +++ b/tests/testnameid.py @@ -38,8 +38,8 @@ idp_a = {'hostname': '${ADDRESS}:${PORT}', sp_g = {'HTTPDCONFD': '${TESTDIR}/${NAME}/conf.d', 'SAML2_TEMPLATE': '${TESTDIR}/templates/install/saml2/sp.conf', - 'SAML2_CONFFILE': '${TESTDIR}/${NAME}/conf.d/ipsilon-saml.conf', - 'SAML2_HTTPDIR': '${TESTDIR}/${NAME}/saml2'} + 'CONFFILE': '${TESTDIR}/${NAME}/conf.d/ipsilon-%s.conf', + 'HTTPDIR': '${TESTDIR}/${NAME}/%s'} sp_a = {'hostname': '${ADDRESS}', diff --git a/tests/testrest.py b/tests/testrest.py index 94850a6..1218981 100755 --- a/tests/testrest.py +++ b/tests/testrest.py @@ -33,8 +33,8 @@ idp_a = {'hostname': '${ADDRESS}:${PORT}', sp_g = {'HTTPDCONFD': '${TESTDIR}/${NAME}/conf.d', 'SAML2_TEMPLATE': '${TESTDIR}/templates/install/saml2/sp.conf', - 'SAML2_CONFFILE': '${TESTDIR}/${NAME}/conf.d/ipsilon-saml.conf', - 'SAML2_HTTPDIR': '${TESTDIR}/${NAME}/saml2'} + 'CONFFILE': '${TESTDIR}/${NAME}/conf.d/ipsilon-%s.conf', + 'HTTPDIR': '${TESTDIR}/${NAME}/%s'} sp_a = {'hostname': '${ADDRESS}', @@ -45,8 +45,8 @@ sp_a = {'hostname': '${ADDRESS}', sp2_g = {'HTTPDCONFD': '${TESTDIR}/${NAME}/conf.d', 'SAML2_TEMPLATE': '${TESTDIR}/templates/install/saml2/sp.conf', - 'SAML2_CONFFILE': '${TESTDIR}/${NAME}/conf.d/ipsilon-saml.conf', - 'SAML2_HTTPDIR': '${TESTDIR}/${NAME}/saml2'} + 'CONFFILE': '${TESTDIR}/${NAME}/conf.d/ipsilon-%s.conf', + 'HTTPDIR': '${TESTDIR}/${NAME}/%s'} sp2_a = {'hostname': '${ADDRESS}', @@ -56,8 +56,8 @@ sp2_a = {'hostname': '${ADDRESS}', sp3_g = {'HTTPDCONFD': '${TESTDIR}/${NAME}/conf.d', 'SAML2_TEMPLATE': '${TESTDIR}/templates/install/saml2/sp.conf', - 'SAML2_CONFFILE': '${TESTDIR}/${NAME}/conf.d/ipsilon-saml.conf', - 'SAML2_HTTPDIR': '${TESTDIR}/${NAME}/saml2'} + 'CONFFILE': '${TESTDIR}/${NAME}/conf.d/ipsilon-%s.conf', + 'HTTPDIR': '${TESTDIR}/${NAME}/%s'} sp3_a = {'hostname': '${ADDRESS}', diff --git a/tests/trans.py b/tests/trans.py index c883e39..4749eb8 100755 --- a/tests/trans.py +++ b/tests/trans.py @@ -33,8 +33,8 @@ idp_a = {'hostname': '${ADDRESS}:${PORT}', sp_g = {'HTTPDCONFD': '${TESTDIR}/${NAME}/conf.d', 'SAML2_TEMPLATE': '${TESTDIR}/templates/install/saml2/sp.conf', - 'SAML2_CONFFILE': '${TESTDIR}/${NAME}/conf.d/ipsilon-saml.conf', - 'SAML2_HTTPDIR': '${TESTDIR}/${NAME}/saml2'} + 'CONFFILE': '${TESTDIR}/${NAME}/conf.d/ipsilon-%s.conf', + 'HTTPDIR': '${TESTDIR}/${NAME}/%s'} sp_a = {'hostname': '${ADDRESS}', From abc7b3d1e6083b6375018cb81dd2ca7d8f8c82ab Mon Sep 17 00:00:00 2001 From: Patrick Uiterwijk Date: May 09 2016 15:15:40 +0000 Subject: [PATCH 4/4] OpenID Connect test suite This is an initial testsuite that tests OpenID Connect flows Reviewed-by: Rob Crittenden Ticket: #93 Signed-off-by: Patrick Uiterwijk --- diff --git a/Makefile b/Makefile index b90cd66..9f88a72 100644 --- a/Makefile +++ b/Makefile @@ -107,6 +107,7 @@ tests: wrappers PYTHONPATH=./ ./tests/tests.py --test=ldap PYTHONPATH=./ ./tests/tests.py --test=ldapdown PYTHONPATH=./ ./tests/tests.py --test=openid + PYTHONPATH=./ ./tests/tests.py --test=openidc PYTHONPATH=./ ./tests/tests.py --test=dbupgrades test: lp-test unittests tests diff --git a/templates/install/idp.conf b/templates/install/idp.conf index 7e41456..57725a2 100644 --- a/templates/install/idp.conf +++ b/templates/install/idp.conf @@ -7,6 +7,9 @@ WSGIScriptAlias /${instance} ${ipsilondir}/ipsilon WSGIDaemonProcess ${instance} user=${sysuser} group=${sysuser} home=${datadir} display-name=ipsilon-${instance} # This header is required to be passed for OIDC client_secret_basic WSGIPassAuthorization On +# Without this, getting the private key in jwcrypto/jwk.py, line 430, fails +# Fix from https://github.com/pyca/cryptography/issues/2299#issuecomment-197075190 +WSGIApplicationGroup %{GLOBAL} ${wsgi_socket} diff --git a/tests/helpers/http.py b/tests/helpers/http.py index ca53765..a385353 100755 --- a/tests/helpers/http.py +++ b/tests/helpers/http.py @@ -235,6 +235,27 @@ class HttpSessions(object): return [method, self.new_url(referer, action_url), {'headers': headers, 'data': payload}] + def handle_openidc_form(self, page): + if not isinstance(page, PageTree): + raise TypeError("Expected PageTree object") + + if not page.first_value('//title/text()') == \ + 'Submitting...': + raise WrongPage('Not OpenIDC autosubmit form') + + url = page.make_referer() + if '#' not in url: + raise WrongPage('Not OpenIDC fragment submit page') + url, arguments = url.split('#', 1) + + arguments = arguments.split('&') + params = {'response_mode': 'fragment'} + for argument in arguments: + key, value = argument.split('=') + params[key] = value + + return ['post', url, {'data': params}] + def fetch_page(self, idp, target_url, follow_redirect=True, krb=False): """ Fetch a page and parse the response code to determine what to do @@ -294,6 +315,12 @@ class HttpSessions(object): except WrongPage: pass + try: + (action, url, args) = self.handle_openidc_form(page) + continue + except WrongPage: + pass + # Either we got what we wanted, or we have to stop anyway return page else: diff --git a/tests/httpd.conf b/tests/httpd.conf index 1c9cc87..10b6dcd 100644 --- a/tests/httpd.conf +++ b/tests/httpd.conf @@ -64,6 +64,8 @@ LoadModule vhost_alias_module modules/mod_vhost_alias.so LoadModule mpm_prefork_module modules/mod_mpm_prefork.so LoadModule wsgi_module modules/mod_wsgi.so LoadModule auth_gssapi_module modules/mod_auth_gssapi.so +# openidc needs to be before mellon: https://bugzilla.redhat.com/show_bug.cgi?id=1332729 +LoadModule auth_openidc_module modules/mod_auth_openidc.so LoadModule auth_mellon_module modules/mod_auth_mellon.so Listen ${HTTPADDR}:${HTTPPORT} https diff --git a/tests/openidc.py b/tests/openidc.py new file mode 100755 index 0000000..665140b --- /dev/null +++ b/tests/openidc.py @@ -0,0 +1,349 @@ +#!/usr/bin/python +# +# Copyright (C) 2016 Ipsilon project Contributors, for license see COPYING + +from helpers.common import IpsilonTestBase # pylint: disable=relative-import +from helpers.http import HttpSessions # pylint: disable=relative-import +import os +import json +import pwd +import sys +import requests +import hashlib +from string import Template + +idp_g = {'TEMPLATES': '${TESTDIR}/templates/install', + 'CONFDIR': '${TESTDIR}/etc', + 'DATADIR': '${TESTDIR}/lib', + 'CACHEDIR': '${TESTDIR}/cache', + 'HTTPDCONFD': '${TESTDIR}/${NAME}/conf.d', + 'STATICDIR': '${ROOTDIR}', + 'BINDIR': '${ROOTDIR}/ipsilon', + 'WSGI_SOCKET_PREFIX': '${TESTDIR}/${NAME}/logs/wsgi'} + + +idp_a = {'hostname': '${ADDRESS}:${PORT}', + 'admin_user': '${TEST_USER}', + 'system_user': '${TEST_USER}', + 'instance': '${NAME}', + 'testauth': 'yes', + 'pam': 'no', + 'gssapi': 'no', + 'ipa': 'no', + 'openidc': 'yes', + 'openidc_subject_salt': 'testcase', + 'server_debugging': 'True'} + + +sp1_g = {'HTTPDCONFD': '${TESTDIR}/${NAME}/conf.d', + 'OPENIDC_TEMPLATE': '${TESTDIR}/templates/install/openidc/rp.conf', + 'CONFFILE': '${TESTDIR}/${NAME}/conf.d/ipsilon-%s.conf', + 'HTTPDIR': '${TESTDIR}/${NAME}/%s'} + + +sp1_a = {'hostname': '${ADDRESS}', + 'auth_location': '/sp', + 'openidc': 'yes', + 'openidc_idp_url': 'https://127.0.0.10:45080/idp1', + 'openidc_response_type': 'code', + 'openidc_skip_ssl_validation': 'yes', + 'httpd_user': '${TEST_USER}'} + + +sp2_g = {'HTTPDCONFD': '${TESTDIR}/${NAME}/conf.d', + 'OPENIDC_TEMPLATE': '${TESTDIR}/templates/install/openidc/rp.conf', + 'CONFFILE': '${TESTDIR}/${NAME}/conf.d/ipsilon-%s.conf', + 'HTTPDIR': '${TESTDIR}/${NAME}/%s'} + + +sp2_a = {'hostname': '${ADDRESS}', + 'auth_location': '/sp', + 'openidc': 'yes', + 'openidc_idp_url': 'https://127.0.0.10:45080/idp1', + 'openidc_response_type': 'id_token', + 'openidc_subject_type': 'public', + 'openidc_skip_ssl_validation': 'yes', + 'httpd_user': '${TEST_USER}'} + + +sp3_g = {'HTTPDCONFD': '${TESTDIR}/${NAME}/conf.d', + 'OPENIDC_TEMPLATE': '${TESTDIR}/templates/install/openidc/rp.conf', + 'CONFFILE': '${TESTDIR}/${NAME}/conf.d/ipsilon-%s.conf', + 'HTTPDIR': '${TESTDIR}/${NAME}/%s'} + + +sp3_a = {'hostname': '${ADDRESS}', + 'auth_location': '/sp', + 'openidc': 'yes', + 'openidc_idp_url': 'https://127.0.0.10:45080/idp1', + 'openidc_response_type': 'id_token token', + 'openidc_skip_ssl_validation': 'yes', + 'httpd_user': '${TEST_USER}'} + + +def fixup_sp_httpd(httpdir): + location = """ +AddOutputFilter INCLUDES .html + +Alias /sp ${HTTPDIR}/sp + + + Options +Includes + Require all granted + +""" + t = Template(location) + text = t.substitute({'HTTPDIR': httpdir}) + with open(httpdir + '/conf.d/ipsilon-openidc.conf', 'a') as f: + f.write(text) + + index = """""" + os.mkdir(httpdir + '/sp') + with open(httpdir + '/sp/index.html', 'w') as f: + f.write(index) + + +def convert_to_dict(envlist): + values = {} + for pair in envlist.split('\n'): + if pair.find('=') > 0: + (key, value) = pair.split('=', 1) + if key.startswith('OIDC_') and not key.endswith('_0'): + values[key] = value + return values + + +def check_info_results(text, expected): + """ + Logout, login, fetch RP page to get the info variables and + compare the OIDC_CLAIM_ ones to what we expect. + """ + + # Confirm that the expected values are in the output and that there + # are no unexpected OIDC_CLAIM_ vars, and drop the _0 version. + data = convert_to_dict(text) + + toreturn = {} + toreturn['access_token'] = data.pop('OIDC_access_token', None) + toreturn['access_token_expires'] = data.pop('OIDC_access_token_expires', + None) + + for key in expected: + item = data.pop('OIDC_CLAIM_' + key) + if item != expected[key]: + raise ValueError('Expected %s, got %s' % (expected[key], item)) + + # Ignore a couple of attributes + ignored = ['exp', 'c_hash', 'at_hash', 'aud', 'nonce', 'iat', 'auth_time', + 'azp'] + for attr in ignored: + data.pop('OIDC_CLAIM_%s' % attr, None) + + if len(data) > 0: + raise ValueError('Unexpected values %s' % data) + + return toreturn + + +class IpsilonTest(IpsilonTestBase): + + def __init__(self): + super(IpsilonTest, self).__init__('openidc', __file__) + + def setup_servers(self, env=None): + print "Installing IDP server" + name = 'idp1' + addr = '127.0.0.10' + port = '45080' + idp = self.generate_profile(idp_g, idp_a, name, addr, port) + conf = self.setup_idp_server(idp, name, addr, port, env) + + print "Starting IDP's httpd server" + self.start_http_server(conf, env) + + print "Installing first SP server" + name = 'sp1' + addr = '127.0.0.11' + port = '45081' + sp = self.generate_profile(sp1_g, sp1_a, name, addr, port) + conf = self.setup_sp_server(sp, name, addr, port, env) + fixup_sp_httpd(os.path.dirname(conf)) + + print "Starting first SP's httpd server" + self.start_http_server(conf, env) + + print "Installing second SP server" + name = 'sp2' + addr = '127.0.0.12' + port = '45082' + sp = self.generate_profile(sp2_g, sp2_a, name, addr, port) + conf = self.setup_sp_server(sp, name, addr, port, env) + fixup_sp_httpd(os.path.dirname(conf)) + + print "Starting second SP's httpd server" + self.start_http_server(conf, env) + + print "Installing third SP server" + name = 'sp3' + addr = '127.0.0.13' + port = '45083' + sp = self.generate_profile(sp3_g, sp3_a, name, addr, port) + conf = self.setup_sp_server(sp, name, addr, port, env) + fixup_sp_httpd(os.path.dirname(conf)) + + print "Starting third SP's httpd server" + self.start_http_server(conf, env) + + +if __name__ == '__main__': + + idpname = 'idp1' + sp1name = 'sp1' + sp2name = 'sp2' + sp3name = 'sp3' + user = pwd.getpwuid(os.getuid())[0] + + sess = HttpSessions() + sess.add_server(idpname, 'https://127.0.0.10:45080', user, 'ipsilon') + sess.add_server(sp1name, 'https://127.0.0.11:45081') + sess.add_server(sp2name, 'https://127.0.0.12:45082') + sess.add_server(sp3name, 'https://127.0.0.13:45083') + + print "openidc: Authenticate to IDP ...", + try: + sess.auth_to_idp(idpname) + except Exception, e: # pylint: disable=broad-except + print >> sys.stderr, " ERROR: %s" % repr(e) + sys.exit(1) + print " SUCCESS" + + print "openidc: Registering test client ...", + try: + client_info = { + 'redirect_uris': ['https://invalid/'], + 'response_types': ['code'], + 'grant_types': ['authorization_code'], + 'application_type': 'web', + 'client_name': 'Test suite client', + 'client_uri': 'https://invalid/', + 'token_endpoint_auth_method': 'client_secret_post' + } + r = requests.post('https://127.0.0.10:45080/idp1/openidc/Registration', + json=client_info) + r.raise_for_status() + reg_resp = r.json() + except Exception, e: # pylint: disable=broad-except + print >> sys.stderr, " ERROR: %s" % repr(e) + sys.exit(1) + print " SUCCESS" + + print "openidc: Access first SP Protected Area ...", + try: + page = sess.fetch_page(idpname, 'https://127.0.0.11:45081/sp/') + h = hashlib.sha256() + h.update('127.0.0.11') + h.update(user) + h.update('testcase') + expect = { + 'sub': h.hexdigest(), + 'iss': 'https://127.0.0.10:45080/idp1/openidc/', + 'amr': json.dumps([]), + 'acr': '0' + } + token = check_info_results(page.text, expect) + except ValueError, e: + print >> sys.stderr, " ERROR: %s" % repr(e) + sys.exit(1) + print " SUCCESS" + + print "openidc: Retrieving token info ...", + try: + # Testing token without client auth + r = requests.post('https://127.0.0.10:45080/idp1/openidc/TokenInfo', + data={'token': token['access_token']}) + if r.status_code != 401: + raise Exception('No 401 provided') + + # Testing token where we removed part of token ID + r = requests.post('https://127.0.0.10:45080/idp1/openidc/TokenInfo', + data={'token': token['access_token'][1:], + 'client_id': reg_resp['client_id'], + 'client_secret': reg_resp['client_secret']}) + r.raise_for_status() + info = r.json() + if info['active']: + raise Exception('Token active') + + # Testing token where we rempoved part of check string + r = requests.post('https://127.0.0.10:45080/idp1/openidc/TokenInfo', + data={'token': token['access_token'][:-1], + 'client_id': reg_resp['client_id'], + 'client_secret': reg_resp['client_secret']}) + r.raise_for_status() + info = r.json() + if info['active']: + raise Exception('Token active') + + # Testing valid token + r = requests.post('https://127.0.0.10:45080/idp1/openidc/TokenInfo', + data={'token': token['access_token'], + 'client_id': reg_resp['client_id'], + 'client_secret': reg_resp['client_secret']}) + r.raise_for_status() + info = r.json() + if 'error' in info: + raise Exception('Token introspection returned error: %s' + % info['error']) + if not info['active']: + raise Exception('Token not active') + if info['username'] != user: + raise Exception('Token for different user?') + if info['token_type'] != 'Bearer': + raise Exception('Unexpected token type: %s' % info['token_type']) + + scopes_needed = ['openid'] + info['scope'] = info['scope'].split(' ') + for scope in scopes_needed: + if scope not in info['scope']: + raise Exception('Missing scope: %s' % scope) + info['scope'].remove(scope) + if len(info['scope']) != 0: + raise Exception('Unexpected scopes found: %s' % info['scope']) + except ValueError, e: + print >> sys.stderr, " ERROR: %s" % repr(e) + sys.exit(1) + print " SUCCESS" + + print "openidc: Access second SP Protected Area ...", + try: + page = sess.fetch_page(idpname, 'https://127.0.0.12:45082/sp/') + expect = { + 'sub': user, + 'iss': 'https://127.0.0.10:45080/idp1/openidc/', + 'amr': json.dumps([]), + 'acr': '0' + } + check_info_results(page.text, expect) + except ValueError, e: + print >> sys.stderr, " ERROR: %s" % repr(e) + sys.exit(1) + print " SUCCESS" + + print "openidc: Access third SP Protected Area ...", + try: + page = sess.fetch_page(idpname, 'https://127.0.0.13:45083/sp/') + h = hashlib.sha256() + h.update('127.0.0.13') + h.update(user) + h.update('testcase') + expect = { + 'sub': h.hexdigest(), + 'iss': 'https://127.0.0.10:45080/idp1/openidc/', + 'amr': json.dumps([]), + 'acr': '0' + } + check_info_results(page.text, expect) + except ValueError, e: + print >> sys.stderr, " ERROR: %s" % repr(e) + sys.exit(1) + print " SUCCESS"