与普通WEB应用一样,WEB服务也需要保护信息,确保未授权的用户无法访问。我们已经知道,REST式WEB服务的特征是无状态,即服务器在2次请求之间不能记住“客户端”的任何信息。客户端必须在发出的请求中包含所有必要信息,因此所有请求都必须包含用户凭据。
Flasky应用当前的登录是由Flask-login实现的,数据存储在用户会话中。我们不妨先来回顾下Flask-Login的工作流程:
- 首先获取user id,如果获取不到有效的id,就将user设为anonymous user;
- 获取到id后,再通过@login_manager.user_loader装饰的函数获取到user对象,如果没有获取到有效的user对象,就认为是anonymous user;
- 最后不管是是正常的用户还是anonymous用户,均保存到请求上下文中;
默认情况下,Flask把会话存储在客户端cookie中,因此服务器没有保存任何用户信息,都转交给客户端保存。这种实现方式看起来遵守了REST架构无状态的要求,但在REST式WEB服务中使用cookie有点不现实,因为WEB浏览器之外的客户端很难实现对cookie的支持。
REST架构基于HTTP协议,所以发送凭据的最佳方式是使用HTTP身份验证,用户凭据包含在每个请求的Authorization首部中。HTTP身份验证协议很简单,可以直接实现。Flask-HTTPAuth扩展提供了一个遍历的包装,把协议的细节隐藏在装饰器中。类似于Flask-Login中的@login_required。
一. 使用Flask-HTTPAuth验证用户身份
Flask-HTTPAuth不对验证用户凭据的步骤做任何假设,所需的信息在回调函数中提供。
app/api/authentication.py:初始化Flask-HTTPAuth
from flask import g
from flask_httpauth import HTTPBasicAuth
from ..models import User
auth = HTTPBasicAuth()
@auth.verify_password
def verify_password(email, password):
if email_or_token == '':
return False
if not user:
return False
g.current_user = user
g.token_used = False
return user.verify_password(password)
如果登录凭据不正确这个函数返回False,否则返回True。如果请求中没有身份验证信息,Flask-HTTPAuth也会调用回调函数,把2个参数都设为空字符串。此处我们把通过身份验证的用户保存到Flask的上下文变量g中,供视图函数稍后访问。
如果身份验证凭据不正确,则服务器向客户端返回401状态码。默认情况下,Flask-HTTPAuth会自动生成这个状态码,但为了与API返回的其它错误保持一致,我们可以自定义这个错误响应。
@auth.error_handler
def auth_error():
return unauthorized('Invalid credentials')
@api.before_request
@auth.login_required
def before_request():
if g.current_user.is_authenticated and not g.current_user.confirmed:
return forbidden('Unconfirmed account.')
像上述代码那样,为了保护路由,可以使用@auth.login_required装饰器。不过这个蓝本中所有的路由都需要使用相同的方式进行保护。我们可以在@api.before_request装饰器中使用一次,将其 应用到整个蓝本。
二. 基于令牌的身份验证
每次请求,客户端都要发送身份验证凭据。为了避免总是发送邮箱,密码等敏感信息,我们可以使用基于令牌的身份验证方案。并且处于安全考虑,令牌有过期时间,令牌过期后,客户端必须重新发送登录凭据,获取新的令牌。为了生成和核查身份验证令牌,我们要在User模型中定义2个新方法。
app/models.py:支持基于令牌的身份验证
from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
from itsdangerous import BadSignature, SignatureExpired
class User(UserMinxin, db.Model):
...
def generate_auth_token(self, expiration):
s = Serializer(current_app.config['SECRET_KEY'], expires_in=expiration)
return s.dumps({'id': self.id}).decode('utf-8')
@staticmethod
def verify_auth_token(token):
s = Serializer(current_app.config['SECRET_KEY'])
try:
data = s.loads(token)
except(SignatureExpired, BadSignature):
return None
return User.query.get(data['id'])
verify_auth_token是静态方法,因为只有解码令牌后才知道用户是谁。特别注意,即使不知道SECRET_KEY借助工具还是可以获取到令牌中的信息,所以令牌中不能包含敏感信息。
为了能够使用令牌验证请求,需要修改verify_password()回调,既可以接收用户名、密码,也可以接收令牌:
@auth.verify_password
def verify_password(email_or_token, password):
"""即支持密码验证,也支持token验证"""
if email_or_token == '':
return False
if password == '':
g.current_user = User.verify_auth_token(email_or_token)
g.token_used = True
return g.current_user is not None
user = User.query.filter_by(email=email_or_token).first()
if not user:
return False
g.current_user = user
g.token_used = False
return user.verify_password(password)
当password为空字符串时,说明客户端只发送了令牌。我们还添加了g.token_used全局上下文变量,以便在视图函数中区分两中身份验证方法。下面我们定义用户获取令牌的视图函数:
@api.route('/token', methods=['POST'])
def get_token():
"""客户端申请令牌时,必须使用邮箱验证"""
if g.current_user.is_anonymous or g.token_used:
return unauthorized('Invalid credentials')
return jsonify({'token': g.current_user.generate_auth_token(expiration=3600), 'expiration': 3600})
在获取令牌时,检查了g.token_used值,拒绝使用令牌申请令牌,防止用户绕过令牌过期机制,使用旧令牌请求新令牌。
使用postman测试get_token视图:
postman生成的请求头:
正常响应:
使用token申请令牌:
请求头:
返回401未授权回应: