Flask Vue.js全栈开发|第5章:个人主页与用户头像

  • 原创
  • Madman
  • /
  • 2018-11-06 10:06
  • /
  • 0
  • 125 次阅读

flask vuejs 全栈开发-min.png

Synopsis: 用户认证修改为 JWT(JSON Web Token),后端使用 pyjwt 库生成 JWT 并验证合法性;前端使用 JSON.parse 解析 JWT 中的 payload 数据。提示消息改用 vue-toasted 插件,方便好用。前端每次访问 API 都需要附带 Token 到 Authorization 请求头中,使用请求拦截器自动添加进去,另外设置了响应拦截器,自动处理 401 和 404 错误。用户头像使用在线的 Gravatar 服务,前端使用 moment.js 格式化时间。个人主页的路由参数变化后默认是重用组件并不会重新加载数据,要使用 vue-router 的 beforeRouteUpdate() 导航守卫

代码已上传到 https://github.com/wangy8961/flask-vuejs-madblog/tree/v0.5 ,欢迎star

1. JWT

上一篇文章通过后端创建 Token 并保存到数据库中(同时记录 Token 的失效时间),结合 Flask-HTTPAuth 插件实现 Basic AuthToken Auth。但是,前端用户登录后,并不知道自己的用户 id,就没办法调用 GET /api/users/<id> 来获取自己的用户信息

1.1 pyjwt

现在改用 JWT 实现,它可以在 Token 中添加一些不是隐私的数据 payload,比如我们可以把用户 id 放进去。后端安装 pyjwt 包:

(venv) D:\python-code\flask-vuejs-madblog\back-end>pip install pyjwt
(venv) D:\python-code\flask-vuejs-madblog\back-end>pip freeze > requirements.txt

修改 User 数据模型 back-end/app/models.py,删除之前的 tokentoken_expiration 字段:

class User(PaginatedAPIMixin, db.Model):
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(64), index=True, unique=True)
    email = db.Column(db.String(120), index=True, unique=True)
    password_hash = db.Column(db.String(128))  # 不保存原始密码

    ...
    def get_jwt(self, expires_in=600):
        now = datetime.utcnow()
        payload = {
            'user_id': self.id,
            'name': self.name if self.name else self.username,
            'exp': now + timedelta(seconds=expires_in),
            'iat': now
        }
        return jwt.encode(
            payload,
            current_app.config['SECRET_KEY'],
            algorithm='HS256').decode('utf-8')

    @staticmethod
    def verify_jwt(token):
        try:
            payload = jwt.decode(
                token,
                current_app.config['SECRET_KEY'],
                algorithms=['HS256'])
        except (jwt.exceptions.ExpiredSignatureError, jwt.exceptions.InvalidSignatureError) as e:
            # Token过期,或被人修改,那么签名验证也会失败
            return None
        return User.query.get(payload.get('user_id'))

修改 back-end/app/api/tokens.py

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_jwt()
    db.session.commit()
    return jsonify({'token': token})

注意: JWT 没办法回收(不需要 DELETE /tokens),只能等它过期,所以有效时间别设置太长

1.2 Flask-Migrate 迁移

修改 User 数据模型后要进行数据库迁移:

