Keystone, Horizon, and multi-factor auth (part 1/2: Keystone)

Fair warning: This post documents a recent work project — it contains neither lush landscape photos nor close-ups of underwater creatures.

For a couple of years now the Wikimedia Labs team has had ambitions to deprecate our homemade OpenStack web interface in favor of the official OpenStack user interface, Horizon. There are dozens of issues to overcome in this transition, but one of the biggest is security: We require two-factor authentication to access all potentially-destructive web interfaces in Labs; Horizon (and, until recently, Keystone) had no support for anything beyond simple username/password logins.

A stock install of Horizon has been publicly available and attached to the backend Labs services for quite a while, but most features were intentionally disabled. Many, many volunteers have rights to manipulate VMs in labs, and I can’t run the risk of of a random stranger logging in with the password ‘password’ and deleting a dozen instances.

Our second factor is standard totp token, enforced on our current UI by the OATHAuth mediawiki extension. Since the migration to Horizon could take a year or more, I want users to be able to use the same credentials on both systems. That means we needed a Keystone plugin that used the same keys that are currently used on our existing system. At my request, security engineer Chris Steipp set up a devstack instance and quickly rattled off a Keystone plugin that works in OpenStack Kilo and Liberty and can authenticate against our existing keys. Here’s the good bit:


@dependency.requires('identity_api')
class Wmtotp(auth.AuthMethodHandler):

method = METHOD_NAME

def authenticate(self, context, auth_payload, auth_context):
"""Try to authenticate against the identity backend."""
user_info = auth_plugins.UserAuthInfo.create(auth_payload, self.method)

try:
self.identity_api.authenticate(
context,
user_id=user_info.user_id,
password=user_info.password)
except AssertionError:
# authentication failed because of invalid username or password
msg = _('Invalid username or password')
raise exception.Unauthorized(msg)

# Password auth succeeded, check two-factor
# LOG.debug("OATH: Doing 2FA for user_info " +
# ( "%s(%r)" % (user_info.__class__, user_info.__dict__) ) )
# LOG.debug("OATH: Doing 2FA for auth_payload " +
# ( "%s(%r)" % (auth_payload.__class__, auth_payload) ) )
cnx = mysql.connector.connect(
user=CONF.oath.dbuser,
password=CONF.oath.dbpass,
database=CONF.oath.dbname,
host=CONF.oath.dbhost)
cur = cnx.cursor(buffered=True)
sql = ('SELECT oath.secret as secret from user '
'left join oathauth_users as oath on oath.id = user.user_id '
'where user.user_name = %s LIMIT 1')
cur.execute(sql, (user_info.user_ref['name'], ))
secret = cur.fetchone()[0]

if secret:
if 'totp' in auth_payload['user']:
(p, d) = oath.accept_totp(
base64.b16encode(base64.b32decode(secret)),
auth_payload['user']['totp'])
if p:
LOG.debug("OATH: 2FA passed")
else:
LOG.debug("OATH: 2FA failed")
msg = _('Invalid two-factor token')
raise exception.Unauthorized(msg)
else:
LOG.debug("OATH: 2FA failed, missing totp param")
msg = _('Missing two-factor token')
raise exception.Unauthorized(msg)
else:
LOG.debug("OATH: user '%s' does not have 2FA enabled.",
user_info.user_ref['name'])

auth_context['user_id'] = user_info.user_id

Rather than build a new Keystone package with the plugin included, I just wrote a puppet patch to drop it into a strategic location. That turns out to be easy because Keystone is designed with this kind of extensibility in mind. (Extensibility patterns tend to change with every release, but that’s a rant for another day.)

The current trunk of Keystone includes a plugin called ‘totp.py’ which I haven’t read but which surely duplicates some or all of Chris’s plugin. As we draw nearer to running Newton we’ll investigate whether or not our custom code can be reduced.

Next post: Getting that OTP from the user and handling it in Horizon.

This entry was posted in Operations. Bookmark the permalink.