【译】基于 Vue-router 实现用户认证

@JChehe 2018-08-07 13:35:52发表于 JChehe/blog

原文:Vue Authentication And Route Handling Using Vue-router

封面

Vue 是一个渐进式 JavaScript 框架,它使得前端应用的构建变得简单。搭配 vue-router 就能构建拥有复杂动态路由的高性能应用。vue-router 是一个高效的工具,它能在 Vue 应用中无缝地处理用户认证。在本教程中,我们将看到如何使用 vue-router 处理用户认证和应用各部分的访问控制。

开始

通过 Vue cli 创建一个 Vue 应用:

$ npm install -g @vue/cli
$ npm install -g @vue/cli-init
$ vue init webpack vue-router-auth

根据安装提示完成应用的安装。如果某个选项不确定,可敲 return 键(既 enter 键)输入默认值以进行下一步。当询问是否安装 vue-router 时,就确认安装。

目录

1. 开始 - Getting Started
2. 启动 Node.js 服务 - Setup Node.js Server
3. 更新 Vue-router 文件 - Updating The Vue-router File
4. 定义组件 - Define Some Components
5. 全局安装 Axios - Setting Up Axios Globally
6. 运行应用 - Running The Application
7. 总结 - Conclusion

启动 Node.js 服务

下一步是启动 Node.js 服务以处理用户认证功能。我们将使用 SQLite 作为数据库。通过以下命令安装 SQLite 驱动:

$ npm install --save sqlite3

因为需要与密码打交道,所以需要对密码进行哈希加密。我们将使用 bcrypt 完成哈希加密操作。通过以下命令安装:

$ npm install --save bcrypt

当用户向应用需要认证的部分发送请求时,我们如何进行用户认证呢?对此,我们将使用 JWT 解决这个问题。通过以下命令安装 JWT 模块:

$ npm install jsonwebtoken --save

为了能读取 json 请求数据,我们需要 body-parser 模块。通过以下命令安装:

$ npm install --save body-parser

现在万事俱备,让我们创建一个简单的 Node.js 服务以处理用户认证。创建名为 server 的新目录,它将存放所有 Node.js 后端文件。在该目录下,创建名为 app.js 文件,其内容如下:

const express = require('express')
const DB = require('./db')
const config = require('./config')
const bcrypt = require('bcrypt')
const jwt = require('jsonwebtoken')
const bodyParser = require('body-parser')

const db = new DB('sqlitedb')
const app = express()
const router = express.Router()

router.use(bodyParser.urlencoded({ extended: false }))
router.use(bodyParser.json())

引入应用所需的所有模块、定义数据库、创建一个 express 服务和一个 express 路由。

现在,让我们定义 CORS 中间件,以确保不陷入任何跨域问题:

// CORS middleware
const allowCrossDomain = function (req, res, next) {
  res.header('Access-Control-Allow-Origin', '*')
  res.header('Access-Control-Allow-Methods', '*')
  res.header('Access-Control-Allow-Headers', '*')
  next()
}

app.use(allowCrossDomain)

很多开发者都会使用 CORS 库,但我们并没有任何复杂的配置,所以适合就好。

接下来,定义注册新用户的路由:

router.post('/register', (req, res) => {
  db.insert([
    req.body.name,
    req.body.email,
    bcrypt.hashSync(req.body.password, 8)
  ], err => {
    if (err) {
      return res.status(500).send('There was a problem registering the user.')
    }
    db.selectByEmail(req.body.email, (err, user) => {
      if (err) {
        return res.status(500).send('There was a problem getting user')
      }
      let token = jwt.sign(
        { id: user.id }, 
        config.secret, 
        { expiresIn: 86400 }
      )
      res.status(200).send({
        auth: true,
        token,
        user,
      })
    })
  })
})

上述代码做了以下事情:向数据库方法(后续定义)传递了请求体(request body)和一个用于处理数据库响应的回调函数。同时,也定义了错误检查,以确保能向用户提供精确的反馈信息。

当用户成功注册时,我们通过 email 选择用户,并通过 jwt 模块为其创建用户认证 token。而 config 文件内的 secret key 则用于对用户认证进行签名。这样,我们就能验证发送到服务器的 token,避免伪造身份。

现在,定义用于注册管理员和登录的路由,两者与上述的注册逻辑类似:

router.post('/register-admin', (req, res) => {
  db.insertAdmin([
    req.body.name,
    req.body.email,
    bcrypt.hashSync(req.body.password, 8),
    1,
  ], err => {
    if (err) {
      return res.status(500).send('There was a problem registering the user.')
    }
    db.selectByEmail(req.body.email, (err, user) => {
      if (err) {
        return res.status(500).send('There was a problem getting user.')
      }
      let token = jwt.sign(
        { id: user.id },
        config.secret,
        { expiresIn: 86400 }
      )
      res.status(200).send({
        auth: true,
        token,
        user,
      })
    })
  })
})

