Fork me on GitHub
余鸢

微信公众号开发(九):用户登录

用户建模

添加登陆入口,通过这个入口让管理员登录进去做商品系列操作,比如添加商品,修改商品等等,这就需要管理员权限,把管理员权限放到用户的操作文件中去。首先给用户建模,将数据存入数据库中。

server/database/schema/user.js

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
const UserSchema = new UserSchema({
role: {
type: String,
default: 'user'
},
openid: [String],
unionid: String,
nickname: String,
address: String,
province: String,
country: String,
city: String,
sex: String,
email: String,
password: String,
hashed_password: String,
loginAttempts: {
type: Number,
require: true,
default: 0
},
lockUntil: Number,
meta: {
createdAt: {
type: Date,
default: Date.now()
},
updatedAt: {
type: Date,
default: Date.now()
}
}
})

role:角色,例如:管理员
openid:用户openid。微信小程序或者公众号对应的网站,保存用户信息,获取到openid。我们开发的应用如果只针对公众号或者小程序的话,只需要一个openid就可以了,如果项目跨多种产品线时,比如同时包含小程序、公众号、网站,这种情况下openid就不是唯一不变的,在不同的平台下openid是不同的,所以这里暂时把openid设置成数组。
unionId:把小程序或网站绑定到开放平台就能拿到用户另一个id,就是unionId。
nickname:用户名称
address:用户地址
province:省份
country:国家
city:用户所在城市

sex:性别

hashed_password:加盐后的密码值

loginAttempts:登录次数
lockUntil:用户登录超过限定次数,锁定账户

虚拟字段

增加虚拟字段,这个字段不会真正的存入数据库,只是在每次解除数据时通过virtual拿到虚拟的字段。虚拟是指可以获取和设置但不会持久保存到MongoDB的文档属性。

通过两次取反,判断lockUntil为true同时锁定的时间大于当前时间,说明当前账户还在锁定期内。

1
2
3
UserSchema.virtual('isLocked').get(function () {
return !!(this.lockUntil && this.lockUntil > Date.now())
})

生成创建时间和更新时间

1
2
3
4
5
6
7
UserSchema.pre('save', function (next) {
if (this.isNew) {
this.meta.createdAt = this.meta.updatedAt = Date.now()
} else {
this.meta.updatedAt = Date.now()
}
})

用户密码是否更改

判断用户的密码是否更改,如果更改了对密码进行加密,加密的同时通过bcrypt.gensalt()加盐,可以增加密码的强度

1
2
3
4
5
6
7
8
9
10
11
12
UserSchema.pre('save', function (next) {
let user = this
if (!user.isModified('password')) return next()
bcrypt.genSalt(SALT_WORK_FACTOR, (err, salt) => {
if (err) return next(err)
bcrypt.hash(user.password, salt, (error, hash) => {
if (error) return next(error)
user.password = hash
})
})
})

比较密码

_password代表用户传过来的密码,password代表存入数据库加盐后的密码

1
2
3
4
5
6
7
8
comparePassword: function (_password, password) {
return new Promise((resolve, reject) => {
bcrypt.compare(_password, password, function (err, isMatch) {
if (!err) resolve(isMatch)
else reject(err)
})
})
}

登录次数

用户在登录时账号或密码匹配不成功,将登录次数+1

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
incLoginAttempts: function (user) {
const that = this
return new Promise((resolve, reject) => {
if (that.lockUntil && that.lockUntil < Date.now()) {
that.update({
$set: {
loginAttempts: 1
},
$unset: {
lockUntil: 1
}
}, function (err) {
if (!err) resolve(true)
else reject(err)
})
} else {
let updates = {
$inc: {
loginAttempts: 1
}
}
if (that.loginAttempts + 1 >= MAX_LOGIN_ATTEMPTS && !that.isLocked) {
updates.$set = {
lockUntil: Date.now() + LOCK_TIME
}
}
that.update(updates, err => {
if (!err) resolve(true)
else reject(err)
})
}
})
}

登录页面

pages/login.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<template lang="pug">
.container
.card
.card-header
.card-inner 登录
.card-body
svg.login-icon(width='80px', height='80px', viewbox='0 0 80 80', version='1.1', xmlns='http://www.w3.org/2000/svg', xmlns:xlink='http://www.w3.org/1999/xlink')
defs
rect#path-1(x='0', y='0', width='80', height='80')
g#Page-1(stroke='none', stroke-width='1', fill='none', fill-rule='evenodd')
g#Group-2
mask#mask-2(fill='white')
use(xlink:href='#path-1')
g#Rectangle
g(id='student-(1)', mask='url(#mask-2)', fill-rule='nonzero')
path#Shape(d='...')
.form
input.form-control(v-model='user.email')
input.form-control(type='password', v-model='user.password')
button.btn.login-btn(@click='login') 登录
v-snacbar(:open.sync='openSnackbar')
</template>

登录

实现登录事件

