uniapp 小程序 Laravel+jwt 权限认证完整系列
环境说明
uni-app
laravel 5.7
+ jwt-auth 1.0.0
权限认证整体说明
- 设计表结构
- 前端 request 类
- 有关权限认证的 js 封装 包含无感知刷新 token
- laravel auth 中间件 包含无感知刷新 token
- 获取手机号登陆
- 无痛刷新 access_token 思路
- 小程序如何判断登陆状态
设计表结构
此表针对的是授权手机号登录的方式
CREATE TABLE `users` (
`u_id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '账号id',
`u_username` varchar(15) NOT NULL DEFAULT '' COMMENT '手机号隐藏 ',
`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 '平台:小程序wx,bd等',
`u_mobile` char(11) NOT NULL COMMENT '手机号必须授权',
`u_openid` varchar(100) DEFAULT NULL COMMENT 'openid',
`u_unionid` varchar(100) DEFAULT NULL COMMENT '开放平台unionid',
`u_regtime` timestamp NULL DEFAULT NULL COMMENT '注册时间',
`u_login_time` timestamp NULL DEFAULT NULL COMMENT '最后登陆时间',
`u_status` tinyint(3) unsigned NOT NULL DEFAULT '1' COMMENT '0禁用1正常',
`account_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '平台联合id',
PRIMARY KEY (`u_id`),
KEY `platform` (`u_platform`,`u_mobile`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4;
2. 前端 request 类
一个较不错的 request 类 luch-request
,支持动态修改配置、拦截器,在 uni-app 插件市场可以找到。
其中 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 = ''; // 登陆时需知道来自哪个平台的小程序用户
// #ifdef MP-BAIDU
platform = 'MP-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 //添加此项可标识注册用户是哪个平台的
}
//加上这段代码是为了区分哪些请求是权限认证的请求,从而对token的管理
//读取token需要调用耗时的localStorage,不需要权限的路由则不必携带token
//同时不需要登录的请求不携带token可节省宽带
if (config.custom.auth) {
// 需要权限认证的路由 需携带自定义参数 {custom: {auth: true}}
config.header.Authorization = jwt.getAccessToken();
}
return config
})
http.interceptor.response(async (response) => { /* 请求之后拦截器 */
console.log(response); //这里对调试很有用,而不必在具体的请求中打印
// 如果发送请求时,指定了是需要权限认证的路由
if(response.config.custom.auth){
//约定token刷新的状态码是4011,此时服务器返回新的token,需要拿着新token重新请求
if(response.data.code == 4011){
// 存储新的token
jwt.setAccessToken(response.data.data.access_token);
// 携带新 token 重新请求
let repeatRes = await http.request(response.config);
if ( repeatRes ) {
response = repeatRes;
}
}
}
return response
}, (response) => { // 请求错误做点什么
if(response.statusCode == 401){
//global记录登录态是有缺陷的,比如下面将isLogin改为false,但不会引起页面登录态的变化,最好用 vue 的 store 代替它
getApp().globalData.isLogin = false;
uni.showToast({icon:'none',duration:2000,title: "请登录"})
}else if(response.statusCode == 403){
uni.showToast({
title: "您没有权限进行此项操作,请联系客服。",
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 = false
App.mpType = 'app'
const app = new Vue({
...App
})
app.$mount()
3.有关权限认证的 js 封装
authorize.js
篇幅原因,没有贴完整的代码,其他并没有使用到。比如 uni.checkSession(),由于使用 jwt 接管了小程序的登陆态,所以目前没有用到这个方法。
// #ifndef H5
const 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失败")}
});
})
}
// #endif
export default {
loginCode //登录获取code
}
jwt.js
专门管理 access_token 的,代码不多,同时将 userinfo 的管理也放在里面。
最好用 vue 的 store 来代替这个文件的功能
const tokenKey = 'accessToken';//键值
const userKey = 'user'; // 用户信息
// token
const 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) {}
}
// userinfo
const 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 ,单独放在一个文件因为到处都用到
import jwt from '@/utils/auth/jwt.js';
import {http} from '@/utils/luch/index.js';
const login=function(detail, code){
return new Promise((resolve, reject) => {
detail.code = code;
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 中间件
这里叨叨一点 jwt-auth 方面的。1,当一个token过期并进行了刷新token,那么原token会被列在“黑名单”,即失效了。实际上 jwt-auth 也维护了一个文件来储存黑名单,而达到刷新时间上限才会清理失效的token。例如过期时间为10分钟,刷新上限为一个月,这期间会产生大量的黑名单,影响性能,所以尽量的调整,比如过期时间为60分钟,刷新上限为两周,或者过期时间一周,刷新上限一个月都没有问题的。2,关于无痛刷新方案,当token过期时,我采用的前端两次请求完成刷新,其中用户是无感知的,网上有直接一次请求自动刷新并登陆的方案,我没有采用,至于为什么,没别的,看不懂。不过我整理了各种 jwt 各种 exception ,需要的同学可以自定义。TokenExpiredException 过期、TokenInvalidException 无法解析令牌、UnauthorizedHttpException 未携带令牌、JWTException 令牌失效或者达到刷新上限或jwt内部错误。
<?php
namespace App\Http\Middleware;
use App\Library\Y;
use Closure;
use Exception;
use Tymon\JWTAuth\Exceptions\JWTException;
use Tymon\JWTAuth\Http\Middleware\BaseMiddleware;
use Tymon\JWTAuth\Exceptions\TokenExpiredException;
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); //验证通过
}
}catch(Exception $e){
// 如果token 过期
if ($e instanceof TokenExpiredException) {
try{
// 尝试刷新 如果成功 返给前端 关于前端如何处理的 看前边 index.js
$token = $this->auth->refresh();
return Y::json(4011, $e->getMessage(),['access_token'=>$token]);
}catch(JWTException $e){
// 达到刷新时间上限
return Y::json(401, $e->getMessage());
}
}else{
// 其他各种 直接返回 401 状态码 不再细分
return Y::json(401, $e->getMessage());
}
}
}
protected $except = [
'v1/auth/login',
];
}
笔者认为这种刷新很不好维护,直接使用一次性token,过期直接重新登录比较好,视小程序或网站是否要求极强的安全性而定,一般不需求很高的安全性,https请求下一次性token更好,这里的中间件只需要 auth()->check(),true 即登录状态,false 即未登录。
<?php
namespace App\Http\Middleware;
use Closure;
use App\Library\Y;
class UserAuth
{
public function handle($request, Closure $next){
$auth = auth();
$auth->shouldUse('user');
// 排除路由
if( $request->is(...$this->except) ){
return $next($request);
}
// true已登录
if ($auth->check()) {
return $next($request);
}
return Y::json(401, '用户验证失败,请重新登录');
}
protected $except = [
'v1/auth/login',
];
}
5. 获取手机号登陆
<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 auth from '@/utils/auth/auth.js';
import jwt from '@/utils/auth/jwt.js';
var _self;
export default{
data() {return {
code: ''
}},
onLoad(option) {
authorize.loginCode().then(code=>{
this.code = code;
}).catch(err=>{
console.log(err);
})
},
onShow(){},
methods: {
decryptPhoneNumber: function(e){
// console.log(e.detail);
if( e.detail.errMsg == "getPhoneNumber:ok" ){ //成功
auth.login(e.detail, this.code).then(user=>{
console.log(user);
}).catch(err=>{
uni.showToast({icon: 'none',title: '登陆失败',duration:2000});
})
}
},
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, '不支持的平台类型');
}
$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 '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];
}
手机号码解密
<?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('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);
/**
* 错误返回
* 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. 无痛刷新 access_token 思路
先说我使用的方法是,后端判断 token 过期后,自动尝试刷新,刷新成功返回新的 token,前端在响应拦截器里,捕获到后端响应的约定 code,把新的 token 存储,并且紧接着二次请求,最终感知上是一次正常的请求。
另外一种思路,后端尝试刷新成功后,自动为当前用户登陆,并在 header 中返回新 token,前端只负责存储。
7. 小程序如何判断登陆状态
其实思路也很简单,非前后端分离怎么做的,前后端分离就怎么做,原理一样。非前后端分离,在每次请求时都会读取 session ,那么前后端分离,更好一些,有些公开请求不走中间件,也就无需判断登陆态,只有在需要权限认证的页面,在页面初始化时发出一次请求走中间件,以此判断登陆状态。
定义全局登陆检查函数
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=>{
// 通过中间件 一定是登陆态
resolve(true);
}).catch(err=>{
resolve(false);
console.log(err) // 这里是401 403 后端500错误或者网络不好
})
}else{
resolve(false) //没有token 一定是未登陆
}
})
}
笔者最终放弃上面的这种检查登录的方式,直接检验storage中有user和token即视为登录状态。以被动的验证代替主动去验证,就是说用户执行一个请求,返回401,那么就改变登录状态。以后再补充。
前端
<script>
export default {
data() {
return {
isLogin:null
}
},
onLoad() {
this.checkLogin().then(loginStatus=>{
this.isLogin = loginStatus;
});
},
methods: {
},
components: {}
}
</script>
本作品采用《CC 协议》,转载必须注明作者和本文链接
很不错
感恩无私的分享
不错
太棒了!
哇,支持,我用的taro,可以参考下
jwt-auth 不是自带的有过期刷新token的中间件吗?为什么还要自己写个呢
正好最近项目在做可以试试
为什么我下载的uniapp-luch插件跟你的目录完全不一样
老铁,中间件refresh_token那里,$guard是怎么使用的,如何验证来自不同Model用户的token?
截止到2020/09/22, 前端部分 拦截器部分需要根据官方文档做出修改。
:+1: :+1: :+1:最后感谢作者的辛苦分享,下面准备去阅读作者 的另一篇 websocket文章,正好最近想做个扫码点餐的小程序 :blush:
@osang 先要在你的model中 extends Authenticatable implements JWTSubject 然后配置