router.post('/login', (req, res) => {
  db.selectByEmail(req.body.email, (err, user) => {
    if (err) {
      return res.status(500).send('Error on the server.')
    }
    if (!user) {
      return res.status(404).send('No user Found.')
    }
    let passwordIsValid = bcrypt.compareSync(req.body.password, user.user_pass)
    if (!passwordIsValid) {
      return res.status(401).send({
        auth: false,
        token: null
      })
    }
    let token = jwt.sign(
      { id: user.id },
      config.secret,
      { expiresIn: 86400 }
    )
    res.status(200).send({
      auth: true,
      token,
      user,
    })
  })
})

对于登录,我们使用 bcrypt 模块对哈希后的密码和用户提供的密码进行比较。若两者相同,则登录成功。反之,则登录失败,并向用户进行反馈。

通过 express 服务器让应用可响应请求:

app.use(router)

let port = process.env.PORT || 3000

let server = app.listen(port, () => {
  console.log(`Express server listening on port ${port}`)
})

我们在 port: 3000 创建了一个服务,任意动态生成的端口亦可(heroku 提供 生成动态端口服务)。

然后,在同样目录下创建 config.js 文件:

module.exports = {
  'secret': 'supersecret'
}

最后,创建 db.js 文件:

const sqlite3 = require('sqlite3').verbose()

class DB {
  constructor(file) {
    this.db = new sqlite3.Database(file)
    this.createTable()
  }

  createTable() {
    const sql = `
      CREATE TABLE IF NOT EXISTS user (
        id integer PRIMARY KEY, 
        name text, 
        email text UNIQUE, 
        user_pass text,
        is_admin integer)`
    return this.db.run(sql);
  }

  selectByEmail(email, callback) {
    return this.db.get(
      `SELECT * FROM user WHERE email = ?`,
      [email], function (err, row) {
        callback(err, row)
      })
  }
  
  insertAdmin(user, callback) {
    return this.db.run(
      'INSERT INTO user (name,email,user_pass,is_admin) VALUES (?,?,?,?)',
      user, (err) => {
        callback(err)
      })
  }

  selectAll(callback) {
    return this.db.all(`SELECT * FROM user`, function (err, rows) {
      callback(err, rows)
    })
  }

  insert(user, callback) {
    return this.db.run(
      'INSERT INTO user (name,email,user_pass) VALUES (?,?,?)',
      user, (err) => {
        callback(err)
      })
  }
}
module.exports = DB

为数据库创建一个类以抽象出所需的基本函数。你可能希望在此使用更通用和可复用的方法来进行数据库操作,甚至是使用 promise 来提高其效率。既需要一个所有类可共用的库(特别是使用 MVC 架构的应用)。

服务器端已完成开发,接下来让我们看看 Vue 应用。

更新 Vue-router 文件

该 vue-router 文件在 ./src/router 目录下。我们将在 index.js 文件定义应用的所有路由。这与服务器不同,切记混淆。

打开该文件并添加以下内容:

import Vue from 'vue'
import Router from 'vue-router'
import HelloWorld from '@/components/HelloWorld'
import Login from '@/components/Login'
import Register from '@/components/Register'
import UserBoard from '@/components/UserBoard'
import Admin from '@/components/Admin'

Vue.use(Router)

引入所有组件,这些组件将在后续创建。

定义应用的路由:

let router = new Router({
  mode: 'history',
  routes: [
    {
      path: '/',
      name: 'HelloWorld',
      component: HelloWorld
    },
    {
      path: '/login',
      name: 'login',
      component: Login,
      meta: { 
        guest: true
      }
    },
    {
      path: '/register',
      name: 'register',
      component: Register,
      meta: { 
        guest: true
      }
    },
    {
      path: '/dashboard',
      name: 'userboard',
      component: UserBoard,
      meta: { 
        requiresAuth: true
      }
    },
    {
      path: '/admin',
      name: 'admin',
      component: Admin,
      meta: { 
        requiresAuth: true,
        is_admin : true
      }
    },
  ]
})

Vue router 可定义元数据(meta),可基于此指定额外行为。上述代码,我们分别定义了 访客(未认证用户可见)、认证用户(认证用户可见)和管理员的路由。

基于元数据(meta)处理路由请求:

