Keystone认证公共库(keystoneauth)

keystoneauth是一个认证的公共库,它把和keystone进行认证的逻辑单独抽出来,提供了一个标准的认证过程,其实这个库就是把keystoneclient里的一部分和认证相关的代码拿了出来,单独作为一个库,然后就可以在各个服务的client中复用,这么做有下面的好处:

  1. 各个服务在和keystone进行认证时,就可以直接使用复用这个库,不用关心认证的逻辑了
  2. 安全相关的代码维护在一个地方,如果有问题,可以很快的进行修复

keystoneauth抽象出来3个概念:

  • session:它封装了requests.session,并且引用了auth_plugin,通过auth_plugin拿到认证信息,然后由session向一个URL发起HTTP请求, session是可以复用的
  • auth_plugin:抽象出了多种认证的方式,包括keystone的v2, v3的password, token等认证,还有v3中的联合认证
  • loader:loader主要用来加载auth_plugin,即用来根据配置项实例化一个auth_plugin,每一个auth_plugin使用的认证参数都不一样,loader对这些参数进行了抽象,可以根据选择的plugin,来加载相应的配置项;loader还可以用来load session

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
from keystoneauth1.identity import v3
from keystoneauth1 import session
from novaclient import client
auth = v3.Password(auth_url='http://localhost:5000/v3',
username='admin',
password='rachel',
project_name='admin',
user_domain_id='default',
project_domain_id='default')
sess = session.Session(auth=auth)
c = client.Client('2', session=sess)
servers = c.servers.list()
print servers

设计

keystoneauth中抽象运用的非常经典,尤其是对auth_plugin的抽象,这是一个非常不错的学习素材,本节主要围绕上面的3个概念,对其设计和实现进行剖析。

Session

理解Session非常重要,因为它贯穿了认证的整个过程,联系了keystonemiddleware, keystoneauth, keystoneclient等多个库。Session从功能上理解就是用来发送http请求的,它封装了requests这个库的session,并尽可能提供足够多的参数,与Session紧密相关的还有一个Adapter,它是Session的一个适配器,对Session进行简单的封装,主要是增加了几个用于过滤endpoint的属性。下面是Session相关的类图:

Session中的session属性就是request.session对象,用来发送http请求,auth属性就是Auth类的对象,用来获取认证需要的信息的,Auth相关的内容在下一个小节详细介绍。这里重点介绍一下request()这个方法,这个方法就是收集header和相关的参数,然后调用requests.session发送http请求的,其原型如下:

1
2
3
4
5
6
7

def request(self, url, method, json=None, original_ip=None,
user_agent=None, redirect=None, authenticated=None,
endpoint_filter=None, auth=None, requests_auth=None,
raise_exc=True, allow_reauth=True, log=True,
endpoint_override=None, connect_retries=0, logger=_logger,
**kwargs):

其中比较重要的是url和endpoint_filter,还有authenticated这三个参数:

  • authenticated, 当为False时,即不需要认证,那么就不会去获取认证的header信息,即X-Auth-Token
  • endpoint_filter,这个是用来从keystone的endpoint列表中过滤出需要用到的endpoint,有下面几个过滤项:
    • service_type
    • service_name
    • interface
    • region_name
  • url,http请求的路径,这个url可以有两种:
    • 相对路径,即只有一个request_path,这种情况,会使用上面的endpoint_filter参数去keystone请求endpoint,然后拼接成一个完整的url地址
    • 全路径,即是一个完整的uri地址,这种情况,就不会再去请求endpoint了,直接使用这个完整的url就可以了

为什么会有这些开关和判断逻辑呢?要知道session是复用的,比如在session中去向某一个url发送请求时,如果是相对路径,那么还要去获取endpoint,而获取endpoint这个操作也是在相同的session中做的,而获取endpoint的url就是一个全路径了,就是keystone的服务地址,获取到endpoint之后,再继续执行之前的动作,这类似于一个递归操作。

Adapter除了增加了几个本地化的属性之外,还实现了和Session一样的接口,在外界看来,Adapter其实就可以当作是一个Session了,是透明的,唯一的区别在哪里呢?区别就在那几个本地化的属性,在Adapter的request()方法中,将那几个属性组成了endpoint_filter,传递给了Session的request()方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def _set_endpoint_filter_kwargs(self, kwargs):
if self.service_type:
kwargs.setdefault('service_type', self.service_type)
if self.service_name:
kwargs.setdefault('service_name', self.service_name)
if self.interface:
kwargs.setdefault('interface', self.interface)
if self.region_name:
kwargs.setdefault('region_name', self.region_name)
if self.version:
kwargs.setdefault('version', self.version)

