今天的文章介绍一种适用于restful+json的API认证方法,这个方法是基于jwt,而且加入了一些从oauth2.0借鉴的改良。html
首先要明白,认证和鉴权是不一样的。认证是断定用户的合法性,鉴权是断定用户的权限级别是否可执行后续操做。这里所讲的仅含认证。认证有几种方法:前端
这是http协议中所带带基本认证,是一种简单为上的认证方式。原理是在每一个请求的header中添加用户名和密码的字符串(格式为“username:password”,用base64编码)。python
这种方式至关于将“用户名:密码”绑定为一个开放式证书,这会有几个问题:web
- 每次请求都须要用户名密码,若是此链接未使用SSL/TLS,或加密被破解,用户名密码基本就暴露了;
- 没法注销用户的登陆状态;
- 证书不会过时,除非修改密码。
整体来讲,这种方法的特色就是,简单但不安全。算法
将认证的结果存在客户端的cookie中,经过检查cookie中的身份信息来做为认证结果。
这种方式的特色是便捷,且只须要一次认证,屡次可用;也能够注销登陆状态和设置过时时间;甚至也有办法(好比设置httpOnly)来避免XSS攻击。shell
但它的缺点十分明显,使用cookie那即是有状态的服务了。数据库
JWT协议彷佛已经应用十分普遍,JSON Web Token——一种基于token的json格式web认证方法。基本的原理是,第一次认证经过用户名密码,服务端签发一个json格式的token。后续客户端的请求都携带这个token,服务端仅须要解析这个token,来判别客户端的身份和合法性。json
而JWT协议仅仅规定了这个协议的格式(RFC7519),它的序列生成方法在JWS协议中描述(https://tools.ietf.org/html/rfc7515),分为三个部分:flask
声明类型,这里是jwt后端
声明加密的算法 一般直接使用 HMAC SHA256
一种常见的头部是这样的:
{ 'typ': 'JWT', 'alg': 'HS256' }
再将其进行base64编码。
payload是放置实际有效使用信息的地方。JWT定义了几种内容,包括:
一个常见的payload是这样的:
{'user_id': 123456, 'user_role': admin, 'iat': 1467255177}
事实上,payload中的内容是自由的,按照本身开发的须要加入。
Ps.有个小问题。使用itsdangerous包的TimedJSONWebSignatureSerializer进行token序列生成的结果,exp是在头部里的。这里彷佛违背了jwt的协议规则。
存储了序列化的secreate key和salt key。这个部分须要base64加密后的header和base64加密后的payload使用.链接组成的字符串,而后经过header中声明的加密方式进行加盐secret组合加密,而后就构成了jwt的第三部分。
目标场景是一个先后端分离的后端系统,用于运维工做,虽在内网使用,也有必定的保密性要求。
- API为restful+json的无状态接口,要求认证也是相同模式
- 可横向扩展
- 较低数据库压力
- 证书可注销
- 证书可自动延期
选择JWT。
这里使用python模块itsdangerous,这个模块能作不少编码工做,其中一个是实现JWS的token序列。
genTokenSeq这个函数用于生成token。其中使用的是TimedJSONWebSignatureSerializer进行序列的生成,这里secret_key密钥、salt盐值从配置文件中读取,固然也能够直接写死在这里。expires_in是超时时间间隔,这个间隔以秒记,能够直接在这里设置,我选择将其设为方法的形参(由于这个函数也用在了解决下提到的问题2)。
# serializer for JWT from itsdangerous import TimedJSONWebSignatureSerializer as Serializer """ token is generated as the JWT protocol. JSON Web Tokens(JWT) are an open, industry standard RFC 7519 method """ def genTokenSeq(self, expires): s = Serializer( secret_key=app.config['SECRET_KEY'], salt=app.config['AUTH_SALT'], expires_in=expires) timestamp = time.time() return s.dumps( {'user_id': self.user_id, 'user_role': self.role_id, 'iat': timestamp}) # The token contains userid, user role and the token generation time. # u can add sth more inside, if needed. # 'iat' means 'issued at'. claimed in JWT.
使用这个Serializer能够帮咱们处理好header、signature的问题。咱们只须要用s.dumps将payload的内容写进来。这里我准备在每一个token中写入三个值:用户id、用户角色id和当前时间(‘iat’是JWT标准注册声明中的一项)。
假设我所写入的信息是
{ "iat": 1467271277.131803, "user_id": "46501228343b11e6aaa6a45e60ed5ed5f973ba0fcf783bb8ade34c7b492d9e55", "user_role": 3 }
采用以上的方法所生成的token为
eyJhbGciOiJIUzI1NiIsImV4cCI6MTQ2NzM0MTQ3NCwiaWF0IjoxNDY3MzM3ODc0fQ.eyJpYXQiOjE0NjczMzc4NzQuNzE3MDYzLCJ1c2VyX2lkIjoiNDY1MDEyMjgzNDNiMTFlNmFhYTZhNDVlNjBlZDVlZDVmOTczYmEwZmNmNzgzYmI4YWRlMzRjN2I0OTJkOWU1NSIsInVzZXJfcm9sZSI6M30.23QD0OwLjdioKu5BgbaH2gHT2GoMz90n8VZcpvdyp7U
它是由“header.payload.signature”构成的。
解析须要使用到一样的serializer,配置同样的secret key和salt,使用loads方法来解析token。itsdangerous提供了各类异常处理类,用起来也很方便:
若是是SignatureExpired,则能够直接返回过时;
若是是BadSignature,则表明了全部其余签名错误的状况,因而又分为:
- 能读取到payload:那么这个消息是一个内容被篡改、消息体加密过程正确的消息,secret key和salt极可能泄露了;
- 不能读取到payload: 消息体直接被篡改,secret key和salt应该仍然安全。
以上内容写成一个函数,用于验证用户token。若是实如今python flask,能够考虑将此函数改成一个decorator修饰漆,将修饰器@到全部须要验证token的方法前面,则代码能够更加优雅。
# serializer for JWT from itsdangerous import TimedJSONWebSignatureSerializer as Serializer # exceptions for JWT from itsdangerous import SignatureExpired, BadSignature, BadData # Class xxx # after definition of your class, here goes the auth method: def tokenAuth(token): # token decoding s = Serializer( secret_key=api.app.config['SECRET_KEY'], salt=api.app.config['AUTH_SALT']) try: data = s.loads(token) # token decoding faild # if it happend a plenty of times, there might be someone # trying to attact your server, so it should be a warning. except SignatureExpired: msg = 'token expired' app.logger.warning(msg) return [None, None, msg] except BadSignature, e: encoded_payload = e.payload if encoded_payload is not None: try: s.load_payload(encoded_payload) except BadData: # the token is tampered. msg = 'token tampered' app.logger.warning(msg) return [None, None, msg] msg = 'badSignature of token' app.logger.warning(msg) return [None, None, msg] except: msg = 'wrong token with unknown reason' app.logger.warning(msg) return [None, None, msg] if ('user_id' not in data) or ('user_role' not in data): msg = 'illegal payload inside' app.logger.warning(msg) return [None, None, msg] msg = 'user(' + data['user_id'] + ') logged in by token.' # app.logger.info(msg) userId = data['user_id'] roleId = data['user_role'] return [userId, roleId, msg]
检查和断定的机制以下:
- 使用加密的类,再用来解密(用上以前的密钥和盐值),获得结果存入data;
- 若是捕获到SignatureExpired异常,则表明根据token中的expired设置,token已经超时失效,返回‘token expired’;
- 若是是其余BadSignature异常,又要分为:
3.1 若是payload还完整,则解析payload,若是捕获BadData异常,则表明token已经被篡改,返回‘token tampered’;
3.2 若是payload不完整,直接返回‘badSignature of token’;- 若是以上异常都不对,那只能返回未知异常‘wrong token with unknown reason’;
- 最后,若是data能正常解析,则将payload中的数据取出来,验证payload中是否有合法信息(这里是user_id和user_role键值的json数据),若是数据不合法,则返回‘illegal payload inside’。一旦出现这种状况,则表明密钥和盐值泄露的可能性很大。
上述的方法能够作到基本的JWT认证,但在实际开发过程当中还有其余问题:
token在生成以后,是靠expire使其过时失效的。签发以后的token,是没法收回修改的,所以涉及token的有效期的更改是个难题,它体如今如下两个问题:
如何解决更改token有效期的问题,网上看到不少讨论,主要集中在如下内容:
- JWT是一次性认证完毕加载信息到token里的,token的信息内含过时信息。过时时间过长则被重放攻击的风险太大,而过时时间过短则请求端体验太差(动不动就要从新登陆)
- 把token存进库里,很天然能想到的是把每一个token存库,设置一个valid字段,一旦注销了就valid=0;设置有效期字段,想要延期就增长有效期时间。openstack keystone就是这么作的。这个作法虽方便,但对数据库的压力较大,甚至在访问量较大,签发token较多的状况下,是对数据库的一个挑战。何况这也有悖于JWT的初衷。
- 为了使用户不须要常常从新登陆,客户端将用户名密码保存起来(cookie),而后使用用户名密码验证,但那还得考虑防护CSRF攻击的问题。
这里,笔者借鉴了第三方认证协议Oauth2.0(RFC6749),它采起了另外一种方法:refresh token,一个用于更新令牌的令牌。在用户首次认证后,签发两个token:
- 一个为access token,用于用户后续的各个请求中携带的认证信息
- 另外一个是refresh token,为access token过时后,用于申请一个新的access token。
由此能够给两类不一样token设置不一样的有效期,例如给access token仅1小时的有效时间,而refresh token则能够是一个月。api的登出经过access token的过时来实现(前端则可直接抛弃此token实现登出),在refresh token的存续期内,访问api时可执refresh token申请新的access token(前端可存此refresh token,access token过其实进行更新,达到自动延期的效果)。
refresh token不可再延期,过时需从新使用用户名密码登陆。
这种方式的理念在于,将证书分为三种级别:
- access token 短时间证书,用于最终鉴权
- refresh token 较长期的证书,用于产生短时间证书,不可直接用于服务请求
- 用户名密码 几乎永久的证书,用于产生长期证书和短时间证书,不可直接用于服务请求
经过这种方式,使证书功效和证书时效结合考虑。
ps.前面提到建立token的时候将expire_in(jwt的推荐字段,超时时间间隔)做为函数的形参,是为了将此函数用于生成access token和refresh token,而二者的expire_in时间是不一样的。
咱们作了一个JWT的认证模块:
(access token在如下代码中为'token',refresh token在代码中为'rftoken')
client -----用户名密码-----------> server
client <------token、rftoken----- server
client ------请求(携带token)----> server
client <-----结果----------------- server
client ------请求(携带token)----> server
client <-----msg:token expired--- server
client -请求新token(携带rftoken)-> server
client <-----新token-------------- server
client -请求新token(携带rftoken)-> server
client <----msg:rftoken expired--- server
若是设计一个针对此认证的前端,须要:
存储access token、refresh token
访问时携带access token,自动检查access token超时,超时则使用refresh token更新access token;状态延期用户无感知
用户登出直接抛弃access token与refresh token