Merge "Add openid connect client support"
This commit is contained in:
		
							
								
								
									
										189
									
								
								keystoneclient/contrib/auth/v3/oidc.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										189
									
								
								keystoneclient/contrib/auth/v3/oidc.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,189 @@ | ||||
| # 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 keystoneclient import access | ||||
| from keystoneclient.auth.identity.v3 import federated | ||||
| from keystoneclient import utils | ||||
|  | ||||
|  | ||||
| class OidcPassword(federated.FederatedBaseAuth): | ||||
|     """Implement authentication plugin for OpenID Connect protocol. | ||||
|  | ||||
|     OIDC or OpenID Connect is a protocol for federated authentication. | ||||
|  | ||||
|     The OpenID Connect specification can be found at:: | ||||
|     ``http://openid.net/specs/openid-connect-core-1_0.html`` | ||||
|     """ | ||||
|  | ||||
|     @classmethod | ||||
|     def get_options(cls): | ||||
|         options = super(OidcPassword, cls).get_options() | ||||
|         options.extend([ | ||||
|             cfg.StrOpt('username', help='Username'), | ||||
|             cfg.StrOpt('password', help='Password'), | ||||
|             cfg.StrOpt('client-id', help='OAuth 2.0 Client ID'), | ||||
|             cfg.StrOpt('client-secret', help='OAuth 2.0 Client Secret'), | ||||
|             cfg.StrOpt('access-token-endpoint', | ||||
|                        help='OpenID Connect Provider Token Endpoint'), | ||||
|             cfg.StrOpt('scope', default="profile", | ||||
|                        help='OpenID Connect scope that is requested from OP') | ||||
|         ]) | ||||
|         return options | ||||
|  | ||||
|     @utils.positional(4) | ||||
|     def __init__(self, auth_url, identity_provider, protocol, | ||||
|                  username, password, client_id, client_secret, | ||||
|                  access_token_endpoint, scope='profile', | ||||
|                  grant_type='password'): | ||||
|         """The OpenID Connect plugin expects the following: | ||||
|  | ||||
|         :param auth_url: URL of the Identity Service | ||||
|         :type auth_url: string | ||||
|  | ||||
|         :param identity_provider: Name of the Identity Provider the client | ||||
|                                   will authenticate against | ||||
|         :type identity_provider: string | ||||
|  | ||||
|         :param protocol: Protocol name as configured in keystone | ||||
|         :type protocol: string | ||||
|  | ||||
|         :param username: Username used to authenticate | ||||
|         :type username: string | ||||
|  | ||||
|         :param password: Password used to authenticate | ||||
|         :type password: string | ||||
|  | ||||
|         :param client_id: OAuth 2.0 Client ID | ||||
|         :type client_id: string | ||||
|  | ||||
|         :param client_secret: OAuth 2.0 Client Secret | ||||
|         :type client_secret: string | ||||
|  | ||||
|         :param access_token_endpoint: OpenID Connect Provider Token Endpoint, | ||||
|                                       for example: | ||||
|                                       https://localhost:8020/oidc/OP/token | ||||
|         :type access_token_endpoint: string | ||||
|  | ||||
|         :param scope: OpenID Connect scope that is requested from OP, | ||||
|                       defaults to "profile", for example: "profile email" | ||||
|         :type scope: string | ||||
|  | ||||
|         :param grant_type: OpenID Connect grant type, it represents the flow | ||||
|                            that is used to talk to the OP. Valid values are: | ||||
|                            "authorization_code", "refresh_token", or | ||||
|                            "password". | ||||
|         :type grant_type: string | ||||
|         """ | ||||
|         super(OidcPassword, self).__init__(auth_url, identity_provider, | ||||
|                                            protocol) | ||||
|         self.username = username | ||||
|         self.password = password | ||||
|         self.client_id = client_id | ||||
|         self.client_secret = client_secret | ||||
|         self.access_token_endpoint = access_token_endpoint | ||||
|         self.scope = scope | ||||
|         self.grant_type = grant_type | ||||
|  | ||||
|     def get_unscoped_auth_ref(self, session): | ||||
|         """Authenticate with OpenID Connect and get back claims. | ||||
|  | ||||
|         This is a multi-step process. First an access token must be retrieved, | ||||
|         to do this, the username and password, the OpenID Connect client ID | ||||
|         and secret, and the access token endpoint must be known. | ||||
|  | ||||
|         Secondly, we then exchange the access token upon accessing the | ||||
|         protected Keystone endpoint (federated auth URL). This will trigger | ||||
|         the OpenID Connect Provider to perform a user introspection and | ||||
|         retrieve information (specified in the scope) about the user in | ||||
|         the form of an OpenID Connect Claim. These claims will be sent | ||||
|         to Keystone in the form of environment variables. | ||||
|  | ||||
|         :param session: a session object to send out HTTP requests. | ||||
|         :type session: keystoneclient.session.Session | ||||
|  | ||||
|         :returns: a token data representation | ||||
|         :rtype: :py:class:`keystoneclient.access.AccessInfo` | ||||
|         """ | ||||
|  | ||||
|         # get an access token | ||||
|         client_auth = (self.client_id, self.client_secret) | ||||
|         payload = {'grant_type': self.grant_type, 'username': self.username, | ||||
|                    'password': self.password, 'scope': self.scope} | ||||
|         response = self._get_access_token(session, client_auth, payload, | ||||
|                                           self.access_token_endpoint) | ||||
|         access_token = response.json()['access_token'] | ||||
|  | ||||
|         # use access token against protected URL | ||||
|         headers = {'Authorization': 'Bearer ' + access_token} | ||||
|         response = self._get_keystone_token(session, headers, | ||||
|                                             self.federated_token_url) | ||||
|  | ||||
|         # grab the unscoped token | ||||
|         token = response.headers['X-Subject-Token'] | ||||
|         token_json = response.json()['token'] | ||||
|         return access.AccessInfoV3(token, **token_json) | ||||
|  | ||||
|     def _get_access_token(self, session, client_auth, payload, | ||||
|                           access_token_endpoint): | ||||
|         """Exchange a variety of user supplied values for an access token. | ||||
|  | ||||
|         :param session: a session object to send out HTTP requests. | ||||
|         :type session: keystoneclient.session.Session | ||||
|  | ||||
|         :param client_auth: a tuple representing client id and secret | ||||
|         :type client_auth: tuple | ||||
|  | ||||
|         :param payload: a dict containing various OpenID Connect values, for | ||||
|                         example:: | ||||
|                           {'grant_type': 'password', 'username': self.username, | ||||
|                            'password': self.password, 'scope': self.scope} | ||||
|         :type payload: dict | ||||
|  | ||||
|         :param access_token_endpoint: URL to use to get an access token, for | ||||
|                                       example: https://localhost/oidc/token | ||||
|         :type access_token_endpoint: string | ||||
|         """ | ||||
|         op_response = session.post(self.access_token_endpoint, | ||||
|                                    requests_auth=client_auth, | ||||
|                                    data=payload, | ||||
|                                    authenticated=False) | ||||
|         return op_response | ||||
|  | ||||
|     def _get_keystone_token(self, session, headers, federated_token_url): | ||||
|         """Exchange an acess token for a keystone token. | ||||
|  | ||||
|         By Sending the access token in an `Authorization: Bearer` header, to | ||||
|         an OpenID Connect protected endpoint (Federated Token URL). The | ||||
|         OpenID Connect server will use the access token to look up information | ||||
|         about the authenticated user (this technique is called instrospection). | ||||
|         The output of the instrospection will be an OpenID Connect Claim, that | ||||
|         will be used against the mapping engine. Should the mapping engine | ||||
|         succeed, a Keystone token will be presented to the user. | ||||
|  | ||||
|         :param session: a session object to send out HTTP requests. | ||||
|         :type session: keystoneclient.session.Session | ||||
|  | ||||
|         :param headers: an Authorization header containing the access token. | ||||
|         :type headers_: dict | ||||
|  | ||||
|         :param federated_auth_url: Protected URL for federated authentication, | ||||
|                                    for example: https://localhost:5000/v3/\ | ||||
|                                    OS-FEDERATION/identity_providers/bluepages/\ | ||||
|                                    protocols/oidc/auth | ||||
|         :type federated_auth_url: string | ||||
|         """ | ||||
|         auth_response = session.post(self.federated_token_url, | ||||
|                                      headers=headers, | ||||
|                                      authenticated=False) | ||||
|         return auth_response | ||||
							
								
								
									
										190
									
								
								keystoneclient/tests/unit/v3/test_auth_oidc.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										190
									
								
								keystoneclient/tests/unit/v3/test_auth_oidc.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,190 @@ | ||||