def request(self, url, method, **kwargs):
endpoint_filter = kwargs.setdefault('endpoint_filter', {})
self._set_endpoint_filter_kwargs(endpoint_filter)
...
return self.session.request(url, method, **kwargs)

这样通过这个adapter发出去的请求,就只能发送到某个固定的endpoint了,比如service_type=’identity’, interface=’admin’, version=’v3’,那通过这个adapter发出去的请求就都是请求到keystone的v3的admin地址了。为什么这么做呢?因为Session是可以复用的,多个client可能使用同一个session,但是每一个client所使用的endpoint地址不一样,也即endpoint_filter不一样,那这些信息不能保存在全局的session中,因为session是共享的,只能加一层封装,放到session的外部,也就是这个adapter了,所以可以见到多个adapter,一个session的情况。在各个client中,都会使用这个Adapter来封装Session,如果没有使用keystoneauth中的Adapter,也会自己写一个类似的,比如keystoneclient中就是自己写了一个(其实是原来就有的,只不过keystoneauth是从keystoneclient抽出去的)

Auth Plugin

Keystone提供很多种认证的方式, 可以通过password,还可以直接通过token进行认证,并且每种还提供v2和v3版本,除此之外,还有OpenID认证,联合认证以等,这些认证方式都被keystoneauth抽象出来以插件的形式存在,可以根据自己的情况来选择相应的插件。那这些插件到底做了什么工作呢?一句话概括就是根据原始的认证参数,比如username/password,对keystone进行认证,然后获取到相关的信息,比如token, endpoint等。我们这里只对v2和v3的password和token的这几种插件进行分析。

首先我们先来看看最本质的东西,即如何获取一个token,所有的插件其实都是围绕这个主题组织代码的,获取token使用下面的接口:

1
2
3
POST /v3/auth/tokens
或者
POST /v2.0/tokens

示例如下:

1
curl -v -X POST -H "Content-Type: application/json" http://localhost:35357/v3/auth/tokens -d '{"auth":{"identity": {"methods": ["password"], "password": {"user":{"name": "admin", "password": "password", "domain": {"name": "Default"}}}}, "scope":{"project":{"name": "admin", "domain": {"name": "Default"}}}}}' | python -m json.tool

从上面的示例上,我们可以看到一个插件想要进行认证,需要两种信息:一个是认证服务的地址,即auth_url,还有一个就是认证原始信息了,username, password, project_name等,Auth Plugin就是对这些信息进行抽象组装,使用上小节讲的Session去获取token,然后就可以获得endpoint, roles等信息。其静态类图大致如下:

可以看到整个类图的核心逻辑,就是根据原始的认证信息,获取到认证之后的信息,即auth_ref,它包含了token, service_catalog, user_id, project_id等完备的信息。从父类继承下来,分为了三个模块:v2, v3, generic,v2就是使用keystone v2的接口进行认证,v3就是使用keystone v3的接口进行认证,那generic是干嘛的呢?generic就是不显示指定使用v2还是v3,而是根据传递的参数,还有当前keystone支持的API版本信息,来决定使用哪种认证方式的。

v2和v3相对来说比较简单直接,他们就是各自收集自己的参数,然后向auth_url发起认证请求,注意这里的auth_url是需要带版本信息的,比如你使用v2.Password的插件进行认证,那么传递给v2.Password的auth_url就必须得加上”/v2.0”,,比如:auth_url = "http://localhost:5000/v2.0/",因为从上面的类图中可以看到,它是直接使用auth_url拼接成的路径,如果不传版本信息,路径就不对了,就会报404了。v3也是类似。

generic就比较复杂一点了,它有个判断使用哪种认证方式的逻辑,首先它构造了一个Discover类,这个类在上面的类图上没有画出来,不过它的作用很简单,就是根据auth_url去keystone请求版本信息,然后解析这些信息,可以根据版本号进行排序,还可以获取指定版本的url,比如auth_url = "http://localhost:5000/v3",从keystone获取到的版本信息是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

suo@ubuntu:~$ curl http://localhost:35357/v3 | python -m json.tool

{
"version": {
"id": "v3.5",
"links": [
{
"href": "http://localhost:35357/v3/",
"rel": "self"
}
],
"media-types": [
{
"base": "application/json",
"type": "application/vnd.openstack.identity-v3+json"
}
],
"status": "stable",
"updated": "2015-09-15T00:00:00Z"
}
}

当然,也可以请求根路径,即auth_url = "http://localhost:5000/",这样得到的就是一个版本信息的列表了,包括v2和v3的。这个请求也是通过session发送出去的,因为是全路径,而且authenticated设置为False,所以不需要任何认证认证信息,就可以直接发送出去。所以判断使用哪种认证方式的逻辑大致如下:

  1. 首先构造Discover,根据auth_url去keystone拿版本信息,这个auth_url可以是带版本的,也可以是不带版本的
  2. 如果因为某种原因从keystone获取版本信息失败,那么就会直接根据auth_url中带的版本信息来确定使用哪种认证方式,如果auth_url没有带版本信息,那么就报错了
  3. 如果第1步从keystone正常获取到了版本信息,那么会将解析的结果根据版本信息排序组成一个列表,然后遍历这个列表,会使用第一个符合条件的版本信息来构造认证插件,即使用第一个符合条件的版本的认证方式,这个条件是什么呢?就是不能v2和domain同时存在,因为domain是v3中的概念。比如指定了auth_url="http://localhost:5000/v2.0",然后又指定了domain_id或者domain_name等参数,这是不行的。但是指定了auth_url="http://localhost:5000/",然后指定了domain信息,那么它就会判断使用v3的认证方式了。即在满足v3认证的条件下,优先使用v3。这里的逻辑有点多,但是并不复杂,下面的代码片段描述了完整的逻辑:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
def _do_create_plugin(self, session):
plugin = None
try:
disc = self.get_discovery(session,
self.auth_url,
authenticated=False)
except (exceptions.DiscoveryFailure,
exceptions.HttpError,
exceptions.ConnectionError):
LOG.warning('Discovering versions from the identity service '
'failed when creating the password plugin. '
'Attempting to determine version from URL.')
url_parts = urlparse.urlparse(self.auth_url)
path = url_parts.path.lower()
if path.startswith('/v2.0'):
if self._has_domain_scope:
raise exceptions.DiscoveryFailure(
'Cannot use v2 authentication with domain scope')
plugin = self.create_plugin(session, (2, 0), self.auth_url)
elif path.startswith('/v3'):
plugin = self.create_plugin(session, (3, 0), self.auth_url)
else:
# NOTE(jamielennox): version_data is always in oldest to newest
# order. This is fine normally because we explicitly skip v2 below
# if there is domain data present. With default_domain params
# though we want a v3 plugin if available and fall back to v2 so we
# have to process in reverse order. FIXME(jamielennox): if we ever
# go for another version we should reverse this logic as we always
# want to favour the newest available version.
reverse = self._default_domain_id or self._default_domain_name
disc_data = disc.version_data(reverse=bool(reverse))
v2_with_domain_scope = False
for data in disc_data:
version = data['version']
if (discover.version_match((2,), version) and
self._has_domain_scope):
# NOTE(jamielennox): if there are domain parameters there
# is no point even trying against v2 APIs.
v2_with_domain_scope = True
continue
plugin = self.create_plugin(session,
version,
data['url'],
raw_status=data['raw_status'])
if plugin:
break
if not plugin and v2_with_domain_scope:
raise exceptions.DiscoveryFailure(
'Cannot use v2 authentication with domain scope')
if plugin:
return plugin

# so there were no URLs that i could use for auth of any version.
raise exceptions.DiscoveryFailure('Could not determine a suitable URL '
'for the plugin')

Loader

在各个client中使用上面的两个概念就完全足够了,loader是用来做什么的呢?上面的认证插件,每一个都有对应的一个loader,即这个loader是专门用来加载这个认证插件的,并且最重要的是每一个loader都维护了一个要初始化其对应的认证插件的参数列表,使用这些参数列表,就可以实例化一个认证插件了。这些参数可以注册到配置文件中,然后loader在实例化这个认证插件时,就可以从配置文件中读取参数信息,然后实例化。这套机制主要是用在keystonemiddleware中的,即认证中间件,用在每一个服务的API中。下面是Loader相关的类图:

