用户登录(下)

后端 API 处理流程

login_api.png

搭建 https 服务

详见 搭建 https 服务

创建 /user/login API

router/user.js 中填入以下代码:

router.post('/login', function(req, res, next) {
  console.log('/user/login', req.body)
  res.json({
    code: 0,
    msg: '登录成功'
  })
})
1
2
3
4
5
6
7
$ curl https://book.youbaobao.xyz:18082/user/login -X POST -d "username=sam&password=123456"

{"code":0,"msg":"登录成功"}
1
2
3

上述命令可以简写为:

curl https://book.youbaobao.xyz:18082/user/login -d "username=sam&password=123456"
1

这里我们通过 req.body 获取 POST 请求中的参数,但是没有获取成功,我们需要通过 body-parser 中间件来解决这个问题:

npm i -S body-parser
1

在 app.js 中加入:

const bodyParser = require('body-parser')

// 创建 express 应用
const app = express()

app.use(bodyParser.urlencoded({ extended: true }))
app.use(bodyParser.json())
1
2
3
4
5
6
7

TIP

关于 body-parser 的实现原理与细节可以参考这篇文档,说得非常明白:https://juejin.im/post/59222c5d2f301e006b1616ae

返回前端使用登录按钮请求登录接口,发现控制台报错:

Access to XMLHttpRequest at 'https://book.youbaobao.xyz:18082/user/login' from origin 'http://localhost:9527' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.
1

这是由于前端部署在 http://localhost:9527 而后端部署在 https://book.youbaobao.xyz:18082,所以导致了跨域错误,我们需要在 node 服务中添加跨域中间件 cors:

npm i -S cors
1

然后修改 app.js:

const cors = require('cors')

// ...
app.use(cors())
1
2
3
4

再次请求即可成功,这里我们在 Network 中会发现发起了两次 https 请求,这是因为由于触发跨域,所以会首先进行 OPTIONS 请求,判断服务端是否允许跨域请求,如果允许才能实际进行请求

TIP

关于为什么要发起 OPTIONS 请求,大家可以参考这篇文档:https://juejin.im/post/5cb3eedcf265da038f7734c4

响应结果封装

/user/login 我们看到返回值是:

res.json({
  code: 0,
  msg: '登录成功'
})
1
2
3
4

之后我们还要定义错误返回值,但如果每个接口都编写以上代码就显得非常冗余,而且不易维护,比如我们要将 code 默认值从 0 改为 1,就要修改每个接口,所以我们创建一个 Result 类来解决这个问题,详见 node 响应结果封装

有了 Result 类后,我们可以将登录 API 改为:

router.post('/login', function(req, res, next) {
  const username = req.body.username
  const password = req.body.password

  if (username === 'admin' && password === '123456') {
    new Result('登录成功').success(res)
  } else {
    new Result('登录失败').fail(res)
  }
})
1
2
3
4
5
6
7
8
9
10

如果在响应前抛出 Error,此时 Error 将被我们自定义的异常处理捕获,并返回 500 至前端

登录用户数据库查询

响应过程封装完毕后,我们需要在数据库中查询用户信息来验证用户名和密码是否准确,mysql 入门详见 mysql

这里我们需要基于 mysql 查询库封装一层 service,用来协调业务逻辑和数据库查询,我们不希望直接把业务逻辑写在 router 中,创建 /service/user.js

const { querySql } = require('../db')

function login(username, password) {
  const sql = `select * from admin_user where username='${username}' and password='${password}'`
  return querySql(sql)
}

module.exports = {
  login
}
1
2
3
4
5
6
7
8
9
10

改造 /user/login API:

router.post('/login', function(req, res, next) {
  const username = req.body.username
  const password = req.body.password

  login(username, password).then(user => {
    if (!user || user.length === 0) {
      new Result('登录失败').fail(res)
    } else {
      new Result('登录成功').success(res)
    }
  })
})
1
2
3
4
5
6
7
8
9
10
11
12

此时即使我们输入正确的用户名和密码仍然无法登录,这是因为密码采用了 MD5 + SALT 加密,所以我们需要对密码进行对等加密,才能查询成功。在 /utils/constant.js 中加入 SALT:

module.exports = {
  // ...
  PWD_SALT: 'admin_imooc_node',
}
1
2
3
4

安装 crypto 库:

npm i -S crypto
1

然后在 /utils/index.js 中创建 md5 方法:

const crypto = require('crypto')

function md5(s) {
  // 注意参数需要为 String 类型,否则会出错
  return crypto.createHash('md5')
    .update(String(s)).digest('hex');
}
1
2
3
4
5
6
7

再次输入正确的用户名和密码,查询成功:

