Vue 2.0 起步(4) 轻量级后端Flask用户认证 - 微信公众号RSS

1字数 2492阅读 12746

参考:

本篇实现

  1. Flask框架搭建
  2. 后端用户注册、认证 (注意:改用Flask-Security了:http://www.jianshu.com/p/f37871e31231)
  3. 跨域(Access-Control-Allow-Origin)本地调试
  4. 表单验证validation

Demo(http://vue2.heroku.com)

本章完成效果

使用RESTful思想,互联网应用的后端仅提供API接口来提供鉴权服务和资源,前端Vue使用Ajax访问获取数据并显示。
MVVM模型 - 这里Flask担任Model(数据模型)、View(路由)角色,Vue担任VM(ViewModel视图模型)角色。

工具选择

  • 后端:使用Flask这一广受好评的Python微(Micro)框架,非常适合快速开发。当然,使用Node.js、PHP、Java等等其它语言,思路也是大同小异的。Flask被称为“micro framework”,是因为它使用简单的核心,用extension增加其他功能。Flask没有默认使用的数据库、窗体等等验证工具。然而,Flask保留了扩增的弹性,可以用Flask-extension加入这些功能:ORM、窗体验证工具、文件上传、各种开放式身份验证技术。是不是跟vue很像啊?
flask logo

Flask最基本的hello-world,用几行代码、一个文件就能实现。但为了适应大型项目扩展,跟vue-cli创建的脚手架类似,Flask也有推荐的项目典型目录,结构如下:

vue-tutorial/
    |--app/
        |--api_1_0/    # api目录,对于REST访问返回数据
            |--users.py
        |--main/
            |--views.py  # 路由文件,SPA里,只需要返回"/"根路由
        |--static/      # js, css
        |--templates/    # SPA里,只需要index.html  
        |--__init__.py  # flask app初始化
        |--models.py    # model数据库定义
    |--config.py  # Flask配置
    |--manage.py  # Flask启动文件,包含命令行

参考:Flask官网
我的Flask快速入门

  • 用户鉴权:使用JWT(JSON Web Token)。本实例是SPA(单页面应用),前端vue-router插件已经实现路由功能,后端Flask只需要提供api接口就行。所以不需要使用Flask_login来管理session。JWT是一个非常轻巧的规范,允许我们使用JWT在用户和服务器之间传递安全可靠的信息。

参考:我的Flask-JWT入门

jwt.png

(注意:改用Flask-Security了:http://www.jianshu.com/p/f37871e31231)

1. Flask框架搭建

请先下载项目源码,对照源码阅读和实践会效率更高

首先,设计一个结构清晰的关系型数据库(Model):

  • User用户表,肯定是要有的,记录用户名、密码Hash和他关注的公众号
  • Mp公众号表,记录哪些人关注了这个公众号,以及这个公众号有哪些文章
  • Article文章表,相对简单,记录公众号文章

关系

  • Mp和Article是一对多的关系
  • User和Mp,看起来像一对多,但其实是多对多的关系:一个用户关注多个公众号,同一个公众号也可能被多个用户共同关注,所以需要另加一张“关联表” - Subscription
  • User和Subscription:一对多
  • Mp和Subscription:一对多
数据库EER

下面就来创建model,在Flask models.py实现。

/app/models.py

  • Subscription:关联到User和Mp两个表
# encoding: utf-8
from datetime import datetime
import hashlib
from werkzeug.security import generate_password_hash, check_password_hash
from flask import current_app, request, url_for, jsonify
from flask_login import UserMixin, AnonymousUserMixin
from . import db

# 订阅公众号和User是多对多关系
class Subscription(db.Model):
    __tablename__ = 'subscriptions'
    # follower_id
    subscriber_id = db.Column(db.Integer, db.ForeignKey('users.id'),
                            primary_key=True)
    # followed_id
    mp_id = db.Column(db.Integer, db.ForeignKey('mps.id'),
                            primary_key=True)
    subscribe_timestamp = db.Column(db.DateTime, default=datetime.utcnow)
  • User:功能最为复杂。
    1. 关联到Subscription表
    2. password.setter:用户注册时,密码转化为hash存储。永远不要存储密码明文!
    3. subscribed_mps方法:用过滤器和联结查询,返回该用户订阅的所有公众号
class User(UserMixin, db.Model):
    __tablename__ = 'users'
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(64), unique=True, index=True)
    password_hash = db.Column(db.String(128))
    member_since = db.Column(db.DateTime(), default=datetime.utcnow)
    last_seen = db.Column(db.DateTime(), default=datetime.utcnow)
    mps = db.relationship('Subscription',
                               foreign_keys=[Subscription.subscriber_id],
                               backref=db.backref('subscriber', lazy='joined'),
                               lazy='dynamic',
                               cascade='all, delete-orphan')

    @property
    def password(self):
        raise AttributeError('password is not a readable attribute')

    @password.setter
    def password(self, password):
        self.password_hash = generate_password_hash(password)

    def verify_password(self, password):
        return check_password_hash(self.password_hash, password)

    @property
    def subscribed_mps(self):
        # SQLAlchemy 过滤器和联结
        return Mp.query.join(Subscription, Subscription.mp_id == Mp.id)\
            .filter(Subscription.subscriber_id == self.id)

    def __repr__(self):
        return '<User %r>' % self.username
  • Mp:
    1. 关联到Subscription表
    2. to_json方法:在REST访问时,用来返回json格式的公众号数据
# 公众号
class Mp(db.Model):
    __tablename__ = 'mps'
    id = db.Column(db.Integer, primary_key=True)
    weixinhao = db.Column(db.Text)
    image = db.Column(db.Text)
    summary = db.Column(db.Text)
    sync_time = db.Column(db.DateTime, index=True, default=datetime.utcnow)
    articles = db.relationship('Article', backref='mp', lazy='dynamic')
    subscribers = db.relationship('Subscription',
                               foreign_keys=[Subscription.mp_id],
                               backref=db.backref('mp', lazy='joined'),
                               lazy='dynamic',
                               cascade='all, delete-orphan')
    def to_json(self):
        json_mp = {
            'weixinhao': self.weixinhao,
            'image': self.image,
            'summary': self.summary,
            'articles_count': self.articles.count()
        }
        return json_mp
  • Article:最为简单
    1. 关联到Mp表
    2. to_json方法:在REST访问时,用来返回json格式的文章数据
# 公众号的文章
class Article(db.Model):
    __tablename__ = 'articles'
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.Text)
    image = db.Column(db.Text)
    summary = db.Column(db.Text)
    url = db.Column(db.Text)
    timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow)
    mp_id = db.Column(db.Integer, db.ForeignKey('mps.id'))

    def to_json(self):
        json_article = {
            'url': url_for('api.get_comment', id=self.id, _external=True),
            'body': self.body,
            'timestamp': self.timestamp
        }
        return json_article