可以看到跟上面Auth Plugin的类图基本上是一一对应的,其用法大致如下:

1
2
3
4
5
6
7
8
9
10
11
plugin_loader = loading.get_plugin_loader(plugin_name) # 根据plugin_name使用stevedore实例化一个loader
plugin_opts = [o._to_oslo_opt() for o in plugin_loader.get_options()] #获取这个loader的配置项,并且转成oslo_config格式的配置项
plugin_kwargs = dict()
(self._local_oslo_config or CONF).register_opts(plugin_opts,
group=group) # 注册到CONF中
for opt in plugin_opts:
val = self._conf_get(opt.dest, group=group) #从配置文件读取配置
if val is not None:
val = opt.type(val)
plugin_kwargs[opt.dest] = val
plugin = plugin_loader.load_from_options(**plugin_kwargs) # 实例化一个认证插件

数据流

通过上面的分析,可以看到这里面的关系其实是很乱的,尤其是还有递归的操作,很容易绕进去绕不出来了,下面我们就以最开始的例子为例,分析下这个过程的数据流,以及各个组件之间是如何交互的。下面是novaclient和keystoneauth之间的关系类图:

SessionClient继承自keystoneauth的Adapter,并且v2.Client引用了SessionClient,跟我们在上面在Session小节中的分析是一致的,即在每一个client中,都会使用Adapter来封装Session,目的是为了维护endpoinit_filter等参数,SessionClient中enpoint_filter分别为: service_type=”compute”,interface=”publicURL”,v2.Client中发出的请求,都是通过SessionClient来发出的,SessionClient又调用Session。

好,下面我们就让这些都“动”起来,并且以v2, v3, generic的Password插件为例,每一个都举一个例子:

v2

先来看v2,例子如下:

1
2
3
4
5
6
7
8
9
10
11
from keystoneauth1.identity import v2
from keystoneauth1 import session
from novaclient import client
auth = v2.Password(auth_url='http://localhost:5000/v2.0',
username='admin',
password='rachel',
tenant_name='admin')
sess = session.Session(auth=auth)
c = client.Client('2', session=sess)
servers = c.servers.list()
print servers

整个过程的序列图大致如下:

注意上面的Client指的是nova中的v2.Client,而SessionClient其实是一个keystoneauth.Adapter,重点是Session和v2.Password的交互:

  1. 第一步Session要通过v2.Password拿headers,然后v2.Password又通过Session去向Keystone发送请求,拿到一个token,然后v2.Password才将headers返回给Session,这里的headers就是X-Auth-Token
  2. 第二步因为Sessin要向nova发送请求,但是现在只知道部分url,即“/servers”,并不知道nova的endpoint是多少,因此又通过v2.Password去请求nova的endpoint,其实service_catalog已经包含在第一步请求的token中了,只需要用endpoint_filter过滤出nova的endpoint就行了
  3. Session拿到了headers和endpoint,就可以向nova发送请求了

v3

v3的例子如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
from keystoneauth1.identity import v3
from keystoneauth1 import session
from novaclient import client
auth = v3.Password(auth_url='http://localhost:5000/v3',
username='admin',
password='rachel',
project_name='admin',
user_domain_id='default',
project_domain_id='default')
sess = session.Session(auth=auth)
c = client.Client('2', session=sess)
servers = c.servers.list()
print servers

v3和v2其实在流程上并没有什么大的区别,只是抽象不同,请求的路径不同:

generic

generic的情况稍微特殊一点,它有个discover的过程,diccover首先会去根据auth_url请求版本信息,这会向Keystone发起一次请求,然后会根据参数创建v2或者v3的认证插件,然后接下来就和v2, v3的过程是一样的了。

代码示例如下(假设keysone是支持v2和v3的):

1
2
3
4
5
6
7
8
9
10
11
12
13
from keystoneauth1.identity import generic
from keystoneauth1 import session
from novaclient import client
auth = generic.Password(auth_url='http://localhost:5000/',
username='admin',
password='rachel',
project_name='admin',
user_domain_id='default',
project_domain_id='default')
sess = session.Session(auth=auth)
c = client.Client('2', session=sess)
servers = c.servers.list()
print servers

其序列图如下:

通过这个图,可以看到,Discover从Keystone获得版本信息,然后generic.Password根据这些信息,结合自己的参数决定应该使用v3的Password认证插件,于是使用新的auth_url创建了v3.Password,然后由v3.Password去跟Session和Keystone交互拿到token,也就拿到了auth_ref,之后就跟前两个一样了。

