API 认证
我在前一节中添加的 API endpoint 当前对任何客户端都是开放的。 显然,执行这些操作需要认证用户才安全,为此我需要添加 认证 和 授权 ,简称“AuthN”和“AuthZ”。 思路是,客户端发送的请求提供了某种标识,以便服务器知道客户端代表的是哪位用户,并且可以验证是否允许该用户执行请求的操作。
保护这些 API endpoint 的最明显的方法是使用 Flask-Login 中的 @login_required
装饰器,但是这种方法存在一些问题。 装饰器检测到未通过身份验证的用户时,会将用户重定向到 HTML 登录页面。 在 API 中没有 HTML 或登录页面的概念,如果客户端发送带有无效或缺少凭证的请求,服务器必须拒绝请求并返回 401 状态码。 服务器不能假定 API 客户端是 Web 浏览器,或者它可以处理重定向,或者它可以渲染和处理 HTML 登录表单。 当 API 客户端收到 401 状态码时,它知道它需要向用户询问凭证,但是它是如何实现的,服务器不需要关心。
User 模型中实现 Token
对于 API 身份验证需求,我将使用 token 身份验证方案。 当客户端想要开始与 API 交互时,它需要使用用户名和密码进行验证,然后获得一个临时 token。 只要 token 有效,客户端就可以发送附带 token 的 API 请求以通过认证。 一旦 token 到期,需要请求新的 token。 为了支持用户 token,我将扩展 User
模型:
app/models.py :支持用户 token。
import base64
from datetime import datetime, timedelta
import os
class User(UserMixin, PaginatedAPIMixin, db.Model):
# ...
token = db.Column(db.String(32), index=True, unique=True)
token_expiration = db.Column(db.DateTime)
# ...
def get_token(self, expires_in=3600):
now = datetime.utcnow()
if self.token and self.token_expiration > now + timedelta(seconds=60):
return self.token
self.token = base64.b64encode(os.urandom(24)).decode('utf-8')
self.token_expiration = now + timedelta(seconds=expires_in)
db.session.add(self)
return self.token
def revoke_token(self):
self.token_expiration = datetime.utcnow() - timedelta(seconds=1)
@staticmethod
def check_token(token):
user = User.query.filter_by(token=token).first()
if user is None or user.token_expiration < datetime.utcnow():
return None
return user
我为用户模型添加了一个 token
属性,并且因为我需要通过它搜索数据库,所以我为它设置了唯一性和索引。 我还添加了 token_expiration
字段,它保存 token 过期的日期和时间。 这使得 token 不会长时间有效,以免成为安全风险。
我创建了三种方法来处理这些 token。 get_token()
方法为用户返回一个 token。 以 base64 编码的 24 位随机字符串来生成这个 token,以便所有字符都处于可读字符串范围内。 在创建新 token 之前,此方法会检查当前分配的 token 在到期之前是否至少还剩一分钟,并且在这种情况下会返回现有的 token。
使用 token 时,有一个策略可以立即使 token 失效总是一件好事,而不是仅依赖到期日期。 这是一个经常被忽视的安全最佳实践。 revoke_token()
方法使得当前分配给用户的 token 失效,只需设置到期时间为当前时间的前一秒。
check_token()
方法是一个静态方法,它将一个 token 作为参数传入并返回此 token 所属的用户。 如果 token 无效或过期,则该方法返回 None
。
由于我对数据库进行了更改,因此需要生成新的数据库迁移,然后使用它升级数据库:
(venv) $ flask db migrate -m "user tokens"
(venv) $ flask db upgrade
带 Token 的请求
当你编写一个 API 时,你必须考虑到你的客户端并不总是要连接到 Web 应用程序的 Web 浏览器。 当独立客户端(如智能手机 APP)甚至是基于浏览器的单页应用程序访问后端服务时,API 展示力量的机会就来了。 当这些专用客户端需要访问 API 服务时,他们首先需要请求 token,对应传统 Web 应用程序中登录表单的部分。
为了简化使用 token 认证时客户端和服务器之间的交互,我将使用名为 Flask-HTTPAuth 的 Flask 插件。 Flask-HTTPAuth 可以使用 pip 安装:
(venv) $ pip install flask-httpauth
Flask-HTTPAuth 支持几种不同的认证机制,都对 API 友好。 首先,我将使用 HTTPBasic Authentication ,该机制要求客户端在标准的 Authorization 头部中附带用户凭证。 要与 Flask-HTTPAuth 集成,应用需要提供两个函数:一个用于检查用户提供的用户名和密码,另一个用于在认证失败的情况下返回错误响应。这些函数通过装饰器在 Flask-HTTPAuth 中注册,然后在认证流程中根据需要由插件自动调用。 实现如下:
app/api/auth.py :基本认证支持。
from flask import g
from flask_httpauth import HTTPBasicAuth
from app.models import User
from app.api.errors import error_response
basic_auth = HTTPBasicAuth()
@basic_auth.verify_password
def verify_password(username, password):
user = User.query.filter_by(username=username).first()
if user is None:
return False
g.current_user = user
return user.check_password(password)
@basic_auth.error_handler
def basic_auth_error():
return error_response(401)
Flask-HTTPAuth 的 HTTPBasicAuth
类实现了基本的认证流程。 这两个必需的函数分别通过 verify_password
和 error_handler
装饰器进行注册。
验证函数接收客户端提供的用户名和密码,如果凭证有效则返回 True
,否则返回 False
。 我依赖 User
类的 check_password()
方法来检查密码,它在 Web 应用的认证过程中,也会被 Flask-Login 使用。 我将认证用户保存在 g.current_user
中,以便我可以从 API 视图函数中访问它。
错误处理函数只返回由 app/api/errors.py 模块中的 error_response()
函数生成的 401 错误。 401 错误在 HTTP 标准中定义为“未授权”错误。 HTTP 客户端知道当它们收到这个错误时,需要重新发送有效的凭证。
现在我已经实现了基本认证的支持,因此我可以添加一条 token 检索路由,以便客户端在需要 token 时调用:
app/api/tokens.py :生成用户 token。
from flask import jsonify, g
from app import db
from app.api import bp
from app.api.auth import basic_auth
@bp.route('/tokens', methods=['POST'])
@basic_auth.login_required
def get_token():
token = g.current_user.get_token()
db.session.commit()
return jsonify({'token': token})
这个视图函数使用了 HTTPBasicAuth
实例中的 @basic_auth.login_required
装饰器,它将指示 Flask-HTTPAuth 验证身份(通过我上面定义的验证函数),并且仅当提供的凭证是有效的才运行下面的视图函数。 该视图函数的实现依赖于用户模型的 get_token()
方法来生成 token。 数据库提交在生成 token 后发出,以确保 token 及其到期时间被写回到数据库。
如果你尝试直接向 token API 路由发送 POST 请求,则会发生以下情况:
(venv) $ http POST http://localhost:5000/api/tokens
HTTP/1.0 401 UNAUTHORIZED
Content-Length: 30
Content-Type: application/json
Date: Mon, 27 Nov 2017 20:01:00 GMT
Server: Werkzeug/0.12.2 Python/3.6.3
WWW-Authenticate: Basic realm="Authentication Required"
{
"error": "Unauthorized"
}
HTTP 响应包括 401 状态码和我在 basic_auth_error()
函数中定义的错误负载。 下面请求带上了基本认证需要的凭证:
(venv) $ http --auth <username>:<password> POST http://localhost:5000/api/tokens
HTTP/1.0 200 OK
Content-Length: 50
Content-Type: application/json
Date: Mon, 27 Nov 2017 20:01:22 GMT
Server: Werkzeug/0.12.2 Python/3.6.3
{
"token": "pC1Nu9wwyNt8VCj1trWilFdFI276AcbS"
}
现在状态码是 200,这是成功请求的代码,并且有效载荷包括用户的 token。 请注意,当你发送这个请求时,你需要用你自己的凭证来替换 <username>:<password>
。 用户名和密码需要以冒号作为分隔符。
使用 Token 机制保护 API 路由
客户端现在可以请求一个 token 来和 API endpoint 一起使用,所以剩下的就是向这些 endpoint 添加 token 验证。 Flask-HTTPAuth 也可以为我处理的这些事情。 我需要创建基于 HTTPTokenAuth
类的第二个身份验证实例,并提供 token 验证回调:
app/api/auth.py : Token 认证支持。
# ...
from flask_httpauth import HTTPTokenAuth
# ...
token_auth = HTTPTokenAuth()
# ...
@token_auth.verify_token
def verify_token(token):
g.current_user = User.check_token(token) if token else None
return g.current_user is not None
@token_auth.error_handler
def token_auth_error():
return error_response(401)
使用 token 认证时,Flask-HTTPAuth 使用的是 verify_token
装饰器注册验证函数,除此之外,token 认证的工作方式与基本认证相同。 我的 token 验证函数使用 User.check_token()
来定位 token 所属的用户。 该函数还通过将当前用户设置为 None
来处理缺失 token 的情况。返回值是 True
还是 False
,决定了 Flask-HTTPAuth 是否允许视图函数的运行。
为了使用 token 保护 API 路由,需要添加 @token_auth.login_required
装饰器:
app/api/users.py :使用 token 认证保护用户路由。
from app.api.auth import token_auth
@bp.route('/users/<int:id>', methods=['GET'])
@token_auth.login_required
def get_user(id):
# ...
@bp.route('/users', methods=['GET'])
@token_auth.login_required
def get_users():
# ...
@bp.route('/users/<int:id>/followers', methods=['GET'])
@token_auth.login_required
def get_followers(id):
# ...
@bp.route('/users/<int:id>/followed', methods=['GET'])
@token_auth.login_required
def get_followed(id):
# ...
@bp.route('/users', methods=['POST'])
def create_user():
# ...
@bp.route('/users/<int:id>', methods=['PUT'])
@token_auth.login_required
def update_user(id):
# ...
请注意,装饰器被添加到除 create_user()
之外的所有 API 视图函数中,显而易见,这个函数不能使用 token 认证,因为用户都不存在时,更不会有 token 了。
如果你直接对上面列出的受 token 保护的 endpoint 发起请求,则会得到一个 401 错误。为了成功访问,你需要添加 Authorization
头部,其值是请求 /api/tokens 获得的 token 的值。Flask-HTTPAuth 期望的是"不记名"token,但是它没有被 HTTPie 直接支持。就像针对基本认证,HTTPie 提供了 --auth
选项来接受用户名和密码,但是 token 的头部则需要显式地提供了。下面是发送不记名 token 的格式:
(venv) $ http GET http://localhost:5000/api/users/1 \
"Authorization:Bearer pC1Nu9wwyNt8VCj1trWilFdFI276AcbS"
撤销 Token
我将要实现的最后一个 token 相关功能是 token 撤销,如下所示:
app/api/tokens.py :撤销 token。
from app.api.auth import token_auth
@bp.route('/tokens', methods=['DELETE'])
@token_auth.login_required
def revoke_token():
g.current_user.revoke_token()
db.session.commit()
return '', 204
客户端可以向 /tokens URL 发送 DELETE
请求,以使 token 失效。此路由的身份验证是基于 token 的,事实上,在 Authorization
头部中发送的 token 就是需要被撤销的。撤销使用了 User
类中的辅助方法,该方法重新设置 token 过期日期来实现撤销操作。之后提交数据库会话,以确保将更改写入数据库。这个请求的响应没有正文,所以我可以返回一个空字符串。Return 语句中的第二个值设置状态代码为 204,该代码用于成功请求却没有响应主体的响应。
下面是撤销 token 的一个 HTTPie 请求示例:
(venv) $ http DELETE http://localhost:5000/api/tokens \
Authorization:"Bearer pC1Nu9wwyNt8VCj1trWilFdFI276AcbS"
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论