本文涉及的有:
- 前端 request 類
- 前端 有關許可權認證的 js 封裝
- 多平臺使用者的處理方式
- 後端 基於laravel/jwt-auth 的許可權中介軟體
- 實現獲取手機號登陸
- 後端 jwt 異常捕獲
- 實現無感知重新整理 access_token
開發環境說明
uni-app
+Hbuilder
基於 vue 的聚合開發laravel 5.7
+jwt-auth 1.0.0
luch-request
前端 request 庫專案定位
多小程式平臺,可能還有 h5 站使用者,不涉及微博等第三方登陸,因為各小程式都開放了獲取手機號,所以使用手機號作為使用者的聯合標識。前端 request 類封裝
一個封裝較不錯的 request 類luch-request
,支援動態修改配置、攔截器,在 uni-app 外掛市場可以找到。
request.js 不需要動,我們只需要自定義 index.js,接下來根據需求補充。有關許可權認證的 js 封裝
storge.js
我將 storge 儲存、獲取、清除的方法單獨封裝一個 js 檔案,因為官方文件中呼叫 uni.getStorageSync
的寫法是用 try{}catch(){}
包圍,但並不是必要的,畢竟會佔用包的體積和檔案數量,說實話挺雞肋的。
//同步獲取storge const getStorageSync = (key)=>{ let value=''; try {value = uni.getStorageSync(key);} catch (e) {} return value; } //同步儲存storge const setStorageSync = (key,value)=>{ try {uni.setStorageSync(key, value);return true;} catch (e) {} return false; } //同步移除指定的 key const removeStorageSync = (key)=>{ try {uni.removeStorageSync(key);return true;} catch (e) {} return false; } //同步清理所有 const clearStorageSync = (key,value)=>{ try {uni.clearStorageSync();return true;} catch (e) {} return false; } export default {getStorageSync,setStorageSync,removeStorageSync,clearStorageSync}
有關授權相關的方法
authorize.js
包括獲取服務商
、獲取登陸態
、登陸獲取code
、獲得授權列表
、開啟授權列表
、獲取使用者資訊
。需要說明的是,目前我只使用了登陸獲取code
,其他還未涉及,根據需要大家可自行修改。
// #ifndef H5 const getProvider = (service) => { return new Promise((resolve, reject) => { if(!service){ service = 'oauth' }// 預設登入授權 uni.getProvider({ service: service,//oauth登入 share分享 payment支付 push推送 success: function(res) {resolve(res)}, fail:function() { reject("獲取服務商失敗") } }); }) } // #endif // #ifdef MP-WEIXIN || MP-BAIDU || MP-TOUTIAO || MP-QQ const checkSession=()=>{ return new Promise((resolve,reject) => { const user = uni.getStorageSync('user');//使用者快取資訊 if(user){ uni.checkSession({ success() {resolve(user);}//狀態未過期 ,fail() {resolve(false);}//狀態已過期 }) }else{resolve(false);}//未存貯 }) } // #endif // #ifndef H5 const login = provider => { return new Promise((resolve, reject) => { uni.login({ provider: provider, success: function(loginRes) { if (loginRes && loginRes.code) { resolve(loginRes.code) } else { reject("獲取code失敗") } }, fail:function(){ reject("獲取code失敗")} }); }) } // #endif // #ifndef H5 const getUserInfo = (provider)=>{ return new Promise( (resolve,reject)=>{ // if (!provider) { reject("獲取缺少provider引數");return; } uni.getUserInfo({ provider: provider, success: (detail) => { if(detail.iv != ''){resolve(detail);//授權 }else{reject(0);}//拒絕授權 } ,fail: (error) => {reject(0);}//如果使用者之前已拒絕,直接走這裡 }); }) } // #endif // #ifdef MP const getSetting = function(scope) { return new Promise((resolve, reject) => { uni.getSetting({ success: function(res) { if (res.authSetting[scope]) {resolve(1);return;} //授權成功 if (res.authSetting[scope] === false) {resolve(0);return;} //拒絕授權 resolve(2) //未操作 }, fail: function() {reject("獲取使用者授權失敗")} }) }) } // #endif // #ifdef MP const openSetting = function() { return new Promise((resolve, reject) => { uni.openSetting({ success(res) {resolve(res.authSetting);return;}, fail: function() {reject("開啟授權失敗")} }) }) } // #endif export default { getProvider, //獲取服務提供商 checkSession, //檢視登入狀態 login,//登入獲取code getSetting,//獲得授權列表 openSetting,//開啟授權介面 getUserInfo//獲取使用者資訊 }
jwt.js
這個 js 是專門處理 access_token 的,程式碼不多,也很簡單。
import storage from '@/utils/storage.js'; const tokenKey = 'accesstoken';//鍵值 const getAccessToken = function(){ return 'Bearer '+ storage.getStorageSync(tokenKey);//獲取 } const setAccessToken = (access_token) => { storage.setStorageSync(tokenKey, access_token);//儲存 } const clearAccessToken = function(){ return storage.removeStorageSync(tokenKey);//清除 } export default { getAccessToken,setAccessToken,clearAccessToken }
多平臺使用者的處理
首先針對兩個問題說明下,也是我糾結了挺久的事。
1,通常使用 jwt 的印象是賬號密碼註冊,然後登陸。不同的是小程式是沒有賬號密碼的,所以它是沒有註冊的,只有登陸。
2,多平臺比如微信小程式、百度小程式等、h5。看了較多文章,大體思路是有一個聯合賬號表記錄唯一使用者用於聯合各平臺賬號,使用手機號辨別,還有一個表就是各平臺的user
表,前者與後者是一對多的關係。那麼問題是jwt
攜帶哪個表的id
呢?我使用的是user
表,而聯合賬號表僅當作附表,需要時才會用到,因為專案本身使用者的粘合度比較小,對同一使用者在不同平臺的資料做整合的需求不大。
獲取手機號並登陸
- 自定義 request 類
1.1 識別使用者來自哪個平臺。
1.2 公開請求不需要攜帶 token, 需要使用者登陸的介面需要攜帶 token
1.3 注意響應攔截器裡實現了使用者無感知重新整理
全域性掛載 httpimport Request from './request'; import jwt from '@/utils/auth/jwt.js'; const http = new Request(); const baseUrl = 'http://xxx'; var platform = ''; // #ifdef MP-BAIDU platform = 'baidu'; // #endif /* 設定全域性配置 */ http.setConfig((config) => { config.baseUrl = baseUrl; //設定 api 地址 config.header = { ...config.header } return config }) /* 請求之前攔截器 */ http.interceptor.request((config, cancel) => { if (!platform) {cancel('缺少平臺引數');} config.header = { ...config.header, platform:platform } if (config.custom.auth) { config.header.Authorization = jwt.getAccessToken(); } return config }) /* 請求之後攔截器 */ http.interceptor.response(async (response) => { if (response.data.code !== 0) { // 服務端返回的狀態碼不等於200,則reject() if(response.config.custom.auth){ // 當後端返回6666狀態碼時代表token過期並返回新的token if( response.data.code == 6666 ){ //console.log(response); jwt.setAccessToken(response.data.data.access_token); // 重新請求 使用者無感知 let repeatRes = await http.request(response.config); // console.log(repeatRes) if ( repeatRes ) { response = repeatRes; } } }else{ return Promise.reject(response) } } return response }, (response) => { // 請求錯誤做點什麼 return response }) export { http }
import Vue from 'vue' import App from './App' import { http } from '@/utils/luch/index.js' Vue.prototype.$http = http
登陸頁面
<template> <view> <button type="default" open-type="getPhoneNumber" @getphonenumber="decryptPhoneNumber">獲取手機號</button> <button @tap="me">獲取使用者資料</button> <button @tap="clear">清除使用者資料</button> </view> </template> <script> import authorize from '@/utils/auth/authorize.js'; import jwt from '@/utils/auth/jwt.js'; var _self; export default{ data() { return {} }, onLoad(option) { _self = this; }, methods: { decryptPhoneNumber: function(e){ // console.log(e.detail); if( e.detail.errMsg == "getPhoneNumber:ok" ){ //成功 authorize.login().then(code=>{ e.detail.code = code; //console.log(e.detail); return _self.$http.post('/api/auth/login',e.detail); }) .then(res=>{ //console.log(res,'success'); jwt.setAccessToken(res.data.data.access_token); //console.log(jwt.getAccessToken()) uni.showToast({ icon: 'success', title: '登入成功', duration: 2000 }); }) .catch(err=>{ console.log(err,'error'); uni.showToast({ icon: 'none', title: err.data.msg, duration: 2000 }); }) } }, me: function(){ _self.$http.get('/api/auth/me',{custom: {auth: true}}).then(res=>{ console.log(res,'success') //console.log(jwt.getAccessToken()) }).catch(err=>{ console.log(err,'error') //console.log(jwt.getAccessToken()) }) }, clear: function(){ let res = jwt.clearAccessToken(); if(res){ uni.showToast({ icon: 'success', title: '清除成功', duration: 2000 }); } } }, components: {} } </script> <style> </style>
後端編寫,僅以百度小程式為例
解密類
<?php namespace App\Library; use App\Library\Y; class BdDataDecrypt { private $_appid; private $_app_key; private $_secret; private $_session_key; public function __construct() { $this->_appid = env('BDAPPID'); $this->_app_key = env('BDKEY'); $this->_secret = env('BDSECRET'); } public function decrypt($encryptedData, $iv, $code){ $res = $this->getSessionKey($code); if($res === false){return false;} $data['openid'] = $res['openid']; $res = $this->handle($encryptedData,$iv,$this->_app_key,$res['session_key']); if($res === false){return false;} $res = json_decode($res,true); $data['mobile'] = $res['mobile']; return $data; } public function getSessionKey($code) { $params['code'] = $code; $params['client_id'] = $this->_app_key; $params['sk'] = $this->_secret; $res = Y::curl("https://spapi.baidu.com/oauth/jscode2sessionkey",$params,0,1); /** * return: * array(3) { ["errno"]=> int(1104) ["error"]=> string(33) "invalid code , expired or revoked" ["error_description"]=> string(33) "invalid code , expired or revoked" } or: array(2) { ["openid"]=> string(26) "z45QjEfvkUaawYlVaajwST5G8w" ["session_key"]=> string(32) "51b9297ababbcf43c1a099256bf82d75" } */ if( isset($res['error']) ){ return false; } return $res; } /** * return string(24) "{"mobile":"15912341111"}" or false */ private function handle($ciphertext, $iv, $app_key, $session_key) { $session_key = base64_decode($session_key); $iv = base64_decode($iv); $ciphertext = base64_decode($ciphertext); $plaintext = false; if (function_exists("openssl_decrypt")) { $plaintext = openssl_decrypt($ciphertext, "AES-192-CBC", $session_key, OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING, $iv); } else { $td = mcrypt_module_open(MCRYPT_RIJNDAEL_128, null, MCRYPT_MODE_CBC, null); mcrypt_generic_init($td, $session_key, $iv); $plaintext = mdecrypt_generic($td, $ciphertext); mcrypt_generic_deinit($td); mcrypt_module_close($td); } if ($plaintext == false) {return false;} $pad = ord(substr($plaintext, -1)); $pad = ($pad < 1 || $pad > 32) ? 0 : $pad; $plaintext = substr($plaintext, 0, strlen($plaintext) - $pad); $plaintext = substr($plaintext, 16); $unpack = unpack("Nlen/", substr($plaintext, 0, 4)); $content = substr($plaintext, 4, $unpack['len']); $app_key_decode = substr($plaintext, $unpack['len'] + 4); return $app_key == $app_key_decode ? $content : false; } }
控制器
public function login(Request $request) { $platform = $request->header('platform'); if(!$platform || !in_array($platform,User::$platforms)){ return Y::json(1001, '不支援的平臺型別'); } $post = $request->only(['encryptedData', 'iv', 'code']); $validator = Validator::make($post, [ 'encryptedData' => 'required', 'iv' => 'required', 'code' => 'required' ]); if ($validator->fails()) {return Y::json(1002,'非法請求');} switch ($platform) { case 'baidu': $decryption = (new BdDataDecrypt())->decrypt($post['encryptedData'],$post['iv'],$post['code']); break; default: $decryption = false; break; } // var_dump($decryption); if($decryption !== false){ $user = User::where('u_platform',$platform)->where('phone',$decryption['mobile'])->first(); if($user){ $user->login_time = date('Y-m-d H:i:s',time()); $user->save(); }else{ $user = User::create([ 'platform'=> $platform, 'phone' => $decryption['mobile'], 'openid' => $decryption['openid'], 'register_time' => date('Y-m-d H:i:s',time()) ]); } $token = auth()->login($user); return Y::json( array_merge( $this->respondWithToken($token), ['userInfo'=>['nickName'=>$user->u_nickname]] ) ); } return Y::json(1003,'登入失敗'); } public function me() { return Y::json(auth()->user()); } protected function respondWithToken($token) { return [ 'access_token' => $token, // 'token_type' => 'Bearer', // 'expires_in' => auth()->factory()->getTTL() * 60, ]; }
基於laravel/jwt-auth 的許可權中介軟體
談一談這幾天對 jwt
的理解。jwt 的荷載主要是 生成的時間戳
,過期時的時間戳
,使用者id
。
- 當沒有攜帶 token 時,丟擲
UnauthorizedHttpException
異常。 - 令牌過期時,丟擲
TokenExpiredException
異常。 - 無法解析的令牌,丟擲
TokenInvalidException
異常。 - 令牌已在黑名單或超出重新整理時間上限或內部錯誤,丟擲
JWTException
異常。
前3點好理解,重點說下第4點,當一個token過期並進行了重新整理token,那麼原token會被列在“黑名單”,即失效了。實際上 jwt-auth 也維護了一個檔案來儲存黑名單,而達到重新整理時間上限才會清理失效的token。例如過期時間為10分鐘,重新整理上限為一個月,這期間會產生大量的黑名單,影響效能,所以儘量的調整,比如過期時間為60分鐘,重新整理上線為兩週。但達到重新整理上限時間後,無法重新整理token,會強行登陸失效,如果正在進行聊天,如何處理而不影響體驗,我沒有想到好的方法,如果你有好的想法麻煩在下方留言。
但這裡不妨有疑問,為何要捕獲異常呢?因為jwt的異常返回不是json的,前端需要知道具體的狀態是什麼,比如沒有攜帶token或者token失效就需要重新登陸,如果是過期那麼就接收新的token,所以需要自定義狀態碼告訴前端該怎麼做。
建立一箇中介軟體 apiAuth.php
,實現 token 認證和自動重新整理過期 token,並攜帶約定狀態碼返回前端,前端接收新 token,儲存後重新傳送請求,從而實現了無感知重新整理令牌。
<?php namespace App\Http\Middleware; use App\Library\Y; use Closure; use Illuminate\Support\Facades\Auth; use Exception; use Tymon\JWTAuth\Exceptions\JWTException; use Tymon\JWTAuth\Http\Middleware\BaseMiddleware; use Tymon\JWTAuth\Exceptions\TokenExpiredException; use Tymon\JWTAuth\Exceptions\TokenInvalidException; use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; class ApiAuth extends BaseMiddleware { public function handle($request, Closure $next, $guard = 'api') { // 排除路由 比如登入 if($request->is(...$this->except)){ return $next($request); } try { $this->checkForToken($request); //沒有攜帶token丟擲異常 if ( $this->auth->parseToken()->authenticate() ) { return $next($request);//token未通過許可權認證丟擲異常 } }catch(Exception $e){ if ($e instanceof TokenExpiredException) { try{// 過期異常 $token = $this->auth->refresh();//嘗試重新整理,如達到重新整理時間上限丟擲異常 return Y::json(6666, $e->getMessage(),['access_token'=>$token]); // 注意這裡自動重新整理返回前端新的token }catch(JWTException $e){ // 令牌失效或者達到重新整理上限 return Y::json(7777, $e->getMessage()); } } if ($e instanceof TokenInvalidException) { return Y::json(8888, $e->getMessage());//無法解析令牌 } if ($e instanceof UnauthorizedHttpException) { return Y::json(401, $e->getMessage());//未攜帶令牌 } if ($e instanceof JWTException) { return Y::json(9999, $e->getMessage());//內部錯誤 } } } // 注: 狀態碼是我亂寫的,如果有單獨的前端,與前端約定 protected $except = [ 'v1/auth/login', // 'v1/auth/register' ]; }
本作品採用《CC 協議》,轉載必須註明作者和本文連結