第二步,在app初始化时,引用models.py,并创建JWT

/app/_init_.py

# encoding: utf-8
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_jwt import JWT
from config import config

db = SQLAlchemy()

# models引用必须在 db之后,不然会循环引用
from .models import User

# JWT鉴权:默认参数为username/password,在数据库里查找并比较password_hash
def authenticate(username, password):
    print 'JWT auth argvs:', username, password
    user = User.query.filter_by(username=username).first()
    if user is not None and user.verify_password(password):
        return user
# JWT检查user_id是否存在
def identity(payload):
    print 'JWT payload:', payload
    user_id = payload['identity']
    user = User.query.filter_by(id=user_id).first()
    return user_id if user is not None else None
# 创建jwt实例
jwt = JWT(authentication_handler=authenticate, identity_handler=identity)

def create_app(config_name):
    app = Flask(__name__)
# 引入Flask用户配置
    app.config.from_object(config[config_name])
    config[config_name].init_app(app)
# 初始化数据库和JWT
    db.init_app(app)
    jwt.init_app(app)
# 注册main/api蓝本,这样用户访问路径“/xxx”指向main,“/api/v1.0”指向api
    from .main import main as main_blueprint
    app.register_blueprint(main_blueprint)
    from .api_1_0 import api as api_1_0_blueprint
    app.register_blueprint(api_1_0_blueprint, url_prefix='/api/v1.0')

    return app

第三步,在启动文件中,创建命令行(数据库、部署、测试等等),启动app

/manage.py

#!/usr/bin/env python
import os
from app import create_app, db, jwt
from app.models import User, Subscription, Mp, Article
from flask_script import Manager, Shell
from flask_migrate import Migrate, MigrateCommand

app = create_app(os.getenv('FLASK_CONFIG') or 'default')
manager = Manager(app)
migrate = Migrate(app, db)

def make_shell_context():
    return dict(app=app, db=db, User=User, Subscription=Subscription, Mp=Mp,
                Article=Article)
manager.add_command("shell", Shell(make_context=make_shell_context))
manager.add_command('db', MigrateCommand)

