summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.zuul.yaml19
-rw-r--r--devstack/files/oidc/apache_oidc.conf47
-rw-r--r--devstack/lib/oidc.sh160
-rw-r--r--devstack/plugin.sh29
-rw-r--r--devstack/tools/oidc/__init__.py0
-rw-r--r--devstack/tools/oidc/docker-compose.yaml33
-rw-r--r--devstack/tools/oidc/setup_keycloak_client.py61
-rw-r--r--keystone/api/os_ep_filter.py2
-rw-r--r--keystone/api/os_oauth2.py292
-rw-r--r--keystone/cmd/doctor/database.py2
-rw-r--r--keystone/common/password_hashing.py22
-rw-r--r--keystone/common/render_token.py4
-rw-r--r--keystone/common/sql/migrations/versions/27e647c0fad4_initial_version.py2
-rw-r--r--keystone/common/sql/upgrades.py4
-rw-r--r--keystone/common/utils.py68
-rw-r--r--keystone/conf/__init__.py2
-rw-r--r--keystone/conf/identity.py6
-rw-r--r--keystone/conf/oauth2.py52
-rw-r--r--keystone/federation/utils.py2
-rw-r--r--keystone/identity/backends/ldap/common.py21
-rw-r--r--keystone/models/token_model.py6
-rw-r--r--keystone/revoke/backends/base.py4
-rw-r--r--keystone/tests/unit/common/test_utils.py119
-rw-r--r--keystone/tests/unit/core.py75
-rw-r--r--keystone/tests/unit/fakeldap.py9
-rw-r--r--keystone/tests/unit/test_backend_ldap_pool.py118
-rw-r--r--keystone/tests/unit/test_sql_upgrade.py1
-rw-r--r--keystone/tests/unit/test_v3_auth.py198
-rw-r--r--keystone/tests/unit/test_v3_oauth2.py1621
-rw-r--r--keystone/tests/unit/token/test_fernet_provider.py12
-rw-r--r--keystone/token/provider.py25
-rw-r--r--keystone/token/providers/base.py1
-rw-r--r--keystone/token/providers/fernet/core.py5
-rw-r--r--keystone/token/providers/jws/core.py12
-rw-r--r--keystone/token/token_formatters.py106
-rw-r--r--releasenotes/notes/bp-support-oauth2-mtls-8552892a8e0c72d2.yaml13
-rw-r--r--releasenotes/notes/max-password-length-truncation-and-warning-bd69090315ec18a7.yaml9
-rw-r--r--releasenotes/notes/token_expiration_to_match_application_credential-56d058355a9f240d.yaml10
-rw-r--r--test-requirements.txt11
-rw-r--r--tox.ini45
40 files changed, 3036 insertions, 192 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 f19edd00c..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):
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/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/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
diff --git a/tox.ini b/tox.ini
index 8f5ba12c0..4a168a9e1 100644
--- a/tox.ini
+++ b/tox.ini
@@ -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 =