前後端分離的登錄驗證
我們的程序一般是通過微信掃碼來進行登錄的,但是在接進前後端分離之後,發現登錄驗證過程不是很友好,於是查了一些資料。比較推薦用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)
})
}
整個過程就結束了
結束語
這種方式的驗證並不是說絕對安全的,只是有效降低了風險。