@manager.command
def deploy():
    """Run deployment tasks."""
    from flask_migrate import upgrade
    from app.models import User

    # migrate database to latest revision
    upgrade()

if __name__ == '__main__':
    manager.run()

好了,总算初始框架都完成了。看上去很复杂,但跟vue-cli脚手架工具一样,你下载项目源码,框架都是现成的(而且是成熟可靠的),稍微修改一下就能跑起来了。主要是数据库定义models.py,完全要自己来定!

Python的依赖模块安装:
跟<code>npm install</code> node_modules类似,直接用<code>pip install -r requirements.txt</code>就一步完成了。

现在数据库还没有,我们来创建吧,很简单,三行命令:
打开CMD(Windows),或Shell(Linux)

c:\git\vue-tutorial>python manage.py db init
Creating directory c:\git\vue-tutorial\migrations ... done
Creating directory c:\git\vue-tutorial\migrations\versions ... done
Generating c:\git\vue-tutorial\migrations\alembic.ini ... done
Generating c:\git\vue-tutorial\migrations\env.py ... done
Generating c:\git\vue-tutorial\migrations\env.pyc ... done
Generating c:\git\vue-tutorial\migrations\README ... done
Generating c:\git\vue-tutorial\migrations\script.py.mako ... done
Please edit configuration/connection/logging settings in 'c:\\git\\vue-tutorial\\migrations\\alembic.ini' before proceed
ing.

c:\git\vue-tutorial>python manage.py db migrate -m "init"
INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.autogenerate.compare] Detected added table 'mps'
INFO  [alembic.autogenerate.compare] Detected added index 'ix_mps_sync_time' on '['sync_time']'
INFO  [alembic.autogenerate.compare] Detected added table 'users'
INFO  [alembic.autogenerate.compare] Detected added index 'ix_users_username' on '['username']'
INFO  [alembic.autogenerate.compare] Detected added table 'articles'
INFO  [alembic.autogenerate.compare] Detected added index 'ix_articles_timestamp' on '['timestamp']'
INFO  [alembic.autogenerate.compare] Detected added table 'subscriptions'
Generating c:\git\vue-tutorial\migrations\versions\599e99548c86_init.py ... done

c:\git\vue-tutorial>python manage.py db upgrade
INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.runtime.migration] Running upgrade  -> 599e99548c86, init

现在,数据库文件已经产生了,默认是config.py文件里定义的<code>/data-dev.sqlite</code>,打开来看看:
models.py里定义的表,都创建好了,是不是很清楚啊?比如:Subscription表,有两个Foreign_Key,指向Mp和User。


sqlite数据库.png

2. 后端用户注册、认证

回到Vue.js,我们准备把注册功能,放在右侧Siderbar.vue

  • template第一部分:如果已经登录is_login,则显示用户头像和退出按钮
  • template第二部分:如果没有登录,则显示一个表单,可以输入username/password,注册和登录两个按钮
# /src/components/Sidebar.vue (部分)
<template>
    <div class="card">
        <div v-if="is_login" class="card-header" align="center">
            <img src="http://avatar.csdn.net/1/E/E/1_kevin_qq.jpg"
                 class="avatar img-circle img-responsive" />
            <p><strong v-text="username"></strong>
                <a href="javascript:" @click="logout()" title="退出">
                    <i class="fa fa-sign-out float-xs-right"></i></a>
            </p>
        </div>
        <div v-else class="card-header" align="center">
            <form class="form" @submit.prevent>
                <div class="form-group">
                    <input class="form-control" name="username" type="text" placeholder="用户名" v-model="username"
                           required pattern="\w{3,12}" />
                           <p class="text-muted"><small>3~12位字母、数字、下划线</small></p>
                </div>
                <div class="form-group">
                    <input class="form-control" name= "password" type="password" placeholder="密码" v-model="password"
                           required pattern="\w{4,}"/>
                           <p class="text-muted"><small>至少4位,字母数字下划线</small></p>
                </div>
                <div class="form-group clearfix">
                    <input type="submit" @click="register()" class="btn btn-outline-danger float-xs-left"                       value="注册" />
                    <input type="submit" @click="login()" class="btn btn-outline-success float-xs-right"                        value="登录" />
                </div>
            </form>
        </div>
。。。
    </div>
</template>
  • Script部分:加入新的data和methods
  • methods.register(),用vue-resource post功能,提交username/password到Flask,由<code>/api/users.py</code>处理
