前后端分离的登录验证
我们的程序一般是通过微信扫码来进行登录的,但是在接进前后端分离之后,发现登录验证过程不是很友好,于是查了一些资料。比较推荐用JWT来做一个token的验证实现登录,但是有些文章提到,JWT token会有token失效时间过短造成要重新登录的问题。考虑到这个,参考一些文章在jwt的基础上添加了auth2.0中的refresh token的机制。
关于代码
我们的前后端架构是flask + npm + iview。
验证流程图
为方便理解整个过程的逻辑,特画了下面这个图。
实现代码
JWT token 生成模块
jwt不在这里详解,可以查阅相关资料,构造为 header,payload和Signature。我的代码中是通过itsdangerous模块的TimedJSONWebSignatureSerializer这个JWT生成器来生成jwt token的
access_token,用于登录验证的
from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
def genAccessToken(workId,expires=86400):
s = Serializer(
secret_key=current_app.config['SECRET_KEY'],
expires_in=expires
)
return s.dumps({
'workId': workId,
'iat': time.time()
})
secret_key 是生成 Signature的加密字符串 ,用于签证
expires_in 是这个token的过期时间,这里设置为86400秒,即一天
dumps函数中的字典结构是jwt的payload部分,也是我们的有效信息载体部分
refresh_token,用于刷新access_token
鉴于access_token有超时时间,而且为了安全,access_token的超时时间不能过于太长,所以参照auth2.0 的 refresh token机制,这里也添加一个refresh token来讲access_token进行刷新,超时时间为5天
def genRefreshToken(workId,expires=432000):
s = Serializer(
secret_key=current_app.config['SECRET_KEY'],
expires_in=expires
)
return s.dumps({
'workId': workId,
'iat': time.time()
})
超时时间设置为5天,也就是说,在refresh token生成后的5天内,access_token一旦超时,那么将会重新生成一个新的access token用于验证
前端的登录信息存储
我们用vuex这个前端的状态管理 来存储验证后的用户信息和验证信息
store/module/user.js:
state: {
userName: '',
firstName: '',
workId: '',
access_token: getAccessToken(),
refresh_token: getRefreshToken(),
access: ['super_admin'],
hasGetInfo: false
},
为避免关闭页面后,token被销毁,我们把access token 和 refresh token存放在浏览器的缓存中
libs/util.js:
export const TOKEN_KEY = 'access_token'
export const REFRESH_TOKEN_KEY = 'refresh_token'
export const setAccessToken = (token) => {
Cookies.set(TOKEN_KEY, token, {expires: config.cookieExpires || 1})
}
export const getAccessToken = () => {
const token = Cookies.get(TOKEN_KEY)
if (token) return token
else return false
}
export const setRefreshToken = (token) => {
Cookies.set(REFRESH_TOKEN_KEY, token, {expires: config.cookieExpires || 5})
}
export const getRefreshToken = () => {
const token = Cookies.get(REFRESH_TOKEN_KEY)
if (token) return token
else return false
}
前端的登录,路由钩子beforeEach
在路由钩子beforeEach中,判断是否有登录信息
router/index.js:
router.beforeEach((to, from, next) => {
iView.LoadingBar.start()
const token = getAccessToken()
if (!token && to.name !== LOGIN_PAGE_NAME) {
// 未登录且要跳转的页面不是登录页
next({
name: LOGIN_PAGE_NAME // 跳转到登录页
})
} else if (!token && to.name === LOGIN_PAGE_NAME) {
// 未登陆且要跳转的页面是登录页
next() // 跳转
} else if (token && to.name === LOGIN_PAGE_NAME) {
// 已登录且要跳转的页面是登录页
next({
name: homeName // 跳转到homeName页
})
} else {
if (store.state.user.hasGetInfo) {
turnTo(to, store.state.user.access, next)
} else {
store.dispatch('getUserInfo')
.then(user => {
// 拉取用户信息,通过用户权限和跳转的页面的name来判断是否有权限访问;access必须是一个数组,如:['super_admin'] ['super_admin', 'admin']
turnTo(to, store.state.user.access, next)
})
.catch(() => {
setAccessToken('')
next({
name: 'login'
})
})
}
}
})
当在状态管理中没有找到登录信息后,跳到/login到flask进行登录验证
后端flask login接口
login接口在处理一下信息后生成两个token返回给前端
@blueprint.route('/login', methods=['GET'])
def login():
resp_data = json.dumps({
'token': genAccessToken(workId).decode("utf-8"),
'refresh_token': genRefreshToken(workId).decode("utf-8")
})
return Response(response=resp_data, status=200, mimetype="application/json")
前端收到token, 存放到缓存中
handlePolarLogin({ commit },info) {
return new Promise((resolve, reject) => {
polarLogin(info).then(res => {
const data = res.data
commit('setAccessToken',data.token)
commit('setRefreshToken',data.refresh_token)
resolve(res)
}).catch(err => {
reject(err)
})
})
}
使用access token获取数据过程分析
前端在拿到access token之后,后续前端获取数据的请求中都要带上access token。后端的接口则需要判断access token是否超时,payload中的信息是否正确。
前端请求携带access token
为了让每个请求都带上access token,需要在前端的请求拦截器中将access token放到请求的header中
class HttpRequest {
...
getInsideConfig () {
const config = {
baseURL: this.baseUrl,
headers: {
'Authorization': store.state.user.access_token
}
}
return config
}
后端验证access token
后端收到请求后,需要验证access token,因为大部分接口都需要验证,所以我们可以将这个验证过程写成一个装饰器
def accessTokenAuth(func):
@wraps(func)
def wrapper(*args,**kwargs):
token = request.headers.get('Authorization')
if not token:
return jsonify(u'access token 不存在验证信息!'), 251
s = Serializer(
secret_key=current_app.config['SECRET_KEY']
)
try:
data = s.loads(token)
except SignatureExpired:
return jsonify(u'access token超时!'), 253
except BadSignature as e:
encoded_payload = e.payload
if encoded_payload is not None:
try:
s.load_payload(encoded_payload)
except BadData:
return jsonify(u'access token被篡改!'), 251
return jsonify(u'access token错误的验证信息!'), 251
except:
return jsonify(u'access token验证失败,未知的错误!'), 251
if ('workId' not in data):
return jsonify(u'access token错误的信息载体!'), 251
if func.__name__ == 'getUserInfo':
return func(int(data['workId']))
else:
return func(*args,**kwargs)
return wrapper
从request的头部信息中获取access token, 通过secret_key解密获取信息进行验证。
这里我们根据不同的验证结果定义了251和253的状态码,方便标识。
前端请求拦截器处理response
class HttpRequest {
...
interceptors (instance, url, options) {
...
// 响应拦截
instance.interceptors.response.use(res => {
let { data, status } = res
// 检查flask后台的接口状态
if (status && status === 253) {
// access_token 超时
this.refresh = true
store.dispatch('handleCheckRefreshToken').then(res => {
// 重新刷新当前页面
this.request(options)
history.go(0)
return Promise.reject(new Error("token超时刷新"))
},error => {
return Promise.reject(error)
})
}
this.destroy(url)
if (status && [250,251,252].includes(status)) {
// 登出 登录
store.commit('setAccessToken','')
store.commit('setRefreshToken','')
router.push({name: 'login'})
return Promise.reject(data)
}
...
检测返回的状态码,如果为 253,说明access token超时,需要刷新access token;如果为251,说明验证不通过,则需要重置token,重新登录
刷新 access token
当状态码为253时,前端需要触发进行access token刷新,这个时候需要用到refresh token
handleCheckRefreshToken({ state, commit }) {
return new Promise((resolve, reject) => {
checkRefreshToken(state.refresh_token).then(res => {
const data = res.data
commit('setAccessToken',data.token)
resolve(res)
}).catch(err => {
reject(err)
})
})
},
后端验证refresh token
@blueprint.route('/refreshTokenAuth', methods=['POST'])
def checkRefreshToken():
data = json.loads(request.data)
code,info = refreshTokenAuth(data['token'])
if code:
resp_data = json.dumps({
'token': genAccessToken(int(info)).decode("utf-8")
})
return Response(response=resp_data, status=200, mimetype="application/json")
else:
return jsonify(info), 252
def refreshTokenAuth(token):
if not token:
return False,u'refresh token不存在验证信息!'
s = Serializer(
secret_key=current_app.config['SECRET_KEY']
)
try:
data = s.loads(token)
except SignatureExpired:
return False,u'refresh token超时!'
except BadSignature as e:
encoded_payload = e.payload
if encoded_payload is not None:
try:
s.load_payload(encoded_payload)
except BadData:
return False,u'refresh token被篡改!'
return False,u'refresh token错误的验证信息!'
except:
return False,u'refresh token验证失败,未知的错误!'
if ('workId' not in data):
return False,u'refresh token错误的信息载体!'
return True,data['workId']
refresh在验证通过之后,会生成新的access token返回给前端;如果没通过或者超时,则会返回252状态码
前端保存新的token
前端收到新的token之后,会把之前的请求重新发送一次,确保之前的请求成功,然后刷新页面:
if (status && status === 253) {
// access_token 超时
this.refresh = true
store.dispatch('handleCheckRefreshToken').then(res => {
// 重新刷新当前页面
this.request(options)
history.go(0)
return Promise.reject(new Error("token超时刷新"))
},error => {
return Promise.reject(error)
})
}
整个过程就结束了
结束语
这种方式的验证并不是说绝对安全的,只是有效降低了风险。