(venv) D:\python-code\flask-vuejs-madblog\back-end>flask db migrate -m "users jwt"
(venv) D:\python-code\flask-vuejs-madblog\back-end>flask db upgrade
...
sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) near "DROP": syntax error [SQL: 'ALTER TABLE user DROP COLUMN token_expiration'] (Background on this error at: http://sqlalche.me/e/e3q8)

注意: 报错的原因是 SQLite 默认不支持删除列

可以 batch_alter_table 上下文管理器来解决,参考: https://stackoverflow.com/questions/30394222/why-flask-migrate-cannot-upgrade-when-drop-column ,需要修改 back-end/migrations/env.py

def run_migrations_online():
    ...
    context.configure(connection=connection,
                      target_metadata=target_metadata,
                      process_revision_directives=process_revision_directives,
                      render_as_batch=True,  # 增加这个配置项
                      **current_app.extensions['migrate'].configure_args)

删除刚才新创建的迁移脚本,比如我的是 back-end/migrations/versions/8ba2e85acf5b_users_jwt.py。然后再次创建迁移脚本:

(venv) D:\python-code\flask-vuejs-madblog\back-end>flask db migrate -m "users jwt"

查看新迁移脚本,会发现使用了 batch_alter_table 上下文管理器:

def upgrade():
    # ### commands auto generated by Alembic - please adjust! ###
    with op.batch_alter_table('user', schema=None) as batch_op:
        batch_op.drop_column('token')
        batch_op.drop_column('token_expiration')

    # ### end Alembic commands ###

最后,应用迁移脚本:

(venv) D:\python-code\flask-vuejs-madblog\back-end>flask db upgrade

1.3 JSON.parse

前端用户在登录页面输入用户名和密码后,axios 调用 POST /tokens。后端首先要进行 Basic Auth 认证, 如果用户名和密码正确,则进入视图函数 get_token 中执行并返回 JWT 给前端

用户 id 存放在 JWT 的第二部分中(三个部分以点号 . 分隔),所以前端可以使用如下方法解析 JSON 数据:

JSON.parse(atob(window.localStorage.getItem('madblog-token').split('.')[1])).user_id

修改 front-end/src/store.js

export default {
  debug: true,
  state: {
    is_authenticated: window.localStorage.getItem('madblog-token') ? true : false,
    // 用户登录后,就算刷新页面也能再次计算出 user_id
    user_id: window.localStorage.getItem('madblog-token') ? JSON.parse(atob(window.localStorage.getItem('madblog-token').split('.')[1])).user_id : 0
  },
  loginAction () {
    if (this.debug) { console.log('loginAction triggered') }
    this.state.is_authenticated = true
    this.state.user_id = JSON.parse(atob(window.localStorage.getItem('madblog-token').split('.')[1])).user_id
  },
  logoutAction () {
    if (this.debug) console.log('logoutAction triggered')
    window.localStorage.removeItem('madblog-token')
    this.state.is_authenticated = false
    this.state.user_id = 0
  }
}

修改路由 front-end/src/router/index.js,用户个人主页的路由需要动态传入用户 id

{
  path: '/user/:id',
  name: 'Profile',
  component: Profile,
  meta: {
    requiresAuth: true
  }
}

修改导航栏组件 front-end/src/components/Navbar.vue

<li class="nav-item">
  <router-link v-bind:to="{ name: 'Profile', params: { id: sharedState.user_id }}" class="nav-link">Profile</router-link>
</li>

这样,登录的用户就能访问类似 http://localhost:8080/#/user/1 的地址来获取个人主页了

2. vue-toasted 插件

之前考虑用 Bootstrap 4Alert 实现一个组件来显示提示信息,但是组件间路由后消息传递太麻烦了,考虑使用 vue-toasted 插件

D:\python-code\flask-vuejs-madblog\front-end> cnpm install vue-toasted --save

修改 front-end/src/main.js,引入该插件:

// register the vue-toasted plugin on vue
import VueToasted  from 'vue-toasted'

Vue.use(VueToasted, {
  // 主题样式 primary/outline/bubble
  theme: 'bubble',
  // 显示在页面哪个位置
  position: 'top-center',
  // 显示多久时间(毫秒)
  duration: 3000,
  // 支持哪个图标集合
  iconPack : 'material', // set your iconPack, defaults to material. material|fontawesome|custom-class
  // 可以执行哪些动作
  action: {
    text: 'Cancel',
    onClick: (e, toastObject) => {
      toastObject.goAway(0)
    }
  },
});

在组件中使用:

this.$toasted.success(`Welcome ${name}!`, { icon: 'fingerprint' })

3. axios 全局配置

3.1 Base URL

前端如果每次通过 axios 调用后端 API 时,指定的 API URL都写死到各个组件中,现在开发环境是类似 http://localhost:5000/api/users/1 这样的,如果后续部署到生产环境上,可能IP和端口会变动,所以可以通过 axios 全局配置一次指定

创建 front-end/src/http.js

import axios from 'axios'
...

// 基础配置
axios.defaults.timeout = 5000  // 超时时间
axios.defaults.baseURL = 'http://localhost:5000/api'

export default axios

然后在 front-end/src/main.js 中引入它:

// 导入配置了全局拦截器后的 axios
import axios from './http'
...

// 将 $axios 挂载到 prototype 上,在组件中可以直接使用 this.$axios 访问
Vue.prototype.$axios = axios

之后,比如在组件 front-end/src/components/Login.vue 中这样使用:

const path = '/tokens'  // 不用指定完整的 API 地址
this.$axios.post(path, {}, {
  auth: {
    'username': this.loginForm.username,
    'password': this.loginForm.password
  }
}).then((response) => {
    ...
}).catch((error) => {
    ...
})

3.2 Request Interceptor

用户以后访问后端需要认证的 API 时都要传输 Token,而 axios 可以通过创建 request interceptor 自动帮你添加 Token 到请求头 Authorization 中:

修改front-end/src/http.js,添加:

// Add a request interceptor
axios.interceptors.request.use(function (config) {
  // Do something before request is sent
  const token = window.localStorage.getItem('madblog-token')
  if (token) {
    config.headers.Authorization = `Bearer ${token}`
  }
  return config
}, function (error) {
  // Do something with request error
  return Promise.reject(error)
})

3.3 Response Interceptor

前面说过 JWT 的有效期要设置的短一些,当它过期后,用户再通过它访问后端 API 时会返回 401 UNAUTHORIZED 错误,我们希望 axios 自动处理这个错误,如果用户当前访问的不是 /login 路由(正常登录)时,会自动跳转到登录页,要求用户重新认证

修改front-end/src/http.js,添加:

// Add a response interceptor
axios.interceptors.response.use(function (response) {
  // Do something with response data
  return response
}, function (error) {
  // Do something with response error
  switch  (error.response.status) {
    case 401:
      // 清除 Token 及 已认证 等状态
      store.logoutAction()
      // 跳转到登录页
      if (router.currentRoute.path !== '/login') {
        Vue.toasted.error('401: 认证已失效,请先登录', { icon: 'fingerprint' })
        router.replace({
          path: '/login',
          query: { redirect: router.currentRoute.path },
        })
      }
      break

    case 404:
      Vue.toasted.error('404: NOT FOUND', { icon: 'fingerprint' })
      router.back()
      break
  }
  return Promise.reject(error)
})

export default axios

4. 用户头像

使用 Gravatar 服务根据用户的 Email 地址在线生成头像,为 User 数据模型增加一个方法:

def avatar(self, size):
    '''头像'''
    digest = md5(self.email.lower().encode('utf-8')).hexdigest()
    return 'https://www.gravatar.com/avatar/{}?d=identicon&s={}'.format(digest, size)

5. 个人主页

后端给 User 数据模型增加一些字段,并修改 to_dict() 方法:

class User(PaginatedAPIMixin, db.Model):
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(64), index=True, unique=True)
    email = db.Column(db.String(120), index=True, unique=True)
    password_hash = db.Column(db.String(128))  # 不保存原始密码
    name = db.Column(db.String(64))
    location = db.Column(db.String(64))
    about_me = db.Column(db.Text())
    member_since = db.Column(db.DateTime(), default=datetime.utcnow)
    last_seen = db.Column(db.DateTime(), default=datetime.utcnow)

    ...
    def to_dict(self, include_email=False):
        data = {
            'id': self.id,
            'username': self.username,
            'name': self.name,
            'location': self.location,
            'about_me': self.about_me,
            'member_since': self.member_since.isoformat() + 'Z',
            'last_seen': self.last_seen.isoformat() + 'Z',
            '_links': {
                'self': url_for('api.get_user', id=self.id),
                'avatar': self.avatar(128)
            }
        }
        if include_email:
            data['email'] = self.email
        return data