# /src/components/Sidebar.vue (部分)
<script>
    export default {
        name : 'Sidebar',
        data() {
            return {
                is_login: false,
                username: '',
                password: '',
                token: ''
            }
        },
。。。
        methods : {
            register() {
                this.$http.post('http://127.0.0.1:5000/api/v1.0/register',
//body
                        {   username: this.username,
                            password: this.password
                        },
                        //options
                        {
                            headers: {'Content-Type':'application/json; charset=UTF-8'}
                        }                 ).then((response) => {
                    // 响应成功回调
                    var data = response.body;
                if (data.status=='success') {
                    alert('Success! ' + data.msg)
                }
                else {
                    alert(data.msg)
                }
                this.password = ''
            }, (response) => {
                    // 响应错误回调
                    alert('注册出错了! '+ JSON.stringify(response))
                });
            }
</script>
  • 后端处理register请求:
    对于ajax post请求,上面是用json提交的,所以用<code>request.get_json</code>来得到数据。然后检查数据是否有效,用户名是否已经注册。一切无误的话就会添加用户到数据库。
# /app/api_1_0/users.py
@api.route('/register', methods=['GET', 'POST'])
def register():
    username = request.get_json()['username']
    password = request.get_json()['password']
    print 'register Header: %s\nusername: %s, password:%s'% (request.headers, username, password)
    if username <> '' and password <> '':
        if User.query.filter_by(username=username).first():
             return jsonify({
            'status': 'failure',
            'msg': u'用户名已被占用,换一个吧'
            })           

        user = User(username=username, password=password)
        db.session.add(user)
        db.session.commit()
        return jsonify({
        'status': 'success',
        'msg': 'register OK, please login!'
        })
    return jsonify({
    'status': 'failure',
    'msg': 'register fail, check username and password.'
    })

Flask跑起来,用的是5000端口:

c:\git\vue-tutorial>python manage.py runserver
 * Restarting with stat
 * Debugger is active!
 * Debugger pin code: 302-156-201
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)

前端点击注册按钮,应该成功了!咦,怎么返回Bad Request (400)?

127.0.0.1 - - [15:25:09] "OPTIONS /api/v1.0/register HTTP/1.1" 400 -

我明明发的是POST,为什么服务器端说收到是的OPTIONS呢?哈哈,这就是著名的CORS跨域请求了!请看下一节的灵巧解决方案!

3. 跨域(Access-Control-Allow-Origin)本地调试

前端跨域Post请求,由于CORS(cross origin resource share)规范的存在,浏览器会首先发送一次Options嗅探,同时header带上origin,判断是否有跨域请求权限,服务器响应access control allow origin的值,供浏览器与origin匹配,如果匹配,浏览器则正式发送post请求。
如果有服务器程序权限,设置headeraccess control allow origin等于*,就可以允许前端跨域访问了。
我以前也是这么解决的:Flask: Ajax 设置Access-Control-Allow-Origin实现跨域访问
CORS深入

目前jsonp是最简单跨域方案,不过只能GET,不支持POST。如果要POST,则服务器端设置ACAO很麻烦,或用其它的绕路方法。

但是:
我们上一篇,不是有这个方案吗:Vue+Flask轻量级前端、后端框架,如何完美同步开发
这样就不存在跨域烦恼了,我们本身就在一个服务器(localhost:5000,包含端口号)下呀!

好了,马上来试试。上一篇的步骤是适用于最简的Flask项目,在这里有个小小改动,因为我们用了新的Flask目录框架,需要把修改后的index.html放入

/app/templates/index.html

同样,static文件放入新的目录:

/app/static/font-awesome/

再修改Siderbar.vue,POST不需要跨域了,直接是同一服务器+端口号上的路径<code>/api/v1.0/register</code>:

# /src/components/Sidebar.vue (部分)
<script>
。。。
        methods : {
            register() {
                this.$http.post('/api/v1.0/register',
。。。
            }
</script>

Flask跑起来,再点击“注册”,成功啦!

注册成功.png

对应的Flask log:

register Header: Referer: http://localhost:5000/
Origin: http://localhost:5000
Content-Length: 41
User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTM
537.36
Connection: keep-alive
X-Requested-With: XMLHttpRequest
Host: localhost:5000
Accept: application/json, text/plain, */*
Accept-Language: en-US,en;q=0.8,zh-CN;q=0.6,zh;q=0.4
Content-Type: application/json; charset=UTF-8
Accept-Encoding: gzip, deflate


username: hellovue, password:1111
127.0.0.1 - - [23/Dec/2016 12:16:03] "POST /api/v1.0/register HTTP/1.1" 200 -

想不到,我们一个小小改进,不仅前后端完美同步开发,而且还解决了CORS跨域问题!

OK,继续完成用户登录、登出功能,很简单,在Siderbar.vue里添加methods就行

  • login():/auth是Flask-JWT默认的鉴权路由,鉴权方法已经在/app/_init_.py里写好了。如果登录成功,本地LocalStorage把得到的token保存下来,以后REST请求会用到这个。
  • logout():is_login设成false,然后本地LocalStorage删除user(目的是删除保存的token)
# /src/components/Sidebar.vue (部分)
       methods : {
            login() {
                this.$http.post('/auth',
                    //body
                        {   username: this.username,
                            password: this.password
                        },
                        //options
                        {
                            headers: {'Content-Type':'application/json; charset=UTF-8'}
                        }                 ).then((response) => {
                    // 响应成功回调
                    var data = response.body;
                this.token = data.access_token;
                this.is_login = true;
   //             alert(data.access_token);
                var userData = {'username': this.username, 'token': this.token};
                window.localStorage.setItem("user", JSON.stringify(userData))

            }, (response) => {
                    // 响应错误回调
                    alert('登录出错了! '+ response.status+ response.statusText)
                });
            },
            logout() {
                this.is_login = false;
                this.password = '';
                this.token = '';
                window.localStorage.removeItem("user")
            },

4. 表单验证validation

表单验证是个很普遍的需求,如果用户不停地收到输入错误的告警,会很抓狂滴!所以,最好在提交前做好验证。
vue-validator是Vue全家桶里用到的表单验证插件,但适用于Vue2.0的版本迟迟没推出。
那就自己写一个简单的呗,几行代码而已。
对于HTML5,本身就有基本的表单验证功能,提交时浏览器会自动检查,但仅限于部分浏览器

无效输入,按钮不可点击
输入有效,按钮可点击
  • template input里,<code>required pattern="\w{3,12}"</code>是HTML5的功能
  • 按钮上,绑定一个计算属性validation: <code>:disabled="!validation"</code>
  • 提交事件: <form class="form" @submit.prevent>,阻止了默认submit事件,由vue方法接手
# /src/components/Sidebar.vue (部分)
          <form class="form" @submit.prevent>
                <div class="form-group">
                    <input class="form-control" name="username" type="text" placeholder="用户名" v-model="username"
                           required pattern="\w{3,12}" />
                           <p class="text-muted"><small>3~12位字母、数字、下划线</small></p>
                </div>
                <div class="form-group">
                    <input class="form-control" name= "password" type="password" placeholder="密码" v-model="password"
                           required pattern="\w{4,}"/>
                           <p class="text-muted"><small>至少4位,字母数字下划线</small></p>
                </div>
                <div class="form-group clearfix">
                    <input type="submit" @click="register()" class="btn btn-outline-danger float-xs-left" 
                        value="注册" :disabled="!validation" />
                    <input type="submit" @click="login()" class="btn btn-outline-success float-xs-right" 
                        value="登录" :disabled="!validation" />
                </div>
  • 计算属性validation,实时计算用户输入内容是否有效。比如:/(\w{3,12})/是判断是否为:3到12位的数字、字母、下划线。
  • login/register方法:在post提交前,如果计算属性validation为false(输入有误),就不提交
# /src/components/Sidebar.vue (部分)
       computed : {
            validation() {
                var patt1 = /(\w{3,12})/;
                var patt2 = /(\w{4,})/;
//              alert(this.username + patt1.test(this.username));
                return patt1.test(this.username) && patt2.test(this.password)
            }
        },
        methods : {
            login() {
                if (!this.validation) return;
                this.$http.post('/auth',
。。。

舒了一口气,这篇写得时间较长,因为相当于把后端Flask启蒙了一遍。。。
后续的ajax保存、请求订阅列表,相对比较简单明了,大家有什么其它需求,请评论留言哦!
项目源码 https://github.com/kevinqqnj/vue-tutorial
请使用新的template: https://github.com/kevinqqnj/flask-template-advanced

TODO:

  • 后端保存用户订阅的公众号,搜狗的链接都是临时的
  • 公众号文章的更新,这个Python爬虫最拿手了

敬请关注Vue 2.0 起步(5) 订阅列表上传和下载 - 微信公众号RSS

http://www.jianshu.com/p/ab778fde3b99

推荐阅读更多精彩内容