router.beforeEach((to, from, next) => {
  if (to.matched.some(record => record.meta.requiresAuth)) {
    if (localStorage.getItem('jwt') === null) {
      next({
        path: '/login',
        params: { nextUrl: to.fullPath },
      })
    } else {
      const user = JSON.parse(localStorage.getItem('user'))
      if (to.matched.some(record => record.meta.is_admin)) {
        if (user.is_admin === 1) {
          next()
        } else {
          next({ name: 'userboard' })
        }
      } else {
        next()
      }
    }
  } else if (to.matched.some(record => record.meta.guest)) {
    if (localStorage.getItem('jwt') === null) {
      next()
    } else {
      next({ name: 'userboard' })
    }
  } else {
    next()
  }
})

export default router

Vue-router 拥有一个 beforeEach 方法,其在每个路由处理前调用。可利用此特性定义检查条件并限制用户访问权限。该方法共接收 3 个参数——to、from 和 next。其中,to 是用户将去哪、from 是用户来自哪、next 是一个继续用户请求处理的回调函数。因此,检查操作将在 to 对象上进行。

我们将进行以下几点检查:

  • 若路由有 requireAuth,则检查 jwt token,以表明用户是否已登录。
  • 若路由有 requireAuth 且要求是管理员,则检查已登录用户是否是管理员。
  • 若路由有 guest,则检查用户是否已登录。

我们根据检查内容对用户请求进行重定向。由于上述代码使用路由的 name 属性进行重定向,需确保应用拥有该 name 属性的路由。

重要:始终确保检查的每个条件语句末尾均调用 next() 函数,以防止应用存在检查遗漏。

定义组件

下面将定义一些组件,用于测试上面构建的内容。在 ./src/components/ 目录下,打开 HelloWorld.vue 文件并输入以下内容:

<template>
  <div class="hello">
    <h1>This is homepage</h1>
    <h2>{{msg}}</h2>
  </div>
</template>

<script>
  export default {
    data () {
      return {
        msg: 'Hello World!'
      }
    }
  }
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
  h1, h2 {
    font-weight: normal;
  }
  ul {
    list-style-type: none;
    padding: 0;
  }
  li {
    display: inline-block;
    margin: 0 10px;
  }
  a {
    color: #42b983;
  }
</style>

在同样目录下,创建 Login.vue 文件并输入以下内容:

<template>
  <div>
    <h4>Login</h4>
    <form>
      <label for="email" >E-Mail Address</label>
      <div>
        <input id="email" type="email" v-model="email" required autofocus>
      </div>
      <div>
        <label for="password" >Password</label>
        <div>
          <input id="password" type="password" v-model="password" required>
        </div>
      </div>
      <div>
        <button type="submit" @click="handleSubmit">
          Login
        </button>
      </div>
    </form>
  </div>
</template>

上面是 HTML 模板,下面将为其定义处理登录的脚本:

<script>
  export default {
    data(){
      return {
        email : '',
        password : ''
      }
    },
    methods : {
      handleSubmit(e){
        e.preventDefault()
        if (this.password.length > 0) {
          this.$http.post('http://localhost:3000/login', {
            email: this.email,
            password: this.password
          })
          .then(response => {

          })
          .catch(function (error) {
            console.error(error.response);
          });
        }
      }
    }
  }
</script>

此时,我们将 emailpassword 数据绑定到表单域以收集用户输入的信息。最终,将用户提供的信息发送到服务器以验证凭证。

处理服务器返回的信息:

  [...]
  methods : {
    handleSubmit(e){
      [...]
        .then(response => {
          let is_admin = response.data.user.is_admin
          localStorage.setItem('user',JSON.stringify(response.data.user))
          localStorage.setItem('jwt',response.data.token)

          if (localStorage.getItem('jwt') !== null){
            this.$emit('loggedIn')
            if(this.$route.params.nextUrl !== null){
              this.$router.push(this.$route.params.nextUrl)
            }
            else {
              if(is_admin === 1){
                this.$router.push('admin')
              }
              else {
                this.$router.push('dashboard')
              }
            }
          }
        })
        [...]
      }
    }
  }
}

jwt token 和 user 信息存储到 localStorage,以便在应用随时获取。当然,我们还会将认证处理后的用户重定向至认证前的路由。若用户来自登录路由,则取决于用户类型。

接下来,创建 Register.vue 文件并添加以下内容:

<template>
  <div>
    <h4>Register</h4>
    <form>
      <label for="name">Name</label>
      <div>
        <input id="name" type="text" v-model="name" required autofocus>
      </div>

      <label for="email" >E-Mail Address</label>
      <div>
        <input id="email" type="email" v-model="email" required>
      </div>

      <label for="password">Password</label>
      <div>
        <input id="password" type="password" v-model="password" required>
      </div>

      <label for="password-confirm">Confirm Password</label>
      <div>
        <input id="password-confirm" type="password" v-model="password_confirmation" required>
      </div>

      <label for="password-confirm">Is this an administrator account?</label>
      <div>
        <select v-model="is_admin">
          <option value=1>Yes</option>
          <option value=0>No</option>
        </select>
      </div>

      <div>
        <button type="submit" @click="handleSubmit">
          Register
        </button>
      </div>
    </form>
  </div>
