From 9e29e6e9c51d0458764db1689a4a7cbe34c4b907 Mon Sep 17 00:00:00 2001 From: guang-yee Date: Tue, 16 Feb 2016 22:17:16 -0800 Subject: [PATCH] Support TOTP auth plugin Add support for time-based one-time password (TOTP) authentication. Change-Id: I004677ac7f0e2fb8c059ad14868e661e8ee4c1f9 --- doc/source/authentication-plugins.rst | 5 ++ keystoneauth1/identity/__init__.py | 6 +- keystoneauth1/identity/v3/__init__.py | 8 +- keystoneauth1/identity/v3/totp.py | 81 +++++++++++++++++++ keystoneauth1/loading/_plugins/identity/v3.py | 58 ++++++++++--- keystoneauth1/tests/unit/loading/test_v3.py | 51 ++++++++++++ ...add-totp-auth-plugin-0650d220899c25b7.yaml | 16 ++++ setup.cfg | 1 + 8 files changed, 210 insertions(+), 16 deletions(-) create mode 100644 keystoneauth1/identity/v3/totp.py create mode 100644 releasenotes/notes/add-totp-auth-plugin-0650d220899c25b7.yaml diff --git a/doc/source/authentication-plugins.rst b/doc/source/authentication-plugins.rst index 981865d7..b8d04c1b 100644 --- a/doc/source/authentication-plugins.rst +++ b/doc/source/authentication-plugins.rst @@ -55,6 +55,8 @@ this V3 defines a number of different against a V3 identity service using a username and password. - :py:class:`~keystoneauth1.identity.v3.TokenMethod`: Authenticate against a V3 identity service using an existing token. +- :py:class:`~keystoneauth1.identity.v3.TOTPMethod`: Authenticate against + a V3 identity service using Time-Based One-Time Password (TOTP). - :py:class:`~keystoneauth1.extras.kerberos.KerberosMethod`: Authenticate against a V3 identity service using Kerberos. @@ -81,6 +83,8 @@ like the V2 plugins: only a :py:class:`~keystoneauth1.identity.v3.PasswordMethod`. - :py:class:`~keystoneauth1.identity.v3.Token`: Authenticate using only a :py:class:`~keystoneauth1.identity.v3.TokenMethod`. +- :py:class:`~keystoneauth1.identity.v3.TOTP`: Authenticate using + only a :py:class:`~keystoneauth1.identity.v3.TOTPMethod`. - :py:class:`~keystoneauth1.extras.kerberos.Kerberos`: Authenticate using only a :py:class:`~keystoneauth1.extras.kerberos.KerberosMethod`. @@ -175,6 +179,7 @@ authentication plugins that are available in `keystoneauth` are: - v2token: :py:class:`keystoneauth1.identity.v2.Token` - v3password: :py:class:`keystoneauth1.identity.v3.Password` - v3token: :py:class:`keystoneauth1.identity.v3.Token` +- v3totp: :py:class:`keystoneauth1.identity.v3.TOTP` Creating Authentication Plugins diff --git a/keystoneauth1/identity/__init__.py b/keystoneauth1/identity/__init__.py index 750fae48..3d745522 100644 --- a/keystoneauth1/identity/__init__.py +++ b/keystoneauth1/identity/__init__.py @@ -46,6 +46,9 @@ V3OidcAuthorizationCode = oidc.OidcAuthorizationCode V3OidcAccessToken = oidc.OidcAccessToken """See :class:`keystoneauth1.identity.v3.oidc.OidcAccessToken`""" +V3TOTP = v3.TOTP +"""See :class:`keystoneauth1.identity.v3.TOTP`""" + __all__ = ('BaseIdentityPlugin', 'Password', 'Token', @@ -55,4 +58,5 @@ __all__ = ('BaseIdentityPlugin', 'V3Token', 'V3OidcPassword', 'V3OidcAuthorizationCode', - 'V3OidcAccessToken') + 'V3OidcAccessToken', + 'V3TOTP') diff --git a/keystoneauth1/identity/v3/__init__.py b/keystoneauth1/identity/v3/__init__.py index 4a03b6c9..dff9e0d1 100644 --- a/keystoneauth1/identity/v3/__init__.py +++ b/keystoneauth1/identity/v3/__init__.py @@ -13,9 +13,10 @@ from keystoneauth1.identity.v3.base import * # noqa from keystoneauth1.identity.v3.federation import * # noqa from keystoneauth1.identity.v3.k2k import * # noqa +from keystoneauth1.identity.v3.oidc import * # noqa from keystoneauth1.identity.v3.password import * # noqa from keystoneauth1.identity.v3.token import * # noqa -from keystoneauth1.identity.v3.oidc import * # noqa +from keystoneauth1.identity.v3.totp import * # noqa __all__ = ('Auth', @@ -34,4 +35,7 @@ __all__ = ('Auth', 'TokenMethod', 'OidcAuthorizationCode', - 'OidcPassword',) + 'OidcPassword', + + 'TOTPMethod', + 'TOTP') diff --git a/keystoneauth1/identity/v3/totp.py b/keystoneauth1/identity/v3/totp.py new file mode 100644 index 00000000..ac0a7540 --- /dev/null +++ b/keystoneauth1/identity/v3/totp.py @@ -0,0 +1,81 @@ +# 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 copy + +from keystoneauth1.identity.v3 import base + + +__all__ = ('TOTPMethod', 'TOTP') + + +class TOTPMethod(base.AuthMethod): + """Construct a User/Passcode based authentication method. + + :param string passcode: TOTP passcode for authentication. + :param string username: Username for authentication. + :param string user_id: User ID for authentication. + :param string user_domain_id: User's domain ID for authentication. + :param string user_domain_name: User's domain name for authentication. + """ + + _method_parameters = ['user_id', + 'username', + 'user_domain_id', + 'user_domain_name', + 'passcode'] + + def get_auth_data(self, session, auth, headers, **kwargs): + user = {'passcode': self.passcode} + + if self.user_id: + user['id'] = self.user_id + elif self.username: + user['name'] = self.username + + if self.user_domain_id: + user['domain'] = {'id': self.user_domain_id} + elif self.user_domain_name: + user['domain'] = {'name': self.user_domain_name} + + return 'totp', {'user': user} + + def get_cache_id_elements(self): + # NOTE(gyee): passcode is not static so we cannot use it as part of + # the key in caching. + params = copy.copy(self._method_parameters) + params.remove('passcode') + return dict(('totp_%s' % p, getattr(self, p)) + for p in self._method_parameters) + + +class TOTP(base.AuthConstructor): + """A plugin for authenticating with a username and TOTP passcode. + + :param string auth_url: Identity service endpoint for authentication. + :param string passcode: TOTP passcode for authentication. + :param string username: Username for authentication. + :param string user_id: User ID for authentication. + :param string user_domain_id: User's domain ID for authentication. + :param string user_domain_name: User's domain name for authentication. + :param string trust_id: Trust ID for trust scoping. + :param string domain_id: Domain ID for domain scoping. + :param string domain_name: Domain name for domain scoping. + :param string project_id: Project ID for project scoping. + :param string project_name: Project name for project scoping. + :param string project_domain_id: Project's domain ID for project. + :param string project_domain_name: Project's domain name for project. + :param bool reauthenticate: Allow fetching a new token if the current one + is going to expire. (optional) default True + """ + + _auth_method_class = TOTPMethod diff --git a/keystoneauth1/loading/_plugins/identity/v3.py b/keystoneauth1/loading/_plugins/identity/v3.py index 00426bb0..96872680 100644 --- a/keystoneauth1/loading/_plugins/identity/v3.py +++ b/keystoneauth1/loading/_plugins/identity/v3.py @@ -15,6 +15,27 @@ from keystoneauth1 import identity from keystoneauth1 import loading +def _add_common_identity_options(options): + options.extend([ + loading.Opt('user-id', help='User ID'), + loading.Opt('username', + help='Username', + deprecated=[loading.Opt('user-name')]), + loading.Opt('user-domain-id', help="User's domain id"), + loading.Opt('user-domain-name', help="User's domain name"), + ]) + + +def _assert_identity_options(options): + if (options.get('username') and + not (options.get('user_domain_name') or + options.get('user_domain_id'))): + m = "You have provided a username. In the V3 identity API a " \ + "username is only unique within a domain so you must " \ + "also provide either a user_domain_id or user_domain_name." + raise exceptions.OptionError(m) + + class Password(loading.BaseV3Loader): @property @@ -23,27 +44,16 @@ class Password(loading.BaseV3Loader): def get_options(self): options = super(Password, self).get_options() + _add_common_identity_options(options) options.extend([ - loading.Opt('user-id', help='User ID'), - loading.Opt('username', - help='Username', - deprecated=[loading.Opt('user-name')]), - loading.Opt('user-domain-id', help="User's domain id"), - loading.Opt('user-domain-name', help="User's domain name"), loading.Opt('password', secret=True, help="User's password"), ]) return options def load_from_options(self, **kwargs): - if (kwargs.get('username') and - not (kwargs.get('user_domain_name') or - kwargs.get('user_domain_id'))): - m = "You have provided a username. In the V3 identity API a " \ - "username is only unique within a domain so you must " \ - "also provide either a user_domain_id or user_domain_name." - raise exceptions.OptionError(m) + _assert_identity_options(kwargs) return super(Password, self).load_from_options(**kwargs) @@ -139,3 +149,25 @@ class OpenIDConnectAccessToken(loading.BaseFederationLoader): help='OAuth 2.0 Access Token'), ]) return options + + +class TOTP(loading.BaseV3Loader): + + @property + def plugin_class(self): + return identity.V3TOTP + + def get_options(self): + options = super(TOTP, self).get_options() + _add_common_identity_options(options) + + options.extend([ + loading.Opt('passcode', secret=True, help="User's TOTP passcode"), + ]) + + return options + + def load_from_options(self, **kwargs): + _assert_identity_options(kwargs) + + return super(TOTP, self).load_from_options(**kwargs) diff --git a/keystoneauth1/tests/unit/loading/test_v3.py b/keystoneauth1/tests/unit/loading/test_v3.py index af6e508b..1d41b62a 100644 --- a/keystoneauth1/tests/unit/loading/test_v3.py +++ b/keystoneauth1/tests/unit/loading/test_v3.py @@ -10,6 +10,7 @@ # License for the specific language governing permissions and limitations # under the License. +import random import uuid from keystoneauth1 import exceptions @@ -64,3 +65,53 @@ class V3PasswordTests(utils.TestCase): password=uuid.uuid4().hex, user_domain_id=uuid.uuid4().hex, project_name=uuid.uuid4().hex) + + +class TOTPTests(utils.TestCase): + + def setUp(self): + super(TOTPTests, self).setUp() + + self.auth_url = uuid.uuid4().hex + + def create(self, **kwargs): + kwargs.setdefault('auth_url', self.auth_url) + loader = loading.get_plugin_loader('v3totp') + return loader.load_from_options(**kwargs) + + def test_basic(self): + username = uuid.uuid4().hex + user_domain_id = uuid.uuid4().hex + # passcode is 6 digits + passcode = ''.join(str(random.randint(0, 9)) for x in range(6)) + project_name = uuid.uuid4().hex + project_domain_id = uuid.uuid4().hex + + p = self.create(username=username, + user_domain_id=user_domain_id, + project_name=project_name, + project_domain_id=project_domain_id, + passcode=passcode) + + totp_method = p.auth_methods[0] + + self.assertEqual(username, totp_method.username) + self.assertEqual(user_domain_id, totp_method.user_domain_id) + self.assertEqual(passcode, totp_method.passcode) + + self.assertEqual(project_name, p.project_name) + self.assertEqual(project_domain_id, p.project_domain_id) + + def test_without_user_domain(self): + self.assertRaises(exceptions.OptionError, + self.create, + username=uuid.uuid4().hex, + passcode=uuid.uuid4().hex) + + def test_without_project_domain(self): + self.assertRaises(exceptions.OptionError, + self.create, + username=uuid.uuid4().hex, + passcode=uuid.uuid4().hex, + user_domain_id=uuid.uuid4().hex, + project_name=uuid.uuid4().hex) diff --git a/releasenotes/notes/add-totp-auth-plugin-0650d220899c25b7.yaml b/releasenotes/notes/add-totp-auth-plugin-0650d220899c25b7.yaml new file mode 100644 index 00000000..b96b46d5 --- /dev/null +++ b/releasenotes/notes/add-totp-auth-plugin-0650d220899c25b7.yaml @@ -0,0 +1,16 @@ +--- +features: + - > + [`blueprint totp-auth `_] + Add an auth plugin to handle Time-Based One-Time Password (TOTP) + authentication via the ``totp`` method. This new plugin will accept the + following identity options: + + - ``user-id``: user ID + - ``username``: username + - ``user-domain-id``: user's domain ID + - ``user-domain-name``: user's domain name + - ``passcode``: passcode generated by TOTP app or device + + User is uniquely identified by either ``user-id`` or combination of + ``username`` and ``user-domain-id`` or ``user-domain-name``. diff --git a/setup.cfg b/setup.cfg index 827c8dae..95784c0f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -49,6 +49,7 @@ keystoneauth1.plugin = v3oidcaccesstoken = keystoneauth1.loading._plugins.identity.v3:OpenIDConnectAccessToken v3oauth1 = keystoneauth1.extras.oauth1._loading:V3OAuth1 v3kerberos = keystoneauth1.extras.kerberos._loading:Kerberos + v3totp = keystoneauth1.loading._plugins.identity.v3:TOTP [build_sphinx] source-dir = doc/source