| #    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. | ||||
|  | ||||
| import uuid | ||||
|  | ||||
| from oslo_config import fixture as config | ||||
| from six.moves import urllib | ||||
| import testtools | ||||
|  | ||||
| from keystoneclient.auth import conf | ||||
| from keystoneclient.contrib.auth.v3 import oidc | ||||
| from keystoneclient import session | ||||
| from keystoneclient.tests.unit.v3 import utils | ||||
|  | ||||
|  | ||||
| ACCESS_TOKEN_ENDPOINT_RESP = {"access_token": "z5H1ITZLlJVDHQXqJun", | ||||
|                               "token_type": "bearer", | ||||
|                               "expires_in": 3599, | ||||
|                               "scope": "profile", | ||||
|                               "refresh_token": "DCERsh83IAhu9bhavrp"} | ||||
|  | ||||
| KEYSTONE_TOKEN_VALUE = uuid.uuid4().hex | ||||
| UNSCOPED_TOKEN = { | ||||
|     "token": { | ||||
|         "issued_at": "2014-06-09T09:48:59.643406Z", | ||||
|         "extras": {}, | ||||
|         "methods": ["oidc"], | ||||
|         "expires_at": "2014-06-09T10:48:59.643375Z", | ||||
|         "user": { | ||||
|             "OS-FEDERATION": { | ||||
|                 "identity_provider": { | ||||
|                     "id": "bluepages" | ||||
|                 }, | ||||
|                 "protocol": { | ||||
|                     "id": "oidc" | ||||
|                 }, | ||||
|                 "groups": [ | ||||
|                     {"id": "1764fa5cf69a49a4918131de5ce4af9a"} | ||||
|                 ] | ||||
|             }, | ||||
|             "id": "oidc_user%40example.com", | ||||
|             "name": "oidc_user@example.com" | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
|  | ||||
| class AuthenticateOIDCTests(utils.TestCase): | ||||
|  | ||||
|     GROUP = 'auth' | ||||
|  | ||||
|     def setUp(self): | ||||
|         super(AuthenticateOIDCTests, self).setUp() | ||||
|  | ||||
|         self.conf_fixture = self.useFixture(config.Config()) | ||||
|         conf.register_conf_options(self.conf_fixture.conf, group=self.GROUP) | ||||
|  | ||||
|         self.session = session.Session() | ||||
|  | ||||
|         self.IDENTITY_PROVIDER = 'bluepages' | ||||
|         self.PROTOCOL = 'oidc' | ||||
|         self.USER_NAME = 'oidc_user@example.com' | ||||
|         self.PASSWORD = uuid.uuid4().hex | ||||
|         self.CLIENT_ID = uuid.uuid4().hex | ||||
|         self.CLIENT_SECRET = uuid.uuid4().hex | ||||
|         self.ACCESS_TOKEN_ENDPOINT = 'https://localhost:8020/oidc/token' | ||||
|         self.FEDERATION_AUTH_URL = '%s/%s' % ( | ||||
|             self.TEST_URL, | ||||
|             'OS-FEDERATION/identity_providers/bluepages/protocols/oidc/auth') | ||||
|  | ||||
|         self.oidcplugin = oidc.OidcPassword( | ||||
|             self.TEST_URL, | ||||
|             self.IDENTITY_PROVIDER, | ||||
|             self.PROTOCOL, | ||||
|             username=self.USER_NAME, | ||||
|             password=self.PASSWORD, | ||||
|             client_id=self.CLIENT_ID, | ||||
|             client_secret=self.CLIENT_SECRET, | ||||
|             access_token_endpoint=self.ACCESS_TOKEN_ENDPOINT) | ||||
|  | ||||
|     @testtools.skip("TypeError: __init__() got an unexpected keyword" | ||||
|                     " argument 'project_name'") | ||||
|     def test_conf_params(self): | ||||
|         """Ensure OpenID Connect config options work.""" | ||||
|  | ||||
|         section = uuid.uuid4().hex | ||||
|         identity_provider = uuid.uuid4().hex | ||||
|         protocol = uuid.uuid4().hex | ||||
|         username = uuid.uuid4().hex | ||||
|         password = uuid.uuid4().hex | ||||
|         client_id = uuid.uuid4().hex | ||||
|         client_secret = uuid.uuid4().hex | ||||
|         access_token_endpoint = uuid.uuid4().hex | ||||
|  | ||||
|         self.conf_fixture.config(auth_section=section, group=self.GROUP) | ||||
|         conf.register_conf_options(self.conf_fixture.conf, group=self.GROUP) | ||||
|  | ||||
|         self.conf_fixture.register_opts(oidc.OidcPassword.get_options(), | ||||
|                                         group=section) | ||||
|         self.conf_fixture.config(auth_plugin='v3oidcpassword', | ||||
|                                  identity_provider=identity_provider, | ||||
|                                  protocol=protocol, | ||||
|                                  username=username, | ||||
|                                  password=password, | ||||
|                                  client_id=client_id, | ||||
|                                  client_secret=client_secret, | ||||
|                                  access_token_endpoint=access_token_endpoint, | ||||
|                                  group=section) | ||||
|  | ||||
|         a = conf.load_from_conf_options(self.conf_fixture.conf, self.GROUP) | ||||
|         self.assertEqual(identity_provider, a.identity_provider) | ||||
|         self.assertEqual(protocol, a.protocol) | ||||
|         self.assertEqual(username, a.username) | ||||
|         self.assertEqual(password, a.password) | ||||
|         self.assertEqual(client_id, a.client_id) | ||||
|         self.assertEqual(client_secret, a.client_secret) | ||||
|         self.assertEqual(access_token_endpoint, a.access_token_endpoint) | ||||
|  | ||||
|     def test_initial_call_to_get_access_token(self): | ||||
|         """Test initial call, expect JSON access token.""" | ||||
|  | ||||
|         # Mock the output that creates the access token | ||||
|         self.requests_mock.post( | ||||
|             self.ACCESS_TOKEN_ENDPOINT, | ||||
|             json=ACCESS_TOKEN_ENDPOINT_RESP) | ||||
|  | ||||
|         # Prep all the values and send the request | ||||
|         grant_type = 'password' | ||||
|         scope = 'profile email' | ||||
|         client_auth = (self.CLIENT_ID, self.CLIENT_SECRET) | ||||
|         payload = {'grant_type': grant_type, 'username': self.USER_NAME, | ||||
|                    'password': self.PASSWORD, 'scope': scope} | ||||
|         res = self.oidcplugin._get_access_token(self.session, | ||||
|                                                 client_auth, | ||||
|                                                 payload, | ||||
|                                                 self.ACCESS_TOKEN_ENDPOINT) | ||||
|  | ||||
|         # Verify the request matches the expected structure | ||||
|         self.assertEqual(self.ACCESS_TOKEN_ENDPOINT, res.request.url) | ||||
|         self.assertEqual('POST', res.request.method) | ||||
|         encoded_payload = urllib.parse.urlencode(payload) | ||||
|         self.assertEqual(encoded_payload, res.request.body) | ||||
|  | ||||
|     def test_second_call_to_protected_url(self): | ||||
|         """Test subsequent call, expect Keystone token.""" | ||||
|  | ||||
|         # Mock the output that creates the keystone token | ||||
|         self.requests_mock.post( | ||||
|             self.FEDERATION_AUTH_URL, | ||||
|             json=UNSCOPED_TOKEN, | ||||
|             headers={'X-Subject-Token': KEYSTONE_TOKEN_VALUE}) | ||||
|  | ||||
|         # Prep all the values and send the request | ||||
|         access_token = uuid.uuid4().hex | ||||
|         headers = {'Authorization': 'Bearer ' + access_token} | ||||
|         res = self.oidcplugin._get_keystone_token(self.session, | ||||
|                                                   headers, | ||||
|                                                   self.FEDERATION_AUTH_URL) | ||||
|  | ||||
|         # Verify the request matches the expected structure | ||||
|         self.assertEqual(self.FEDERATION_AUTH_URL, res.request.url) | ||||
|         self.assertEqual('POST', res.request.method) | ||||
|         self.assertEqual(headers['Authorization'], | ||||
|                          res.request.headers['Authorization']) | ||||
|  | ||||
|     def test_end_to_end_workflow(self): | ||||
|         """Test full OpenID Connect workflow.""" | ||||
|  | ||||
|         # Mock the output that creates the access token | ||||
|         self.requests_mock.post( | ||||
|             self.ACCESS_TOKEN_ENDPOINT, | ||||
|             json=ACCESS_TOKEN_ENDPOINT_RESP) | ||||
|  | ||||
|         # Mock the output that creates the keystone token | ||||
|         self.requests_mock.post( | ||||
|             self.FEDERATION_AUTH_URL, | ||||
|             json=UNSCOPED_TOKEN, | ||||
|             headers={'X-Subject-Token': KEYSTONE_TOKEN_VALUE}) | ||||
|  | ||||
|         response = self.oidcplugin.get_unscoped_auth_ref(self.session) | ||||
|         self.assertEqual(KEYSTONE_TOKEN_VALUE, response.auth_token) | ||||
| @@ -34,6 +34,7 @@ keystoneclient.auth.plugin = | ||||
|     v2token = keystoneclient.auth.identity.v2:Token | ||||
|     v3password = keystoneclient.auth.identity.v3:Password | ||||
|     v3token = keystoneclient.auth.identity.v3:Token | ||||
|     v3oidcpassword = keystoneclient.contrib.auth.v3.oidc:OidcPassword | ||||
|     v3unscopedsaml = keystoneclient.contrib.auth.v3.saml2:Saml2UnscopedToken | ||||
|     v3scopedsaml = keystoneclient.contrib.auth.v3.saml2:Saml2ScopedToken | ||||
|     v3unscopedadfs = keystoneclient.contrib.auth.v3.saml2:ADFSUnscopedToken | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Jenkins
					Jenkins