select * from admin_user where username='admin' and password='91fe0e80d07390750d46ab6ed3a99316'
查询成功 [{"id":1,"username":"admin","password":"91fe0e80d07390750d46ab6ed3a99316","role":"admin","nicknamedmin","avatar":"https://www.youbaobao.xyz/mpvue-res/logo.jpg"}]
{ code: 0, msg: '登录成功' }
1
2
3

express-validator

express-validator 是一个功能强大的表单验证器,它是 validator.js 的中间件

使用 express-validator 可以简化 POST 请求的参数验证,使用方法如下:

安装

npm i -S express-validator
1

验证

const { body, validationResult } = require('express-validator')
const boom = require('boom')

router.post(
  '/login',
  [
    body('username').isString().withMessage('username类型不正确'),
    body('password').isString().withMessage('password类型不正确')
  ],
  function(req, res, next) {
    const err = validationResult(req)
    if (!err.isEmpty()) {
      const [{ msg }] = err.errors
      next(boom.badRequest(msg))
    } else {
      const username = req.body.username
      const password = md5(`${req.body.password}${PWD_SALT}`)

      login(username, password).then(user => {
        if (!user || user.length === 0) {
          new Result('登录失败').fail(res)
        } else {
          new Result('登录成功').success(res)
        }
      })
    }
  })
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

express-validator 使用技巧:

  • router.post 方法中使用 body 方法判断参数类型,并指定出错时的提示信息
  • 使用 const err = validationResult(req) 获取错误信息,err.errors 是一个数组,包含所有错误信息,如果 err.errors 为空则表示校验成功,没有参数错误
  • 如果发现错误我们可以使用 next(boom.badRequest(msg)) 抛出异常,交给我们自定义的异常处理方法进行处理

JWT 基本概念

详见 jwt

生成 JWT Token

安装 jsonwebtoken

npm i -S jsonwebtoken
1

使用

const jwt = require('jsonwebtoken')
const { PRIVATE_KEY, JWT_EXPIRED } = require('../utils/constant')