</template>

定义处理注册的脚本:

<script>
export default {
  props : ['nextUrl'],
  data(){
    return {
      name : '',
      email : '',
      password : '',
      password_confirmation : '',
      is_admin : null
    }
  },
  methods : {
    handleSubmit(e) {
      e.preventDefault()

      if (this.password === this.password_confirmation && this.password.length > 0) {
        let url = 'http://localhost:3000/register'
        if (this.is_admin !== null || this.is_admin === 1) url = 'http://localhost:3000/register-admin'
        this.$http.post(url, {
          name: this.name,
          email: this.email,
          password: this.password,
          is_admin: this.is_admin
        })
          .then(response => {
            localStorage.setItem('user',JSON.stringify(response.data.user))
            localStorage.setItem('jwt',response.data.token)

            if (localStorage.getItem('jwt') !== null) {
              this.$emit('loggedIn')
              if (this.$route.params.nextUrl !== null) {
                this.$router.push(this.$route.params.nextUrl)
              } else {
                this.$router.push('/')
              }
            }
          })
          .catch(error => {
            console.error(error)
          })
      } else {
        this.password = ''
        this.passwordConfirm = ''

        return alert('Passwords do not match')
      }
    }
  }
}
</script>

这与 Login.vue 文件结构类似。

创建 Admin.vue 并添加以下内容:

<template>
  <div class="hello">
    <h1>Welcome to administrator page</h1>
    <h2>{{msg}}</h2>
  </div>
</template>

<script>
export default {
  data () {
    return {
      msg: 'The superheros'
    }
  }
}
</script>
<style scoped>
    h1, h2 {
        font-weight: normal;
    }
    ul {
        list-style-type: none;
        padding: 0;
    }
    li {
        display: inline-block;
        margin: 0 10px;
    }
    a {
        color: #42b983;
    }
</style>

以上是当用户访问管理员页面时挂载的组件。

最后,创建 UserBoard.vue 文件并添加以下内容:

<template>
  <div class="hello">
    <h1>Welcome to regular users page</h1>
    <h2>{{msg}}</h2>
  </div>
</template>

<script>
export default {
  data () {
    return {
      msg: 'The commoners'
    }
  }
}
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
    h1, h2 {
        font-weight: normal;
    }
    ul {
        list-style-type: none;
        padding: 0;
    }
    li {
        display: inline-block;
        margin: 0 10px;
    }
    a {
        color: #42b983;
    }
</style>

以上是当用户访问 dashboard 页面看到组件。

以上就是所需的所有组件。

全局安装 Axios

对于服务端请求,我们将使用 axios。axios 是一个基于 promise 的 HTTP 库,适用于浏览器和 Node.js。使用以下命令安装 axios:

$ npm install --save axios

为了让所有组件获取它,打开 ./src/main.js 文件并添加以下内容:

import Vue from 'vue'
import App from './App'
import router from './router'
import Axios from 'axios'

Vue.prototype.$http = Axios

Vue.config.productionTip = false

new Vue({
  el: '#app',
  router,
  components: { App },
  template: '<App/>'
})

通过定义 Vue.prototype.$http = Axios 改变 Vue 以添加 axios。这样我们就能在所有组件通过 this.$http 使用 axios。

运行应用

至此已完成整个应用的开发。由于 Node.js 服务器与 Vue 应用相互依存,需要同时运行。

通过添加脚本,方便启动 Node.js 服务器。打开 package.json 文件并添加以下内容:

[...]
"scripts": {
    "dev": "webpack-dev-server --inline --progress --config build/webpack.dev.conf.js",
    "start": "npm run dev",
    "server": "node server/app",
    "build": "node build/build.js"
  },
[...]

server 脚本是为了便于启动 Node.js 服务器。现在执行以下命令以启动服务器:

$ npm run server

可看到以下类似内容:

run server

创建另一个终端实例并运行 Vue 应用:

$ npm run dev

这将会构建所有资源并启动应用。

home page

login

总结

在本教程中,我们学到了如何使用 vue-router 为路由定义检查条件,防止用户访问特定路由,也学到了如何根据认证状态进行重定向。当然,用户认证是通过我们建立的小型 Node.js 服务器进行处理。

其实,我们所做的访问控制与 Laravel 之类的框架类似。你可以看看 vue-router 还有什么有趣的事情可做。