1
2
3
4
5
6
7
8
9
10
11
methods: {
async login() {
let { email, password } = this.user
if (!email || !password) {
this.openSnackbar = true
return ''
}
let res = await this.$store.dispatch('login', this.user)
if (res.success) this.$router.push('/admin')
}
}

设置请求路径和传入的参数,如果数据返回正确,更新数据commit('SET_USER', data.data)

store/action.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
async login({ commit }, { email, password }) {
try {
let res = await axios.post('/admin/login', {
email,
password
})
const { data } = res
if (data.success) commit('SET_USER', data.data)
return data
} catch (e) {
if (e.response.status === 401) {
throw new Error('来错地方了')
}
}
}

登录路由

session中间件

有用户登录就会涉及到session机制,在common增加session中间件

server/middlewares/common.js

1
2
3
4
5
6
7
8
9
10
11
12
13
import session from 'koa-session'
export const addSession = app => {
app.keys = ['nuxtssr']
const CONFIG = {
key: 'koa:sess',
maxAge: 86400000,
overwrite: true,
signed: true,
rolling: false
}
app.use(session(CONFIG, app))
}

通过

客户端传过来的email和password,执行login方法获取数据,如果match为true表示匹配成功也就是当前密码正确,将user同步到当前user

server/routers/admin.js

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
@controller('/admin')
export class adminController {
@post('login')
@required({body: ['email', 'password']})
async login (ctx, next) {
const { email, password } = ctx.request.body
const data = await api.admin.login(email, password)
const { user, match } = data
if (match) {
if (user.role !== 'admin') {
return (ctx.body = {
success: false,
err: '来错地方了'
})
}
ctx.session.user = {
_id: user._id,
email: user.email,
role: user.role,
nickname: user.nickname,
avatarUrl: user.avatarUrl
}
return (ctx.body = {
success: true,
data: {
email: user.email,
nickname: user.nickname,
avatarUrl: user.avatarUrl
}
})
}
return (ctx.body = {
success: false,
err: '密码错误'
})
}
}

@required({body: ['email', 'password']}),添加中间件,表示必须有email和passowrd字段

通过findOne()查找数据库里的用户,将客户端传过来的password和数据库里的password做比较,如果匹配成功返回user数据

server/api/admin.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import mongoose from 'mongoose'
const User = mongoose.model('User')
export async function login(login) {
let match = false
const user = await User.findOne({ email: email }).exec()
if (user) {
match = await user.comparePassword(password, user.password)
}
return {
match,
user
}
}

实现required中间件

接收参数rules,制定参数规则,声明errors来存放错误信息,声明passRules遍历制定的参数规则,如果有错误,给出报错信息,反之执行next()函数

convert:传入中间件middleware做转换工作,转换时将所有的参数(...args)通过decorate()方法将数据做进一步的处理

server/decorator/router.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const decorate = (args, middleware) => {
let [ target, key, descriptor ] = args
target[key] = isArray(target[key])
target[key].unshift(middleware)
return descriptor
}
export const convert = middleware => (...args) => decorate(args, middleware)
export const required = rules => convert(async (ctx, next) => {
let errors = []
const passRules = R.forEachObjIndexed(
(value, key) => {
errors = R.filter(i => !R.has(i, ctx.request[key]))(value)
}
)
passRules(rules)
if (errors.length) ctx.throw(412, `${errors.join(', ')} 参数缺失`)
await next()
})

在浏览器中输入http://localhost:8080/login进入登录页面

测试

将管理员账号和密码通过脚本的方式写入数据库里,就可以测试登录功能

server/middlewares/database.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
mongoose.connection.on('open', async () => {
console.log('数据库连接成功 ', config.db)
...
const User = mongoose.model('User')
...
let user = await User.findOne({
email: 'nuxtssr@112.com'
}).exec()
if (!user) {
console.log('写入管理员数据')
user = new User({
email: 'nuxtssr@112.com',
password: 'nuxtssr',
role: 'admin'
})
await user.save()
}
})

user成功写入数据库,如图:

wx48

登录成功后,让后台页面跳转到某个页面,新建页面作为登录后的后台首页

pages/admin/index.vue

1
2
3
4
<template lang="pug">
.content
h3 登录成功 {{user.email}}
</template>

前端中间件

登录表单提交之后只是在后台做个密码比对返回登录状态,但是这个状态需要被保持,在后台页面上我们可能是点击前端页面刷新,也可能是直接进入另一个页面,这就需要会话方式同步现在的登录状态。新建middleware文件夹用来存放前端中间件

每次打开后台页面都需要经过这个中间件的过滤,如果当前用户没有登录,重定向到登录页面,如果有登录状态就直接进入目标页面

middleware/auth.js

1
2
3
4
5
export default function ({ store, redirect }) {
if (!store.state.user || !store.state.user.email) {
return redirect('/login')
}
}

与服务器同步渲染,会从服务器端将session带过来,判断session是否有用户信息,有的话设置用户信息