login(username, password).then(user => {
    if (!user || user.length === 0) {
      new Result('登录失败').fail(res)
    } else {
      const token = jwt.sign(
        { username },
        PRIVATE_KEY,
        { expiresIn: JWT_EXPIRED }
      )
      new Result({ token }, '登录成功').success(res)
    }
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

这里需要定义 jwt 的私钥和过期时间,过期时间不宜过短,也不宜过长,课程里设置为 1 小时,实际业务中可根据场景来判断,通常建议不超过 24 小时,保密性要求高的业务可以设置为 1-2 小时:

module.exports = {
  // ...
  PRIVATE_KEY: 'admin_imooc_node_test_youbaobao_xyz',
  JWT_EXPIRED: 60 * 60, // token失效时间
}
1
2
3
4
5

前端再次请求,结果如下:

{
  "code":0,
  "msg":"登录成功",
  "data":{
    "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiaWF0IjoxNTc0NDk1NzA0LCJleHAiOjE1NzQ0OTkzMDR9.9lnxdTn1MmMbKsPvhvRHDRIufbMcUD437CWjnoJsmfo"
  }
}
1
2
3
4
5
6
7

我们可以将该 token 在 jwt.io 网站上进行验证,可以得到如下结果:

{
  "username": "admin",
  "iat": 1574495704,
  "exp": 1574499304
}
1
2
3
4
5

可以看到 username 被正确解析,说明 token 生成成功

前端登录请求改造

修改 src/utils/request.js

service.interceptors.response.use(
  response => {
    const res = response.data

    if (res.code !== 0) {
      Message({
        message: res.msg || 'Error',
        type: 'error',
        duration: 5 * 1000
      })
      // 判断 token 失效的场景
      if (res.code === -2) {
        // 如果 token 失效,则弹出确认对话框,用户点击后,清空 token 并返回登录页面
        MessageBox.confirm('Token 失效,请重新登录', '确认退出登录', {
          confirmButtonText: '重新登录',
          cancelButtonText: '取消',
          type: 'warning'
        }).then(() => {
          store.dispatch('user/resetToken').then(() => {
            location.reload()
          })
        })
      }
      return Promise.reject(new Error(res.msg || '请求失败'))
    } else {
      return res
    }
  },
  error => {
    let message = error.message || '请求失败'
    if (error.response && error.response.data) {
      const { data } = error.response
      message = data.msg
    }
    Message({
      message,
      type: 'error',
      duration: 5 * 1000
    })
    return Promise.reject(error)
  }
)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42

JWT 认证

安装 express-jwt

npm i -S express-jwt
1

创建 /router/jwt.js

const expressJwt = require('express-jwt');
const { PRIVATE_KEY } = require('../utils/constant');

const jwtAuth = expressJwt({
  secret: PRIVATE_KEY,
  credentialsRequired: true // 设置为false就不进行校验了,游客也可以访问
}).unless({
  path: [
    '/',
    '/user/login'
  ], // 设置 jwt 认证白名单
});

module.exports = jwtAuth;
1
2
3
4
5
6
7
8
9
10
11
12
13
14

/router/index.js 中使用中间件

const jwtAuth = require('./jwt')

// 注册路由
const router = express.Router()

// 对所有路由进行 jwt 认证
router.use(jwtAuth)
1
2
3
4
5
6
7

/utils/contants.js 中添加:

module.exports = {
  // ...
  CODE_TOKEN_EXPIRED: -2
}
1
2
3
4

修改 /model/Result.js

expired(res) {
  this.code = CODE_TOKEN_EXPIRED
  this.json(res)
}
1
2
3
4

修改自定义异常:

router.use((err, req, res, next) => {
  if (err.name === 'UnauthorizedError') {
    new Result(null, 'token失效', {
      error: err.status,
      errorMsg: err.name
    }).expired(res.status(err.status))
  } else {
    const msg = (err && err.message) || '系统错误'
    const statusCode = (err.output && err.output.statusCode) || 500;
    const errorMsg = (err.output && err.output.payload && err.output.payload.error) || err.message
    new Result(null, msg, {
      error: statusCode,
      errorMsg
    }).fail(res.status(statusCode))
  }
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

前端传入 JWT Token

后端添加路由的 jwt 认证后,再次请求 /user/info 将抛出 401 错误,这是由于前端未传递合理的 Token 导致,下面我们就修改 /utils/request.js,使得前端请求时可以传递 Token:

service.interceptors.request.use(
  config => {
    // 如果存在 token 则附带在 http header 中
    if (store.getters.token) {
      config.headers['Authorization'] = `Bearer ${getToken()}`
    }
    return config
  },
  error => {
    return Promise.reject(error)
  }
)
1
2
3
4
5
6
7
8
9
10
11
12

前端去掉 /user/info 请求时传入的 token,因为我们已经从 token 中传入,修改 src/api/user.js

export function getInfo() {
  return request({
    url: '/user/info',
    method: 'get'
  })
}
1
2
3
4
5
6

用户查询 /user/info API

/db/index.js 中添加:

function queryOne(sql) {
  return new Promise((resolve, reject) => {
    querySql(sql)
      .then(results => {
        if (results && results.length > 0) {
          resolve(results[0])
        } else {
          resolve(null)
        }
      })
      .catch(error => {
        reject(error)
      })
  })
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

/services/user.js 中添加:

function findUser(username) {
  const sql = `select * from admin_user where username='${username}'`
  return queryOne(sql)
}
1
2
3
4

此时有个问题,前端仅在 Http Header 中传入了 Token,如果通过 Token 获取 username 呢?这里就需要通过对 JWT Token 进行解析了,在 /utils/index.js 中添加 decode 方法:

const jwt = require('jsonwebtoken')
const { PRIVATE_KEY } = require('./constant')

function decode(req) {
  const authorization = req.get('Authorization')
  let token = ''
  if (authorization.indexOf('Bearer') >= 0) {
    token = authorization.replace('Bearer ', '')
  } else {
    token = authorization
  }
  return jwt.verify(token, PRIVATE_KEY)
}
1
2
3
4
5
6
7
8
9
10
11
12
13

修改 /router/user.js

router.get('/info', function(req, res) {
  const decoded = decode(req)
  if (decoded && decoded.username) {
    findUser(decoded.username).then(user => {
      if (user) {
        user.roles = [user.role]
        new Result(user, '获取用户信息成功').success(res)
      } else {
        new Result('获取用户信息失败').fail(res)
      }
    })
  } else {
    new Result('用户信息解析失败').fail(res)
  }
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

此时在前端重新登录,登录终于成功了!

修改 Logout 方法

修改 src/store/modules/user.js

logout({ commit, state, dispatch }) {
    return new Promise((resolve, reject) => {
      try {
        commit('SET_TOKEN', '')
        commit('SET_ROLES', [])
        removeToken()
        resetRouter()
        // reset visited views and cached views
        // to fixed https://github.com/PanJiaChen/vue-element-admin/issues/2485
        dispatch('tagsView/delAllViews', null, { root: true })
        resolve()
      } catch (e) {
        reject(e)
      }
    })
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

关于 RefreshToken

如果你的场景需要授权给第三方 app,那么通常我们需要再增加一个 RefreshToken 的 API,该 API 的用途是根据现有的 Token 获取用户名,然后生成一个新的 Token,这样做的目的是为了防止 Token 失效后退出登录,所以 app 一般会在打开时刷新一次 Token,该 API 的实现方法比较简单,所需的技术之前都已经介绍过,大家可以参考之前的文档进行实现

上次更新: 11/23/2019, 5:01:07 PM