uni-app開發(fā)教程欄目介紹系列權(quán)限認(rèn)證的方法。
推薦:uni-app開發(fā)教程
環(huán)境說(shuō)明
uni-app
laravel 5.7
+ jwt-auth 1.0.0
權(quán)限認(rèn)證整體說(shuō)明
- 設(shè)計(jì)表結(jié)構(gòu)
- 前端 request 類
- 有關(guān)權(quán)限認(rèn)證的 js 封裝 包含無(wú)感知刷新 token
- laravel auth 中間件 包含無(wú)感知刷新 token
- 獲取手機(jī)號(hào)登陸
- 無(wú)痛刷新 access_token 思路
- 小程序如何判斷登陸狀態(tài)
設(shè)計(jì)表結(jié)構(gòu)
和一般設(shè)計(jì)表沒有什么區(qū)別,如果是多平臺(tái)小程序,通過(guò) account_id 關(guān)聯(lián)聯(lián)合表。
CREATE TABLE `users` ( `u_id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '賬號(hào)id', `u_username` varchar(15) NOT NULL DEFAULT '' COMMENT '手機(jī)號(hào)隱藏 ', `u_nickname` varchar(15) NOT NULL COMMENT '分配用戶名', `u_headimg` varchar(200) DEFAULT NULL COMMENT '頭像', `u_province` varchar(50) DEFAULT NULL, `u_city` varchar(50) DEFAULT NULL, `u_platform` varchar(30) NOT NULL COMMENT '平臺(tái):小程序wx,bd等', `u_mobile` char(11) NOT NULL COMMENT '手機(jī)號(hào)必須授權(quán)', `u_openid` varchar(100) DEFAULT NULL COMMENT 'openid', `u_regtime` timestamp NULL DEFAULT NULL COMMENT '注冊(cè)時(shí)間', `u_login_time` timestamp NULL DEFAULT NULL COMMENT '最后登陸時(shí)間', `u_status` tinyint(3) unsigned NOT NULL DEFAULT '1' COMMENT '0禁用1正常', `account_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '平臺(tái)聯(lián)合id', PRIMARY KEY (`u_id`), KEY `platform` (`u_platform`,`u_mobile`) USING BTREE) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4;
2. 前端 request 類
一個(gè)較不錯(cuò)的 request 類 luch-request
,支持動(dòng)態(tài)修改配置、攔截器,在 uni-app 插件市場(chǎng)可以找到。
其中 request.js 不需要更改。自定義邏輯在 index.js。
index.js
import Request from './request';import jwt from '@/utils/auth/jwt.js'; // jwt 管理 見下文const http = new Request();const baseUrl = 'http://xxx'; // api 地址var platform = ''; // 登陸時(shí)需知道來(lái)自哪個(gè)平臺(tái)的小程序用戶// #ifdef MP-BAIDUplatform = 'MP-BAIDU';// #endif/* 設(shè)置全局配置 */http.setConfig((config) => { config.baseUrl = baseUrl; //設(shè)置 api 地址 config.header = { ...config.header } return config})/* 請(qǐng)求之前攔截器 */http.interceptor.request((config, cancel) => { if (!platform) {cancel('缺少平臺(tái)參數(shù)');} config.header = { ...config.header, platform:platform } if (config.custom.auth) { // 需要權(quán)限認(rèn)證的路由 需攜帶自定義參數(shù) {custom: {auth: true}} config.header.Authorization = jwt.getAccessToken(); } return config})http.interceptor.response(async (response) => { /* 請(qǐng)求之后攔截器 */ console.log(response); // 如果是需要權(quán)限認(rèn)證的路由 if(response.config.custom.auth){ if(response.data.code == 4011){ // 刷新 token jwt.setAccessToken(response.data.data.access_token); // 攜帶新 token 重新請(qǐng)求 let repeatRes = await http.request(response.config); if ( repeatRes ) { response = repeatRes; } } } return response}, (response) => { // 請(qǐng)求錯(cuò)誤做點(diǎn)什么 if(response.statusCode == 401){ getApp().globalData.isLogin = false; uni.showToast({icon:'none',duration:2000,title: "請(qǐng)登錄"}) }else if(response.statusCode == 403){ uni.showToast({ title: "您沒有權(quán)限進(jìn)行此項(xiàng)操作,請(qǐng)聯(lián)系客服。", icon: "none" }); } return response})export { http}
全局掛載
import Vue from 'vue'import App from './App'import { http } from '@/utils/luch/index.js' //這里Vue.prototype.$http = http Vue.config.productionTip = falseApp.mpType = 'app'const app = new Vue({ ...App})app.$mount()
3.有關(guān)權(quán)限認(rèn)證的 js 封裝
authorize.js
篇幅原因,沒有貼完整的代碼,其他并沒有使用到。比如 uni.checkSession(),由于使用 jwt 接管了小程序的登陸態(tài),所以目前沒有用到這個(gè)方法。
// #ifndef H5const loginCode = 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失敗")} }); })}// #endifexport { loginCode //登錄獲取code}
jwt.js
專門管理 access_token 的,代碼不多,同時(shí)將 userinfo 的管理也放在里面。
const tokenKey = 'accessToken';//鍵值const userKey = 'user'; // 用戶信息// tokenconst getAccessToken = function(){ let token=''; try {token = 'Bearer '+ uni.getStorageSync(tokenKey);} catch (e) {} return token;}const setAccessToken = (access_token) => { try {uni.setStorageSync(tokenKey, access_token);return true;} catch (e) {return false;}}const clearAccessToken = function(){ try {uni.removeStorageSync(tokenKey);} catch (e) {}}// userinfoconst setUser = (user)=>{ try {uni.setStorageSync(userKey, user);return true;} catch (e) {return false;}}const getUser = function(){ try {return uni.getStorageSync(userKey)} catch (e) {return false;}}const clearUser = function(){ try {uni.removeStorageSync(userKey)} catch (e) {}}export default { getAccessToken,setAccessToken,clearAccessToken,getUser,setUser,clearUser}
auth.js
只處理 login ,為什么單獨(dú)放在一個(gè)文件,沒別的,因?yàn)榈教幎加玫?/p>
import {loginCode} from '@/utils/auth/authorize.js';import jwt from '@/utils/auth/jwt.js';import {http} from '@/utils/luch/index.js';const login=function(detail){ return new Promise((resolve, reject) => { loginCode().then(code=>{ detail.code = code; return http.post('/v1/auth/login',detail); }) .then(res=>{ jwt.setAccessToken(res.data.data.access_token); jwt.setUser(res.data.data.user); getApp().globalData.isLogin = true; resolve(res.data.data.user); }) .catch(err=>{ reject('登陸失敗') }) })}export default {login}
4. laravel auth 中間件
這里叨叨一點(diǎn) jwt-auth 方面的。1,當(dāng)一個(gè)token過(guò)期并進(jìn)行了刷新token,那么原token會(huì)被列在“黑名單”,即失效了。實(shí)際上 jwt-auth 也維護(hù)了一個(gè)文件來(lái)儲(chǔ)存黑名單,而達(dá)到刷新時(shí)間上限才會(huì)清理失效的token。例如過(guò)期時(shí)間為10分鐘,刷新上限為一個(gè)月,這期間會(huì)產(chǎn)生大量的黑名單,影響性能,所以盡量的調(diào)整,比如過(guò)期時(shí)間為60分鐘,刷新上限為兩周,或者過(guò)期時(shí)間一周,刷新上限一個(gè)月都沒有問題的。2,關(guān)于無(wú)痛刷新方案,當(dāng)token過(guò)期時(shí),我采用的前端兩次請(qǐng)求完成刷新,其中用戶是無(wú)感知的,網(wǎng)上有直接一次請(qǐng)求自動(dòng)刷新并登陸的方案,我沒有采用,至于為什么,沒別的,看不懂。不過(guò)我整理了各種 jwt 各種 exception ,需要的同學(xué)可以自定義。TokenExpiredException 過(guò)期、TokenInvalidException 無(wú)法解析令牌、UnauthorizedHttpException 未攜帶令牌、JWTException 令牌失效或者達(dá)到刷新上限或jwt內(nèi)部錯(cuò)誤。
<?phpnamespace AppHttpMiddleware;use AppLibraryY;use Closure;use Exception;use TymonJWTAuthExceptionsJWTException;use TymonJWTAuthHttpMiddlewareBaseMiddleware;use TymonJWTAuthExceptionsTokenExpiredException;class ApiAuth extends BaseMiddleware{ public function handle($request, Closure $next, $guard = 'api') { // 在排除名單中 比如登錄 if($request->is(...$this->except)){ return $next($request); } try { $this->checkForToken($request);// 是否攜帶令牌 if ( $this->auth->parseToken()->authenticate() ) { return $next($request); //驗(yàn)證通過(guò) } }catch(Exception $e){ // 如果token 過(guò)期 if ($e instanceof TokenExpiredException) { try{ // 嘗試刷新 如果成功 返給前端 關(guān)于前端如何處理的 看前邊 index.js $token = $this->auth->refresh(); return Y::json(4011, $e->getMessage(),['access_token'=>$token]); }catch(JWTException $e){ // 達(dá)到刷新時(shí)間上限 return Y::json(401, $e->getMessage()); } }else{ // 其他各種 直接返回 401 狀態(tài)碼 不再細(xì)分 return Y::json(401, $e->getMessage()); } } } protected $except = [ 'v1/auth/login', ];}
筆者認(rèn)為這種刷新很不好維護(hù),直接使用一次性token,過(guò)期直接重新登錄比較好,視小程序或網(wǎng)站是否要求極強(qiáng)的安全性而定,一般不需求很高的安全性,https請(qǐng)求下一次性token更好,這里的中間件只需要 auth()->check(),true 即登錄狀態(tài),false 即未登錄。
5. 獲取手機(jī)號(hào)登陸
<template> <view> <button type="default" open-type="getPhoneNumber" @getphonenumber="decryptPhoneNumber">獲取手機(jī)號(hào)</button> <button @tap="me">獲取用戶數(shù)據(jù)</button> <button @tap="clear">清除用戶數(shù)據(jù)</button> </view></template><script> import auth from '@/utils/auth/auth.js'; import jwt from '@/utils/auth/jwt.js'; var _self; export default{ data() {return {}}, onLoad(option) {}, onShow(){}, methods: { decryptPhoneNumber: function(e){ // console.log(e.detail); if( e.detail.errMsg == "getPhoneNumber:ok" ){ //成功 auth.login(e.detail); } }, me: function(){ this.$http.get('/v1/auth/me',{custom: {auth: true}}).then(res=>{ console.log(res,'success') }).catch(err=>{ console.log(err,'error60') }) }, clear: function(){ jwt.clearAccessToken(); jwt.clearUser(); uni.showToast({ icon: 'success', title: '清除成功', duration:2000, }); } }, components: {} }</script><style></style>
后端
// 登陸 public function login(Request $request) { $platform = $request->header('platform'); if(!$platform || !in_array($platform,User::$platforms)){ return Y::json(1001, '不支持的平臺(tái)類型'); } $post = $request->only(['encryptedData', 'iv', 'code']); $validator = Validator::make($post, [ 'encryptedData' => 'required', 'iv' => 'required', 'code' => 'required' ]); if ($validator->fails()) {return Y::json(1002,'非法請(qǐng)求');} switch ($platform) { case 'MP-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('u_mobile',$decryption['mobile'])->first(); if($user){ $user->u_login_time = date('Y-m-d H:i:s',time()); $user->save(); }else{ $user = User::create([ 'u_username'=> substr_replace($decryption['mobile'],'******',3,6), 'u_nickname'=> User::crateNickName(), 'u_platform'=> $platform, 'u_mobile' => $decryption['mobile'], 'u_openid' => $decryption['openid'], 'u_regtime' => date('Y-m-d H:i:s',time()) ]); } $token = auth()->login($user); return Y::json( array_merge( $this->respondWithToken($token), ['user'=>['nickName'=>$user->u_nickname]] ) ); } return Y::json(1003,'登錄失敗'); } // 返回 token protected function respondWithToken($token) { return ['access_token' => $token]; }
手機(jī)號(hào)碼解密
<?phpnamespace AppLibrary;use AppLibraryY;class BdDataDecrypt{ private $_appid; private $_app_key; private $_secret; private $_session_key; public function __construct() { $this->_appid = env('BD_APPID'); $this->_app_key = env('BAIDU_KEY'); $this->_secret = env('BD_SECRET'); } 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); // var_dump($res); /** * 錯(cuò)誤返回 * array(3) { ["errno"]=> int(1104) ["error"]=> string(33) "invalid code , expired or revoked" ["error_description"]=> string(33) "invalid code , expired or revoked" } 成功返回: array(2) { ["openid"]=> string(26) "z45QjEfvkUJFwYlVcpjwST5G8w" ["session_key"]=> string(32) "51b9297ababbcf43c1a099256bf82d75" } */ if( isset($res['error']) ){ return false; } return $res; } /** * 官方 demo * return string(24) "{"mobile":"18288881111"}" 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; } // trim pkcs#7 padding $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; }}
6. 無(wú)痛刷新 access_token 思路
先說(shuō)我使用的方法是,后端判斷 token 過(guò)期后,自動(dòng)嘗試刷新,刷新成功返回新的 token,前端在響應(yīng)攔截器里,捕獲到后端響應(yīng)的約定 code,把新的 token 存儲(chǔ),并且緊接著二次請(qǐng)求,最終感知上是一次正常的請(qǐng)求。
另外一種思路,后端嘗試刷新成功后,自動(dòng)為當(dāng)前用戶登陸,并在 header 中返回新 token,前端只負(fù)責(zé)存儲(chǔ)。
7. 小程序如何判斷登陸狀態(tài)
其實(shí)思路也很簡(jiǎn)單,非前后端分離怎么做的,前后端分離就怎么做,原理一樣。非前后端分離,在每次請(qǐng)求時(shí)都會(huì)讀取 session ,那么前后端分離,更好一些,有些公開請(qǐng)求不走中間件,也就無(wú)需判斷登陸態(tài),只有在需要權(quán)限認(rèn)證的頁(yè)面,在頁(yè)面初始化時(shí)發(fā)出一次請(qǐng)求走中間件,以此判斷登陸狀態(tài)。
定義全局登陸檢查函數(shù)
import jwt from '@/utils/auth/jwt.js';Vue.prototype.checkLogin = function(){ var TOKEN = jwt.getAccessToken(); return new Promise((resolve, reject) => { if(TOKEN){ http.get('/v1/auth/check',{custom: {auth: true}}).then(res=>{ // 通過(guò)中間件 一定是登陸態(tài) resolve(true); }).catch(err=>{ resolve(false); console.log(err) // 這里是401 403 后端500錯(cuò)誤或者網(wǎng)絡(luò)不好 }) }else{ resolve(false) //沒有token 一定是未登陸 } })}
筆者最終放棄上面的這種檢查登錄的方式,直接檢驗(yàn)storage中有user和token即視為登錄狀態(tài)。以被動(dòng)的驗(yàn)證代替主動(dòng)去驗(yàn)證,就是說(shuō)用戶執(zhí)行一個(gè)請(qǐng)求,返回401,那么就改變登錄狀態(tài)。以后再補(bǔ)充。
前端
<script> export default { data() { return { isLogin:null } }, onLoad() { this.checkLogin().then(loginStatus=>{ this.isLogin = loginStatus; }); }, methods: { }, components: {} }</script>