用户登录(下)
后端 API 处理流程
搭建 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: '登录成功'
})
})
2
3
4
5
6
7
$ curl https://book.youbaobao.xyz:18082/user/login -X POST -d "username=sam&password=123456"
{"code":0,"msg":"登录成功"}
2
3
上述命令可以简写为:
curl https://book.youbaobao.xyz:18082/user/login -d "username=sam&password=123456"
这里我们通过 req.body
获取 POST 请求中的参数,但是没有获取成功,我们需要通过 body-parser
中间件来解决这个问题:
npm i -S body-parser
在 app.js 中加入:
const bodyParser = require('body-parser')
// 创建 express 应用
const app = express()
app.use(bodyParser.urlencoded({ extended: true }))
app.use(bodyParser.json())
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.
这是由于前端部署在 http://localhost:9527
而后端部署在 https://book.youbaobao.xyz:18082
,所以导致了跨域错误,我们需要在 node 服务中添加跨域中间件 cors:
npm i -S cors
然后修改 app.js:
const cors = require('cors')
// ...
app.use(cors())
2
3
4
再次请求即可成功,这里我们在 Network 中会发现发起了两次 https 请求,这是因为由于触发跨域,所以会首先进行 OPTIONS 请求,判断服务端是否允许跨域请求,如果允许才能实际进行请求
TIP
关于为什么要发起 OPTIONS 请求,大家可以参考这篇文档:https://juejin.im/post/5cb3eedcf265da038f7734c4
响应结果封装
在 /user/login
我们看到返回值是:
res.json({
code: 0,
msg: '登录成功'
})
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)
}
})
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
}
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)
}
})
})
2
3
4
5
6
7
8
9
10
11
12
此时即使我们输入正确的用户名和密码仍然无法登录,这是因为密码采用了 MD5 + SALT 加密,所以我们需要对密码进行对等加密,才能查询成功。在 /utils/constant.js
中加入 SALT:
module.exports = {
// ...
PWD_SALT: 'admin_imooc_node',
}
2
3
4
安装 crypto 库:
npm i -S crypto
然后在 /utils/index.js
中创建 md5 方法:
const crypto = require('crypto')
function md5(s) {
// 注意参数需要为 String 类型,否则会出错
return crypto.createHash('md5')
.update(String(s)).digest('hex');
}
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: '登录成功' }
2
3
express-validator
express-validator 是一个功能强大的表单验证器,它是 validator.js 的中间件
使用 express-validator 可以简化 POST 请求的参数验证,使用方法如下:
安装
npm i -S express-validator
验证
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)
}
})
}
})
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
使用
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)
}
})
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失效时间
}
2
3
4
5
前端再次请求,结果如下:
{
"code":0,
"msg":"登录成功",
"data":{
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiaWF0IjoxNTc0NDk1NzA0LCJleHAiOjE1NzQ0OTkzMDR9.9lnxdTn1MmMbKsPvhvRHDRIufbMcUD437CWjnoJsmfo"
}
}
2
3
4
5
6
7
我们可以将该 token 在 jwt.io
网站上进行验证,可以得到如下结果:
{
"username": "admin",
"iat": 1574495704,
"exp": 1574499304
}
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)
}
)
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
创建 /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;
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)
2
3
4
5
6
7
在 /utils/contants.js
中添加:
module.exports = {
// ...
CODE_TOKEN_EXPIRED: -2
}
2
3
4
修改 /model/Result.js
:
expired(res) {
this.code = CODE_TOKEN_EXPIRED
this.json(res)
}
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))
}
})
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)
}
)
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'
})
}
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)
})
})
}
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)
}
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)
}
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)
}
})
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)
}
})
}
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 的实现方法比较简单,所需的技术之前都已经介绍过,大家可以参考之前的文档进行实现