前序文章

本文项目地址: https://github.com/frostming/flask-vue-todo

作者按: 几天前我收到一封邮件,有读者说看了我的前后端分离实践的文章获益很多。然而我却丧尽天良的断更了?不行不行,我不是这样的人,所以一年后,我再补上这个系列最后一篇文章吧。

CSRF 防护

如果你们是看了 Miguel 的狗书,或是李辉大大的狼书,一定知道我们在提交表单时,常常会附带上一个隐藏的 csrf 值,用来防止 CSRF 攻击。关于 CSRF 是什么这里就不过多介绍了,大家可以参阅维基百科。那么我们来到前后端分离的世界,CSRF 应该如何做呢?因为是前后端分离,所以服务端产生的 CSRF 值并不能实时更新到页面上,页面的更新全都要依赖客户端去主动请求。那我是不是要每次渲染表单的时候,就去服务器取一次 CSRF token 呢?这未免太麻烦,我们完全可以减少请求的次数,请求一次,然后在客户端(浏览器)上存起来,要用的时候带上即可。

在 Flask 中引入 CSRF 保护主要是用 Flask-WTF 这个扩展,但既然我们不用 WTF 去渲染表单了,那么表单的 CSRF 保护也用不上了,所幸,这个扩展还提供了一个全局 CSRF 保护方法,就是所有 view 都可以通过一个模板变量去获取 CSRF token 的值,并不仅限于表单。开启方法也很简单:

from flask_wtf.csrf import CSRFProtect

csrf = CSRFProtect(app)
# 或者使用工厂函数模式:
csrf = CSRFProtect()
def create_app():
    app = Flask(__name__)
    ...
    csrf.init_app(app)
    return app

这样在模板中,可以通过{{ csrf_token() }}获得 CSRF token 的值。推荐放在返回的前端页面index.html的 meta 标签中,以供 ajax 方法获取

...
<header>
  <meta name="csrf-token" content="{{ csrf_token() }}" />
  ...
</header>

然后在 ajax 请求中,取出这个值然后带上即可,这里展示一下如何用axios实现:

const api = axios.create({
  headers: {
    "Content-Type": "application/json",
    "X-CSRF-TOKEN": document.querySelector('meta[name="csrf-token"]').getAttribute("content"),
  },
})

这也是我这个 todo 项目采用的方法,但这种方法有一个很大的限制:前端页面必须至少由 Flask 应用渲染一次,这只能叫做半个前后端分离。实际开发中,前端和后端可能完全是分离部署,通过 nginx 等其他 web 服务器返回的。这样一来,{{ csrf_token() }}就完全没机会透给前端。不要紧,我们还可以用 Cookies 嘛。当然,这需要自己定制一下Flask-WTF这个扩展,可以查看这个代码示例。在 Django 中,默认采用的就是这种方式。

后端鉴权

好了,我们又用到了 Cookie,如果有人对上一篇还有印象的话(并没有),用户的登录态也是放在 cookie 里面的,这种方案对于一般的普通应用就足够了,我一直提倡如果某种方法够用,就不用急着使用更高级的方法。但当某些客户端不支持 cookie 的时候(比如手机 app),我们就需要新的方法了。

当然,这个解决方案现在也很成熟了,就是JWT(JSON Web Token)。大概流程是,第一次打开页面时,请求后端,如果没登录,则返回 401 让前端跳转登录,如果是登录状态,则返还一个 Token,这个 token 自带某些用户信息,和过期时间。前端收到这个 token 则自己保存起来,保存方式可以是 cookie,也可以是 localstorage,然后后续的请求均带上这个 token,前后端之间仅仅依靠这个 token 鉴定身份,无需来回传送 cookie 或会话信息。

jwt.jpg

JWT 的好处是服务端无需保存这个 token 值,token 本身就带有是否有效的信息,以及登录态的关键信息(比如 user id),而 token 是通过服务端密钥加密的,所以难以被破解。Flask 内置了一个itsdangerous的库来生成这种 token,先总结一下,Flask 要做的事有:

  1. 每次请求都校验这个 token 值,若不通过则返回 401
  2. login 端点生成 token 值
  3. logout 端点清除 token 值
@app.before_request
def validate_request():
    token = request.headers.get('X-Token')
    if not token:
        abort(401)
    user = User.verify_token(token)
    if not user:
        abort(401)
    g.current_user = user

@api.route('/user/login', methods=['POST'])
def login():
    data = request.get_json()
    if not verify_auth(data.get('username'), data.get('password')):
        return jsonify(
            {'code': 60204, 'message': 'Account and password are incorrect.'}
        )
    return jsonify({'code': 20000, 'data': {'token': g.user.generate_token().decode()}})
from itsdangerous import (
    TimedJSONWebSignatureSerializer as Serializer,
    BadSignature,
    SignatureExpired,
)

class User(db.Model):
    ...
    @classmethod
    def verify_auth_token(cls, token):
        s = Serializer(current_app.config["SECRET_KEY"])
        try:
            data = s.loads(token)
        except (BadSignature, SignatureExpired):
            return None
        user = cls.query.get(data["id"])
        return user

    def generate_token(self, expiration=24 * 60 * 60):
        s = Serializer(current_app.config["SECRET_KEY"], expires_in=expiration)
        return s.dumps({"id": self.id})

而前端请求 ajax 时,只需要把这个事先保存好的 token 值取出来加到请求头部X-Token就可以了。

总结

好了,我想这三篇文章已经覆盖了前后端分离与传统 MVC 架构的主要区别和开发技巧,当然还有更多的点我没法覆盖到,欢迎到评论区或邮件骚扰我。