5.1 moment.js格式化时间

后端传递过来的时间是 UTC 时间,前端可以使用 moment.js 库来格式化成本地时间:

D:\python-code\flask-vuejs-madblog\front-end>cnpm install moment --save

修改 front-end/src/main.js 引入 moment.js

// 导入 moment.js 用来格式化 UTC 时间为本地时间
import moment from 'moment'
...

// 将 $moment 挂载到 prototype 上,在组件中可以直接使用 this.$moment 访问
Vue.prototype.$moment = moment

然后在组件 front-end/src/components/Profile.vue 中按如下方式使用:

<!-- Member since -->
<h4 class="h6 g-font-weight-300 g-mb-10">
  <i class="icon-badge g-pos-rel g-top-1 g-color-gray-dark-v5 g-mr-5"></i> Member since : <span v-if="user">{{ $moment(user.member_since).format('LLL') }}</span>
</h4>
<!-- End Member since -->

<!-- Last seen -->
<h4 class="h6 g-font-weight-300 g-mb-10">
  <i class="icon-eye g-pos-rel g-top-1 g-color-gray-dark-v5 g-mr-5"></i> Last seen : <span v-if="user">{{ $moment(user.last_seen).fromNow() }}</span>
</h4>
<!-- End Last seen -->

5.2 响应路由参数的变化

