diff options
42 files changed, 3083 insertions, 207 deletions
diff --git a/.zuul.yaml b/.zuul.yaml index ef9782f4c..4a3ccf244 100644 --- a/.zuul.yaml +++ b/.zuul.yaml @@ -202,10 +202,25 @@ name: keystone-dsvm-py35-functional-federation parent: keystone-dsvm-py35-functional-federation-ubuntu-xenial +# Experimental +- job: + name: keystone-dsvm-functional-oidc-federation + parent: keystone-dsvm-functional + vars: + devstack_localrc: + TEMPEST_PLUGINS: '/opt/stack/keystone-tempest-plugin' + USE_PYTHON3: True + OS_CACERT: '/opt/stack/data/ca_bundle.pem' + devstack_services: + tls-proxy: true + keystone-oidc-federation: true + devstack_plugins: + keystone: https://opendev.org/openstack/keystone + - project: templates: - openstack-cover-jobs - - openstack-python3-zed-jobs + - openstack-python3-jobs - publish-openstack-docs-pti - periodic-stable-jobs - check-requirements @@ -279,3 +294,5 @@ irrelevant-files: *irrelevant-files - keystone-dsvm-py35-functional-federation-ubuntu-xenial: irrelevant-files: *irrelevant-files + - keystone-dsvm-functional-oidc-federation: + irrelevant-files: *irrelevant-files diff --git a/devstack/files/oidc/apache_oidc.conf b/devstack/files/oidc/apache_oidc.conf new file mode 100644 index 000000000..eab84fd07 --- /dev/null +++ b/devstack/files/oidc/apache_oidc.conf @@ -0,0 +1,47 @@ +# DO NOT USE THIS IN PRODUCTION ENVIRONMENTS! +OIDCSSLValidateServer Off +OIDCOAuthSSLValidateServer Off +OIDCCookieSameSite On + +OIDCClaimPrefix "OIDC-" +OIDCResponseType "id_token" +OIDCScope "openid email profile" +OIDCProviderMetadataURL "%OIDC_METADATA_URL%" +OIDCClientID "%OIDC_CLIENT_ID%" +OIDCClientSecret "%OIDC_CLIENT_SECRET%" +OIDCPKCEMethod "S256" +OIDCCryptoPassphrase "openstack" + +OIDCRedirectURI "https://%HOST_IP%/identity/v3/auth/OS-FEDERATION/identity_providers/%IDP_ID%/protocols/openid/websso" +OIDCRedirectURI "https://%HOST_IP%/identity/v3/auth/OS-FEDERATION/websso/openid" + +<LocationMatch "/v3/auth/OS-FEDERATION/websso/openid"> + AuthType "openid-connect" + Require valid-user + LogLevel debug +</LocationMatch> + +<LocationMatch "/v3/auth/OS-FEDERATION/identity_providers/%IDP_ID%/protocols/openid/websso"> + AuthType "openid-connect" + Require valid-user + LogLevel debug +</LocationMatch> + +<LocationMatch "/v3/auth/OS-FEDERATION/identity_providers/%IDP_ID%/protocols/openid/auth"> + AuthType "openid-connect" + Require valid-user + LogLevel debug +</LocationMatch> + +<Location ~ "/v3/OS-FEDERATION/identity_providers/%IDP_ID%/protocols/openid/auth"> + AuthType oauth20 + Require valid-user +</Location> + +OIDCOAuthClientID "%OIDC_CLIENT_ID%" +OIDCOAuthClientSecret "%OIDC_CLIENT_SECRET%" +OIDCOAuthIntrospectionEndpoint "%OIDC_INTROSPECTION_URL%" + +# Horizon favors the referrer to the Keystone URL that is set. +# https://github.com/openstack/horizon/blob/5e4ca1a9fdec04db08552e9e93fe372b8b8b45ae/openstack_auth/views.py#L192 +Header always set Referrer-Policy "no-referrer" diff --git a/devstack/lib/oidc.sh b/devstack/lib/oidc.sh new file mode 100644 index 000000000..ab8731d98 --- /dev/null +++ b/devstack/lib/oidc.sh @@ -0,0 +1,160 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +DOMAIN_NAME=${DOMAIN_NAME:-federated_domain} +PROJECT_NAME=${PROJECT_NAME:-federated_project} +GROUP_NAME=${GROUP_NAME:-federated_users} + +OIDC_CLIENT_ID=${CLIENT_ID:-devstack} +OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-nomoresecret} + +OIDC_ISSUER=${OIDC_ISSUER:-"https://$HOST_IP:8443"} +OIDC_ISSUER_BASE="${OIDC_ISSUER}/realms/master" + +OIDC_METADATA_URL=${OIDC_METADATA_URL:-"https://$HOST_IP:8443/realms/master/.well-known/openid-configuration"} +OIDC_INTROSPECTION_URL=${OIDC_INTROSPECTION_URL:-"https://$HOST_IP:8443/realms/master/protocol/openid-connect/token/introspect"} + +IDP_ID=${IDP_ID:-sso} +IDP_USERNAME=${IDP_USERNAME:-admin} +IDP_PASSWORD=${IDP_PASSWORD:-nomoresecret} + +MAPPING_REMOTE_TYPE=${MAPPING_REMOTE_TYPE:-OIDC-preferred_username} +MAPPING_USER_NAME=${MAPPING_USER_NAME:-"{0}"} +PROTOCOL_ID=${PROTOCOL_ID:-openid} + +REDIRECT_URI="https://$HOST_IP/identity/v3/auth/OS-FEDERATION/identity_providers/$IDP_ID/protocols/openid/websso" + +OIDC_PLUGIN="$DEST/keystone/devstack" + +function install_federation { + if is_ubuntu; then + install_package libapache2-mod-auth-openidc + sudo a2enmod headers + install_package docker.io + install_package docker-compose + elif is_fedora; then + install_package mod_auth_openidc + install_package podman + install_package podman-docker + install_package docker-compose + sudo systemctl start podman.socket + else + echo "Skipping installation. Only supported on Ubuntu and RHEL based." + fi +} + +function configure_federation { + # Specify the header that contains information about the identity provider + iniset $KEYSTONE_CONF openid remote_id_attribute "HTTP_OIDC_ISS" + iniset $KEYSTONE_CONF auth methods "password,token,openid,application_credential" + iniset $KEYSTONE_CONF federation trusted_dashboard "https://$HOST_IP/auth/websso/" + + cp $DEST/keystone/etc/sso_callback_template.html /etc/keystone/ + + if [[ "$WSGI_MODE" == "uwsgi" ]]; then + restart_service "devstack@keystone" + fi + + if [[ "$OIDC_ISSUER_BASE" == "https://$HOST_IP:8443/realms/master" ]]; then + # Assuming we want to setup a local keycloak here. + sed -i "s#DEVSTACK_DEST#${DATA_DIR}#" ${OIDC_PLUGIN}/tools/oidc/docker-compose.yaml + sudo docker-compose --file ${OIDC_PLUGIN}/tools/oidc/docker-compose.yaml up -d + + # wait for the server to be up + attempt_counter=0 + max_attempts=100 + until $(curl --output /dev/null --silent --fail $OIDC_METADATA_URL); do + if [ ${attempt_counter} -eq ${max_attempts} ];then + echo "Keycloak server failed to come up in time" + exit 1 + fi + + attempt_counter=$(($attempt_counter+1)) + sleep 5 + done + + KEYCLOAK_URL="https://$HOST_IP:8443" \ + KEYCLOAK_USERNAME="admin" \ + KEYCLOAK_PASSWORD="nomoresecret" \ + HOST_IP="$HOST_IP" \ + python3 $OIDC_PLUGIN/tools/oidc/setup_keycloak_client.py + fi + + local keystone_apache_conf=$(apache_site_config_for keystone-wsgi-public) + cat $OIDC_PLUGIN/files/oidc/apache_oidc.conf | sudo tee -a $keystone_apache_conf + sudo sed -i -e " + s|%OIDC_CLIENT_ID%|$OIDC_CLIENT_ID|g; + s|%OIDC_CLIENT_SECRET%|$OIDC_CLIENT_SECRET|g; + s|%OIDC_METADATA_URL%|$OIDC_METADATA_URL|g; + s|%OIDC_INTROSPECTION_URL%|$OIDC_INTROSPECTION_URL|g; + s|%HOST_IP%|$HOST_IP|g; + s|%IDP_ID%|$IDP_ID|g; + " $keystone_apache_conf + + restart_apache_server +} + +function register_federation { + local federated_domain=$(get_or_create_domain $DOMAIN_NAME) + local federated_project=$(get_or_create_project $PROJECT_NAME $DOMAIN_NAME) + local federated_users=$(get_or_create_group $GROUP_NAME $DOMAIN_NAME) + local member_role=$(get_or_create_role Member) + + openstack role add --group $federated_users --domain $federated_domain $member_role + openstack role add --group $federated_users --project $federated_project $member_role + + openstack identity provider create \ + --remote-id $OIDC_ISSUER_BASE \ + --domain $DOMAIN_NAME $IDP_ID +} + +function configure_tests_settings { + # Here we set any settings that might be need by the fed_scenario set of tests + iniset $TEMPEST_CONFIG identity-feature-enabled federation True + + # we probably need an oidc version of this flag based on local oidc + iniset $TEMPEST_CONFIG identity-feature-enabled external_idp True + + # Identity provider settings + iniset $TEMPEST_CONFIG fed_scenario idp_id $IDP_ID + iniset $TEMPEST_CONFIG fed_scenario idp_remote_ids $OIDC_ISSUER_BASE + iniset $TEMPEST_CONFIG fed_scenario idp_username $IDP_USERNAME + iniset $TEMPEST_CONFIG fed_scenario idp_password $IDP_PASSWORD + iniset $TEMPEST_CONFIG fed_scenario idp_oidc_url $OIDC_ISSUER + iniset $TEMPEST_CONFIG fed_scenario idp_client_id $OIDC_CLIENT_ID + iniset $TEMPEST_CONFIG fed_scenario idp_client_secret $OIDC_CLIENT_SECRET + + # Mapping rules settings + iniset $TEMPEST_CONFIG fed_scenario mapping_remote_type $MAPPING_REMOTE_TYPE + iniset $TEMPEST_CONFIG fed_scenario mapping_user_name $MAPPING_USER_NAME + iniset $TEMPEST_CONFIG fed_scenario mapping_group_name $GROUP_NAME + iniset $TEMPEST_CONFIG fed_scenario mapping_group_domain_name $DOMAIN_NAME + iniset $TEMPEST_CONFIG fed_scenario enable_k2k_groups_mapping False + + # Protocol settings + iniset $TEMPEST_CONFIG fed_scenario protocol_id $PROTOCOL_ID +} + +function uninstall_federation { + # Ensure Keycloak is stopped and the containers are cleaned up + sudo docker-compose --file ${OIDC_PLUGIN}/tools/oidc/docker-compose.yaml down + if is_ubuntu; then + sudo docker rmi $(sudo docker images -a -q) + uninstall_package docker-compose + elif is_fedora; then + sudo podman rmi $(sudo podman images -a -q) + uninstall_package podman + else + echo "Skipping uninstallation of OIDC federation for non ubuntu nor fedora nor suse host" + fi +} + diff --git a/devstack/plugin.sh b/devstack/plugin.sh index 8f7a38535..eca1d1ac0 100644 --- a/devstack/plugin.sh +++ b/devstack/plugin.sh @@ -14,7 +14,13 @@ # under the License. KEYSTONE_PLUGIN=$DEST/keystone/devstack -source $KEYSTONE_PLUGIN/lib/federation.sh + +if is_service_enabled keystone-saml2-federation; then + source $KEYSTONE_PLUGIN/lib/federation.sh +elif is_service_enabled keystone-oidc-federation; then + source $KEYSTONE_PLUGIN/lib/oidc.sh +fi + source $KEYSTONE_PLUGIN/lib/scope.sh # For more information on Devstack plugins, including a more detailed @@ -25,6 +31,10 @@ if [[ "$1" == "stack" && "$2" == "install" ]]; then # This phase is executed after the projects have been installed echo "Keystone plugin - Install phase" if is_service_enabled keystone-saml2-federation; then + echo "installing saml2 federation" + install_federation + elif is_service_enabled keystone-oidc-federation; then + echo "installing oidc federation" install_federation fi @@ -33,6 +43,10 @@ elif [[ "$1" == "stack" && "$2" == "post-config" ]]; then # before they are started echo "Keystone plugin - Post-config phase" if is_service_enabled keystone-saml2-federation; then + echo "configuring saml2 federation" + configure_federation + elif is_service_enabled keystone-oidc-federation; then + echo "configuring oidc federation" configure_federation fi @@ -40,12 +54,21 @@ elif [[ "$1" == "stack" && "$2" == "extra" ]]; then # This phase is executed after the projects have been started echo "Keystone plugin - Extra phase" if is_service_enabled keystone-saml2-federation; then + echo "registering saml2 federation" + register_federation + elif is_service_enabled keystone-oidc-federation; then + echo "registering oidc federation" register_federation fi + elif [[ "$1" == "stack" && "$2" == "test-config" ]]; then # This phase is executed after Tempest was configured echo "Keystone plugin - Test-config phase" if is_service_enabled keystone-saml2-federation; then + echo "config tests settings for saml" + configure_tests_settings + elif is_service_enabled keystone-oidc-federation; then + echo "config tests settings for oidc" configure_tests_settings fi if [[ "$(trueorfalse False KEYSTONE_ENFORCE_SCOPE)" == "True" ]] ; then @@ -66,6 +89,10 @@ if [[ "$1" == "clean" ]]; then # Called by clean.sh after the "unstack" phase # Undo what was performed during the "install" phase if is_service_enabled keystone-saml2-federation; then + echo "uninstalling saml" + uninstall_federation + elif is_service_enabled keystone-oidc-federation; then + echo "uninstalling oidc" uninstall_federation fi fi diff --git a/devstack/tools/oidc/__init__.py b/devstack/tools/oidc/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/devstack/tools/oidc/__init__.py diff --git a/devstack/tools/oidc/docker-compose.yaml b/devstack/tools/oidc/docker-compose.yaml new file mode 100644 index 000000000..6e4a428c9 --- /dev/null +++ b/devstack/tools/oidc/docker-compose.yaml @@ -0,0 +1,33 @@ +version: "3" + +services: + keycloak: + image: quay.io/keycloak/keycloak:latest + command: start-dev --log-level debug --log=console,file --https-certificate-file=/etc/certs/devstack-cert.pem --https-certificate-key-file=/etc/certs/devstack-cert.pem + container_name: oidc_keycloak_1 + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: nomoresecret + KEYCLOAK_USER: admin + KEYCLOAK_PASSWORD: nomoresecret + KEYCLOAK_LOG_LEVEL: DEBUG + DB_VENDOR: mariadb + DB_DATABASE: keycloak + DB_USER: keycloak + DB_PASSWORD: "nomoresecret" + DB_ADDR: "keycloak-database" + DB_PORT: "3306" + JAVA_OPTS: "-server -Xms128m -Xmx1024m -XX:MetaspaceSize=128M -XX:MaxMetaspaceSize=512m -Djava.net.preferIPv4Stack=true -Djboss.modules.system.pkgs=org.jboss.byteman -Djava.awt.headless=true" + ports: + - "8088:8080" # host:container + - "8443:8443" + volumes: + - DEVSTACK_DEST:/etc/certs:rw + + keycloak-database: + image: quay.io/metal3-io/mariadb:latest + environment: + MYSQL_ROOT_PASSWORD: nomoresecret + MYSQL_DATABASE: keycloak + MYSQL_USER: keycloak + MYSQL_PASSWORD: nomoresecret diff --git a/devstack/tools/oidc/setup_keycloak_client.py b/devstack/tools/oidc/setup_keycloak_client.py new file mode 100644 index 000000000..15fa37b41 --- /dev/null +++ b/devstack/tools/oidc/setup_keycloak_client.py @@ -0,0 +1,61 @@ +import os +import requests + +KEYCLOAK_USERNAME = os.environ.get('KEYCLOAK_USERNAME') +KEYCLOAK_PASSWORD = os.environ.get('KEYCLOAK_PASSWORD') +KEYCLOAK_URL = os.environ.get('KEYCLOAK_URL') +HOST_IP = os.environ.get('HOST_IP', 'localhost') + +class KeycloakClient(object): + def __init__(self): + self.session = requests.session() + + @staticmethod + def construct_url(realm, path): + return f'{KEYCLOAK_URL}/admin/realms/{realm}/{path}' + + @staticmethod + def token_endpoint(realm): + return f'{KEYCLOAK_URL}/realms/{realm}/protocol/openid-connect/token' + + def _admin_auth(self, realm): + params = { + 'grant_type': 'password', + 'client_id': 'admin-cli', + 'username': KEYCLOAK_USERNAME, + 'password': KEYCLOAK_PASSWORD, + 'scope': 'openid', + } + r = requests.post(self.token_endpoint(realm), data=params).json() + headers = { + 'Authorization': ("Bearer %s" % r['access_token']), + 'Content-Type': 'application/json' + } + self.session.headers.update(headers) + return r + + def create_client(self, realm, client_id, client_secret, redirect_uris): + self._admin_auth(realm) + data = { + 'clientId': client_id, + 'secret': client_secret, + 'redirectUris': redirect_uris, + 'implicitFlowEnabled': True, + 'directAccessGrantsEnabled': True, + } + return self.session.post(self.construct_url(realm, 'clients'), json=data) + + +def main(): + c = KeycloakClient() + + redirect_uris = [ + f'http://{HOST_IP}/identity/v3/auth/OS-FEDERATION/identity_providers/sso/protocols/openid/websso', + f'http://{HOST_IP}/identity/v3/auth/OS-FEDERATION/websso/openid' + ] + + c.create_client('master', 'devstack', 'nomoresecret', redirect_uris) + + +if __name__ == "__main__": + main() diff --git a/keystone/api/os_ep_filter.py b/keystone/api/os_ep_filter.py index c26098347..055d21deb 100644 --- a/keystone/api/os_ep_filter.py +++ b/keystone/api/os_ep_filter.py @@ -110,7 +110,7 @@ class EndpointGroupsResource(ks_flask.ResourceBase): class EPFilterEndpointProjectsResource(flask_restful.Resource): def get(self, endpoint_id): - """"Return a list of projects associated with the endpoint.""" + """Return a list of projects associated with the endpoint.""" ENFORCER.enforce_call(action='identity:list_projects_for_endpoint') PROVIDERS.catalog_api.get_endpoint(endpoint_id) refs = PROVIDERS.catalog_api.list_projects_for_endpoint(endpoint_id) diff --git a/keystone/api/os_oauth2.py b/keystone/api/os_oauth2.py index ed37eacaa..81f3dbd3d 100644 --- a/keystone/api/os_oauth2.py +++ b/keystone/api/os_oauth2.py @@ -16,23 +16,29 @@ import flask from flask import make_response import http.client from oslo_log import log +from oslo_serialization import jsonutils from keystone.api._shared import authentication from keystone.api._shared import json_home_relations +from keystone.common import provider_api +from keystone.common import utils from keystone.conf import CONF from keystone import exception +from keystone.federation import utils as federation_utils from keystone.i18n import _ from keystone.server import flask as ks_flask LOG = log.getLogger(__name__) +PROVIDERS = provider_api.ProviderAPIs + _build_resource_relation = json_home_relations.os_oauth2_resource_rel_func class AccessTokenResource(ks_flask.ResourceBase): def _method_not_allowed(self): - """Raise a method not allowed error""" + """Raise a method not allowed error.""" raise exception.OAuth2OtherError( int(http.client.METHOD_NOT_ALLOWED), http.client.responses[http.client.METHOD_NOT_ALLOWED], @@ -40,27 +46,27 @@ class AccessTokenResource(ks_flask.ResourceBase): @ks_flask.unenforced_api def get(self): - """The method is not allowed""" + """The method is not allowed.""" self._method_not_allowed() @ks_flask.unenforced_api def head(self): - """The method is not allowed""" + """The method is not allowed.""" self._method_not_allowed() @ks_flask.unenforced_api def put(self): - """The method is not allowed""" + """The method is not allowed.""" self._method_not_allowed() @ks_flask.unenforced_api def patch(self): - """The method is not allowed""" + """The method is not allowed.""" self._method_not_allowed() @ks_flask.unenforced_api def delete(self): - """The method is not allowed""" + """The method is not allowed.""" self._method_not_allowed() @ks_flask.unenforced_api @@ -69,45 +75,6 @@ class AccessTokenResource(ks_flask.ResourceBase): POST /v3/OS-OAUTH2/token """ - - client_auth = flask.request.authorization - if not client_auth: - error = exception.OAuth2InvalidClient( - int(http.client.UNAUTHORIZED), - http.client.responses[http.client.UNAUTHORIZED], - _('OAuth2.0 client authorization is required.')) - LOG.info('Get OAuth2.0 Access Token API: ' - 'field \'authorization\' is not found in HTTP Headers.') - raise error - if client_auth.type != 'basic': - error = exception.OAuth2InvalidClient( - int(http.client.UNAUTHORIZED), - http.client.responses[http.client.UNAUTHORIZED], - _('OAuth2.0 client authorization type %s is not supported.') - % client_auth.type) - LOG.info('Get OAuth2.0 Access Token API: ' - f'{error.message_format}') - raise error - client_id = client_auth.username - client_secret = client_auth.password - - if not client_id: - error = exception.OAuth2InvalidClient( - int(http.client.UNAUTHORIZED), - http.client.responses[http.client.UNAUTHORIZED], - _('OAuth2.0 client authorization is invalid.')) - LOG.info('Get OAuth2.0 Access Token API: ' - 'client_id is not found in authorization.') - raise error - if not client_secret: - error = exception.OAuth2InvalidClient( - int(http.client.UNAUTHORIZED), - http.client.responses[http.client.UNAUTHORIZED], - _('OAuth2.0 client authorization is invalid.')) - LOG.info('Get OAuth2.0 Access Token API: ' - 'client_secret is not found in authorization.') - raise error - grant_type = flask.request.form.get('grant_type') if grant_type is None: error = exception.OAuth2InvalidRequest( @@ -126,6 +93,45 @@ class AccessTokenResource(ks_flask.ResourceBase): LOG.info('Get OAuth2.0 Access Token API: ' f'{error.message_format}') raise error + + auth_method = '' + client_id = flask.request.form.get('client_id') + client_secret = flask.request.form.get('client_secret') + client_cert = flask.request.environ.get("SSL_CLIENT_CERT") + client_auth = flask.request.authorization + if not client_cert and client_auth and client_auth.type == 'basic': + client_id = client_auth.username + client_secret = client_auth.password + + if not client_id: + error = exception.OAuth2InvalidClient( + int(http.client.UNAUTHORIZED), + http.client.responses[http.client.UNAUTHORIZED], + _('Client authentication failed.')) + LOG.info('Get OAuth2.0 Access Token API: ' + 'failed to get a client_id from the request.') + raise error + if client_cert: + auth_method = 'tls_client_auth' + elif client_secret: + auth_method = 'client_secret_basic' + + if auth_method in CONF.oauth2.oauth2_authn_methods: + if auth_method == 'tls_client_auth': + return self._tls_client_auth(client_id, client_cert) + if auth_method == 'client_secret_basic': + return self._client_secret_basic(client_id, client_secret) + + error = exception.OAuth2InvalidClient( + int(http.client.UNAUTHORIZED), + http.client.responses[http.client.UNAUTHORIZED], + _('Client authentication failed.')) + LOG.info('Get OAuth2.0 Access Token API: ' + 'failed to get client credentials from the request.') + raise error + + def _client_secret_basic(self, client_id, client_secret): + """Get an OAuth2.0 basic Access Token.""" auth_data = { 'identity': { 'methods': ['application_credential'], @@ -169,6 +175,202 @@ class AccessTokenResource(ks_flask.ResourceBase): resp.status = '200 OK' return resp + def _check_mapped_properties(self, cert_dn, user, user_domain): + mapping_id = CONF.oauth2.get('oauth2_cert_dn_mapping_id') + try: + mapping = PROVIDERS.federation_api.get_mapping(mapping_id) + except exception.MappingNotFound: + error = exception.OAuth2InvalidClient( + int(http.client.UNAUTHORIZED), + http.client.responses[http.client.UNAUTHORIZED], + _('Client authentication failed.')) + LOG.info('Get OAuth2.0 Access Token API: ' + 'mapping id %s is not found. ', + mapping_id) + raise error + + rule_processor = federation_utils.RuleProcessor( + mapping.get('id'), mapping.get('rules')) + try: + mapped_properties = rule_processor.process(cert_dn) + except exception.Error as error: + LOG.exception(error) + error = exception.OAuth2InvalidClient( + int(http.client.UNAUTHORIZED), + http.client.responses[http.client.UNAUTHORIZED], + _('Client authentication failed.')) + LOG.info('Get OAuth2.0 Access Token API: ' + 'mapping rule process failed. ' + 'mapping_id: %s, rules: %s, data: %s.', + mapping_id, mapping.get('rules'), + jsonutils.dumps(cert_dn)) + raise error + except Exception as error: + LOG.exception(error) + error = exception.OAuth2OtherError( + int(http.client.INTERNAL_SERVER_ERROR), + http.client.responses[http.client.INTERNAL_SERVER_ERROR], + str(error)) + LOG.info('Get OAuth2.0 Access Token API: ' + 'mapping rule process failed. ' + 'mapping_id: %s, rules: %s, data: %s.', + mapping_id, mapping.get('rules'), + jsonutils.dumps(cert_dn)) + raise error + + mapping_user = mapped_properties.get('user', {}) + mapping_user_name = mapping_user.get('name') + mapping_user_id = mapping_user.get('id') + mapping_user_email = mapping_user.get('email') + mapping_domain = mapping_user.get('domain', {}) + mapping_user_domain_id = mapping_domain.get('id') + mapping_user_domain_name = mapping_domain.get('name') + if mapping_user_name and mapping_user_name != user.get('name'): + error = exception.OAuth2InvalidClient( + int(http.client.UNAUTHORIZED), + http.client.responses[http.client.UNAUTHORIZED], + _('Client authentication failed.')) + LOG.info('Get OAuth2.0 Access Token API: %s check failed. ' + 'DN value: %s, DB value: %s.', + 'user name', mapping_user_name, user.get('name')) + raise error + if mapping_user_id and mapping_user_id != user.get('id'): + error = exception.OAuth2InvalidClient( + int(http.client.UNAUTHORIZED), + http.client.responses[http.client.UNAUTHORIZED], + _('Client authentication failed.')) + LOG.info('Get OAuth2.0 Access Token API: %s check failed. ' + 'DN value: %s, DB value: %s.', + 'user id', mapping_user_id, user.get('id')) + raise error + if mapping_user_email and mapping_user_email != user.get('email'): + error = exception.OAuth2InvalidClient( + int(http.client.UNAUTHORIZED), + http.client.responses[http.client.UNAUTHORIZED], + _('Client authentication failed.')) + LOG.info('Get OAuth2.0 Access Token API: %s check failed. ' + 'DN value: %s, DB value: %s.', + 'user email', mapping_user_email, user.get('email')) + raise error + if (mapping_user_domain_id and + mapping_user_domain_id != user_domain.get('id')): + error = exception.OAuth2InvalidClient( + int(http.client.UNAUTHORIZED), + http.client.responses[http.client.UNAUTHORIZED], + _('Client authentication failed.')) + LOG.info('Get OAuth2.0 Access Token API: %s check failed. ' + 'DN value: %s, DB value: %s.', + 'user domain id', mapping_user_domain_id, + user_domain.get('id')) + raise error + if (mapping_user_domain_name and + mapping_user_domain_name != user_domain.get('name')): + error = exception.OAuth2InvalidClient( + int(http.client.UNAUTHORIZED), + http.client.responses[http.client.UNAUTHORIZED], + _('Client authentication failed.')) + LOG.info('Get OAuth2.0 Access Token API: %s check failed. ' + 'DN value: %s, DB value: %s.', + 'user domain name', mapping_user_domain_name, + user_domain.get('name')) + raise error + + def _tls_client_auth(self, client_id, client_cert): + """Get an OAuth2.0 certificate-bound Access Token.""" + try: + cert_subject_dn = utils.get_certificate_subject_dn(client_cert) + except exception.ValidationError: + error = exception.OAuth2InvalidClient( + int(http.client.UNAUTHORIZED), + http.client.responses[http.client.UNAUTHORIZED], + _('Client authentication failed.')) + LOG.info('Get OAuth2.0 Access Token API: ' + 'failed to get the subject DN from the certificate.') + raise error + try: + cert_issuer_dn = utils.get_certificate_issuer_dn(client_cert) + except exception.ValidationError: + error = exception.OAuth2InvalidClient( + int(http.client.UNAUTHORIZED), + http.client.responses[http.client.UNAUTHORIZED], + _('Client authentication failed.')) + LOG.info('Get OAuth2.0 Access Token API: ' + 'failed to get the issuer DN from the certificate.') + raise error + client_cert_dn = {} + for key in cert_subject_dn: + client_cert_dn['SSL_CLIENT_SUBJECT_DN_%s' % + key.upper()] = cert_subject_dn.get(key) + for key in cert_issuer_dn: + client_cert_dn['SSL_CLIENT_ISSUER_DN_%s' % + key.upper()] = cert_issuer_dn.get(key) + + try: + user = PROVIDERS.identity_api.get_user(client_id) + except exception.UserNotFound: + error = exception.OAuth2InvalidClient( + int(http.client.UNAUTHORIZED), + http.client.responses[http.client.UNAUTHORIZED], + _('Client authentication failed.')) + LOG.info('Get OAuth2.0 Access Token API: ' + 'the user does not exist. user id: %s.', + client_id) + raise error + project_id = user.get('default_project_id') + if not project_id: + error = exception.OAuth2InvalidClient( + int(http.client.UNAUTHORIZED), + http.client.responses[http.client.UNAUTHORIZED], + _('Client authentication failed.')) + LOG.info('Get OAuth2.0 Access Token API: ' + 'the user does not have default project. user id: %s.', + client_id) + raise error + + user_domain = PROVIDERS.resource_api.get_domain( + user.get('domain_id')) + self._check_mapped_properties(client_cert_dn, user, user_domain) + thumbprint = utils.get_certificate_thumbprint(client_cert) + LOG.debug(f'The mTLS certificate thumbprint: {thumbprint}') + try: + token = PROVIDERS.token_provider_api.issue_token( + user_id=client_id, + method_names=['oauth2_credential'], + project_id=project_id, + thumbprint=thumbprint + ) + except exception.Error as error: + if error.code == 401: + error = exception.OAuth2InvalidClient( + error.code, error.title, + str(error)) + elif error.code == 400: + error = exception.OAuth2InvalidRequest( + error.code, error.title, + str(error)) + else: + error = exception.OAuth2OtherError( + error.code, error.title, + 'An unknown error occurred and failed to get an OAuth2.0 ' + 'access token.') + LOG.exception(error) + raise error + except Exception as error: + error = exception.OAuth2OtherError( + int(http.client.INTERNAL_SERVER_ERROR), + http.client.responses[http.client.INTERNAL_SERVER_ERROR], + str(error)) + LOG.exception(error) + raise error + + resp = make_response({ + 'access_token': token.id, + 'token_type': 'Bearer', + 'expires_in': CONF.token.expiration + }) + resp.status = '200 OK' + return resp + class OSAuth2API(ks_flask.APIBase): _name = 'OS-OAUTH2' diff --git a/keystone/cmd/doctor/database.py b/keystone/cmd/doctor/database.py index e0def5d63..95c5bdd87 100644 --- a/keystone/cmd/doctor/database.py +++ b/keystone/cmd/doctor/database.py @@ -23,7 +23,7 @@ def symptom_database_connection_is_not_SQLite(): migrations, making it unsuitable for use in keystone. Please change your `keystone.conf [database] connection` value to point to a supported database driver, such as MySQL. - """ + """ # noqa: D403 return ( CONF.database.connection is not None and 'sqlite' in CONF.database.connection) diff --git a/keystone/common/password_hashing.py b/keystone/common/password_hashing.py index 4e62d9c38..b38d3cba7 100644 --- a/keystone/common/password_hashing.py +++ b/keystone/common/password_hashing.py @@ -57,8 +57,26 @@ def _get_hasher_from_ident(hashed): def verify_length_and_trunc_password(password): - """Verify and truncate the provided password to the max_password_length.""" - max_length = CONF.identity.max_password_length + """Verify and truncate the provided password to the max_password_length. + + We also need to check that the configured password hashing algorithm does + not silently truncate the password. For example, passlib.hash.bcrypt does + this: + https://passlib.readthedocs.io/en/stable/lib/passlib.hash.bcrypt.html#security-issues + + """ + # When using bcrypt, we limit the password length to 54 to ensure all + # bytes are fully mixed. See: + # https://passlib.readthedocs.io/en/stable/lib/passlib.hash.bcrypt.html#security-issues + BCRYPT_MAX_LENGTH = 54 + if (CONF.identity.password_hash_algorithm == 'bcrypt' and # nosec: B105 + CONF.identity.max_password_length > BCRYPT_MAX_LENGTH): + msg = "Truncating password to algorithm specific maximum length %d characters." + LOG.warning(msg, BCRYPT_MAX_LENGTH) + max_length = BCRYPT_MAX_LENGTH + else: + max_length = CONF.identity.max_password_length + try: if len(password) > max_length: if CONF.strict_password_check: diff --git a/keystone/common/render_token.py b/keystone/common/render_token.py index 320260b1f..4a84f5c0c 100644 --- a/keystone/common/render_token.py +++ b/keystone/common/render_token.py @@ -142,5 +142,9 @@ def render_token_response_from_model(token, include_catalog=True): token_reference['token'][key]['access_rules'] = ( token.application_credential['access_rules'] ) + if token.oauth2_thumbprint: + token_reference['token']['oauth2_credential'] = { + 'x5t#S256': token.oauth2_thumbprint + } return token_reference diff --git a/keystone/common/sql/migrations/versions/27e647c0fad4_initial_version.py b/keystone/common/sql/migrations/versions/27e647c0fad4_initial_version.py index eec97c573..0f4994903 100644 --- a/keystone/common/sql/migrations/versions/27e647c0fad4_initial_version.py +++ b/keystone/common/sql/migrations/versions/27e647c0fad4_initial_version.py @@ -10,7 +10,7 @@ # License for the specific language governing permissions and limitations # under the License. -"""Initial version +"""Initial version. Revision ID: 27e647c0fad4 Revises: diff --git a/keystone/common/sql/upgrades.py b/keystone/common/sql/upgrades.py index 41a094819..a075716e9 100644 --- a/keystone/common/sql/upgrades.py +++ b/keystone/common/sql/upgrades.py @@ -51,7 +51,7 @@ VERSIONS_PATH = os.path.join( def _find_migrate_repo(branch): - """Get the project's change script repository + """Get the project's change script repository. :param branch: Name of the repository "branch" to be used; this will be transformed to repository path. @@ -70,7 +70,7 @@ def _find_migrate_repo(branch): def _find_alembic_conf(): - """Get the project's alembic configuration + """Get the project's alembic configuration. :returns: An instance of ``alembic.config.Config`` """ diff --git a/keystone/common/utils.py b/keystone/common/utils.py index 70d277e52..3f8088f27 100644 --- a/keystone/common/utils.py +++ b/keystone/common/utils.py @@ -15,7 +15,7 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. - +import base64 import collections.abc import contextlib import grp @@ -25,6 +25,7 @@ import os import pwd import uuid +from cryptography import x509 from oslo_log import log from oslo_serialization import jsonutils from oslo_utils import reflection @@ -60,6 +61,14 @@ hash_user_password = password_hashing.hash_user_password check_password = password_hashing.check_password +# NOTE(hiromu): This dict defines alternative DN string for X.509. When +# retriving DN from X.509, converting attributes types that are not listed +# in the RFC4514 to a corresponding alternative DN string. +ATTR_NAME_OVERRIDES = { + x509.NameOID.EMAIL_ADDRESS: "emailAddress", +} + + def resource_uuid(value): """Convert input to valid UUID hex digits.""" try: @@ -458,6 +467,63 @@ def check_endpoint_url(url): raise exception.URLValidationError(url=url) +def get_certificate_subject_dn(cert_pem): + """Get subject DN from the PEM certificate content. + + :param str cert_pem: the PEM certificate content + :rtype: JSON data for subject DN + :raises keystone.exception.ValidationError: if the PEM certificate content + is invalid + """ + dn_dict = {} + try: + cert = x509.load_pem_x509_certificate(cert_pem.encode('utf-8')) + for item in cert.subject: + name, value = item.rfc4514_string().split('=') + if item.oid in ATTR_NAME_OVERRIDES: + name = ATTR_NAME_OVERRIDES[item.oid] + dn_dict[name] = value + except Exception as error: + LOG.exception(error) + message = _('The certificate content is not PEM format.') + raise exception.ValidationError(message=message) + return dn_dict + + +def get_certificate_issuer_dn(cert_pem): + """Get issuer DN from the PEM certificate content. + + :param str cert_pem: the PEM certificate content + :rtype: JSON data for issuer DN + :raises keystone.exception.ValidationError: if the PEM certificate content + is invalid + """ + dn_dict = {} + try: + cert = x509.load_pem_x509_certificate(cert_pem.encode('utf-8')) + for item in cert.issuer: + name, value = item.rfc4514_string().split('=') + if item.oid in ATTR_NAME_OVERRIDES: + name = ATTR_NAME_OVERRIDES[item.oid] + dn_dict[name] = value + except Exception as error: + LOG.exception(error) + message = _('The certificate content is not PEM format.') + raise exception.ValidationError(message=message) + return dn_dict + + +def get_certificate_thumbprint(cert_pem): + """Get certificate thumbprint from the PEM certificate content. + + :param str cert_pem: the PEM certificate content + :rtype: certificate thumbprint + """ + thumb_sha256 = hashlib.sha256(cert_pem.encode('ascii')).digest() + thumbprint = base64.urlsafe_b64encode(thumb_sha256).decode('ascii') + return thumbprint + + def create_directory(directory, keystone_user_id=None, keystone_group_id=None): """Attempt to create a directory if it doesn't exist. diff --git a/keystone/conf/__init__.py b/keystone/conf/__init__.py index 5de0ec183..de4e745d6 100644 --- a/keystone/conf/__init__.py +++ b/keystone/conf/__init__.py @@ -40,6 +40,7 @@ from keystone.conf import jwt_tokens from keystone.conf import ldap from keystone.conf import memcache from keystone.conf import oauth1 +from keystone.conf import oauth2 from keystone.conf import policy from keystone.conf import receipt from keystone.conf import resource @@ -78,6 +79,7 @@ conf_modules = [ ldap, memcache, oauth1, + oauth2, policy, receipt, resource, diff --git a/keystone/conf/identity.py b/keystone/conf/identity.py index 0dffe58d6..5cce78cf9 100644 --- a/keystone/conf/identity.py +++ b/keystone/conf/identity.py @@ -99,7 +99,11 @@ max_password_length = cfg.IntOpt( max=passlib.utils.MAX_PASSWORD_SIZE, help=utils.fmt(""" Maximum allowed length for user passwords. Decrease this value to improve -performance. Changing this value does not effect existing passwords. +performance. Changing this value does not effect existing passwords. This value +can also be overridden by certain hashing algorithms maximum allowed length +which takes precedence over the configured value. + +The bcrypt max_password_length is 54. """)) list_limit = cfg.IntOpt( diff --git a/keystone/conf/oauth2.py b/keystone/conf/oauth2.py new file mode 100644 index 000000000..dbe26cf59 --- /dev/null +++ b/keystone/conf/oauth2.py @@ -0,0 +1,52 @@ +# Copyright 2022 OpenStack Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from oslo_config import cfg + +from keystone.conf import utils + +oauth2_authn_methods = cfg.ListOpt( + 'oauth2_authn_methods', + default=['tls_client_auth', 'client_secret_basic'], + help=utils.fmt(""" +The OAuth2.0 authentication method supported by the system when user obtains +an access token through the OAuth2.0 token endpoint. This option can be set to +certificate or secret. If the option is not set, the default value is +certificate. When the option is set to secret, the OAuth2.0 token endpoint +uses client_secret_basic method for authentication, otherwise tls_client_auth +method is used for authentication. +""")) + +oauth2_cert_dn_mapping_id = cfg.StrOpt( + 'oauth2_cert_dn_mapping_id', + default='oauth2_mapping', + help=utils.fmt(""" +Used to define the mapping rule id. When not set, the mapping rule id is +oauth2_mapping. +""")) + + +GROUP_NAME = __name__.split('.')[-1] +ALL_OPTS = [ + oauth2_authn_methods, + oauth2_cert_dn_mapping_id +] + + +def register_opts(conf): + conf.register_opts(ALL_OPTS, group=GROUP_NAME) + + +def list_opts(): + return {GROUP_NAME: ALL_OPTS} diff --git a/keystone/federation/utils.py b/keystone/federation/utils.py index 5f53dfbb5..71e6318a4 100644 --- a/keystone/federation/utils.py +++ b/keystone/federation/utils.py @@ -251,7 +251,7 @@ class DirectMaps(object): self._matches = [] def __str__(self): - """return the direct map array as a string.""" + """Return the direct map array as a string.""" return '%s' % self._matches def add(self, values): @@ -562,17 +562,31 @@ class RuleProcessor(object): LOG.debug('mapped_properties: %s', mapped_properties) return mapped_properties + def _ast_literal_eval(self, value): + # This is a workaround for the fact that ast.literal_eval handles the + # case of either a string or a list of strings, but not a potential + # list of ints. + + try: + values = ast.literal_eval(value) + # NOTE(mnaser): It's possible that the group_names_list is a + # numerical value which would successfully parse + # and not raise an exception, so we forcefully + # raise is here. + if not isinstance(values, list): + raise ValueError + except (ValueError, SyntaxError): + values = [value] + + return values + def _normalize_groups(self, identity_value): # In this case, identity_value['groups'] is a string # representation of a list, and we want a real list. This is # due to the way we do direct mapping substitutions today (see # function _update_local_mapping() ) if 'name' in identity_value['groups']: - try: - group_names_list = ast.literal_eval( - identity_value['groups']) - except (ValueError, SyntaxError): - group_names_list = [identity_value['groups']] + group_names_list = self._ast_literal_eval(identity_value['groups']) def convert_json(group): if group.startswith('JSON:'): @@ -594,11 +608,8 @@ class RuleProcessor(object): "specified.") msg = msg % {'identity_value': identity_value} raise exception.ValidationError(msg) - try: - group_names_list = ast.literal_eval( - identity_value['groups']) - except (ValueError, SyntaxError): - group_names_list = [identity_value['groups']] + group_names_list = self._ast_literal_eval( + identity_value['groups']) domain = identity_value['domain'] group_dicts = [{'name': name, 'domain': domain} for name in group_names_list] @@ -699,11 +710,8 @@ class RuleProcessor(object): # group_ids parameter contains only one element, it will be # parsed as a simple string, and not a list or the # representation of a list. - try: - group_ids.update( - ast.literal_eval(identity_value['group_ids'])) - except (ValueError, SyntaxError): - group_ids.update([identity_value['group_ids']]) + group_ids.update( + self._ast_literal_eval(identity_value['group_ids'])) if 'projects' in identity_value: projects = identity_value['projects'] diff --git a/keystone/identity/backends/ldap/common.py b/keystone/identity/backends/ldap/common.py index 1033a4efd..d9c07fd87 100644 --- a/keystone/identity/backends/ldap/common.py +++ b/keystone/identity/backends/ldap/common.py @@ -860,11 +860,22 @@ class PooledLDAPHandler(LDAPHandler): cleaned up when message.clean() is called. """ - results = message.connection.result3(message.id, all, timeout) - - # Now that we have the results from the LDAP server for the message, we - # don't need the the context manager used to create the connection. - message.clean() + # message.connection.result3 might throw an exception + # so the code must ensure that message.clean() is invoked + # regardless of the result3's result. Otherwise, the + # connection will be marked as active forever, which + # ultimately renders the pool unusable, causing a DoS. + try: + results = message.connection.result3(message.id, all, timeout) + except Exception: + # We don't want to ignore thrown + # exceptions, raise them + raise + finally: + # Now that we have the results from the LDAP server for + # the message, we don't need the the context manager used + # to create the connection. + message.clean() return results diff --git a/keystone/models/token_model.py b/keystone/models/token_model.py index d68b8eb96..78146295d 100644 --- a/keystone/models/token_model.py +++ b/keystone/models/token_model.py @@ -79,6 +79,9 @@ class TokenModel(object): self.application_credential_id = None self.__application_credential = None + self.oauth2_credential_id = None + self.oauth2_thumbprint = None + def __repr__(self): """Return string representation of TokenModel.""" desc = ('<%(type)s (audit_id=%(audit_id)s, ' @@ -440,6 +443,9 @@ class TokenModel(object): return roles + def _get_oauth2_credential_roles(self): + return self._get_project_roles() + @property def roles(self): if self.system_scoped: diff --git a/keystone/revoke/backends/base.py b/keystone/revoke/backends/base.py index 228db4d5c..52ee957dc 100644 --- a/keystone/revoke/backends/base.py +++ b/keystone/revoke/backends/base.py @@ -36,7 +36,7 @@ class RevokeDriverBase(object, metaclass=abc.ABCMeta): @abc.abstractmethod def list_events(self, last_fetch=None, token=None): - """return the revocation events, as a list of objects. + """Return the revocation events, as a list of objects. :param last_fetch: Time of last fetch. Return all events newer. :param token: dictionary of values from a token, normalized for @@ -52,7 +52,7 @@ class RevokeDriverBase(object, metaclass=abc.ABCMeta): @abc.abstractmethod def revoke(self, event): - """register a revocation event. + """Register a revocation event. :param event: An instance of keystone.revoke.model.RevocationEvent diff --git a/keystone/tests/unit/common/test_utils.py b/keystone/tests/unit/common/test_utils.py index 39962b4f6..673175aea 100644 --- a/keystone/tests/unit/common/test_utils.py +++ b/keystone/tests/unit/common/test_utils.py @@ -134,6 +134,17 @@ class UtilsTestCase(unit.BaseTestCase): common_utils.hash_password, invalid_length_password) + def test_max_algo_length_truncates_password(self): + self.config_fixture.config(strict_password_check=True) + self.config_fixture.config(group='identity', + password_hash_algorithm='bcrypt') + self.config_fixture.config(group='identity', + max_password_length='64') + invalid_length_password = '0' * 64 + self.assertRaises(exception.PasswordVerificationError, + common_utils.hash_password, + invalid_length_password) + def _create_test_user(self, password=OPTIONAL): user = {"name": "hthtest"} if password is not self.OPTIONAL: @@ -214,6 +225,114 @@ class UtilsTestCase(unit.BaseTestCase): expected_string_ending = str(time.second) + 'Z' self.assertTrue(string_time.endswith(expected_string_ending)) + def test_get_certificate_subject_dn(self): + cert_pem = unit.create_pem_certificate( + unit.create_dn( + common_name='test', + organization_name='dev', + locality_name='suzhou', + state_or_province_name='jiangsu', + country_name='cn', + user_id='user_id', + domain_component='test.com', + email_address='user@test.com' + )) + + dn = common_utils.get_certificate_subject_dn(cert_pem) + self.assertEqual('test', dn.get('CN')) + self.assertEqual('dev', dn.get('O')) + self.assertEqual('suzhou', dn.get('L')) + self.assertEqual('jiangsu', dn.get('ST')) + self.assertEqual('cn', dn.get('C')) + self.assertEqual('user_id', dn.get('UID')) + self.assertEqual('test.com', dn.get('DC')) + self.assertEqual('user@test.com', dn.get('emailAddress')) + + def test_get_certificate_issuer_dn(self): + root_cert, root_key = unit.create_certificate( + unit.create_dn( + country_name='jp', + state_or_province_name='kanagawa', + locality_name='kawasaki', + organization_name='fujitsu', + organizational_unit_name='test', + common_name='root' + )) + + cert_pem = unit.create_pem_certificate( + unit.create_dn( + common_name='test', + organization_name='dev', + locality_name='suzhou', + state_or_province_name='jiangsu', + country_name='cn', + user_id='user_id', + domain_component='test.com', + email_address='user@test.com' + ), ca=root_cert, ca_key=root_key) + + dn = common_utils.get_certificate_subject_dn(cert_pem) + self.assertEqual('test', dn.get('CN')) + self.assertEqual('dev', dn.get('O')) + self.assertEqual('suzhou', dn.get('L')) + self.assertEqual('jiangsu', dn.get('ST')) + self.assertEqual('cn', dn.get('C')) + self.assertEqual('user_id', dn.get('UID')) + self.assertEqual('test.com', dn.get('DC')) + self.assertEqual('user@test.com', dn.get('emailAddress')) + + dn = common_utils.get_certificate_issuer_dn(cert_pem) + self.assertEqual('root', dn.get('CN')) + self.assertEqual('fujitsu', dn.get('O')) + self.assertEqual('kawasaki', dn.get('L')) + self.assertEqual('kanagawa', dn.get('ST')) + self.assertEqual('jp', dn.get('C')) + self.assertEqual('test', dn.get('OU')) + + def test_get_certificate_subject_dn_not_pem_format(self): + self.assertRaises( + exception.ValidationError, + common_utils.get_certificate_subject_dn, + 'MIIEkTCCAnkCFDIzsgpdRGF//5ukMuueXnRxQALhMA0GCSqGSIb3DQEBCwUAMIGC') + + def test_get_certificate_issuer_dn_not_pem_format(self): + self.assertRaises( + exception.ValidationError, + common_utils.get_certificate_issuer_dn, + 'MIIEkTCCAnkCFDIzsgpdRGF//5ukMuueXnRxQALhMA0GCSqGSIb3DQEBCwUAMIGC') + + def test_get_certificate_thumbprint(self): + cert_pem = '''-----BEGIN CERTIFICATE----- + MIIEkTCCAnkCFDIzsgpdRGF//5ukMuueXnRxQALhMA0GCSqGSIb3DQEBCwUAMIGC + MQswCQYDVQQGEwJjbjEQMA4GA1UECAwHamlhbmdzdTEPMA0GA1UEBwwGc3V6aG91 + MQ0wCwYDVQQKDARqZnR0MQwwCgYDVQQLDANkZXYxEzARBgNVBAMMCnJvb3QubG9j + YWwxHjAcBgkqhkiG9w0BCQEWD3Rlc3RAcm9vdC5sb2NhbDAeFw0yMjA2MTYwNzM3 + NTZaFw0yMjEyMTMwNzM3NTZaMIGGMQswCQYDVQQGEwJjbjEQMA4GA1UECAwHamlh + bmdzdTEPMA0GA1UEBwwGc3V6aG91MQ0wCwYDVQQKDARqZnR0MQwwCgYDVQQLDANk + ZXYxFTATBgNVBAMMDGNsaWVudC5sb2NhbDEgMB4GCSqGSIb3DQEJARYRdGVzdEBj + bGllbnQubG9jYWwwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCah1Uz + 2OVbk8zLslxxGV+AR6FTy9b/VoinmB6A0jJA1Zz2D6rsjN2S5xQ5wHIO2WSVX9Ry + SonOmeZZqRA9faNJcNNcrBhJICScAhMGHCuli3EUMry/6xK0OYHGgI2X6mcTaIjv + tFKHO1BCb5YGdNBa+ff+ncTeVX/PeN3nKjA4xvQb9JZxJTgY0JVhledbaoepFSdW + EFW0nbUF+8lj1gCo5E4cAX1eTcUKs43FnWGCJcJT6FB1vP9x8e4h9p0RWbb9GMrU + DXKbzF5e28qIiCkYHv2/A/G/J+aeg2K4Cbqy+8908I5BdWZEsJBhWJ0+CEtC3n91 + fU6dnAyipO496aa/AgMBAAEwDQYJKoZIhvcNAQELBQADggIBABoOOmLrWNlQzodS + n2wfkiF0Lz+pj3FKFPz3sYUYWkAiKXU/6RRu1Md7INRo0MFau4iAN8Raq4JFdbnU + HRN9G/UU58ETqi/8cYfOA2+MHHRif1Al9YSvTgHQa6ljZPttGeigOqmGlovPd+7R + vLXlKtcr5XBVk9pWPmVpwtAN3bMVlphgEqBO26Ff9J3G5PaNQ6UdpwXC19mRqk6r + BUsFBRwy7EeeGNy8DvoHTJfMc2JUbLjesSMOmIkaOGbhe327iRd/GJe4dO91+prE + HNWVR/bVoGiUZvSLPqrwU173XbdNd6yMKC+fULICI34eaWDe1zHrg9XdRxtessUx + OyJw5bgH09lOs8DSYXjFyx5lDxtERKHaLRgpSNd5foQO/mHiegC2qmdtxqKyOwub + V/h6vziDsFZfciwmo6iw3ZpdBvjbYqw32joURQ1IVh1naY6ZzMwq/PsyYVhMYUNB + XYPKvm68YfKuYmpwF7Z5Wll4EWm5DTq1dbmjdo+OQsMyiwWepWE0WV7Ng+AEbTqP + /akzUXt/AEbbBpZskB6v5q/YOcglWuAQVXs2viguyDvOQVbEB7JKDi4xzlZg3kQP + apjt17fip7wQi2jJkwdyAqvrdi/xLhK5+6BSo04lNc8sGZ9wToIoNkgv0cG+BrVU + 4cJHNiTQl8bxfSgwemgSYnnyXM4k + -----END CERTIFICATE-----''' + thumbprint = common_utils.get_certificate_thumbprint(cert_pem) + self.assertEqual('dMmoJKE9MIJK9VcyahYCb417JDhDfdtTiq_krco8-tk=', + thumbprint) + class ServiceHelperTests(unit.BaseTestCase): diff --git a/keystone/tests/unit/contrib/federation/test_utils.py b/keystone/tests/unit/contrib/federation/test_utils.py index f9153cb09..4d9f98f2d 100644 --- a/keystone/tests/unit/contrib/federation/test_utils.py +++ b/keystone/tests/unit/contrib/federation/test_utils.py @@ -764,6 +764,24 @@ class MappingRuleEngineTests(unit.BaseTestCase): self.assertEqual('ALL USERS', mapped_properties['group_names'][0]['name']) + def test_rule_engine_groups_mapping_only_one_numerical_group(self): + """Test mapping engine when groups is explicitly set. + + If the groups list has only one group, + test if the transformation is done correctly + + """ + mapping = mapping_fixtures.MAPPING_GROUPS_WITH_EMAIL + assertion = mapping_fixtures.GROUPS_ASSERTION_ONLY_ONE_NUMERICAL_GROUP + rp = mapping_utils.RuleProcessor(FAKE_MAPPING_ID, mapping['rules']) + mapped_properties = rp.process(assertion) + self.assertIsNotNone(mapped_properties) + self.assertEqual('jsmith', mapped_properties['user']['name']) + self.assertEqual('jill@example.com', + mapped_properties['user']['email']) + self.assertEqual('1234', + mapped_properties['group_names'][0]['name']) + def test_rule_engine_group_ids_mapping_whitelist(self): """Test mapping engine when group_ids is explicitly set. diff --git a/keystone/tests/unit/core.py b/keystone/tests/unit/core.py index 2a6c12038..6e0cad62e 100644 --- a/keystone/tests/unit/core.py +++ b/keystone/tests/unit/core.py @@ -28,6 +28,10 @@ import socket import sys import uuid +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.serialization import Encoding +from cryptography import x509 import fixtures import flask from flask import testing as flask_testing @@ -433,6 +437,77 @@ def new_totp_credential(user_id, project_id=None, blob=None): return credential +def create_dn( + common_name=None, + locality_name=None, + state_or_province_name=None, + organization_name=None, + organizational_unit_name=None, + country_name=None, + street_address=None, + domain_component=None, + user_id=None, + email_address=None, +): + oid = x509.NameOID + attr = x509.NameAttribute + dn = [] + if common_name: + dn.append(attr(oid.COMMON_NAME, common_name)) + if locality_name: + dn.append(attr(oid.LOCALITY_NAME, locality_name)) + if state_or_province_name: + dn.append(attr(oid.STATE_OR_PROVINCE_NAME, state_or_province_name)) + if organization_name: + dn.append(attr(oid.ORGANIZATION_NAME, organization_name)) + if organizational_unit_name: + dn.append(attr(oid.ORGANIZATIONAL_UNIT_NAME, organizational_unit_name)) + if country_name: + dn.append(attr(oid.COUNTRY_NAME, country_name)) + if street_address: + dn.append(attr(oid.STREET_ADDRESS, street_address)) + if domain_component: + dn.append(attr(oid.DOMAIN_COMPONENT, domain_component)) + if user_id: + dn.append(attr(oid.USER_ID, user_id)) + if email_address: + dn.append(attr(oid.EMAIL_ADDRESS, email_address)) + return x509.Name(dn) + + +def update_dn(dn1, dn2): + dn1_attrs = {attr.oid: attr for attr in dn1} + dn2_attrs = {attr.oid: attr for attr in dn2} + dn1_attrs.update(dn2_attrs) + return x509.Name([attr for attr in dn1_attrs.values()]) + + +def create_certificate(subject_dn, ca=None, ca_key=None): + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + ) + issuer = ca.subject if ca else subject_dn + if not ca_key: + ca_key = private_key + today = datetime.datetime.today() + cert = x509.CertificateBuilder( + issuer_name=issuer, + subject_name=subject_dn, + public_key=private_key.public_key(), + serial_number=x509.random_serial_number(), + not_valid_before=today, + not_valid_after=today + datetime.timedelta(365, 0, 0), + ).sign(ca_key, hashes.SHA256()) + + return cert, private_key + + +def create_pem_certificate(subject_dn, ca=None, ca_key=None): + cert, _ = create_certificate(subject_dn, ca=ca, ca_key=ca_key) + return cert.public_bytes(Encoding.PEM).decode('ascii') + + def new_application_credential_ref(roles=None, name=None, expires=None, diff --git a/keystone/tests/unit/fakeldap.py b/keystone/tests/unit/fakeldap.py index f374322d1..5119305a7 100644 --- a/keystone/tests/unit/fakeldap.py +++ b/keystone/tests/unit/fakeldap.py @@ -296,6 +296,9 @@ class FakeLdap(common.LDAPHandler): raise ldap.SERVER_DOWN whos = ['cn=Admin', CONF.ldap.user] if (who in whos and cred in ['password', CONF.ldap.password]): + self.connected = True + self.who = who + self.cred = cred return attrs = self.db.get(self.key(who)) @@ -316,6 +319,9 @@ class FakeLdap(common.LDAPHandler): def unbind_s(self): """Provide for compatibility but this method is ignored.""" + self.connected = False + self.who = None + self.cred = None if server_fail: raise ldap.SERVER_DOWN @@ -534,7 +540,7 @@ class FakeLdap(common.LDAPHandler): raise exception.NotImplemented() # only passing a single server control is supported by this fake ldap - if len(serverctrls) > 1: + if serverctrls and len(serverctrls) > 1: raise exception.NotImplemented() # search_ext is async and returns an identifier used for @@ -589,6 +595,7 @@ class FakeLdapPool(FakeLdap): def __init__(self, uri, retry_max=None, retry_delay=None, conn=None): super(FakeLdapPool, self).__init__(conn=conn) self.url = uri + self._uri = uri self.connected = None self.conn = self self._connection_time = 5 # any number greater than 0 diff --git a/keystone/tests/unit/mapping_fixtures.py b/keystone/tests/unit/mapping_fixtures.py index 51f1526bb..5a6dbf8c3 100644 --- a/keystone/tests/unit/mapping_fixtures.py +++ b/keystone/tests/unit/mapping_fixtures.py @@ -1735,6 +1735,12 @@ GROUPS_ASSERTION_ONLY_ONE_GROUP = { 'groups': 'ALL USERS' } +GROUPS_ASSERTION_ONLY_ONE_NUMERICAL_GROUP = { + 'userEmail': 'jill@example.com', + 'UserName': 'jsmith', + 'groups': '1234' +} + GROUPS_DOMAIN_ASSERTION = { 'openstack_user': 'bwilliams', 'openstack_user_domain': 'default', diff --git a/keystone/tests/unit/test_backend_ldap_pool.py b/keystone/tests/unit/test_backend_ldap_pool.py index 9b5e92748..1c4b19804 100644 --- a/keystone/tests/unit/test_backend_ldap_pool.py +++ b/keystone/tests/unit/test_backend_ldap_pool.py @@ -163,12 +163,23 @@ class LdapPoolCommonTestMixin(object): # Then open 3 connections again and make sure size does not grow # over 3 - with _get_conn() as _: # conn1 + with _get_conn() as c1: # conn1 + self.assertEqual(3, len(ldappool_cm)) + c1.connected = False + with _get_conn() as c2: # conn2 + self.assertEqual(3, len(ldappool_cm)) + c2.connected = False + with _get_conn() as c3: # conn3 + c3.connected = False + c3.unbind_ext_s() + self.assertEqual(3, len(ldappool_cm)) + + with _get_conn() as c1: # conn1 self.assertEqual(1, len(ldappool_cm)) - with _get_conn() as _: # conn2 + with _get_conn() as c2: # conn2 self.assertEqual(2, len(ldappool_cm)) - with _get_conn() as _: # conn3 - _.unbind_ext_s() + with _get_conn() as c3: # conn3 + c3.unbind_ext_s() self.assertEqual(3, len(ldappool_cm)) def test_password_change_with_pool(self): @@ -209,6 +220,105 @@ class LdapPoolCommonTestMixin(object): user_id=self.user_sna['id'], password=old_password) + @mock.patch.object(fakeldap.FakeLdap, 'search_ext') + def test_search_ext_ensure_pool_connection_released(self, mock_search_ext): + """Test search_ext exception resiliency. + + Call search_ext function in isolation. Doing so will cause + search_ext to borrow a connection from the pool and associate + it with an AsynchronousMessage object. Borrowed connection ought + to be released if anything goes wrong during LDAP API call. This + test case intentionally throws an exception to ensure everything + goes as expected when LDAP connection raises an exception. + """ + class CustomDummyException(Exception): + pass + + # Throw an exception intentionally when LDAP + # connection search_ext function is called + mock_search_ext.side_effect = CustomDummyException() + self.config_fixture.config(group='ldap', pool_size=1) + pool = self.conn_pools[CONF.ldap.url] + user_api = ldap.UserApi(CONF) + + # setUp primes the pool so pool + # must have one connection + self.assertEqual(1, len(pool)) + for i in range(1, 10): + handler = user_api.get_connection() + # Just to ensure that we're using pooled connections + self.assertIsInstance(handler.conn, common_ldap.PooledLDAPHandler) + # LDAP API will throw CustomDummyException. In this scenario + # we expect LDAP connection to be made available back to the + # pool. + self.assertRaises( + CustomDummyException, + lambda: handler.search_ext( + 'dc=example,dc=test', + 'dummy', + 'objectclass=*', + ['mail', 'userPassword'] + ) + ) + # Pooled connection must not be evicted from the pool + self.assertEqual(1, len(pool)) + # Ensure that the connection is inactive afterwards + with pool._pool_lock: + for slot, conn in enumerate(pool._pool): + self.assertFalse(conn.active) + + self.assertEqual(mock_search_ext.call_count, i) + + @mock.patch.object(fakeldap.FakeLdap, 'result3') + def test_result3_ensure_pool_connection_released(self, mock_result3): + """Test search_ext-->result3 exception resiliency. + + Call search_ext function, grab an AsynchronousMessage object and + call result3 with it. During the result3 call, LDAP API will throw + an exception.The expectation is that the associated LDAP pool + connection for AsynchronousMessage must be released back to the + LDAP connection pool. + """ + class CustomDummyException(Exception): + pass + + # Throw an exception intentionally when LDAP + # connection result3 function is called + mock_result3.side_effect = CustomDummyException() + self.config_fixture.config(group='ldap', pool_size=1) + pool = self.conn_pools[CONF.ldap.url] + user_api = ldap.UserApi(CONF) + + # setUp primes the pool so pool + # must have one connection + self.assertEqual(1, len(pool)) + for i in range(1, 10): + handler = user_api.get_connection() + # Just to ensure that we're using pooled connections + self.assertIsInstance(handler.conn, common_ldap.PooledLDAPHandler) + msg = handler.search_ext( + 'dc=example,dc=test', + 'dummy', + 'objectclass=*', + ['mail', 'userPassword'] + ) + # Connection is in use, must be already marked active + self.assertTrue(msg.connection.active) + # Pooled connection must not be evicted from the pool + self.assertEqual(1, len(pool)) + # LDAP API will throw CustomDummyException. In this + # scenario we expect LDAP connection to be made + # available back to the pool. + self.assertRaises( + CustomDummyException, + lambda: handler.result3(msg) + ) + # Connection must be set inactive + self.assertFalse(msg.connection.active) + # Pooled connection must not be evicted from the pool + self.assertEqual(1, len(pool)) + self.assertEqual(mock_result3.call_count, i) + class LDAPIdentity(LdapPoolCommonTestMixin, test_backend_ldap.LDAPIdentity, diff --git a/keystone/tests/unit/test_sql_upgrade.py b/keystone/tests/unit/test_sql_upgrade.py index 55440c955..5a8211881 100644 --- a/keystone/tests/unit/test_sql_upgrade.py +++ b/keystone/tests/unit/test_sql_upgrade.py @@ -228,6 +228,7 @@ class MigrateBase( db_fixtures.OpportunisticDBTestMixin, ): """Test complete orchestration between all database phases.""" + def setUp(self): super().setUp() diff --git a/keystone/tests/unit/test_v3_auth.py b/keystone/tests/unit/test_v3_auth.py index e710634d0..eb7ea0e29 100644 --- a/keystone/tests/unit/test_v3_auth.py +++ b/keystone/tests/unit/test_v3_auth.py @@ -19,8 +19,10 @@ import itertools import operator import re from unittest import mock +from urllib import parse import uuid +from cryptography.hazmat.primitives.serialization import Encoding import freezegun import http.client from oslo_serialization import jsonutils as json @@ -2645,6 +2647,187 @@ class TokenAPITests(object): r = self._validate_token(token, allow_expired=True) self.assertValidProjectScopedTokenResponse(r) + def _create_project_user(self): + new_domain_ref = unit.new_domain_ref() + PROVIDERS.resource_api.create_domain( + new_domain_ref['id'], new_domain_ref + ) + new_project_ref = unit.new_project_ref(domain_id=self.domain_id) + PROVIDERS.resource_api.create_project( + new_project_ref['id'], new_project_ref + ) + new_user = unit.create_user(PROVIDERS.identity_api, + domain_id=new_domain_ref['id'], + project_id=new_project_ref['id']) + PROVIDERS.assignment_api.create_grant( + self.role['id'], + user_id=new_user['id'], + project_id=new_project_ref['id']) + return new_user, new_domain_ref, new_project_ref + + def _create_certificates(self, + root_dn=None, + server_dn=None, + client_dn=None): + root_subj = unit.create_dn( + country_name='jp', + state_or_province_name='kanagawa', + locality_name='kawasaki', + organization_name='fujitsu', + organizational_unit_name='test', + common_name='root' + ) + if root_dn: + root_subj = unit.update_dn(root_subj, root_dn) + + root_cert, root_key = unit.create_certificate(root_subj) + keystone_subj = unit.create_dn( + country_name='jp', + state_or_province_name='kanagawa', + locality_name='kawasaki', + organization_name='fujitsu', + organizational_unit_name='test', + common_name='keystone.local' + ) + if server_dn: + keystone_subj = unit.update_dn(keystone_subj, server_dn) + + ks_cert, ks_key = unit.create_certificate( + keystone_subj, ca=root_cert, ca_key=root_key) + client_subj = unit.create_dn( + country_name='jp', + state_or_province_name='kanagawa', + locality_name='kawasaki', + organization_name='fujitsu', + organizational_unit_name='test', + common_name='client' + ) + if client_dn: + client_subj = unit.update_dn(client_subj, client_dn) + + client_cert, client_key = unit.create_certificate( + client_subj, ca=root_cert, ca_key=root_key) + return root_cert, root_key, ks_cert, ks_key, client_cert, client_key + + def _get_cert_content(self, cert): + return cert.public_bytes(Encoding.PEM).decode('ascii') + + def _get_oauth2_access_token(self, client_id, client_cert_content, + expected_status=http.client.OK): + headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + } + data = { + 'grant_type': 'client_credentials', + 'client_id': client_id + } + extra_environ = { + 'SSL_CLIENT_CERT': client_cert_content + } + data = parse.urlencode(data).encode() + resp = self.post( + '/OS-OAUTH2/token', + headers=headers, + noauth=True, + convert=False, + body=data, + environ=extra_environ, + expected_status=expected_status) + return resp + + def _create_mapping(self): + mapping = { + 'id': 'oauth2_mapping', + 'rules': [ + { + 'local': [ + { + 'user': { + 'name': '{0}', + 'id': '{1}', + 'email': '{2}', + 'domain': { + 'name': '{3}', + 'id': '{4}' + } + } + } + ], + 'remote': [ + { + 'type': 'SSL_CLIENT_SUBJECT_DN_CN' + }, + { + 'type': 'SSL_CLIENT_SUBJECT_DN_UID' + }, + { + 'type': 'SSL_CLIENT_SUBJECT_DN_EMAILADDRESS' + }, + { + 'type': 'SSL_CLIENT_SUBJECT_DN_O' + }, + { + 'type': 'SSL_CLIENT_SUBJECT_DN_DC' + }, + { + 'type': 'SSL_CLIENT_ISSUER_DN_CN', + 'any_one_of': [ + 'root' + ] + } + ] + } + ] + } + PROVIDERS.federation_api.create_mapping(mapping['id'], mapping) + + def test_verify_oauth2_token_project_scope_ok(self): + cache_on_issue = CONF.token.cache_on_issue + caching = CONF.token.caching + self._create_mapping() + user, user_domain, _ = self._create_project_user() + + *_, client_cert, _ = self._create_certificates( + root_dn=unit.create_dn( + common_name='root' + ), + client_dn=unit.create_dn( + common_name=user['name'], + user_id=user['id'], + email_address=user['email'], + organization_name=user_domain['name'], + domain_component=user_domain['id'] + ) + ) + + cert_content = self._get_cert_content(client_cert) + CONF.token.cache_on_issue = False + CONF.token.caching = False + resp = self._get_oauth2_access_token(user['id'], cert_content) + + json_resp = json.loads(resp.body) + self.assertIn('access_token', json_resp) + self.assertEqual('Bearer', json_resp['token_type']) + self.assertEqual(3600, json_resp['expires_in']) + + verify_resp = self.get( + '/auth/tokens', + headers={ + 'X-Subject-Token': json_resp['access_token'], + 'X-Auth-Token': json_resp['access_token'] + }, + expected_status=http.client.OK) + self.assertIn('token', verify_resp.result) + self.assertIn('oauth2_credential', verify_resp.result['token']) + self.assertIn('roles', verify_resp.result['token']) + self.assertIn('project', verify_resp.result['token']) + self.assertIn('catalog', verify_resp.result['token']) + check_oauth2 = verify_resp.result['token']['oauth2_credential'] + self.assertEqual(utils.get_certificate_thumbprint(cert_content), + check_oauth2['x5t#S256']) + CONF.token.cache_on_issue = cache_on_issue + CONF.token.caching = caching + class TokenDataTests(object): """Test the data in specific token types.""" @@ -5577,6 +5760,21 @@ class ApplicationCredentialAuth(test_v3.RestfulTestCase): self.v3_create_token(auth_data, expected_status=http.client.UNAUTHORIZED) + def test_application_credential_expiration_limits_token_expiration(self): + expires_at = datetime.datetime.utcnow() + datetime.timedelta(minutes=1) + app_cred = self._make_app_cred(expires=expires_at) + app_cred_ref = self.app_cred_api.create_application_credential( + app_cred) + auth_data = self.build_authentication_request( + app_cred_id=app_cred_ref['id'], secret=app_cred_ref['secret']) + resp = self.v3_create_token(auth_data, + expected_status=http.client.CREATED) + token = resp.headers.get('X-Subject-Token') + future = datetime.datetime.utcnow() + datetime.timedelta(minutes=2) + with freezegun.freeze_time(future): + self._validate_token(token, + expected_status=http.client.UNAUTHORIZED) + def test_application_credential_fails_when_user_deleted(self): app_cred = self._make_app_cred() app_cred_ref = self.app_cred_api.create_application_credential( diff --git a/keystone/tests/unit/test_v3_oauth2.py b/keystone/tests/unit/test_v3_oauth2.py index 5d01e8953..6eaa8560f 100644 --- a/keystone/tests/unit/test_v3_oauth2.py +++ b/keystone/tests/unit/test_v3_oauth2.py @@ -13,16 +13,26 @@ # under the License. from base64 import b64encode +from cryptography.hazmat.primitives.serialization import Encoding +import fixtures +import http from http import client from oslo_log import log from oslo_serialization import jsonutils from unittest import mock from urllib import parse +from keystone.api.os_oauth2 import AccessTokenResource +from keystone.common import provider_api +from keystone.common import utils from keystone import conf from keystone import exception +from keystone.federation.utils import RuleProcessor +from keystone.tests import unit from keystone.tests.unit import test_v3 +from keystone.token.provider import Manager +PROVIDERS = provider_api.ProviderAPIs LOG = log.getLogger(__name__) CONF = conf.CONF @@ -31,7 +41,169 @@ class FakeUserAppCredListCreateResource(mock.Mock): pass -class OAuth2Tests(test_v3.OAuth2RestfulTestCase): +class OAuth2AuthnMethodsTests(test_v3.OAuth2RestfulTestCase): + ACCESS_TOKEN_URL = '/OS-OAUTH2/token' + + def setUp(self): + super(OAuth2AuthnMethodsTests, self).setUp() + self.config_fixture.config( + group='oauth2', + oauth2_authn_methods=['client_secret_basic', 'tls_client_auth'], + ) + + def _get_access_token( + self, + headers, + data, + expected_status, + client_cert_content=None): + data = parse.urlencode(data).encode() + kwargs = { + 'headers': headers, + 'noauth': True, + 'convert': False, + 'body': data, + 'expected_status': expected_status + } + if client_cert_content: + kwargs.update({'environ': { + 'SSL_CLIENT_CERT': client_cert_content + }}) + resp = self.post( + self.ACCESS_TOKEN_URL, + **kwargs) + return resp + + def _create_certificates(self): + return unit.create_certificate( + subject_dn=unit.create_dn( + country_name='jp', + state_or_province_name='tokyo', + locality_name='musashino', + organizational_unit_name='test' + ) + ) + + def _get_cert_content(self, cert): + return cert.public_bytes(Encoding.PEM).decode('ascii') + + @mock.patch.object(AccessTokenResource, '_client_secret_basic') + def test_secret_basic_header(self, mock_client_secret_basic): + """client_secret_basic is used if a client sercret is found.""" + client_id = 'client_id' + client_secret = 'client_secret' + b64str = b64encode( + f'{client_id}:{client_secret}'.encode()).decode().strip() + headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': f'Basic {b64str}' + } + data = { + 'grant_type': 'client_credentials' + } + + _ = self._get_access_token( + headers=headers, + data=data, + expected_status=client.OK) + mock_client_secret_basic.assert_called_once_with( + client_id, client_secret) + + @mock.patch.object(AccessTokenResource, '_client_secret_basic') + def test_secret_basic_form(self, mock_client_secret_basic): + """client_secret_basic is used if a client sercret is found.""" + client_id = 'client_id' + client_secret = 'client_secret' + headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + } + data = { + 'grant_type': 'client_credentials', + 'client_id': client_id, + 'client_secret': client_secret + } + + _ = self._get_access_token( + headers=headers, + data=data, + expected_status=client.OK) + mock_client_secret_basic.assert_called_once_with( + client_id, client_secret) + + @mock.patch.object(AccessTokenResource, '_client_secret_basic') + def test_secret_basic_header_and_form(self, mock_client_secret_basic): + """A header is used if secrets are found in a header and body.""" + client_id_h = 'client_id_h' + client_secret_h = 'client_secret_h' + client_id_d = 'client_id_d' + client_secret_d = 'client_secret_d' + b64str = b64encode( + f'{client_id_h}:{client_secret_h}'.encode()).decode().strip() + headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': f'Basic {b64str}' + } + data = { + 'grant_type': 'client_credentials', + 'client_id': client_id_d, + 'client_secret': client_secret_d + } + + _ = self._get_access_token( + headers=headers, + data=data, + expected_status=client.OK) + mock_client_secret_basic.assert_called_once_with( + client_id_h, client_secret_h) + + @mock.patch.object(AccessTokenResource, '_tls_client_auth') + def test_client_cert(self, mock_tls_client_auth): + """tls_client_auth is used if a certificate is found.""" + client_id = 'client_id' + client_cert, _ = self._create_certificates() + cert_content = self._get_cert_content(client_cert) + headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + } + data = { + 'grant_type': 'client_credentials', + 'client_id': client_id + } + _ = self._get_access_token( + headers=headers, + data=data, + expected_status=client.OK, + client_cert_content=cert_content) + mock_tls_client_auth.assert_called_once_with(client_id, cert_content) + + @mock.patch.object(AccessTokenResource, '_tls_client_auth') + def test_secret_basic_and_client_cert(self, mock_tls_client_auth): + """tls_client_auth is used if a certificate and secret are found.""" + client_id_s = 'client_id_s' + client_secret = 'client_secret' + client_id_c = 'client_id_c' + client_cert, _ = self._create_certificates() + cert_content = self._get_cert_content(client_cert) + b64str = b64encode( + f'{client_id_s}:{client_secret}'.encode()).decode().strip() + headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': f'Basic {b64str}' + } + data = { + 'grant_type': 'client_credentials', + 'client_id': client_id_c, + } + + _ = self._get_access_token( + headers=headers, + data=data, + expected_status=client.OK, + client_cert_content=cert_content) + mock_tls_client_auth.assert_called_once_with(client_id_c, cert_content) + + +class OAuth2SecretBasicTests(test_v3.OAuth2RestfulTestCase): APP_CRED_CREATE_URL = '/users/%(user_id)s/application_credentials' APP_CRED_LIST_URL = '/users/%(user_id)s/application_credentials' APP_CRED_DELETE_URL = '/users/%(user_id)s/application_credentials/' \ @@ -41,7 +213,7 @@ class OAuth2Tests(test_v3.OAuth2RestfulTestCase): ACCESS_TOKEN_URL = '/OS-OAUTH2/token' def setUp(self): - super(OAuth2Tests, self).setUp() + super(OAuth2SecretBasicTests, self).setUp() log.set_defaults( logging_context_format_string='%(asctime)s.%(msecs)03d %(' 'color)s%(levelname)s %(name)s [^[[' @@ -53,6 +225,10 @@ class OAuth2Tests(test_v3.OAuth2RestfulTestCase): CONF.log_opt_values(LOG, log.DEBUG) LOG.debug(f'is_debug_enabled: {log.is_debug_enabled(CONF)}') LOG.debug(f'get_default_log_levels: {log.get_default_log_levels()}') + self.config_fixture.config( + group='oauth2', + oauth2_authn_methods=['client_secret_basic'], + ) def _assert_error_resp(self, error_resp, error_msg, error_description): resp_keys = ( @@ -104,15 +280,6 @@ class OAuth2Tests(test_v3.OAuth2RestfulTestCase): expected_status=expected_status) return resp - -class AccessTokenTests(OAuth2Tests): - - def setUp(self): - super(AccessTokenTests, self).setUp() - - def _create_access_token(self, client): - pass - def _get_access_token_method_not_allowed(self, app_cred, http_func): client_id = app_cred.get('id') @@ -139,7 +306,6 @@ class AccessTokenTests(OAuth2Tests): def test_get_access_token(self): """Test case when an access token can be successfully obtain.""" - client_name = 'client_name_test' app_cred = self._create_app_cred(self.user_id, client_name) resp = self._get_access_token( @@ -153,35 +319,31 @@ class AccessTokenTests(OAuth2Tests): self.assertEqual('Bearer', json_resp['token_type']) self.assertEqual(3600, json_resp['expires_in']) - def test_get_access_token_without_client_auth(self): + def test_get_access_token_form(self): """Test case when there is no client authorization.""" - client_name = 'client_name_test' app_cred = self._create_app_cred(self.user_id, client_name) headers = { - 'Content-Type': 'application/x-www-form-urlencoded' + 'Content-Type': 'application/x-www-form-urlencoded', } - error = 'invalid_client' - error_description = 'OAuth2.0 client authorization is required.' - resp = self._get_access_token(app_cred, - b64str=None, - headers=headers, - data=None, - expected_status=client.UNAUTHORIZED) - self.assertNotEmpty(resp.headers.get("WWW-Authenticate")) - self.assertEqual('Keystone uri="http://localhost/v3"', - resp.headers.get("WWW-Authenticate")) + data = { + 'grant_type': 'client_credentials', + 'client_id': app_cred.get('id'), + 'client_secret': app_cred.get('secret'), + } + resp = self._get_access_token( + app_cred, + b64str=None, + headers=headers, + data=data, + expected_status=client.OK) json_resp = jsonutils.loads(resp.body) - LOG.debug(f'error: {json_resp.get("error")}') - LOG.debug(f'error_description: {json_resp.get("error_description")}') - self.assertEqual(error, - json_resp.get('error')) - self.assertEqual(error_description, - json_resp.get('error_description')) + self.assertIn('access_token', json_resp) + self.assertEqual('Bearer', json_resp['token_type']) + self.assertEqual(3600, json_resp['expires_in']) def test_get_access_token_auth_type_is_not_basic(self): """Test case when auth_type is not basic.""" - client_name = 'client_name_test' app_cred = self._create_app_cred(self.user_id, client_name) client_id = app_cred.get('id') @@ -195,8 +357,7 @@ class AccessTokenTests(OAuth2Tests): 'Authorization': f'Digest {base}' } error = 'invalid_client' - error_description = 'OAuth2.0 client authorization type ' \ - 'digest is not supported.' + error_description = 'Client authentication failed.' resp = self._get_access_token(app_cred, b64str=None, headers=headers, @@ -215,14 +376,13 @@ class AccessTokenTests(OAuth2Tests): def test_get_access_token_without_client_id(self): """Test case when there is no client_id.""" - client_name = 'client_name_test' app_cred = self._create_app_cred(self.user_id, client_name) client_secret = app_cred.get('secret') b64str = b64encode( f':{client_secret}'.encode()).decode().strip() error = 'invalid_client' - error_description = 'OAuth2.0 client authorization is invalid.' + error_description = 'Client authentication failed.' resp = self._get_access_token(app_cred, b64str=b64str, headers=None, @@ -241,14 +401,13 @@ class AccessTokenTests(OAuth2Tests): def test_get_access_token_without_client_secret(self): """Test case when there is no client_secret.""" - client_name = 'client_name_test' app_cred = self._create_app_cred(self.user_id, client_name) client_id = app_cred.get('id') b64str = b64encode( f'{client_id}:'.encode()).decode().strip() error = 'invalid_client' - error_description = 'OAuth2.0 client authorization is invalid.' + error_description = 'Client authentication failed.' resp = self._get_access_token(app_cred, b64str=b64str, headers=None, @@ -267,7 +426,6 @@ class AccessTokenTests(OAuth2Tests): def test_get_access_token_without_grant_type(self): """Test case when there is no grant_type.""" - client_name = 'client_name_test' app_cred = self._create_app_cred(self.user_id, client_name) data = {} @@ -288,7 +446,6 @@ class AccessTokenTests(OAuth2Tests): def test_get_access_token_blank_grant_type(self): """Test case when grant_type is blank.""" - client_name = 'client_name_test' app_cred = self._create_app_cred(self.user_id, client_name) data = { @@ -312,7 +469,6 @@ class AccessTokenTests(OAuth2Tests): def test_get_access_token_grant_type_is_not_client_credentials(self): """Test case when grant_type is not client_credentials.""" - client_name = 'client_name_test' app_cred = self._create_app_cred(self.user_id, client_name) data = { @@ -336,7 +492,6 @@ class AccessTokenTests(OAuth2Tests): def test_get_access_token_failed_401(self): """Test case when client authentication failed.""" - client_name = 'client_name_test' app_cred = self._create_app_cred(self.user_id, client_name) error = 'invalid_client' @@ -376,7 +531,6 @@ class AccessTokenTests(OAuth2Tests): def test_get_access_token_failed_400(self): """Test case when the called API is incorrect.""" - client_name = 'client_name_test' app_cred = self._create_app_cred(self.user_id, client_name) error = 'invalid_request' @@ -412,7 +566,6 @@ class AccessTokenTests(OAuth2Tests): def test_get_access_token_failed_500_other(self): """Test case when unexpected error.""" - client_name = 'client_name_test' app_cred = self._create_app_cred(self.user_id, client_name) error = 'other_error' @@ -448,7 +601,6 @@ class AccessTokenTests(OAuth2Tests): def test_get_access_token_failed_500(self): """Test case when internal server error.""" - client_name = 'client_name_test' app_cred = self._create_app_cred(self.user_id, client_name) error = 'other_error' @@ -484,7 +636,6 @@ class AccessTokenTests(OAuth2Tests): def test_get_access_token_method_get_not_allowed(self): """Test case when the request is get method that is not allowed.""" - client_name = 'client_name_test' app_cred = self._create_app_cred(self.user_id, client_name) json_resp = self._get_access_token_method_not_allowed( @@ -496,7 +647,6 @@ class AccessTokenTests(OAuth2Tests): def test_get_access_token_method_patch_not_allowed(self): """Test case when the request is patch method that is not allowed.""" - client_name = 'client_name_test' app_cred = self._create_app_cred(self.user_id, client_name) json_resp = self._get_access_token_method_not_allowed( @@ -508,7 +658,6 @@ class AccessTokenTests(OAuth2Tests): def test_get_access_token_method_put_not_allowed(self): """Test case when the request is put method that is not allowed.""" - client_name = 'client_name_test' app_cred = self._create_app_cred(self.user_id, client_name) json_resp = self._get_access_token_method_not_allowed( @@ -520,7 +669,6 @@ class AccessTokenTests(OAuth2Tests): def test_get_access_token_method_delete_not_allowed(self): """Test case when the request is delete method that is not allowed.""" - client_name = 'client_name_test' app_cred = self._create_app_cred(self.user_id, client_name) json_resp = self._get_access_token_method_not_allowed( @@ -532,7 +680,6 @@ class AccessTokenTests(OAuth2Tests): def test_get_access_token_method_head_not_allowed(self): """Test case when the request is head method that is not allowed.""" - client_name = 'client_name_test' app_cred = self._create_app_cred(self.user_id, client_name) client_id = app_cred.get('id') @@ -548,3 +695,1377 @@ class AccessTokenTests(OAuth2Tests): headers=headers, convert=False, expected_status=client.METHOD_NOT_ALLOWED) + + +class OAuth2CertificateTests(test_v3.OAuth2RestfulTestCase): + ACCESS_TOKEN_URL = '/OS-OAUTH2/token' + + def setUp(self): + super(OAuth2CertificateTests, self).setUp() + self.log_fix = self.useFixture(fixtures.FakeLogger(level=log.DEBUG)) + self.config_fixture.config(group='oauth2', + oauth2_authn_methods=['tls_client_auth']) + self.config_fixture.config(group='oauth2', + oauth2_cert_dn_mapping_id='oauth2_mapping') + ( + self.oauth2_user, + self.oauth2_user_domain, + _, + ) = self._create_project_user() + *_, self.client_cert, self.client_key = self._create_certificates( + client_dn=unit.create_dn( + user_id=self.oauth2_user.get('id'), + common_name=self.oauth2_user.get('name'), + email_address=self.oauth2_user.get('email'), + domain_component=self.oauth2_user_domain.get('id'), + organization_name=self.oauth2_user_domain.get('name') + ) + ) + + def _create_project_user(self, no_roles=False): + new_domain_ref = unit.new_domain_ref() + PROVIDERS.resource_api.create_domain( + new_domain_ref['id'], new_domain_ref + ) + new_project_ref = unit.new_project_ref(domain_id=self.domain_id) + PROVIDERS.resource_api.create_project( + new_project_ref['id'], new_project_ref + ) + new_user = unit.create_user(PROVIDERS.identity_api, + domain_id=new_domain_ref['id'], + project_id=new_project_ref['id']) + if not no_roles: + PROVIDERS.assignment_api.create_grant( + self.role['id'], + user_id=new_user['id'], + project_id=new_project_ref['id']) + return new_user, new_domain_ref, new_project_ref + + def _create_certificates(self, + root_dn=None, + server_dn=None, + client_dn=None): + root_subj = unit.create_dn( + country_name='jp', + state_or_province_name='kanagawa', + locality_name='kawasaki', + organization_name='fujitsu', + organizational_unit_name='test', + common_name='root' + ) + if root_dn: + root_subj = unit.update_dn(root_subj, root_dn) + + root_cert, root_key = unit.create_certificate(root_subj) + keystone_subj = unit.create_dn( + country_name='jp', + state_or_province_name='kanagawa', + locality_name='kawasaki', + organization_name='fujitsu', + organizational_unit_name='test', + common_name='keystone.local' + ) + if server_dn: + keystone_subj = unit.update_dn(keystone_subj, server_dn) + + ks_cert, ks_key = unit.create_certificate( + keystone_subj, ca=root_cert, ca_key=root_key) + client_subj = unit.create_dn( + country_name='jp', + state_or_province_name='kanagawa', + locality_name='kawasaki', + organizational_unit_name='test' + ) + if client_dn: + client_subj = unit.update_dn(client_subj, client_dn) + + client_cert, client_key = unit.create_certificate( + client_subj, ca=root_cert, ca_key=root_key) + return root_cert, root_key, ks_cert, ks_key, client_cert, client_key + + def _create_mapping(self, id='oauth2_mapping', dn_rules=None): + rules = [] + if not dn_rules: + dn_rules = [ + { + 'user.name': 'SSL_CLIENT_SUBJECT_DN_CN', + 'user.id': 'SSL_CLIENT_SUBJECT_DN_UID', + 'user.email': 'SSL_CLIENT_SUBJECT_DN_EMAILADDRESS', + 'user.domain.id': 'SSL_CLIENT_SUBJECT_DN_DC', + 'user.domain.name': 'SSL_CLIENT_SUBJECT_DN_O', + 'SSL_CLIENT_ISSUER_DN_CN': ['root'] + } + ] + for info in dn_rules: + index = 0 + local_user = {} + remote = [] + for k in info: + if k == 'user.name': + local_user['name'] = '{%s}' % index + remote.append({'type': info.get(k)}) + index += 1 + elif k == 'user.id': + local_user['id'] = '{%s}' % index + remote.append({'type': info.get(k)}) + index += 1 + elif k == 'user.email': + local_user['email'] = '{%s}' % index + remote.append({'type': info.get(k)}) + index += 1 + elif k == 'user.domain.name' or k == 'user.domain.id': + if not local_user.get('domain'): + local_user['domain'] = {} + if k == 'user.domain.name': + local_user['domain']['name'] = '{%s}' % index + remote.append({'type': info.get(k)}) + index += 1 + else: + local_user['domain']['id'] = '{%s}' % index + remote.append({'type': info.get(k)}) + index += 1 + else: + remote.append({ + 'type': k, + 'any_one_of': info.get(k) + }) + rule = { + 'local': [ + { + 'user': local_user + } + ], + 'remote': remote + } + rules.append(rule) + + mapping = { + 'id': id, + 'rules': rules + } + + PROVIDERS.federation_api.create_mapping(mapping['id'], mapping) + + def _get_access_token(self, client_id=None, client_cert_content=None, + expected_status=http.client.OK): + headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + } + data = { + 'grant_type': 'client_credentials' + } + if client_id: + data.update({'client_id': client_id}) + data = parse.urlencode(data).encode() + kwargs = { + 'headers': headers, + 'noauth': True, + 'convert': False, + 'body': data, + 'expected_status': expected_status + } + if client_cert_content: + kwargs.update({'environ': { + 'SSL_CLIENT_CERT': client_cert_content + }}) + resp = self.post( + self.ACCESS_TOKEN_URL, + **kwargs) + return resp + + def _get_cert_content(self, cert): + return cert.public_bytes(Encoding.PEM).decode('ascii') + + def assertUnauthorizedResp(self, resp): + LOG.debug(resp) + json_resp = jsonutils.loads(resp.body) + self.assertEqual('invalid_client', json_resp['error']) + self.assertEqual( + 'Client authentication failed.', + json_resp['error_description']) + + def test_get_access_token_project_scope(self): + """Test case when an access token can be successfully obtain.""" + self._create_mapping() + user, user_domain, user_project = self._create_project_user() + *_, client_cert, _ = self._create_certificates( + client_dn=unit.create_dn( + country_name='jp', + state_or_province_name='kanagawa', + locality_name='kawasaki', + organizational_unit_name='test', + user_id=user.get('id'), + common_name=user.get('name'), + email_address=user.get('email'), + domain_component=user_domain.get('id'), + organization_name=user_domain.get('name') + ) + ) + + cert_content = self._get_cert_content(client_cert) + resp = self._get_access_token( + client_id=user.get('id'), + client_cert_content=cert_content) + LOG.debug(resp) + json_resp = jsonutils.loads(resp.body) + self.assertIn('access_token', json_resp) + self.assertEqual('Bearer', json_resp['token_type']) + self.assertEqual(3600, json_resp['expires_in']) + + verify_resp = self.get( + '/auth/tokens', + headers={ + 'X-Subject-Token': json_resp['access_token'], + 'X-Auth-Token': json_resp['access_token'] + } + ) + self.assertIn('token', verify_resp.result) + self.assertIn('oauth2_credential', verify_resp.result['token']) + self.assertIn('roles', verify_resp.result['token']) + self.assertIn('project', verify_resp.result['token']) + self.assertIn('catalog', verify_resp.result['token']) + self.assertEqual(user_project.get('id'), + verify_resp.result['token']['project']['id']) + check_oauth2 = verify_resp.result['token']['oauth2_credential'] + self.assertEqual(utils.get_certificate_thumbprint(cert_content), + check_oauth2['x5t#S256']) + + def test_get_access_token_mapping_config(self): + """Test case when an access token can be successfully obtain.""" + self.config_fixture.config(group='oauth2', + oauth2_cert_dn_mapping_id='oauth2_custom') + self._create_mapping( + id='oauth2_custom', + dn_rules=[ + { + 'user.name': 'SSL_CLIENT_SUBJECT_DN_CN', + 'user.domain.name': 'SSL_CLIENT_SUBJECT_DN_DC', + 'SSL_CLIENT_ISSUER_DN_CN': ['root'] + } + ]) + + user, user_domain, user_project = self._create_project_user() + *_, client_cert, _ = self._create_certificates( + client_dn=unit.create_dn( + country_name='jp', + state_or_province_name='kanagawa', + locality_name='kawasaki', + organizational_unit_name='test', + user_id='test_UID', + common_name=user.get('name'), + domain_component=user_domain.get('name'), + organization_name='test_O' + ) + ) + + cert_content = self._get_cert_content(client_cert) + resp = self._get_access_token( + client_id=user.get('id'), + client_cert_content=cert_content) + LOG.debug(resp) + json_resp = jsonutils.loads(resp.body) + self.assertIn('access_token', json_resp) + self.assertEqual('Bearer', json_resp['token_type']) + self.assertEqual(3600, json_resp['expires_in']) + + verify_resp = self.get( + '/auth/tokens', + headers={ + 'X-Subject-Token': json_resp['access_token'], + 'X-Auth-Token': json_resp['access_token'] + } + ) + self.assertIn('token', verify_resp.result) + self.assertIn('oauth2_credential', verify_resp.result['token']) + self.assertIn('roles', verify_resp.result['token']) + self.assertIn('project', verify_resp.result['token']) + self.assertIn('catalog', verify_resp.result['token']) + self.assertEqual(user_project.get('id'), + verify_resp.result['token']['project']['id']) + check_oauth2 = verify_resp.result['token']['oauth2_credential'] + self.assertEqual(utils.get_certificate_thumbprint(cert_content), + check_oauth2['x5t#S256']) + + self.config_fixture.config(group='oauth2', + oauth2_cert_dn_mapping_id='oauth2_mapping') + + def test_get_access_token_mapping_multi_ca(self): + """Test case when an access token can be successfully obtain.""" + self.config_fixture.config(group='oauth2', + oauth2_cert_dn_mapping_id='oauth2_custom') + self._create_mapping( + id='oauth2_custom', + dn_rules=[ + { + 'user.name': 'SSL_CLIENT_SUBJECT_DN_CN', + 'user.id': 'SSL_CLIENT_SUBJECT_DN_UID', + 'user.email': 'SSL_CLIENT_SUBJECT_DN_EMAILADDRESS', + 'user.domain.id': 'SSL_CLIENT_SUBJECT_DN_DC', + 'user.domain.name': 'SSL_CLIENT_SUBJECT_DN_O', + 'SSL_CLIENT_ISSUER_DN_CN': ['rootA', 'rootB'] + }, + { + 'user.name': 'SSL_CLIENT_SUBJECT_DN_CN', + 'user.domain.name': 'SSL_CLIENT_SUBJECT_DN_DC', + 'SSL_CLIENT_ISSUER_DN_CN': ['rootC'] + } + ]) + + # CA rootA OK + user, user_domain, user_project = self._create_project_user() + *_, client_cert, _ = self._create_certificates( + root_dn=unit.create_dn( + common_name='rootA' + ), + client_dn=unit.create_dn( + country_name='jp', + state_or_province_name='kanagawa', + locality_name='kawasaki', + organizational_unit_name='test', + user_id=user.get('id'), + common_name=user.get('name'), + email_address=user.get('email'), + domain_component=user_domain.get('id'), + organization_name=user_domain.get('name') + ) + ) + + cert_content = self._get_cert_content(client_cert) + resp = self._get_access_token( + client_id=user.get('id'), + client_cert_content=cert_content) + LOG.debug(resp) + json_resp = jsonutils.loads(resp.body) + self.assertIn('access_token', json_resp) + self.assertEqual('Bearer', json_resp['token_type']) + self.assertEqual(3600, json_resp['expires_in']) + + verify_resp = self.get( + '/auth/tokens', + headers={ + 'X-Subject-Token': json_resp['access_token'], + 'X-Auth-Token': json_resp['access_token'] + } + ) + self.assertIn('token', verify_resp.result) + self.assertIn('oauth2_credential', verify_resp.result['token']) + self.assertIn('roles', verify_resp.result['token']) + self.assertIn('project', verify_resp.result['token']) + self.assertIn('catalog', verify_resp.result['token']) + self.assertEqual(user_project.get('id'), + verify_resp.result['token']['project']['id']) + check_oauth2 = verify_resp.result['token']['oauth2_credential'] + self.assertEqual(utils.get_certificate_thumbprint(cert_content), + check_oauth2['x5t#S256']) + + # CA rootB OK + user, user_domain, user_project = self._create_project_user() + *_, client_cert, _ = self._create_certificates( + root_dn=unit.create_dn( + common_name='rootB' + ), + client_dn=unit.create_dn( + country_name='jp', + state_or_province_name='kanagawa', + locality_name='kawasaki', + organizational_unit_name='test', + user_id=user.get('id'), + common_name=user.get('name'), + email_address=user.get('email'), + domain_component=user_domain.get('id'), + organization_name=user_domain.get('name') + ) + ) + + cert_content = self._get_cert_content(client_cert) + resp = self._get_access_token( + client_id=user.get('id'), + client_cert_content=cert_content) + LOG.debug(resp) + json_resp = jsonutils.loads(resp.body) + self.assertIn('access_token', json_resp) + self.assertEqual('Bearer', json_resp['token_type']) + self.assertEqual(3600, json_resp['expires_in']) + + verify_resp = self.get( + '/auth/tokens', + headers={ + 'X-Subject-Token': json_resp['access_token'], + 'X-Auth-Token': json_resp['access_token'] + } + ) + self.assertIn('token', verify_resp.result) + self.assertIn('oauth2_credential', verify_resp.result['token']) + self.assertIn('roles', verify_resp.result['token']) + self.assertIn('project', verify_resp.result['token']) + self.assertIn('catalog', verify_resp.result['token']) + self.assertEqual(user_project.get('id'), + verify_resp.result['token']['project']['id']) + check_oauth2 = verify_resp.result['token']['oauth2_credential'] + self.assertEqual(utils.get_certificate_thumbprint(cert_content), + check_oauth2['x5t#S256']) + + # CA rootC OK + user, user_domain, user_project = self._create_project_user() + *_, client_cert, _ = self._create_certificates( + root_dn=unit.create_dn( + common_name='rootC' + ), + client_dn=unit.create_dn( + country_name='jp', + state_or_province_name='kanagawa', + locality_name='kawasaki', + organizational_unit_name='test', + user_id='test_UID', + common_name=user.get('name'), + domain_component=user_domain.get('name'), + organization_name='test_O' + ) + ) + + cert_content = self._get_cert_content(client_cert) + resp = self._get_access_token( + client_id=user.get('id'), + client_cert_content=cert_content) + LOG.debug(resp) + json_resp = jsonutils.loads(resp.body) + self.assertIn('access_token', json_resp) + self.assertEqual('Bearer', json_resp['token_type']) + self.assertEqual(3600, json_resp['expires_in']) + + verify_resp = self.get( + '/auth/tokens', + headers={ + 'X-Subject-Token': json_resp['access_token'], + 'X-Auth-Token': json_resp['access_token'] + } + ) + self.assertIn('token', verify_resp.result) + self.assertIn('oauth2_credential', verify_resp.result['token']) + self.assertIn('roles', verify_resp.result['token']) + self.assertIn('project', verify_resp.result['token']) + self.assertIn('catalog', verify_resp.result['token']) + self.assertEqual(user_project.get('id'), + verify_resp.result['token']['project']['id']) + check_oauth2 = verify_resp.result['token']['oauth2_credential'] + self.assertEqual(utils.get_certificate_thumbprint(cert_content), + check_oauth2['x5t#S256']) + + # CA not found NG + user, user_domain, user_project = self._create_project_user() + *_, client_cert, _ = self._create_certificates( + root_dn=unit.create_dn( + common_name='root_other' + ), + client_dn=unit.create_dn( + country_name='jp', + state_or_province_name='kanagawa', + locality_name='kawasaki', + organizational_unit_name='test', + user_id=user.get('id'), + common_name=user.get('name'), + email_address=user.get('email'), + domain_component=user_domain.get('id'), + organization_name=user_domain.get('name') + ) + ) + cert_content = self._get_cert_content(client_cert) + resp = self._get_access_token( + client_id=user.get('id'), + client_cert_content=cert_content, + expected_status=http.client.UNAUTHORIZED + ) + self.assertUnauthorizedResp(resp) + self.assertIn( + 'Get OAuth2.0 Access Token API: ' + 'mapping rule process failed.', + self.log_fix.output) + + self.config_fixture.config(group='oauth2', + oauth2_cert_dn_mapping_id='oauth2_mapping') + + def test_get_access_token_no_default_mapping(self): + user, user_domain, _ = self._create_project_user() + *_, client_cert, _ = self._create_certificates( + client_dn=unit.create_dn( + country_name='jp', + state_or_province_name='kanagawa', + locality_name='kawasaki', + organizational_unit_name='test', + user_id=user.get('id'), + common_name=user.get('name'), + email_address=user.get('email'), + domain_component=user_domain.get('id'), + organization_name=user_domain.get('name') + ) + ) + + cert_content = self._get_cert_content(client_cert) + resp = self._get_access_token( + client_id=user.get('id'), + client_cert_content=cert_content, + expected_status=http.client.UNAUTHORIZED + ) + self.assertUnauthorizedResp(resp) + self.assertIn( + 'Get OAuth2.0 Access Token API: ' + 'mapping id %s is not found. ' % 'oauth2_mapping', + self.log_fix.output) + + def test_get_access_token_no_custom_mapping(self): + self.config_fixture.config(group='oauth2', + oauth2_cert_dn_mapping_id='oauth2_custom') + self._create_mapping() + user, user_domain, _ = self._create_project_user() + *_, client_cert, _ = self._create_certificates( + client_dn=unit.create_dn( + country_name='jp', + state_or_province_name='kanagawa', + locality_name='kawasaki', + organizational_unit_name='test', + user_id=user.get('id'), + common_name=user.get('name'), + email_address=user.get('email'), + domain_component=user_domain.get('id'), + organization_name=user_domain.get('name') + ) + ) + + cert_content = self._get_cert_content(client_cert) + resp = self._get_access_token( + client_id=user.get('id'), + client_cert_content=cert_content, + expected_status=http.client.UNAUTHORIZED + ) + self.assertUnauthorizedResp(resp) + self.assertIn( + 'Get OAuth2.0 Access Token API: ' + 'mapping id %s is not found. ' % 'oauth2_custom', + self.log_fix.output) + self.config_fixture.config(group='oauth2', + oauth2_cert_dn_mapping_id='oauth2_mapping') + + def test_get_access_token_ignore_userid(self): + """Test case when an access token can be successfully obtain.""" + self._create_mapping(dn_rules=[ + { + 'user.name': 'SSL_CLIENT_SUBJECT_DN_CN', + 'user.email': 'SSL_CLIENT_SUBJECT_DN_EMAILADDRESS', + 'user.domain.id': 'SSL_CLIENT_SUBJECT_DN_DC', + 'user.domain.name': 'SSL_CLIENT_SUBJECT_DN_O', + 'SSL_CLIENT_ISSUER_DN_CN': ['root'] + }]) + + user, user_domain, user_project = self._create_project_user() + *_, client_cert, _ = self._create_certificates( + client_dn=unit.create_dn( + country_name='jp', + state_or_province_name='kanagawa', + locality_name='kawasaki', + organizational_unit_name='test', + user_id=user.get('id') + "_diff", + common_name=user.get('name'), + email_address=user.get('email'), + domain_component=user_domain.get('id'), + organization_name=user_domain.get('name') + ) + ) + + cert_content = self._get_cert_content(client_cert) + resp = self._get_access_token( + client_id=user.get('id'), + client_cert_content=cert_content) + LOG.debug(resp) + json_resp = jsonutils.loads(resp.body) + self.assertIn('access_token', json_resp) + self.assertEqual('Bearer', json_resp['token_type']) + self.assertEqual(3600, json_resp['expires_in']) + + verify_resp = self.get( + '/auth/tokens', + headers={ + 'X-Subject-Token': json_resp['access_token'], + 'X-Auth-Token': json_resp['access_token'] + } + ) + self.assertIn('token', verify_resp.result) + self.assertIn('oauth2_credential', verify_resp.result['token']) + self.assertIn('roles', verify_resp.result['token']) + self.assertIn('project', verify_resp.result['token']) + self.assertIn('catalog', verify_resp.result['token']) + self.assertEqual(user_project.get('id'), + verify_resp.result['token']['project']['id']) + check_oauth2 = verify_resp.result['token']['oauth2_credential'] + self.assertEqual(utils.get_certificate_thumbprint(cert_content), + check_oauth2['x5t#S256']) + + def test_get_access_token_ignore_username(self): + """Test case when an access token can be successfully obtain.""" + self._create_mapping(dn_rules=[ + { + 'user.id': 'SSL_CLIENT_SUBJECT_DN_UID', + 'user.email': 'SSL_CLIENT_SUBJECT_DN_EMAILADDRESS', + 'user.domain.id': 'SSL_CLIENT_SUBJECT_DN_DC', + 'user.domain.name': 'SSL_CLIENT_SUBJECT_DN_O', + 'SSL_CLIENT_ISSUER_DN_CN': ['root'] + }]) + user, user_domain, user_project = self._create_project_user() + *_, client_cert, _ = self._create_certificates( + client_dn=unit.create_dn( + country_name='jp', + state_or_province_name='kanagawa', + locality_name='kawasaki', + organizational_unit_name='test', + user_id=user.get('id'), + email_address=user.get('email'), + domain_component=user_domain.get('id'), + organization_name=user_domain.get('name') + ) + ) + + cert_content = self._get_cert_content(client_cert) + resp = self._get_access_token( + client_id=user.get('id'), + client_cert_content=cert_content) + LOG.debug(resp) + json_resp = jsonutils.loads(resp.body) + self.assertIn('access_token', json_resp) + self.assertEqual('Bearer', json_resp['token_type']) + self.assertEqual(3600, json_resp['expires_in']) + + verify_resp = self.get( + '/auth/tokens', + headers={ + 'X-Subject-Token': json_resp['access_token'], + 'X-Auth-Token': json_resp['access_token'] + } + ) + self.assertIn('token', verify_resp.result) + self.assertIn('oauth2_credential', verify_resp.result['token']) + self.assertIn('roles', verify_resp.result['token']) + self.assertIn('project', verify_resp.result['token']) + self.assertIn('catalog', verify_resp.result['token']) + self.assertEqual(user_project.get('id'), + verify_resp.result['token']['project']['id']) + check_oauth2 = verify_resp.result['token']['oauth2_credential'] + self.assertEqual(utils.get_certificate_thumbprint(cert_content), + check_oauth2['x5t#S256']) + + def test_get_access_token_ignore_email(self): + """Test case when an access token can be successfully obtain.""" + self._create_mapping(dn_rules=[ + { + 'user.name': 'SSL_CLIENT_SUBJECT_DN_CN', + 'user.id': 'SSL_CLIENT_SUBJECT_DN_UID', + 'user.domain.id': 'SSL_CLIENT_SUBJECT_DN_DC', + 'user.domain.name': 'SSL_CLIENT_SUBJECT_DN_O', + 'SSL_CLIENT_ISSUER_DN_CN': ['root'] + }]) + user, user_domain, user_project = self._create_project_user() + *_, client_cert, _ = self._create_certificates( + client_dn=unit.create_dn( + country_name='jp', + state_or_province_name='kanagawa', + locality_name='kawasaki', + organizational_unit_name='test', + user_id=user.get('id'), + common_name=user.get('name'), + domain_component=user_domain.get('id'), + organization_name=user_domain.get('name') + ) + ) + + cert_content = self._get_cert_content(client_cert) + resp = self._get_access_token( + client_id=user.get('id'), + client_cert_content=cert_content) + LOG.debug(resp) + json_resp = jsonutils.loads(resp.body) + self.assertIn('access_token', json_resp) + self.assertEqual('Bearer', json_resp['token_type']) + self.assertEqual(3600, json_resp['expires_in']) + + verify_resp = self.get( + '/auth/tokens', + headers={ + 'X-Subject-Token': json_resp['access_token'], + 'X-Auth-Token': json_resp['access_token'] + } + ) + self.assertIn('token', verify_resp.result) + self.assertIn('oauth2_credential', verify_resp.result['token']) + self.assertIn('roles', verify_resp.result['token']) + self.assertIn('project', verify_resp.result['token']) + self.assertIn('catalog', verify_resp.result['token']) + self.assertEqual(user_project.get('id'), + verify_resp.result['token']['project']['id']) + check_oauth2 = verify_resp.result['token']['oauth2_credential'] + self.assertEqual(utils.get_certificate_thumbprint(cert_content), + check_oauth2['x5t#S256']) + + def test_get_access_token_ignore_domain_id(self): + """Test case when an access token can be successfully obtain.""" + self._create_mapping(dn_rules=[ + { + 'user.name': 'SSL_CLIENT_SUBJECT_DN_CN', + 'user.id': 'SSL_CLIENT_SUBJECT_DN_UID', + 'user.email': 'SSL_CLIENT_SUBJECT_DN_EMAILADDRESS', + 'user.domain.name': 'SSL_CLIENT_SUBJECT_DN_O', + 'SSL_CLIENT_ISSUER_DN_CN': ['root'] + }]) + user, user_domain, user_project = self._create_project_user() + *_, client_cert, _ = self._create_certificates( + client_dn=unit.create_dn( + country_name='jp', + state_or_province_name='kanagawa', + locality_name='kawasaki', + organizational_unit_name='test', + user_id=user.get('id'), + common_name=user.get('name'), + email_address=user.get('email'), + domain_component=user_domain.get('id') + "_diff", + organization_name=user_domain.get('name') + ) + ) + + cert_content = self._get_cert_content(client_cert) + resp = self._get_access_token( + client_id=user.get('id'), + client_cert_content=cert_content) + LOG.debug(resp) + json_resp = jsonutils.loads(resp.body) + self.assertIn('access_token', json_resp) + self.assertEqual('Bearer', json_resp['token_type']) + self.assertEqual(3600, json_resp['expires_in']) + + verify_resp = self.get( + '/auth/tokens', + headers={ + 'X-Subject-Token': json_resp['access_token'], + 'X-Auth-Token': json_resp['access_token'] + } + ) + self.assertIn('token', verify_resp.result) + self.assertIn('oauth2_credential', verify_resp.result['token']) + self.assertIn('roles', verify_resp.result['token']) + self.assertIn('project', verify_resp.result['token']) + self.assertIn('catalog', verify_resp.result['token']) + self.assertEqual(user_project.get('id'), + verify_resp.result['token']['project']['id']) + check_oauth2 = verify_resp.result['token']['oauth2_credential'] + self.assertEqual(utils.get_certificate_thumbprint(cert_content), + check_oauth2['x5t#S256']) + + def test_get_access_token_ignore_domain_name(self): + """Test case when an access token can be successfully obtain.""" + self._create_mapping(dn_rules=[ + { + 'user.name': 'SSL_CLIENT_SUBJECT_DN_CN', + 'user.id': 'SSL_CLIENT_SUBJECT_DN_UID', + 'user.email': 'SSL_CLIENT_SUBJECT_DN_EMAILADDRESS', + 'user.domain.id': 'SSL_CLIENT_SUBJECT_DN_DC', + 'SSL_CLIENT_ISSUER_DN_CN': ['root'] + }]) + user, user_domain, user_project = self._create_project_user() + *_, client_cert, _ = self._create_certificates( + client_dn=unit.create_dn( + country_name='jp', + state_or_province_name='kanagawa', + locality_name='kawasaki', + organizational_unit_name='test', + user_id=user.get('id'), + common_name=user.get('name'), + email_address=user.get('email'), + domain_component=user_domain.get('id') + ) + ) + + cert_content = self._get_cert_content(client_cert) + resp = self._get_access_token( + client_id=user.get('id'), + client_cert_content=cert_content) + LOG.debug(resp) + json_resp = jsonutils.loads(resp.body) + self.assertIn('access_token', json_resp) + self.assertEqual('Bearer', json_resp['token_type']) + self.assertEqual(3600, json_resp['expires_in']) + + verify_resp = self.get( + '/auth/tokens', + headers={ + 'X-Subject-Token': json_resp['access_token'], + 'X-Auth-Token': json_resp['access_token'] + } + ) + self.assertIn('token', verify_resp.result) + self.assertIn('oauth2_credential', verify_resp.result['token']) + self.assertIn('roles', verify_resp.result['token']) + self.assertIn('project', verify_resp.result['token']) + self.assertIn('catalog', verify_resp.result['token']) + self.assertEqual(user_project.get('id'), + verify_resp.result['token']['project']['id']) + check_oauth2 = verify_resp.result['token']['oauth2_credential'] + self.assertEqual(utils.get_certificate_thumbprint(cert_content), + check_oauth2['x5t#S256']) + + def test_get_access_token_ignore_all(self): + """Test case when an access token can be successfully obtain.""" + self._create_mapping(dn_rules=[ + { + 'SSL_CLIENT_ISSUER_DN_CN': ['root'] + }]) + user, user_domain, user_project = self._create_project_user() + *_, client_cert, _ = self._create_certificates( + client_dn=unit.create_dn( + country_name='jp', + state_or_province_name='kanagawa', + locality_name='kawasaki', + organizational_unit_name='test', + user_id=user.get('id') + "_diff", + common_name=user.get('name') + "_diff", + email_address=user.get('email') + "_diff", + domain_component=user_domain.get('id') + "_diff" + ) + ) + + cert_content = self._get_cert_content(client_cert) + resp = self._get_access_token( + client_id=user.get('id'), + client_cert_content=cert_content) + LOG.debug(resp) + json_resp = jsonutils.loads(resp.body) + self.assertIn('access_token', json_resp) + self.assertEqual('Bearer', json_resp['token_type']) + self.assertEqual(3600, json_resp['expires_in']) + + verify_resp = self.get( + '/auth/tokens', + headers={ + 'X-Subject-Token': json_resp['access_token'], + 'X-Auth-Token': json_resp['access_token'] + } + ) + self.assertIn('token', verify_resp.result) + self.assertIn('oauth2_credential', verify_resp.result['token']) + self.assertIn('roles', verify_resp.result['token']) + self.assertIn('project', verify_resp.result['token']) + self.assertIn('catalog', verify_resp.result['token']) + self.assertEqual(user_project.get('id'), + verify_resp.result['token']['project']['id']) + check_oauth2 = verify_resp.result['token']['oauth2_credential'] + self.assertEqual(utils.get_certificate_thumbprint(cert_content), + check_oauth2['x5t#S256']) + + def test_get_access_token_no_roles_project_scope(self): + self._create_mapping() + user, user_domain, _ = self._create_project_user(no_roles=True) + *_, client_cert, _ = self._create_certificates( + client_dn=unit.create_dn( + country_name='jp', + state_or_province_name='kanagawa', + locality_name='kawasaki', + organizational_unit_name='test', + user_id=user.get('id'), + common_name=user.get('name'), + email_address=user.get('email'), + domain_component=user_domain.get('id'), + organization_name=user_domain.get('name') + ) + ) + + cert_content = self._get_cert_content(client_cert) + resp = self._get_access_token( + client_id=user.get('id'), + client_cert_content=cert_content, + expected_status=http.client.UNAUTHORIZED) + LOG.debug(resp) + + def test_get_access_token_no_default_project_id(self): + self._create_mapping() + user, user_domain, _ = self._create_project_user(no_roles=True) + user['default_project_id'] = None + *_, client_cert, _ = self._create_certificates( + client_dn=unit.create_dn( + country_name='jp', + state_or_province_name='kanagawa', + locality_name='kawasaki', + organizational_unit_name='test', + user_id=user.get('id'), + common_name=user.get('name'), + email_address=user.get('email'), + domain_component=user_domain.get('id'), + organization_name=user_domain.get('name') + ) + ) + + cert_content = self._get_cert_content(client_cert) + _ = self._get_access_token( + client_id=user.get('id'), + client_cert_content=cert_content, + expected_status=http.client.UNAUTHORIZED) + + def test_get_access_token_without_client_id(self): + self._create_mapping() + cert_content = self._get_cert_content(self.client_cert) + resp = self._get_access_token( + client_cert_content=cert_content, + expected_status=http.client.UNAUTHORIZED + ) + self.assertUnauthorizedResp(resp) + self.assertIn('Get OAuth2.0 Access Token API: ' + 'failed to get a client_id from the request.', + self.log_fix.output) + + def test_get_access_token_without_client_cert(self): + self._create_mapping() + resp = self._get_access_token( + client_id=self.oauth2_user.get('id'), + expected_status=http.client.UNAUTHORIZED + ) + self.assertUnauthorizedResp(resp) + self.assertIn('Get OAuth2.0 Access Token API: ' + 'failed to get client credentials from the request.', + self.log_fix.output) + + @mock.patch.object(utils, 'get_certificate_subject_dn') + def test_get_access_token_failed_to_get_cert_subject_dn( + self, mock_get_certificate_subject_dn): + self._create_mapping() + mock_get_certificate_subject_dn.side_effect = \ + exception.ValidationError('Boom!') + cert_content = self._get_cert_content(self.client_cert) + resp = self._get_access_token( + client_id=self.oauth2_user.get('id'), + client_cert_content=cert_content, + expected_status=http.client.UNAUTHORIZED + ) + self.assertUnauthorizedResp(resp) + self.assertIn('Get OAuth2.0 Access Token API: ' + 'failed to get the subject DN from the certificate.', + self.log_fix.output) + + @mock.patch.object(utils, 'get_certificate_issuer_dn') + def test_get_access_token_failed_to_get_cert_issuer_dn( + self, mock_get_certificate_issuer_dn): + self._create_mapping() + mock_get_certificate_issuer_dn.side_effect = \ + exception.ValidationError('Boom!') + cert_content = self._get_cert_content(self.client_cert) + resp = self._get_access_token( + client_id=self.oauth2_user.get('id'), + client_cert_content=cert_content, + expected_status=http.client.UNAUTHORIZED + ) + self.assertUnauthorizedResp(resp) + self.assertIn('Get OAuth2.0 Access Token API: ' + 'failed to get the issuer DN from the certificate.', + self.log_fix.output) + + def test_get_access_token_user_not_exist(self): + self._create_mapping() + cert_content = self._get_cert_content(self.client_cert) + user_id_not_exist = 'user_id_not_exist' + resp = self._get_access_token( + client_id=user_id_not_exist, + client_cert_content=cert_content, + expected_status=http.client.UNAUTHORIZED + ) + self.assertUnauthorizedResp(resp) + self.assertIn( + 'Get OAuth2.0 Access Token API: ' + 'the user does not exist. user id: %s' + % user_id_not_exist, + self.log_fix.output) + + def test_get_access_token_cert_dn_not_match_user_id(self): + self._create_mapping() + user, user_domain, _ = self._create_project_user() + *_, client_cert, _ = self._create_certificates( + client_dn=unit.create_dn( + country_name='jp', + state_or_province_name='kanagawa', + locality_name='kawasaki', + organizational_unit_name='test', + user_id=user.get('id') + "_diff", + common_name=user.get('name'), + email_address=user.get('email'), + domain_component=user_domain.get('id'), + organization_name=user_domain.get('name') + ) + ) + + cert_content = self._get_cert_content(client_cert) + resp = self._get_access_token( + client_id=user.get('id'), + client_cert_content=cert_content, + expected_status=http.client.UNAUTHORIZED + ) + self.assertUnauthorizedResp(resp) + self.assertIn( + 'Get OAuth2.0 Access Token API: %s check failed. ' + 'DN value: %s, DB value: %s.' % ( + 'user id', + user.get('id') + '_diff', + user.get('id')), + self.log_fix.output) + + def test_get_access_token_cert_dn_not_match_user_name(self): + self._create_mapping() + user, user_domain, _ = self._create_project_user() + *_, client_cert, _ = self._create_certificates( + client_dn=unit.create_dn( + country_name='jp', + state_or_province_name='kanagawa', + locality_name='kawasaki', + organizational_unit_name='test', + user_id=user.get('id'), + common_name=user.get('name') + "_diff", + email_address=user.get('email'), + domain_component=user_domain.get('id'), + organization_name=user_domain.get('name') + ) + ) + + cert_content = self._get_cert_content(client_cert) + resp = self._get_access_token( + client_id=user.get('id'), + client_cert_content=cert_content, + expected_status=http.client.UNAUTHORIZED + ) + self.assertUnauthorizedResp(resp) + self.assertIn( + 'Get OAuth2.0 Access Token API: %s check failed. ' + 'DN value: %s, DB value: %s.' % ( + 'user name', + user.get('name') + '_diff', + user.get('name')), + self.log_fix.output) + + def test_get_access_token_cert_dn_not_match_email(self): + self._create_mapping() + user, user_domain, _ = self._create_project_user() + *_, client_cert, _ = self._create_certificates( + client_dn=unit.create_dn( + country_name='jp', + state_or_province_name='kanagawa', + locality_name='kawasaki', + organizational_unit_name='test', + user_id=user.get('id'), + common_name=user.get('name'), + email_address=user.get('email') + "_diff", + domain_component=user_domain.get('id'), + organization_name=user_domain.get('name') + ) + ) + + cert_content = self._get_cert_content(client_cert) + resp = self._get_access_token( + client_id=user.get('id'), + client_cert_content=cert_content, + expected_status=http.client.UNAUTHORIZED + ) + self.assertUnauthorizedResp(resp) + self.assertIn( + 'Get OAuth2.0 Access Token API: %s check failed. ' + 'DN value: %s, DB value: %s.' % ( + 'user email', + user.get('email') + '_diff', + user.get('email')), + self.log_fix.output) + + def test_get_access_token_cert_dn_not_match_domain_id(self): + self._create_mapping() + user, user_domain, _ = self._create_project_user() + *_, client_cert, _ = self._create_certificates( + client_dn=unit.create_dn( + country_name='jp', + state_or_province_name='kanagawa', + locality_name='kawasaki', + organizational_unit_name='test', + user_id=user.get('id'), + common_name=user.get('name'), + email_address=user.get('email'), + domain_component=user_domain.get('id') + "_diff", + organization_name=user_domain.get('name') + ) + ) + + cert_content = self._get_cert_content(client_cert) + resp = self._get_access_token( + client_id=user.get('id'), + client_cert_content=cert_content, + expected_status=http.client.UNAUTHORIZED + ) + self.assertUnauthorizedResp(resp) + self.assertIn( + 'Get OAuth2.0 Access Token API: %s check failed. ' + 'DN value: %s, DB value: %s.' % ( + 'user domain id', + user_domain.get('id') + '_diff', + user_domain.get('id')), + self.log_fix.output) + + def test_get_access_token_cert_dn_not_match_domain_name(self): + self._create_mapping() + user, user_domain, _ = self._create_project_user() + *_, client_cert, _ = self._create_certificates( + client_dn=unit.create_dn( + country_name='jp', + state_or_province_name='kanagawa', + locality_name='kawasaki', + organizational_unit_name='test', + user_id=user.get('id'), + common_name=user.get('name'), + email_address=user.get('email'), + domain_component=user_domain.get('id'), + organization_name=user_domain.get('name') + "_diff" + ) + ) + + cert_content = self._get_cert_content(client_cert) + resp = self._get_access_token( + client_id=user.get('id'), + client_cert_content=cert_content, + expected_status=http.client.UNAUTHORIZED + ) + self.assertUnauthorizedResp(resp) + self.assertIn( + 'Get OAuth2.0 Access Token API: %s check failed. ' + 'DN value: %s, DB value: %s.' % ( + 'user domain name', + user_domain.get('name') + '_diff', + user_domain.get('name')), + self.log_fix.output) + + def test_get_access_token_cert_dn_missing_user_id(self): + self._create_mapping() + user, user_domain, _ = self._create_project_user() + *_, client_cert, _ = self._create_certificates( + client_dn=unit.create_dn( + country_name='jp', + state_or_province_name='kanagawa', + locality_name='kawasaki', + organizational_unit_name='test', + common_name=user.get('name'), + email_address=user.get('email'), + domain_component=user_domain.get('id'), + organization_name=user_domain.get('name') + ) + ) + + cert_content = self._get_cert_content(client_cert) + resp = self._get_access_token( + client_id=user.get('id'), + client_cert_content=cert_content, + expected_status=http.client.UNAUTHORIZED + ) + self.assertUnauthorizedResp(resp) + self.assertIn( + 'Get OAuth2.0 Access Token API: ' + 'mapping rule process failed.', + self.log_fix.output) + + def test_get_access_token_cert_dn_missing_user_name(self): + self._create_mapping() + user, user_domain, _ = self._create_project_user() + *_, client_cert, _ = self._create_certificates( + client_dn=unit.create_dn( + country_name='jp', + state_or_province_name='kanagawa', + locality_name='kawasaki', + organizational_unit_name='test', + user_id=user.get('id'), + email_address=user.get('email'), + domain_component=user_domain.get('id'), + organization_name=user_domain.get('name') + ) + ) + + cert_content = self._get_cert_content(client_cert) + resp = self._get_access_token( + client_id=user.get('id'), + client_cert_content=cert_content, + expected_status=http.client.UNAUTHORIZED + ) + self.assertUnauthorizedResp(resp) + self.assertIn( + 'Get OAuth2.0 Access Token API: ' + 'mapping rule process failed.', + self.log_fix.output) + + def test_get_access_token_cert_dn_missing_email(self): + self._create_mapping() + user, user_domain, _ = self._create_project_user() + *_, client_cert, _ = self._create_certificates( + client_dn=unit.create_dn( + country_name='jp', + state_or_province_name='kanagawa', + locality_name='kawasaki', + organizational_unit_name='test', + user_id=user.get('id'), + common_name=user.get('name'), + domain_component=user_domain.get('id'), + organization_name=user_domain.get('name') + ) + ) + + cert_content = self._get_cert_content(client_cert) + resp = self._get_access_token( + client_id=user.get('id'), + client_cert_content=cert_content, + expected_status=http.client.UNAUTHORIZED + ) + self.assertUnauthorizedResp(resp) + self.assertIn( + 'Get OAuth2.0 Access Token API: ' + 'mapping rule process failed.', + self.log_fix.output) + + def test_get_access_token_cert_dn_missing_domain_id(self): + self._create_mapping() + user, user_domain, _ = self._create_project_user() + *_, client_cert, _ = self._create_certificates( + client_dn=unit.create_dn( + country_name='jp', + state_or_province_name='kanagawa', + locality_name='kawasaki', + organizational_unit_name='test', + user_id=user.get('id'), + common_name=user.get('name'), + email_address=user.get('email'), + organization_name=user_domain.get('name') + ) + ) + + cert_content = self._get_cert_content(client_cert) + resp = self._get_access_token( + client_id=user.get('id'), + client_cert_content=cert_content, + expected_status=http.client.UNAUTHORIZED + ) + self.assertUnauthorizedResp(resp) + self.assertIn( + 'Get OAuth2.0 Access Token API: ' + 'mapping rule process failed.', + self.log_fix.output) + + def test_get_access_token_cert_dn_missing_domain_name(self): + self._create_mapping() + user, user_domain, _ = self._create_project_user() + *_, client_cert, _ = self._create_certificates( + client_dn=unit.create_dn( + country_name='jp', + state_or_province_name='kanagawa', + locality_name='kawasaki', + organizational_unit_name='test', + user_id=user.get('id'), + common_name=user.get('name'), + email_address=user.get('email'), + domain_component=user_domain.get('id'), + ) + ) + + cert_content = self._get_cert_content(client_cert) + resp = self._get_access_token( + client_id=user.get('id'), + client_cert_content=cert_content, + expected_status=http.client.UNAUTHORIZED + ) + self.assertUnauthorizedResp(resp) + self.assertIn( + 'Get OAuth2.0 Access Token API: ' + 'mapping rule process failed.', + self.log_fix.output) + + @mock.patch.object(Manager, 'issue_token') + def test_get_access_token_issue_token_ks_error_400(self, mock_issue_token): + self._create_mapping() + err_msg = 'Boom!' + mock_issue_token.side_effect = exception.ValidationError(err_msg) + cert_content = self._get_cert_content(self.client_cert) + resp = self._get_access_token( + client_id=self.oauth2_user.get('id'), + client_cert_content=cert_content, + expected_status=http.client.BAD_REQUEST + ) + LOG.debug(resp) + json_resp = jsonutils.loads(resp.body) + self.assertEqual('invalid_request', json_resp['error']) + self.assertEqual(err_msg, json_resp['error_description']) + self.assertIn(err_msg, self.log_fix.output) + + @mock.patch.object(Manager, 'issue_token') + def test_get_access_token_issue_token_ks_error_401(self, mock_issue_token): + self._create_mapping() + err_msg = 'Boom!' + mock_issue_token.side_effect = exception.Unauthorized(err_msg) + cert_content = self._get_cert_content(self.client_cert) + resp = self._get_access_token( + client_id=self.oauth2_user.get('id'), + client_cert_content=cert_content, + expected_status=http.client.UNAUTHORIZED + ) + LOG.debug(resp) + json_resp = jsonutils.loads(resp.body) + self.assertEqual('invalid_client', json_resp['error']) + self.assertEqual( + 'The request you have made requires authentication.', + json_resp['error_description']) + + @mock.patch.object(Manager, 'issue_token') + def test_get_access_token_issue_token_ks_error_other( + self, mock_issue_token): + self._create_mapping() + err_msg = 'Boom!' + mock_issue_token.side_effect = exception.NotImplemented(err_msg) + cert_content = self._get_cert_content(self.client_cert) + resp = self._get_access_token( + client_id=self.oauth2_user.get('id'), + client_cert_content=cert_content, + expected_status=exception.NotImplemented.code + ) + LOG.debug(resp) + json_resp = jsonutils.loads(resp.body) + self.assertEqual('other_error', json_resp['error']) + self.assertEqual( + 'An unknown error occurred and failed to get an OAuth2.0 ' + 'access token.', + json_resp['error_description']) + + @mock.patch.object(Manager, 'issue_token') + def test_get_access_token_issue_token_other_exception( + self, mock_issue_token): + self._create_mapping() + err_msg = 'Boom!' + mock_issue_token.side_effect = Exception(err_msg) + cert_content = self._get_cert_content(self.client_cert) + resp = self._get_access_token( + client_id=self.oauth2_user.get('id'), + client_cert_content=cert_content, + expected_status=http.client.INTERNAL_SERVER_ERROR + ) + LOG.debug(resp) + json_resp = jsonutils.loads(resp.body) + self.assertEqual('other_error', json_resp['error']) + self.assertEqual(err_msg, json_resp['error_description']) + + @mock.patch.object(RuleProcessor, 'process') + def test_get_access_token_process_other_exception( + self, mock_process): + self._create_mapping() + err_msg = 'Boom!' + mock_process.side_effect = Exception(err_msg) + cert_content = self._get_cert_content(self.client_cert) + resp = self._get_access_token( + client_id=self.oauth2_user.get('id'), + client_cert_content=cert_content, + expected_status=http.client.INTERNAL_SERVER_ERROR + ) + LOG.debug(resp) + json_resp = jsonutils.loads(resp.body) + self.assertEqual('other_error', json_resp['error']) + self.assertEqual(err_msg, json_resp['error_description']) + self.assertIn( + 'Get OAuth2.0 Access Token API: ' + 'mapping rule process failed.', + self.log_fix.output) diff --git a/keystone/tests/unit/token/test_fernet_provider.py b/keystone/tests/unit/token/test_fernet_provider.py index 997b5e6f7..fbc4faf05 100644 --- a/keystone/tests/unit/token/test_fernet_provider.py +++ b/keystone/tests/unit/token/test_fernet_provider.py @@ -317,7 +317,7 @@ class TestTokenFormatter(unit.TestCase): (user_id, methods, audit_ids, system, domain_id, project_id, trust_id, federated_group_ids, identity_provider_id, protocol_id, - access_token_id, app_cred_id, issued_at, + access_token_id, app_cred_id, thumbprint, issued_at, expires_at) = token_formatter.validate_token(token) self.assertEqual(exp_user_id, user_id) @@ -352,7 +352,7 @@ class TestTokenFormatter(unit.TestCase): (user_id, methods, audit_ids, system, domain_id, project_id, trust_id, federated_group_ids, identity_provider_id, protocol_id, - access_token_id, app_cred_id, issued_at, + access_token_id, app_cred_id, thumbprint, issued_at, expires_at) = token_formatter.validate_token(token) self.assertEqual(exp_user_id, user_id) @@ -473,7 +473,7 @@ class TestPayloads(unit.TestCase): exp_trust_id=None, exp_federated_group_ids=None, exp_identity_provider_id=None, exp_protocol_id=None, exp_access_token_id=None, exp_app_cred_id=None, - encode_ids=False): + encode_ids=False, exp_thumbprint=None): def _encode_id(value): if value is not None and str(value) and encode_ids: return value.encode('utf-8') @@ -496,12 +496,14 @@ class TestPayloads(unit.TestCase): _encode_id(exp_identity_provider_id), exp_protocol_id, _encode_id(exp_access_token_id), - _encode_id(exp_app_cred_id)) + _encode_id(exp_app_cred_id), + exp_thumbprint) (user_id, methods, system, project_id, domain_id, expires_at, audit_ids, trust_id, federated_group_ids, identity_provider_id, protocol_id, - access_token_id, app_cred_id) = payload_class.disassemble(payload) + access_token_id, app_cred_id, + thumbprint) = payload_class.disassemble(payload) self.assertEqual(exp_user_id, user_id) self.assertEqual(exp_methods, methods) diff --git a/keystone/token/provider.py b/keystone/token/provider.py index 2ea4d7e08..9d888fdbc 100644 --- a/keystone/token/provider.py +++ b/keystone/token/provider.py @@ -154,8 +154,8 @@ class Manager(manager.Manager): def _validate_token(self, token_id): (user_id, methods, audit_ids, system, domain_id, project_id, trust_id, federated_group_ids, identity_provider_id, - protocol_id, access_token_id, app_cred_id, issued_at, - expires_at) = self.driver.validate_token(token_id) + protocol_id, access_token_id, app_cred_id, thumbprint, + issued_at, expires_at) = self.driver.validate_token(token_id) token = token_model.TokenModel() token.user_id = user_id @@ -169,6 +169,7 @@ class Manager(manager.Manager): token.trust_id = trust_id token.access_token_id = access_token_id token.application_credential_id = app_cred_id + token.oauth2_thumbprint = thumbprint token.expires_at = expires_at if federated_group_ids is not None: token.is_federated = True @@ -221,7 +222,7 @@ class Manager(manager.Manager): def issue_token(self, user_id, method_names, expires_at=None, system=None, project_id=None, domain_id=None, auth_context=None, trust_id=None, app_cred_id=None, - parent_audit_id=None): + thumbprint=None, parent_audit_id=None): # NOTE(lbragstad): Grab a blank token object and use composition to # build the token according to the authentication and authorization @@ -235,6 +236,7 @@ class Manager(manager.Manager): token.trust_id = trust_id token.application_credential_id = app_cred_id token.audit_id = random_urlsafe_str() + token.oauth2_thumbprint = thumbprint token.parent_audit_id = parent_audit_id if auth_context: @@ -267,6 +269,23 @@ class Manager(manager.Manager): default_expire_time(), subsecond=True ) + # NOTE(d34dh0r53): If this token is being issued with an application + # credential and the application credential expires before the token + # we need to set the token expiration to be the same as the application + # credential. See CVE-2022-2447 for more information. + if app_cred_id is not None: + app_cred_api = PROVIDERS.application_credential_api + app_cred = app_cred_api.get_application_credential( + token.application_credential_id) + token_time = timeutils.normalize_time( + timeutils.parse_isotime(token.expires_at)) + if (app_cred['expires_at'] is not None) and ( + token_time > app_cred['expires_at']): + token.expires_at = app_cred['expires_at'].isoformat() + LOG.debug('Resetting token expiration to the application' + ' credential expiration: %s', + app_cred['expires_at'].isoformat()) + token_id, issued_at = self.driver.generate_id_and_issued_at(token) token.mint(token_id, issued_at) diff --git a/keystone/token/providers/base.py b/keystone/token/providers/base.py index 34547c374..9de93ccfa 100644 --- a/keystone/token/providers/base.py +++ b/keystone/token/providers/base.py @@ -44,6 +44,7 @@ class Provider(object, metaclass=abc.ABCMeta): ``protocol_id`` unique ID of the protocol used to obtain the token ``access_token_id`` the unique ID of the access_token for OAuth1 tokens ``app_cred_id`` the unique ID of the application credential + ``param thumbprint`` thumbprint of the certificate for OAuth2.0 mTLS ``issued_at`` a datetime object of when the token was minted ``expires_at`` a datetime object of when the token expires diff --git a/keystone/token/providers/fernet/core.py b/keystone/token/providers/fernet/core.py index 7c0fda342..9eef56727 100644 --- a/keystone/token/providers/fernet/core.py +++ b/keystone/token/providers/fernet/core.py @@ -58,6 +58,8 @@ class Provider(base.Provider): return tf.FederatedUnscopedPayload elif token.application_credential_id: return tf.ApplicationCredentialScopedPayload + elif token.oauth2_thumbprint: + return tf.Oauth2CredentialsScopedPayload elif token.project_scoped: return tf.ProjectScopedPayload elif token.domain_scoped: @@ -83,7 +85,8 @@ class Provider(base.Provider): identity_provider_id=token.identity_provider_id, protocol_id=token.protocol_id, access_token_id=token.access_token_id, - app_cred_id=token.application_credential_id + app_cred_id=token.application_credential_id, + thumbprint=token.oauth2_thumbprint, ) creation_datetime_obj = self.token_formatter.creation_time(token_id) issued_at = ks_utils.isotime( diff --git a/keystone/token/providers/jws/core.py b/keystone/token/providers/jws/core.py index 7d14d313c..5dc70c870 100644 --- a/keystone/token/providers/jws/core.py +++ b/keystone/token/providers/jws/core.py @@ -70,7 +70,8 @@ class Provider(base.Provider): identity_provider_id=token.identity_provider_id, protocol_id=token.protocol_id, access_token_id=token.access_token_id, - app_cred_id=token.application_credential_id + app_cred_id=token.application_credential_id, + thumbprint=token.oauth2_thumbprint, ) def validate_token(self, token_id): @@ -106,7 +107,8 @@ class JWSFormatter(object): system=None, domain_id=None, project_id=None, trust_id=None, federated_group_ids=None, identity_provider_id=None, protocol_id=None, - access_token_id=None, app_cred_id=None): + access_token_id=None, app_cred_id=None, + thumbprint=None): issued_at = utils.isotime(subsecond=True) issued_at_int = self._convert_time_string_to_int(issued_at) @@ -128,7 +130,8 @@ class JWSFormatter(object): 'openstack_idp_id': identity_provider_id, 'openstack_protocol_id': protocol_id, 'openstack_access_token_id': access_token_id, - 'openstack_app_cred_id': app_cred_id + 'openstack_app_cred_id': app_cred_id, + 'openstack_thumbprint': thumbprint, } # NOTE(lbragstad): Calling .items() on a dictionary in python 2 returns @@ -164,6 +167,7 @@ class JWSFormatter(object): protocol_id = payload.get('openstack_protocol_id', None) access_token_id = payload.get('openstack_access_token_id', None) app_cred_id = payload.get('openstack_app_cred_id', None) + thumbprint = payload.get('openstack_thumbprint', None) issued_at = self._convert_time_int_to_string(issued_at_int) expires_at = self._convert_time_int_to_string(expires_at_int) @@ -171,7 +175,7 @@ class JWSFormatter(object): return ( user_id, methods, audit_ids, system, domain_id, project_id, trust_id, federated_group_ids, identity_provider_id, protocol_id, - access_token_id, app_cred_id, issued_at, expires_at + access_token_id, app_cred_id, thumbprint, issued_at, expires_at, ) def _decode_token_from_id(self, token_id): diff --git a/keystone/token/token_formatters.py b/keystone/token/token_formatters.py index 76220b0ef..b1971ca52 100644 --- a/keystone/token/token_formatters.py +++ b/keystone/token/token_formatters.py @@ -137,14 +137,14 @@ class TokenFormatter(object): methods=None, system=None, domain_id=None, project_id=None, trust_id=None, federated_group_ids=None, identity_provider_id=None, protocol_id=None, - access_token_id=None, app_cred_id=None): + access_token_id=None, app_cred_id=None, + thumbprint=None): """Given a set of payload attributes, generate a Fernet token.""" version = payload_class.version payload = payload_class.assemble( user_id, methods, system, project_id, domain_id, expires_at, audit_ids, trust_id, federated_group_ids, identity_provider_id, - protocol_id, access_token_id, app_cred_id - ) + protocol_id, access_token_id, app_cred_id, thumbprint) versioned_payload = (version,) + payload serialized_payload = msgpack.packb(versioned_payload) @@ -187,7 +187,8 @@ class TokenFormatter(object): (user_id, methods, system, project_id, domain_id, expires_at, audit_ids, trust_id, federated_group_ids, identity_provider_id, protocol_id, access_token_id, - app_cred_id) = payload_class.disassemble(payload) + app_cred_id, thumbprint) = ( + payload_class.disassemble(payload)) break else: # If the token_format is not recognized, raise ValidationError. @@ -211,8 +212,8 @@ class TokenFormatter(object): return (user_id, methods, audit_ids, system, domain_id, project_id, trust_id, federated_group_ids, identity_provider_id, - protocol_id, access_token_id, app_cred_id, issued_at, - expires_at) + protocol_id, access_token_id, app_cred_id, thumbprint, + issued_at, expires_at) class BasePayload(object): @@ -223,7 +224,7 @@ class BasePayload(object): def assemble(cls, user_id, methods, system, project_id, domain_id, expires_at, audit_ids, trust_id, federated_group_ids, identity_provider_id, protocol_id, access_token_id, - app_cred_id): + app_cred_id, thumbprint): """Assemble the payload of a token. :param user_id: identifier of the user in the token request @@ -239,6 +240,7 @@ class BasePayload(object): :param protocol_id: federated protocol used for authentication :param access_token_id: ID of the secret in OAuth1 authentication :param app_cred_id: ID of the application credential in effect + :param thumbprint: thumbprint of the certificate in OAuth2 mTLS :returns: the payload of a token """ @@ -377,7 +379,7 @@ class UnscopedPayload(BasePayload): def assemble(cls, user_id, methods, system, project_id, domain_id, expires_at, audit_ids, trust_id, federated_group_ids, identity_provider_id, protocol_id, access_token_id, - app_cred_id): + app_cred_id, thumbprint): b_user_id = cls.attempt_convert_uuid_hex_to_bytes(user_id) methods = auth_plugins.convert_method_list_to_integer(methods) expires_at_int = cls._convert_time_string_to_float(expires_at) @@ -401,10 +403,11 @@ class UnscopedPayload(BasePayload): protocol_id = None access_token_id = None app_cred_id = None + thumbprint = None return (user_id, methods, system, project_id, domain_id, expires_at_str, audit_ids, trust_id, federated_group_ids, identity_provider_id, protocol_id, access_token_id, - app_cred_id) + app_cred_id, thumbprint) class DomainScopedPayload(BasePayload): @@ -414,7 +417,7 @@ class DomainScopedPayload(BasePayload): def assemble(cls, user_id, methods, system, project_id, domain_id, expires_at, audit_ids, trust_id, federated_group_ids, identity_provider_id, protocol_id, access_token_id, - app_cred_id): + app_cred_id, thumbprint): b_user_id = cls.attempt_convert_uuid_hex_to_bytes(user_id) methods = auth_plugins.convert_method_list_to_integer(methods) try: @@ -455,10 +458,11 @@ class DomainScopedPayload(BasePayload): protocol_id = None access_token_id = None app_cred_id = None + thumbprint = None return (user_id, methods, system, project_id, domain_id, expires_at_str, audit_ids, trust_id, federated_group_ids, identity_provider_id, protocol_id, access_token_id, - app_cred_id) + app_cred_id, thumbprint) class ProjectScopedPayload(BasePayload): @@ -468,7 +472,7 @@ class ProjectScopedPayload(BasePayload): def assemble(cls, user_id, methods, system, project_id, domain_id, expires_at, audit_ids, trust_id, federated_group_ids, identity_provider_id, protocol_id, access_token_id, - app_cred_id): + app_cred_id, thumbprint): b_user_id = cls.attempt_convert_uuid_hex_to_bytes(user_id) methods = auth_plugins.convert_method_list_to_integer(methods) b_project_id = cls.attempt_convert_uuid_hex_to_bytes(project_id) @@ -494,10 +498,11 @@ class ProjectScopedPayload(BasePayload): protocol_id = None access_token_id = None app_cred_id = None + thumbprint = None return (user_id, methods, system, project_id, domain_id, expires_at_str, audit_ids, trust_id, federated_group_ids, identity_provider_id, protocol_id, access_token_id, - app_cred_id) + app_cred_id, thumbprint) class TrustScopedPayload(BasePayload): @@ -507,7 +512,7 @@ class TrustScopedPayload(BasePayload): def assemble(cls, user_id, methods, system, project_id, domain_id, expires_at, audit_ids, trust_id, federated_group_ids, identity_provider_id, protocol_id, access_token_id, - app_cred_id): + app_cred_id, thumbprint): b_user_id = cls.attempt_convert_uuid_hex_to_bytes(user_id) methods = auth_plugins.convert_method_list_to_integer(methods) b_project_id = cls.attempt_convert_uuid_hex_to_bytes(project_id) @@ -536,10 +541,11 @@ class TrustScopedPayload(BasePayload): protocol_id = None access_token_id = None app_cred_id = None + thumbprint = None return (user_id, methods, system, project_id, domain_id, expires_at_str, audit_ids, trust_id, federated_group_ids, identity_provider_id, protocol_id, access_token_id, - app_cred_id) + app_cred_id, thumbprint) class FederatedUnscopedPayload(BasePayload): @@ -559,7 +565,7 @@ class FederatedUnscopedPayload(BasePayload): def assemble(cls, user_id, methods, system, project_id, domain_id, expires_at, audit_ids, trust_id, federated_group_ids, identity_provider_id, protocol_id, access_token_id, - app_cred_id): + app_cred_id, thumbprint): b_user_id = cls.attempt_convert_uuid_hex_to_bytes(user_id) methods = auth_plugins.convert_method_list_to_integer(methods) b_group_ids = list(map(cls.pack_group_id, federated_group_ids)) @@ -590,9 +596,10 @@ class FederatedUnscopedPayload(BasePayload): trust_id = None access_token_id = None app_cred_id = None + thumbprint = None return (user_id, methods, system, project_id, domain_id, expires_at_str, audit_ids, trust_id, group_ids, idp_id, - protocol_id, access_token_id, app_cred_id) + protocol_id, access_token_id, app_cred_id, thumbprint) class FederatedScopedPayload(FederatedUnscopedPayload): @@ -602,7 +609,7 @@ class FederatedScopedPayload(FederatedUnscopedPayload): def assemble(cls, user_id, methods, system, project_id, domain_id, expires_at, audit_ids, trust_id, federated_group_ids, identity_provider_id, protocol_id, access_token_id, - app_cred_id): + app_cred_id, thumbprint): b_user_id = cls.attempt_convert_uuid_hex_to_bytes(user_id) methods = auth_plugins.convert_method_list_to_integer(methods) b_scope_id = cls.attempt_convert_uuid_hex_to_bytes( @@ -641,9 +648,10 @@ class FederatedScopedPayload(FederatedUnscopedPayload): trust_id = None access_token_id = None app_cred_id = None + thumbprint = None return (user_id, methods, system, project_id, domain_id, expires_at_str, audit_ids, trust_id, group_ids, idp_id, - protocol_id, access_token_id, app_cred_id) + protocol_id, access_token_id, app_cred_id, thumbprint) class FederatedProjectScopedPayload(FederatedScopedPayload): @@ -661,7 +669,7 @@ class OauthScopedPayload(BasePayload): def assemble(cls, user_id, methods, system, project_id, domain_id, expires_at, audit_ids, trust_id, federated_group_ids, identity_provider_id, protocol_id, access_token_id, - app_cred_id): + app_cred_id, thumbprint): b_user_id = cls.attempt_convert_uuid_hex_to_bytes(user_id) methods = auth_plugins.convert_method_list_to_integer(methods) b_project_id = cls.attempt_convert_uuid_hex_to_bytes(project_id) @@ -692,11 +700,12 @@ class OauthScopedPayload(BasePayload): identity_provider_id = None protocol_id = None app_cred_id = None + thumbprint = None return (user_id, methods, system, project_id, domain_id, expires_at_str, audit_ids, trust_id, federated_group_ids, identity_provider_id, protocol_id, access_token_id, - app_cred_id) + app_cred_id, thumbprint) class SystemScopedPayload(BasePayload): @@ -706,7 +715,7 @@ class SystemScopedPayload(BasePayload): def assemble(cls, user_id, methods, system, project_id, domain_id, expires_at, audit_ids, trust_id, federated_group_ids, identity_provider_id, protocol_id, access_token_id, - app_cred_id): + app_cred_id, thumbprint): b_user_id = cls.attempt_convert_uuid_hex_to_bytes(user_id) methods = auth_plugins.convert_method_list_to_integer(methods) expires_at_int = cls._convert_time_string_to_float(expires_at) @@ -730,10 +739,11 @@ class SystemScopedPayload(BasePayload): protocol_id = None access_token_id = None app_cred_id = None + thumbprint = None return (user_id, methods, system, project_id, domain_id, expires_at_str, audit_ids, trust_id, federated_group_ids, identity_provider_id, protocol_id, access_token_id, - app_cred_id) + app_cred_id, thumbprint) class ApplicationCredentialScopedPayload(BasePayload): @@ -743,7 +753,7 @@ class ApplicationCredentialScopedPayload(BasePayload): def assemble(cls, user_id, methods, system, project_id, domain_id, expires_at, audit_ids, trust_id, federated_group_ids, identity_provider_id, protocol_id, access_token_id, - app_cred_id): + app_cred_id, thumbprint): b_user_id = cls.attempt_convert_uuid_hex_to_bytes(user_id) methods = auth_plugins.convert_method_list_to_integer(methods) b_project_id = cls.attempt_convert_uuid_hex_to_bytes(project_id) @@ -772,10 +782,55 @@ class ApplicationCredentialScopedPayload(BasePayload): access_token_id = None (is_stored_as_bytes, app_cred_id) = payload[5] app_cred_id = cls._convert_or_decode(is_stored_as_bytes, app_cred_id) + thumbprint = None + return (user_id, methods, system, project_id, domain_id, + expires_at_str, audit_ids, trust_id, federated_group_ids, + identity_provider_id, protocol_id, access_token_id, + app_cred_id, thumbprint) + + +class Oauth2CredentialsScopedPayload(BasePayload): + version = 10 + + @classmethod + def assemble(cls, user_id, methods, system, project_id, domain_id, + expires_at, audit_ids, trust_id, federated_group_ids, + identity_provider_id, protocol_id, access_token_id, + app_cred_id, thumbprint): + b_user_id = cls.attempt_convert_uuid_hex_to_bytes(user_id) + methods = auth_plugins.convert_method_list_to_integer(methods) + b_project_id = cls.attempt_convert_uuid_hex_to_bytes(project_id) + b_domain_id = cls.attempt_convert_uuid_hex_to_bytes(domain_id) + expires_at_int = cls._convert_time_string_to_float(expires_at) + b_audit_ids = list(map(cls.random_urlsafe_str_to_bytes, audit_ids)) + b_thumbprint = (False, thumbprint) + return (b_user_id, methods, b_project_id, b_domain_id, expires_at_int, + b_audit_ids, b_thumbprint) + + @classmethod + def disassemble(cls, payload): + (is_stored_as_bytes, user_id) = payload[0] + user_id = cls._convert_or_decode(is_stored_as_bytes, user_id) + methods = auth_plugins.convert_integer_to_method_list(payload[1]) + (is_stored_as_bytes, project_id) = payload[2] + project_id = cls._convert_or_decode(is_stored_as_bytes, project_id) + (is_stored_as_bytes, domain_id) = payload[3] + domain_id = cls._convert_or_decode(is_stored_as_bytes, domain_id) + expires_at_str = cls._convert_float_to_time_string(payload[4]) + audit_ids = list(map(cls.base64_encode, payload[5])) + (is_stored_as_bytes, thumbprint) = payload[6] + thumbprint = cls._convert_or_decode(is_stored_as_bytes, thumbprint) + system = None + trust_id = None + federated_group_ids = None + identity_provider_id = None + protocol_id = None + access_token_id = None + app_cred_id = None return (user_id, methods, system, project_id, domain_id, expires_at_str, audit_ids, trust_id, federated_group_ids, identity_provider_id, protocol_id, access_token_id, - app_cred_id) + app_cred_id, thumbprint) _PAYLOAD_CLASSES = [ @@ -789,4 +844,5 @@ _PAYLOAD_CLASSES = [ OauthScopedPayload, SystemScopedPayload, ApplicationCredentialScopedPayload, + Oauth2CredentialsScopedPayload, ] diff --git a/releasenotes/notes/bp-support-oauth2-mtls-8552892a8e0c72d2.yaml b/releasenotes/notes/bp-support-oauth2-mtls-8552892a8e0c72d2.yaml new file mode 100644 index 000000000..19b6ccb11 --- /dev/null +++ b/releasenotes/notes/bp-support-oauth2-mtls-8552892a8e0c72d2.yaml @@ -0,0 +1,13 @@ +--- +features: + - | + [`blueprint support-oauth2-mtls <https://blueprints.launchpad.net/keystone/+spec/support-oauth2-mtls>`_] + Provide the option for users to proof-of-possession of OAuth 2.0 access + token based on `RFC8705 OAuth 2.0 Mutual-TLS Client Authentication and + Certificate-Bound Access Tokens`. Users can now use the OAuth 2.0 Access + Token API to get an OAuth 2.0 certificate-bound access token from the + keystone identity server with OAuth 2.0 credentials and Mutual-TLS + certificates. Then users can use the OAuth 2.0 certificate-bound access + token and the Mutual-TLS certificates to access the OpenStack APIs that use + the keystone middleware to support OAuth 2.0 Mutual-TLS client + authentication. diff --git a/releasenotes/notes/max-password-length-truncation-and-warning-bd69090315ec18a7.yaml b/releasenotes/notes/max-password-length-truncation-and-warning-bd69090315ec18a7.yaml new file mode 100644 index 000000000..003dc47df --- /dev/null +++ b/releasenotes/notes/max-password-length-truncation-and-warning-bd69090315ec18a7.yaml @@ -0,0 +1,9 @@ +--- +security: + - | + Passwords will now be automatically truncated if the max_password_length is + greater than the allowed length for the selected password hashing + algorithm. Currently only bcrypt has fixed allowed lengths defined which is + 54 characters. A warning will be generated in the log if a password is + truncated. This will not affect existing passwords, however only the first + 54 characters of existing bcrypt passwords will be validated. diff --git a/releasenotes/notes/token_expiration_to_match_application_credential-56d058355a9f240d.yaml b/releasenotes/notes/token_expiration_to_match_application_credential-56d058355a9f240d.yaml new file mode 100644 index 000000000..d37073a9d --- /dev/null +++ b/releasenotes/notes/token_expiration_to_match_application_credential-56d058355a9f240d.yaml @@ -0,0 +1,10 @@ +--- +security: + - | + [`bug 1992183 <https://bugs.launchpad.net/keystone/+bug/1992183>`_] + [`CVE-2022-2447 <http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2022-2447>`_] + Tokens issued with application credentials will now have their expiration + validated against that of the application credential. If the application + credential expires before the token the token's expiration will be set to + the same expiration as the application credential. Otherwise the token + will use the configured value. diff --git a/test-requirements.txt b/test-requirements.txt index 0213085b8..1fca35803 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,11 +1,6 @@ -# The order of packages is significant, because pip processes them in the order -# of appearance. Changing the order has an impact on the overall integration -# process, which may cause wedges in the gate later. - -hacking>=3.0.1,<3.1.0 # Apache-2.0 -pep257==0.7.0 # MIT License -flake8-docstrings==0.2.1.post1 # MIT -bashate>=0.5.1 # Apache-2.0 +hacking~=4.1.0 # Apache-2.0 +flake8-docstrings~=1.6.0 # MIT +bashate~=2.1.0 # Apache-2.0 stestr>=1.0.0 # Apache-2.0 freezegun>=0.3.6 # Apache-2.0 pytz>=2013.6 # MIT @@ -18,14 +18,14 @@ commands = allowlist_externals = bash find -passenv = http_proxy HTTP_PROXY https_proxy HTTPS_PROXY no_proxy NO_PROXY PBR_VERSION +passenv = http_proxy,HTTP_PROXY,https_proxy,HTTPS_PROXY,no_proxy,NO_PROXY,PBR_VERSION [testenv:pep8] deps = .[bandit] {[testenv]deps} commands = - flake8 --ignore=D100,D101,D102,D103,D104,E305,E402,W503,W504,W605 + flake8 # Run bash8 during pep8 runs to ensure violations are caught by # the check and gate queues bashate devstack/plugin.sh @@ -72,16 +72,7 @@ commands = {posargs} commands = find keystone -type f -name "*.pyc" -delete oslo_debug_helper {posargs} -passenv = - KSTEST_ADMIN_URL - KSTEST_ADMIN_USERNAME - KSTEST_ADMIN_PASSWORD - KSTEST_ADMIN_DOMAIN_ID - KSTEST_PUBLIC_URL - KSTEST_USER_USERNAME - KSTEST_USER_PASSWORD - KSTEST_USER_DOMAIN_ID - KSTEST_PROJECT_ID +passenv = KSTEST_* [testenv:functional] deps = @@ -92,16 +83,7 @@ commands = find keystone -type f -name "*.pyc" -delete stestr run {posargs} stestr slowest -passenv = - KSTEST_ADMIN_URL - KSTEST_ADMIN_USERNAME - KSTEST_ADMIN_PASSWORD - KSTEST_ADMIN_DOMAIN_ID - KSTEST_PUBLIC_URL - KSTEST_USER_USERNAME - KSTEST_USER_PASSWORD - KSTEST_USER_DOMAIN_ID - KSTEST_PROJECT_ID +passenv = KSTEST_* [flake8] filename= *.py,keystone-manage @@ -112,14 +94,24 @@ enable-extensions = H203,H904 # D102: Missing docstring in public method # D103: Missing docstring in public function # D104: Missing docstring in public package +# D106: Missing docstring in public nested class +# D107: Missing docstring in __init__ # D203: 1 blank line required before class docstring (deprecated in pep257) +# D401: First line should be in imperative mood; try rephrasing # TODO(wxy): Fix the pep8 issue. +# E305: # E402: module level import not at top of file +# H211: Use assert{Is,IsNot}instance +# H214: Use assertIn/NotIn(A, B) rather than assertTrue/False(A in/not in B) when checking collection contents. # W503: line break before binary operator -# W504 line break after binary operator -ignore = D100,D101,D102,D103,D104,D203,E402,W503,W504 -exclude=.venv,.git,.tox,build,dist,*lib/python*,*egg,tools,vendor,.update-venv,*.ini,*.po,*.pot -max-complexity=24 +# W504: line break after binary operator +# W605: +ignore = D100,D101,D102,D103,D104,D106,D107,D203,D401,E305,E402,H211,H214,W503,W504,W605 +exclude = .venv,.git,.tox,build,dist,*lib/python*,*egg,tools,vendor,.update-venv,*.ini,*.po,*.pot +max-complexity = 24 +per-file-ignores = +# URL lines too long + keystone/common/password_hashing.py: E501 [testenv:docs] deps = @@ -171,7 +163,6 @@ commands = oslopolicy-sample-generator --config-file config-generator/keystone-p [hacking] import_exceptions = keystone.i18n - six.moves [flake8:local-plugins] extension = |