前情提要:由于前后端分离开发涉及到的无状态问题(前端每次请求后端接口都是新建一个会话,很难通过传统的cookie-session方式来实现验证用户登录,存储用户登录状态等功能),所以考虑了前后端传递token的方式对用户的登录状态进行验证。而为了token的安全性(以及实现的方便性),我使用了flask-httpauth这个第三方模块来实现用户token验证的功能。(注意,我这里使用的是此模块的一部分 – HTTPTokenAuth,如果要使用基本认证请左转出门)
但没想到,这是噩梦的开端
初步使用flask-httpauth
当考虑使用第三方模块的时候,我的第一动作就是去观摩一下前人的使用方式。我很幸运,成功的找到了关于flask-httpauth的教程案例。
首先,是万能第一步
在你项目目录下的命令行(虚拟环境也一样)中敲入这句
pip install flask-httpauth
这样,flask-httpauth就被装到了你的项目环境中
接下来,找到你要用到这个模块的python文件
# 引入头文件
from flask_httpauth import HTTPTokenAuth
# 引入istdangerous模块中的TimedJSONWebSignatureSerializer类用来生成一个具有过期时间的token
# BadSignature是一个异常,当token错误的时候会抛出此异常
# SignatureExpored是一个异常,当token过期的时候会抛出此异常
from itsdangerous import TimedJSONWebSignatureSerializer, BadSignature, SignatureExpired
# 初始化HTTPTokenAuth对象。传入的scheme表示前缀,具体什么作用稍后再说
auth = HTTPTokenAuth(scheme="DAHU")
# 写一个获取token的函数,使用的是API形式,方便测试时获取
@app.route('/api/test-http-auth-token/token', methods=["GET"])
def get_token():
# 创建一个TimedJSONWebSignatureSerializer对象
# 第一个参数是随便一串字符串
# 第二个参数设定过期时间,单位为秒,比如我这里设定的就是1分钟过期(方便测试能否正常捕获token过期异常)
s1 = TimedJSONWebSignatureSerializer("DAHUBIGFOXSHABI", expires_in=60)
# 将userId作为数据装到token中,方便我们之后校验用户
# 默认s1.dumps执行完后返回的是byte格式,但我们需要的是字符串格式,所以用decode函数对结果解码得到token字符串
token = s1.dumps({"userId": 1}).decode('ascii')
# 返回json格式的token
return jsonify(Authorization=token)
# 你可以在这个装饰器下面的函数内实现你自己的token验证方法
@auth.verify_token
def verify_token(token):
"""验证token
:param token: 要验证的token
"""
# 创建TimedJSONWebSignatureSerializer,传入一个参数,就是前文中那一串字符串(要和前文一样)
s = TimedJSONWebSignatureSerializer("DAHUBIGFOXSHABI")
try:
print(token)
# 解析token,获取数据(也就是咱们之前包装在token中的{"userId":1})
data = s.loads(token)
print(data)
except SignatureExpired:
print("token过期")
return False
except BadSignature:
print("token错误")
return False
uid = data['userId']
print(uid)
return True
@app.route('/api/test-http-auth-token/token', methods=["POST"])
# 加了这条语句就表示要访问此路由需要进行token认证
@auth.login_required
def verify_token():
return jsonify(code=200)
以上代码还有一点是需要注意的: 捕捉SignatureExpired异常和捕捉BadSignature异常的顺序是不可以颠倒的,因为在源程序中SignatureExpired类的继承顺序是这样的: SignatureExpired继承于BadTimeSignature, BadTimeSignature继承于BadSignature。
显而易见,SignatureExpired是BadSignature的子孙类,如果先捕获BadSignature异常的话,SignatureExpired也会被作为BadSignature异常被捕获,也就不能精确的获取token过期的错误消息了。
接口全部书写完毕,接下来就是紧张刺激的测试环节了
我使用的测试接口的工具是ApiPost, 具体操作我就不赘述了,我创建了两个接口测试用例
- 第一个是获取token, 也就是对应着刚刚写的代码中的get_token函数
- 第二个是验证token, 也就是对应着刚刚写的代码中的verify_token函数
首先把flask运行起来
然后获取token,对接口发送请求后会返回这么一条json数据
![get_token返回的数据](https://i-blog.csdnimg.cn/blog_migrate/b147cc2817d53139f9f08129af1b0b37.png)
Authorization对应的字符串就是我们需要的token
最后就是验证token了,将Authorization作为请求头,如下
![验证token的请求头示例](https://i-blog.csdnimg.cn/blog_migrate/7cb9597d9310732215e28455a6367c2f.png)
Authorization:DAHU eyJhbGciOiJIUzUx...
发送请求后会有三种情况
-
认证成功
-
token错误
-
token过期
这样,token验证就基本完成了
一切源于不满足
但是,在上面例子里面我们还是能发现一些问题,也就是当token验证失败的时候的返回值(响应)只是一串字符串 – Unauthorized Access,这显然不能满足我们后端API开发的需求,我们需要一个更规范化的返回值。
了解问题,实践开始
惯例第一步,面向搜索引擎。但是这次“可靠的”搜索引擎某度辜负了我的期待,我并没有在搜索引擎上面找到能满足我们方案的办法。
没办法,只能源码走起了
因为是为了自定义验证后的返回值,所以直接聚焦@auth.login_required装饰器的代码
注意:HTTPTokenAuth是继承于HTTPAuth类的,login_required也是定义在HTTPAuth中的
# flask_httpauth.py
# HTTPAuth类
def login_required(self, f=None, role=None, optional=None):
if f is not None and \
(role is not None or optional is not None): # pragma: no cover
raise ValueError(
'role and optional are the only supported arguments')
def login_required_internal(f):
@wraps(f)
def decorated(*args, **kwargs):
auth = self.get_auth()
# Flask normally handles OPTIONS requests on its own, but in
# the case it is configured to forward those to the
# application, we need to ignore authentication headers and
# let the request through to avoid unwanted interactions with
# CORS.
if request.method != 'OPTIONS': # pragma: no cover
password = self.get_auth_password(auth)
status = None
user = self.authenticate(auth, password)
if user in (False, None):
status = 401
elif not self.authorize(role, user, auth):
status = 403
if not optional and status:
# Clear TCP receive buffer of any pending data
request.data
try:
return self.auth_error_callback(status)
except TypeError:
return self.auth_error_callback()
g.flask_httpauth_user = user if user is not True \
else auth.username if auth else None
return f(*args, **kwargs)
return decorated
if f:
return login_required_internal(f)
return login_required_internal
因为是要解决错误时候的返回值自定义问题,所以直接聚焦31~34行的错误处理语句
try:
return self.auth_error_callback(status)
except TypeError:
return self.auth_error_callback()
可以发现一个关键点 – auth_error_callback(),所以直接向上翻,可以看到auth_error_callback是在__init__中定义的属性
# HttpAuth
def __init__(self, scheme=None, realm=None, header=None):
self.scheme = scheme
self.realm = realm or "Authentication Required"
self.header = header
self.get_password_callback = None
self.get_user_roles_callback = None
self.auth_error_callback = None
def default_get_password(username):
return None
def default_auth_error(status):
return "Unauthorized Access", status
self.get_password(default_get_password)
self.error_handler(default_auth_error)
由于这里的auth_error_callback是None,做不到上面的错误处理功能,所以我们可以猜测HTTPAuth类中会在某个地方对其进行赋值。
直接在文件中搜索auth_error_callback
suprise!我们在error_handler中找到了对auth_error_callback的赋值
# HttpAuth
def error_handler(self, f):
@wraps(f)
def decorated(*args, **kwargs):
res = f(*args, **kwargs)
check_status_code = not isinstance(res, (tuple, Response))
res = make_response(res)
if check_status_code and res.status_code == 200:
# if user didn't set status code, use 401
res.status_code = 401
if 'WWW-Authenticate' not in res.headers.keys():
res.headers['WWW-Authenticate'] = self.authenticate_header()
return res
self.auth_error_callback = decorated
return decorated
显而易见,这里传递的参数是一个函数,auth_error_callback得到的值也是一个函数
下一步,找到error_handler函数的调用位置。如果之前仔细看了HttpAuth的__init__函数,会发现error_handler的调用就在那里(见上个代码块第18行)
self.error_handler(default_auth_error)
老惯例,找default_auth_error,也在HttpAuth的__init__函数中
def default_auth_error(status):
return "Unauthorized Access", status
发现了吗?如此熟悉的字符串。对,就是在这里,我们只要修改了这里就可以修改错误时的返回值了!
但是,在源文件上改显然不是个好方法。
我们需要创建一个新类继承于HTTPTokenAuth
Q:既然要重写的函数都是定义在HTTPAuth中的为什么不直接继承于HTTPAuth呢?
A:因为我们要用到HTTPTokenAuth中独有的装饰器verify_token啊小傻瓜
class HTTPTokenAuthReReturn(HTTPTokenAuth):
def __init__(self, scheme=None, realm=None, header=None):
super().__init__(scheme, realm, header)
# 重写default_auth_error, 或者也可以重新定义一个函数
def default_auth_error(status):
return jsonify(data={}, header={},
message="token错误!",code=status,result=False),status
#如果重新定义函数的话,这里就传入新定义的函数名
super().error_handler(default_auth_error)
然后直接创建HTTPTokenAuthReReturn对象取代之前的HTTPTokenAuth对象
auth = HTTPTokenAuthReReturn(scheme="DAHU")
进入紧张刺激的测试环节
随便输入一个错误的token,结果如下
![](https://i-blog.csdnimg.cn/blog_migrate/c2d1b4476e12045de6f7bee5fe2d19b6.png)
![](https://i-blog.csdnimg.cn/blog_migrate/101954197f9e79838b724cd24c7fc49f.png)
然后输入正确的token,结果如下
![](https://i-blog.csdnimg.cn/blog_migrate/27b3e9bdb01934efbcca450370cb4186.png)
![](https://i-blog.csdnimg.cn/blog_migrate/d28abfeceed759727be5728ccbc57fa2.png)
成功了!
两种异常
经过本文上述的操作之后,已经能成功的返回我们自定义的返回值了。但是依旧有一个问题 – token有两种异常状态:1、token错误 2、token过期, 但是我们现在的返回值只能返回token错误,并不能根据实际的情况返回对应的异常。
需求明确,试验开始
首先要明确这两种异常是在哪里被捕获的
即我们前面自定义的verify_token函数
# verify_token
except SignatureExpired:
print("token过期")
return False
except BadSignature:
print("token错误")
return False
从这里可以看出,要区别这两个异常就要从返回值入手。
废话不多说,转入源码
# flask_httpauth.py
# HTTPTokenAuth类
def verify_token(self, f):
self.verify_token_callback = f
return f
这里verify_token_callback被赋值为f,也就是我们自定义的,被@verify_token修饰的verify_token函数
搜索verify_token_callback, 可以发现verify_token_callback被同类下的authenticate函数使用
# flask_httpauth.py
# HTTPTokenAuth类
def authenticate(self, auth, stored_password):
if auth:
token = auth['token']
else:
token = ""
if self.verify_token_callback:
return self.verify_token_callback(token)
先不用在意前面的处理细节,我们只需要注意到这个函数的返回值就相当于verify_token(token),这里的token就是我们在请求头里传入的Authorization
搜索authenticate的使用,会发现这个函数在login_required中被使用了(下面代码块中第16行)
def login_required(self, f=None, role=None, optional=None):
if f is not None and \
(role is not None or optional is not None): # pragma: no cover
raise ValueError(
'role and optional are the only supported arguments')
def login_required_internal(f):
@wraps(f)
def decorated(*args, **kwargs):
auth = self.get_auth()
if request.method != 'OPTIONS': # pragma: no cover
password = self.get_auth_password(auth)
status = None
user = self.authenticate(auth, password)
if user in (False, None):
status = 401
elif not self.authorize(role, user, auth):
status = 403
if not optional and status:
# Clear TCP receive buffer of any pending data
request.data
try:
return self.auth_error_callback(status)
except TypeError:
return self.auth_error_callback()
g.flask_httpauth_user = user if user is not True \
else auth.username if auth else None
return f(*args, **kwargs)
return decorated
if f:
return login_required_internal(f)
return login_required_internal
所以,user就是verify_token(token)的返回值。
思路来了:如果是两个异常中的一个,我们就在verify_token返回一串对应的字符串,而不仅仅是True和False,在login_required中对user进行判断,如果是我们的异常字符串,就给错误处理函数传递一个message,再组合在API返回值中返回
具体实施如下
1、对default_auth_error函数再次重写,添加一个message参数
def default_auth_error(status, message):
return jsonify(data={}, header={},message=message
, code=status, result=False), status
2、修改verify_token函数
@auth.verify_token
def verify_token(token):
s = TimedJSONWebSignatureSerializer("DAHUBIGFOXSHABI")
try:
print(token)
data = s.loads(token)
print(data)
except SignatureExpired:
print("token过期")
# 这里不用False,而是用自定义字符串
return "SignatureExpired"
except BadSignature:
print("token错误")
# 这里不用False,而是用自定义字符串
return "BadSignature"
uid = data['userId']
print(uid)
return True
3、重写login_required函数
def login_required(self, f=None, role=None, optional=None):
if f is not None and \
(role is not None or optional is not None): # pragma: no cover
raise ValueError(
'role and optional are the only supported arguments')
def login_required_internal(f):
@wraps(f)
def decorated(*args, **kwargs):
auth = self.get_auth()
if request.method != 'OPTIONS': # pragma: no cover
password = self.get_auth_password(auth)
status = None
message = None
user = self.authenticate(auth, password)
print(user)
# 这里判断verify_token的返回值是否是这里的一员
if user in (False, None, 'BadSignature', 'SignatureExpired'):
status = 401
if user == 'BadSignature':
message = "登录验证失败"
elif user == 'SignatureExpired':
message = "登录过期"
elif not self.authorize(role, user, auth):
status = 403
if not optional and status:
# Clear TCP receive buffer of any pending data
request.data
try:
# 因为之前重写了default_auth_error所以多传入一个message
return self.auth_error_callback(status, message)
except TypeError:
return self.auth_error_callback()
g.flask_httpauth_user = user if user is not True \
else auth.username if auth else None
return f(*args, **kwargs)
return decorated
if f:
return login_required_internal(f)
return login_required_internal
接下来就是紧张刺激的测试环节了!
-
传入一个过期的token
-
传入一个错误的token
-
传入正确的token
成功!