参考官方文档的说明: https://router.vuejs.org/zh/guide/essentials/dynamic-matching.html#%E5%93%8D%E5%BA%94%E8%B7%AF%E7%94%B1%E5%8F%82%E6%95%B0%E7%9A%84%E5%8F%98%E5%8C%96 ,使用 beforeRouteUpdate 导航守卫

beforeRouteUpdate (to, from, next) {
    // 在当前路由改变,但是该组件被复用时调用
    // 举例来说,对于一个带有动态参数的路径 /foo/:id,在 /foo/1 和 /foo/2 之间跳转的时候,
    // 由于会渲染同样的 Foo 组件,因此组件实例会被复用。而这个钩子就会在这个情况下被调用。
    // 可以访问组件实例 `this`
  }

修改组件 front-end/src/components/Profile.vue

<script>
import store from '../store.js'

export default {
  name: 'Profile',  //this is the name of the component
  data () {
    return {
      sharedState: store.state,
      user: {
        username: '',
        email: '',
        name: '',
        location: '',
        about_me: '',
        member_since: '',
        last_seen: '',
        _links: {
          self: '',
          avatar: ''
        }
      }
    }
  },
  methods: {
    getUser (id) {
      const path = `/users/${id}`
      this.$axios.get(path)
        .then((response) => {
          this.user = response.data
        })
        .catch((error) => {
          // eslint-disable-next-line
          console.error(error)
        });
    }
  },
  created () {
    const user_id = this.$route.params.id
    this.getUser(user_id)
  },
  // 当 id 变化后重新加载数据
  beforeRouteUpdate (to, from, next) {
    this.getUser(to.params.id)
    next()
  }
}
</script>

6. 修改信息

新增组件 front-end/src/components/EditProfile.vue,加载组件时先获取一次用户信息填充到表单中,提交时不需要检查表单,因为可以提交空的信息

<script>
import store from '../store.js'

export default {
  name: 'EditProfile',  //this is the name of the component
  data () {
    return {
      sharedState: store.state,
      profileForm: {
        name: '',
        location: '',
        about_me: '',
        submitted: false  // 是否点击了 submit 按钮
      }
    }
  },
  methods: {
    getUser (id) {
      const path = `/users/${id}`
      this.$axios.get(path)
        .then((response) => {
          this.profileForm.name = response.data.name
          this.profileForm.location = response.data.location
          this.profileForm.about_me = response.data.about_me
        })
        .catch((error) => {
          // eslint-disable-next-line
          console.error(error)
        });
    },
    onSubmit (e) {
      const user_id = this.sharedState.user_id
      const path = `/users/${user_id}`
      const payload = {
        name: this.profileForm.name,
        location: this.profileForm.location,
        about_me: this.profileForm.about_me
      }
      this.$axios.put(path, payload)
        .then((response) => {
          // handle success
          this.$toasted.success('Successed modify your profile.', { icon: 'fingerprint' })
          this.$router.push({
            name: 'Profile',
            params: { id: user_id }
          })
        })
        .catch((error) => {
          // handle error
          console.log(error.response.data)
        })
    },

  },
  created () {
    const user_id = this.sharedState.user_id
    this.getUser(user_id)
  }
}
</script>

组件 front-end/src/components/Profile.vue 中需要判断如果是登录用户自己在查看个人主页,则显示修改按钮;如果是查看别人的主页,不显示修改按钮:

<!-- Actions -->
<router-link v-if="$route.params.id == sharedState.user_id" v-bind:to="{ name: 'EditProfile' }" class="btn btn-block u-btn-outline-primary g-rounded-50 g-py-12 g-mb-10">
    <i class="icon-user-follow g-pos-rel g-top-1 g-mr-5"></i> Edit Profile
</router-link>
<!-- End Actions -->

效果图如下:

7. 提交代码

$ git add .
$ git commit -m "5. 个人主页与用户头像"
$ git checkout master
$ git merge dev
$ git branch -d dev

将本地 master 分支代码上传到 Github 代码仓库中的 master 分支:

$ git push -u origin master

打上标签 tag并上传:

$ git tag v0.5
$ git push origin v0.5

代码已上传到 https://github.com/wangy8961/flask-vuejs-madblog/tree/v0.5 ,欢迎star

未经允许不得转载: LIFE & SHARE - 王颜公子 » Flask Vue.js全栈开发|第5章:个人主页与用户头像

分享

作者

作者头像

Madman

如果博文内容有误或其它任何问题,欢迎留言评论,我会尽快回复; 或者通过QQ、微信等联系我

0 条评论

暂时还没有评论.

发表评论前请先登录