参数

每一个auth plugin的参数都不一致,每个loader也分别维护了对应的plugin的参数列表,可以通过loader拿到参数列表,然后将这些参数注册到配置文件中,这是在keystonemiddleware中的做法,通常将这些配置项注册在keystone_authtoken这个section下面;也可以像上面的例子一样,直接实例化auth plugin,无论哪种做法,每个plugin对应的参数列表都是一样的。下面就整理成一个表格,方便查阅,这样我们就可以知道在使用哪种插件的时候,该配置哪种参数。

v2 v3 generic
common auth_url
tenant_id
tenant_name
trust_id
auth_url
domain_id
domain_name
project_id
project_name
project_domain_id
project_domain_name
trust_id
auth_url
domain_id
domain_name
project_id
project_name
project_domain_id
project_domain_name
trust_id
default_domain_id
default_domain_name
token token token token
password username
user_id
password
user_id
user_name
user_domain_id
user_domain_name
password
user_id
user_name
user_domain_id
user_domain_name
password

此外在loading中还有两个配置项:

  • auth_type/auth_plugin,选择哪种认证插件(auth_plugin被标记为废弃,应该使用auth_type),目前有下面几种可以选择:
    • password, 即generic password
    • token, 即generic token
    • v2password
    • v2token
    • v3password
    • v3token
  • auth_section,auth的配置项所在的section,默认为空,如果不配置的话,就是keystone_authtoken

在上面的参数中,最让人迷惑的就是auth_url了,到底应该是用5000还是35357?到底应不应该加版本信息?根据上面的分析其实很容易决断,auth_url主要是用来获取token的,使用5000和35357都可以,但是如果配置在keystone_authtoken中,是给各个服务使用的,一般都配置成admin的接口,也即35357,如果是给普通用户使用的,比如在各个client中,应该使用public的接口,即5000端口;至于版本信息,如果使用v2的认证插件的话,就必须带上”/v2.0”,如果使用v3的认证插件的话,也必须带上”v3”,如果使用generic的话,可带可不带,带的话,就需要注意信息要匹配,即v2不能和有domain信息的配置项共存,因为domain是v3中的概念,否则就没法决断该用哪个插件了,所以下面的例子都是合法的配置:

v2:

1
2
3
4
5
6
[keystone_authtoken]
auth_type=v2password
auth_url = http://localhost:35357/v2.0
tenant_name = service
username = glance
password = GLANCE_PASS

v3:

1
2
3
4
5
6
7
8
[keystone_authtoken]
auth_type = v3password
auth_url = http://localhost:35357/v3
project_domain_id = default
user_domain_id = default
project_name = service
username = glance
password = GLANCE_PASS

generic:

1
2
3
4
5
6
7
8
[keystone_authtoken]
auth_type = password
auth_url = http://localhost:35357
project_domain_id = default
user_domain_id = default
project_name = service
username = glance
password = GLANCE_PASS

generic v2:

1
2
3
4
5
6
[keystone_authtoken]
auth_type = password
auth_url = http://localhost:35357/v2.0
project_name = service
username = glance
password = GLANCE_PASS

generic v3:

1
2
3
4
5
6
7
8
9

[keystone_authtoken]
auth_type = password
auth_url = http://localhost:35357/v3
project_domain_id = default
user_domain_id = default
project_name = service
username = glance
password = GLANCE_PASS

但下面的这个是不合法的:

1
2
3
4
5
6
7
8
[keystone_authtoken]
auth_type = password
auth_url = http://localhost:35357/v2.0
project_domain_id = default
user_domain_id = default
project_name = service
username = glance
password = GLANCE_PASS

因为auth_url中指定了v2.0,但是在下面又配置了domain等信息,让discover没法判断该使用哪种认证方式。

是不是有点乱?的确,现在的这种做法其实不太友好,不统一,有的必须有,有的必须没有,并且明明auth_type已经选择了v2password,但是在auth_url中,却仍必须要填写上v2.0,这不是重复了吗?社区有Bug在提这件事,但是目前建议还是采用generic的配置方式,auth_url配置到根目录,即不指定版本信息,然后再配置上domain信息,让它选择使用v3的认证方式。

作者

hackerain

发布于

2019-09-20

更新于

2023-03-11

许可协议