keystone/keystone/auth/plugins/totp.py
Adrian Turjak 5572d01300 Add support for previous TOTP windows
Update the TOTP auth plugin so that it can be configured
to allow a passcode from a given number of windows back to
still work.

This gives TOTP some slighly better UX so by default at least
one passcode back will still work. Can be disabled, or more
windows added for clouds less worried about security and more
about clock drift.

Change-Id: I8ba4127a365392f0d0e9de5fd9c979750c354dc7
Closes-Bug: #1839577
2019-08-14 13:16:59 +12:00

125 lines
4.6 KiB
Python

# 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.
"""Time-based One-time Password Algorithm (TOTP) auth plugin.
TOTP is an algorithm that computes a one-time password from a shared secret
key and the current time.
TOTP is an implementation of a hash-based message authentication code (HMAC).
It combines a secret key with the current timestamp using a cryptographic hash
function to generate a one-time password. The timestamp typically increases in
30-second intervals, so passwords generated close together in time from the
same secret key will be equal.
"""
import base64
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.twofactor import totp as crypto_totp
from oslo_log import log
from oslo_utils import timeutils
import six
from keystone.auth import plugins
from keystone.auth.plugins import base
from keystone.common import provider_api
import keystone.conf
from keystone import exception
from keystone.i18n import _
CONF = keystone.conf.CONF
METHOD_NAME = 'totp'
LOG = log.getLogger(__name__)
PROVIDERS = provider_api.ProviderAPIs
PASSCODE_LENGTH = 6
PASSCODE_TIME_PERIOD = 30
def _generate_totp_passcodes(secret, included_previous_windows=0):
"""Generate TOTP passcode.
:param bytes secret: A base32 encoded secret for the TOTP authentication
:returns: totp passcode as bytes
"""
if isinstance(secret, six.text_type):
# NOTE(dstanek): since this may be coming from the JSON stored in the
# database it may be UTF-8 encoded
secret = secret.encode('utf-8')
# NOTE(nonameentername): cryptography takes a non base32 encoded value for
# TOTP. Add the correct padding to be able to base32 decode
while len(secret) % 8 != 0:
secret = secret + b'='
decoded = base64.b32decode(secret)
# NOTE(lhinds) This is marked as #nosec since bandit will see SHA1
# which is marked as insecure. In this instance however, keystone uses
# HMAC-SHA1 when generating the TOTP, which is currently not insecure but
# will still trigger when scanned by bandit.
totp = crypto_totp.TOTP(
decoded, PASSCODE_LENGTH, hashes.SHA1(), PASSCODE_TIME_PERIOD, # nosec
backend=default_backend())
passcode_ts = timeutils.utcnow_ts(microsecond=True)
passcodes = [totp.generate(passcode_ts).decode('utf-8')]
for i in range(included_previous_windows):
# NOTE(adriant): we move back the timestamp the number of seconds in
# PASSCODE_TIME_PERIOD each time.
passcode_ts -= PASSCODE_TIME_PERIOD
passcodes.append(totp.generate(passcode_ts).decode('utf-8'))
return passcodes
class TOTP(base.AuthMethodHandler):
def authenticate(self, auth_payload):
"""Try to authenticate using TOTP."""
response_data = {}
user_info = plugins.TOTPUserInfo.create(auth_payload, METHOD_NAME)
auth_passcode = auth_payload.get('user').get('passcode')
credentials = PROVIDERS.credential_api.list_credentials_for_user(
user_info.user_id, type='totp')
valid_passcode = False
for credential in credentials:
try:
generated_passcodes = _generate_totp_passcodes(
credential['blob'], CONF.totp.included_previous_windows)
if auth_passcode in generated_passcodes:
valid_passcode = True
break
except (ValueError, KeyError):
LOG.debug('No TOTP match; credential id: %s, user_id: %s',
credential['id'], user_info.user_id)
except (TypeError):
LOG.debug('Base32 decode failed for TOTP credential %s',
credential['id'])
if not valid_passcode:
# authentication failed because of invalid username or passcode
msg = _('Invalid username or TOTP passcode')
raise exception.Unauthorized(msg)
response_data['user_id'] = user_info.user_id
return base.AuthHandlerResponse(status=True, response_body=None,
response_data=response_data)