Token的一点历史 Keystone在早期的版本中,其认证Token有好几种类型,最早期的也是当时最成熟的是UUID类型的Token,它将token以及其元数据存储在数据库中,以一个UUID来标识一个Token,这种方式一个明显的问题就是当Token量很大时,会往数据库中写大量的Token,一个中等集群,往往数据库中有十几G的数据都是Token,需要定期清理,否则会拖慢其他请求;后来发展出了基于公私钥非对称加密的PKI/PKIZ Token,这种Token不需要存数据库,Token元数据直接被编码到Token ID中,而且因为是非对称的,甚至都不需要跟Keystone交互,本地就可以完成验证以及解析Token,但是这种Token,因为Token元数据都在Token ID中,随着集群大小以及复杂度的变化,其长度也会变化,有时会变得非常长,超过了HTTP对Header的长度限制,再加上公私钥的管理也比较复杂,所以PKI体系的Token基本没有被用起来;于是就有了Fernet Token,它可以看成是UUID和PKI折中的一种方案,它有以下几个特点:
Token不存数据库
Token长度不会变得很长,一般小于255个字符
采用AES-CBC对称加密算法加密Token,并且使用散列函数SHA256进行签名
Token验证需要到Keystone Server进行,不能本地验证
为了安全,进行对称加密的key,需要定期Rotate
相比UUID,它不存数据库,减少了对数据库的压力,而且使得性能有了一定的提升;相比PKI,它长度固定,而且对称加密,比较好管理,因此随着Fernet逐渐成熟,发展到Rocky版本,UUID和PKI/PKIZ这两种Token类型已经被移除掉了,Fernet现在是唯一的Token类型。
Play around with Fernet Token 先来简单看看Fernet Token长啥样,以及怎么验证一个Token:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 [root@control1 ~]# . admin-openrc [root@control1 ~]# openstack token issue +------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | Field | Value | +------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | expires | 2020-11-10T15:04:48+0000 | | id | gAAAAABfqVqQlgiZgky_i2mWVbknmmGRbHSZ9RGrkqPp2GNuhd_n5D7RB5uD6ngaWzr-zCdIKxDXVk9mgzDxTS7QhRH1mUpnEbMj7JPpHNU3vDXI4Zm5mgPVZqxAQOWLhmBRj_ELcnzY_RtikwoyaLk41ogvMFA6NUu7fh0eeWuipVYgsL8w9YY | | project_id | 3c638b2eb36b4da6944040bb31084421 | | user_id | c9c34b222cae43ef9b721ece47545431 | +------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ [root@control1 ~]# . guangyu-openrc [root@control1 ~]# openstack token issue +------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | Field | Value | +------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | expires | 2020-11-10T15:04:58+0000 | | id | gAAAAABfqVqa85e6hC6SJ8dWM0tE0C0Ast-_NnmInVTZTM8n_XLpkBGiuoAGBIejJW3oyixZoJc4g82ezpPh_WGRBW47SkcFOsVmItAhw_GOrWofTzjPM3Oekt5Fk6bBpa8tVsT-qec8DTW6tEq2Wm2Yc4Jmw3nkX6mbMdNMR-zxeGfq8B5MJcA | | project_id | e9cdf316e25d433bb69278be3339ded0 | | user_id | fee9dca90b2e46dc8f31960c517a3baf | +------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
这里,source了两个openrc文件,一个是管理员admin的,一个是普通用户guangyu的,然后分别用openstack token issue
命令创建了这两个用户的token,其中id那一行就是Fernet Token,可以看到它要比一个UUID长很多,但是也不是特别长,还可以接受。接下来我们使用admin的token调用Keystone的API来验证一下guangyu的Token是否有效,这就好比一个请求带着token去请求Nova API,Nova API本身不具备验证这个Token是否有效的能力,所以它需要向Keystone去验证这个Token是否有效,验证Token的API为 GET /v3/auth/tokens :
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 56 57 58 59 60 61 [root@control1 ~]# ADMIN_TOKEN="gAAAAABfqVqQlgiZgky_i2mWVbknmmGRbHSZ9RGrkqPp2GNuhd_n5D7RB5uD6ngaWzr-zCdIKxDXVk9mgzDxTS7QhRH1mUpnEbMj7JPpHNU3vDXI4Zm5mgPVZqxAQOWLhmBRj_ELcnzY_RtikwoyaLk41ogvMFA6NUu7fh0eeWuipVYgsL8w9YY" [root@control1 ~]# GUANGYU_TOKEN="gAAAAABfqVqa85e6hC6SJ8dWM0tE0C0Ast-_NnmInVTZTM8n_XLpkBGiuoAGBIejJW3oyixZoJc4g82ezpPh_WGRBW47SkcFOsVmItAhw_GOrWofTzjPM3Oekt5Fk6bBpa8tVsT-qec8DTW6tEq2Wm2Yc4Jmw3nkX6mbMdNMR-zxeGfq8B5MJcA" [root@control1 ~]# curl -H "X-Auth-Token: $ADMIN_TOKEN" -H "X-Subject-Token: $GUANGYU_TOKEN" http://10.110.105.30:35357/v3/auth/tokens | jq . { "token": { "is_domain": false, "methods": [ "password" ], "roles": [ { "id": "26796d7d1f8447a3ab95d0d31c3bca37", "name": "reader" }, { "id": "ea022f3532ad4f6cafbc63f9a1bce8f3", "name": "creator" }, { "id": "470a11fdfb7a49b48c1a5d9524a98cf9", "name": "member" } ], "expires_at": "2020-11-10T15:04:58.000000Z", "project": { "domain": { "id": "default", "name": "Default" }, "id": "e9cdf316e25d433bb69278be3339ded0", "name": "guangyu_project" }, "catalog": [ { "endpoints": [ { "url": "http://10.110.105.30:9292", "interface": "admin", "region": "RegionOne", "region_id": "RegionOne", "id": "783d732505fb4ce58fb677b1b974f424" }, ...... } ], "user": { "password_expires_at": null, "domain": { "id": "default", "name": "Default" }, "id": "fee9dca90b2e46dc8f31960c517a3baf", "name": "guangyu" }, "audit_ids": [ "AnPMxLBlQjOZTHrd0ttwlA" ], "issued_at": "2020-11-09T15:04:58.000000Z" } }
可以看到该接口传入的两个Header,X-Auth-Token
为调用API要传入的Token,这个一定是有效的合法的Token,否则会报401,而X-Subject-Token
则是需要验证的Token,这个Token可能是无效的,也可能是有效的,需要在服务端进行验证才行,如果验证有效,则会将该Token所代表的详细信息返回,可以看到包含role, project, domain, catalog, user等信息,如果觉得catalog太长不想要,则可以传一个?nocatalog=true
参数,返回值中就不会包含catalog了。
创建Token 下面我们来看看Token是怎么创建出来的,为什么说其长度基本固定,以及它怎么做的对称加密。其关键代码如下:
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 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 def create_token (self, user_id, expires_at, audit_ids, methods=None , system=None , domain_id=None , project_id=None , trust_id=None , federated_group_ids=None , identity_provider_id=None , protocol_id=None , access_token_id=None , app_cred_id=None ): """Given a set of payload attributes, generate a Fernet token.""" ...... version = payload_class.version payload = payload_class.assemble( user_id, methods, system, project_id, domain_id, expires_at, audit_ids, trust_id, federated_group_ids, identity_provider_id, protocol_id, access_token_id, app_cred_id ) versioned_payload = (version,) + payload serialized_payload = msgpack.packb(versioned_payload) token = self.pack(serialized_payload) if len (token) > 255 : LOG.info('Fernet token created with length of %d ' 'characters, which exceeds 255 characters' , len (token)) return token @property def crypto (self ): """Return a cryptography instance. You can extend this class with a custom crypto @property to provide your own token encoding / decoding. For example, using a different cryptography library (e.g. ``python-keyczar``) or to meet arbitrary security requirements. This @property just needs to return an object that implements ``encrypt(plaintext)`` and ``decrypt(ciphertext)``. """ fernet_utils = utils.FernetUtils( CONF.fernet_tokens.key_repository, CONF.fernet_tokens.max_active_keys, 'fernet_tokens' ) keys = fernet_utils.load_keys() if not keys: raise exception.KeysNotFound() fernet_instances = [fernet.Fernet(key) for key in keys] return fernet.MultiFernet(fernet_instances) def pack (self, payload ): """Pack a payload for transport as a token. :type payload: six.binary_type :rtype: six.text_type """ return self.crypto.encrypt(payload).rstrip(b'=' ).decode('utf-8' )
可见,参与Token生成的一些元数据,大部分由一些id组成的payload,比如user_id, project_id, domain_id等等,还有一些诸如methods, expires_at等长度基本上变化很小的元数据,然后加载用于加密签名的key,对这些数据组成的payload进行加密,签名以及base64编码,最终得到了我们前面看到的Fernet Token。
验证Token 接下来,再来看下是如何验证Token的,这个token在不存数据库的情况下是如何获取其代表的信息的,其关键代码如下:
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 def validate_token (self, token ): """Validate a Fernet token and returns the payload attributes. :type token: six.text_type """ serialized_payload = self.unpack(token) versioned_payload = msgpack.unpackb(serialized_payload) version, payload = versioned_payload[0 ], versioned_payload[1 :] ...... issued_at = TokenFormatter.creation_time(token) issued_at = ks_utils.isotime(at=issued_at, subsecond=True ) expires_at = timeutils.parse_isotime(expires_at) expires_at = ks_utils.isotime(at=expires_at, subsecond=True ) return (user_id, methods, audit_ids, system, domain_id, project_id, trust_id, federated_group_ids, identity_provider_id, protocol_id, access_token_id, app_cred_id, issued_at, expires_at) def unpack (self, token ): """Unpack a token, and validate the payload. :type token: six.text_type :rtype: six.binary_type """ token = TokenFormatter.restore_padding(token) try : return self.crypto.decrypt(token.encode('utf-8' )) except fernet.InvalidToken: raise exception.ValidationError( _('This is not a recognized Fernet token %s' ) % token)
可见,将token id传进来,使用key将其进行解密操作,最终还原回当初创建该token时的那些元数据,所以之所以Fernet Token可以不用存数据库,是因为这些信息本身就是保存在Token中的,经过服务端对该Token进行解密,就可以还原回来元数据信息,只是因为这些元数据大小基本不会发生变化,所以其长度可控。
Fernet Token Rotation 从上面的步骤中可以看出,Fernet Token是采用对称加密的方式进行加密,所以用于加密的key的安全性就非常重要,如果被中间人将这个key破解出来,或者这个key泄露出去,那整个系统的安全性就完全暴露出去了,所以为了保证key的安全,我们定期需要更换这个key,而为了保证key认证的连续性,即在用一个Key签发的最后一个token过期之前,还不能将这个key删除掉,因为还需要用它来解密,所以需要采取渐进式的删除策略,即这里所说的Rotation。
因此会有多个key同时共存在key repo中,由配置参数max_active_keys
来决定,该参数默认为3,最小为1,key repo默认的路径为:/etc/keystone/fernet-keys/
,key的文件名都是以数字表示,示例如下:
1 2 (keystone)[root@control1 /]$ ls /etc/keystone/fernet-keys/ 0 107 108
每个key在其生命周期中都会经历3个阶段:
Primary Key,处在该阶段的key用来加密和解密token,编号最高的那个是Primary Key,如上例中的108;
Secondary Key,处在该阶段的key只用来解密token,它是由Primary Key退变而来的,即它曾经是Primary Key,经历过一次rotate后,Primary Key就变成了Secondary Key,除了最高和最低编号的,其他都是Secondary Key,如上例中的107;
Staged Key,该阶段的key,编号始终为0,每一次rotate,Staged Key被转变成了Primary Key,即编号由0变成了最高,然后再生成一个新的编号为0的key,所以顾名思义,Staged Key就是准备变成Primary的Key。
下面通过一个图,来看一下Token的Rotation的过程:
最开始只有0和1两个key,分别为staged 和 primary key;
经过一次rotate,编号为0的staged key,变成了编号为2的primary key,而编号为1的primary key变成了secondary key,但是编号没有变,然后新生成了一个编号为0的staged key,此时,共有0, 1, 2 三个key。
再经过一次rotate,编号为0的staged key,变成了编号为3的primary key,而编号为2的primary key变成了secondary key,但是编号没有变,原本编号为1的secondary key,则被删除掉了,然后新生成了一个编号为0的staged key,此时,共有0, 2, 3三个key;
所以从上面可以总结出规律,staged key总是编号为0,并且要变成下一个primary key,而primay key总是编号最大的那一个,它要变成下一个secondary key,而剩下的则是secondary key,是要被淘汰删除掉的key。
通过这种方式,渐进的将key进行更新,不会出现中断导致token没法被解析的情况。