store/actions.js

1
2
3
4
5
6
7
8
9
10
11
nuxtServerInit({ commit }, { req }) {
if (req.session && req.session.user) {
const { email, nickname, avatarUrl } = req.session.user
const user = {
email,
nickname,
avatarUrl
}
commit('SET_USER', user)
}
},

设置session

在入口文件设置session,nuxt版本在传递session时可能会拿不到,所以在这里手动设置

server/index.js

1
2
3
4
5
6
7
8
9
10
this.app.use(async (ctx, next) => {
await next()
ctx.status = 200 // koa defaults to 404 when it sees that status is unset
ctx.req.session = ctx.session
return new Promise((resolve, reject) => {
...
})
})
})

每次在页面渲染之前都可以将session同步到request中

测试成功,如图:

wx49

前端微信二跳中间件

之前实现了在微信里授权,也实现了管理员从后台登录,对于管理员或者用户都可以用一套用户模型。我们在微信里打开网页时可以通过微信中间件的处理,让用户和管理员都能存到数据库里。在网页中打开项目首页可以访问,但是放到微信里做些活动页面或者购买行为时要有个中间跳转页面,让我们拿到微信用户资料存到数据库里,为session做持久化。实现从微信跳转时通过换取code过程识别来访用户的功能

微信中间件

每次打开首页时,判断authUser没有的话直接跳转到获取authUser的地址

middleware/wechat-auth.js

1
2
3
4
5
6
7
export default function ({ store, route, redirect }) {
if (!sotre.state.authUser) {
let { fullPath } = route
fullPath = encodeURIComponent(fullPath.substr(1))
return redirect(`/wx-redirect?visit=#{fullPath}`)
}
}

route为当前的路由,也就是当前要访问的全路径

在首页中增加微信中间件

pages/index.vue

1
2
3
export default {
middleware: 'wechat-auth'
}

设置authUser

store/index.js

1
authUser: null

设置微信用户

store/actions.js

1
2
3
setAuthUser({ commit }, authUser) {
commit('SET_AUTHUSER', authUser)
},

store/mutations.js

1
2
3
SET_AUTHUSER: (state, authUser) => {
state.authUser = authUser
}

跳转路由

通过wechat路由的wxRedirect()跳转到目标地址,跳转之后拿到code,再用code获取用户的openid,通过API的接口拿到url,跳转到目标地址后通过ctx.session更新用户信息

server/controllers/wechat.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export async function redirect (ctx, next) {
const target = config.SITE_ROOT_URL + '/oauth'
const scope = 'snsapi_userinfo'
// 参数
const { visit, id } = ctx.query
const params = id ? `${visit}_${id}` : visit
const url = api.wechat.getAuthorizeURL(scope, target, params)
ctx.redirect(url)
}
export async function oauth (ctx, next) {
...
const user = await api.wechat.getUserByCode(code)
ctx.session.user = user
ctx.body = {
success: true,
user: user
}
}

同步session之前存入用户

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
export async function getUserByCode (code) {
const oauth = getOAuth()
const data = await oauth.fetchAccessToken(code)
const user = await oauth.getUserInfo(data.access_token, data.openid)
const existUser = await User.findOne({openid: data.openid}).exec()
if (!existUser) {
let newUser = new User({
openid: [data.openid],
unionid: data.unionid,
unionid: data.unionid,
nickname: user.nickname,
province: user.province,
country: user.country,
city: user.city,
headimgurl: user.headimgurl,
sex: user.sex
})
await newUser.save()
}
return user
}

注意:如果只是面向一种应用,比如只开发公众号或者小程序,只需要通过openid拿到用户资料,如果通过开放平台绑定了小程序、公众号,那么用户id就可以通过unionid来拿到用户资料

修改页面

将auth.vue作为跳板页面进行目标跳转,解析当前的url,解析后跳转到目标地址,这时session中已经有用户信息。在页面加载之前通过beforeMount获取到location里的url拿到数据,如果success为true,将数据设置到当前用户更新用户信息,通过getUrlParam()解析state,并以_拼接参数

pages/oauth.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
export default {
head() {
return {
title: `loading`
}
},
// 组件安装之前拿到wx
async beforeMount() {
const url = window.location.href
const { data } = await this.$store.dispatch('getWechatOAuth', url)
console.log(data)
if (data.success) {
await this.$store.dispatch('setAuthUser', data.data)
const paramsArr = getUrlParam('state').split('_')
const visit = paramsArr.length === 1 ? `/${paramsArr[0]}` : `/${paramsArr[0]}?id=${paramsArr[1]}`
this.$router.replace(visit)
} else {
throw new Error('用户信息获取失败')
}
}
}

store/actions.js

1
2
3
getUserOAuth({ commit }, url) {
return Services.getWechOAuth(url)
},

store/services.js

1
2
3
getWechOAuth(url) {
return axios.get(`${baseUrl}/wx-oauth?url=${encodeURIComponent(url)}`)
}