完整的 uni-App+Laravel+jwt-auth 小程序权限认证

Tip:此文编写时考虑的不是很全面,已更新在另篇 uniapp 小程序 Laravel+jwt 权限认证完整系列

本文涉及的有:

  1. 前端 request 类
  2. 前端 有关权限认证的 js 封装
  3. 多平台用户的处理方式
  4. 后端 基于laravel/jwt-auth 的权限中间件
  5. 实现获取手机号登陆
  6. 后端 jwt 异常捕获
  7. 实现无感知刷新 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表,而联合账号表仅当作附表,需要时才会用到,因为项目本身用户的粘合度比较小,对同一用户在不同平台的数据做整合的需求不大。

获取手机号并登陆

  1. 自定义 request 类
    1.1 识别用户来自哪个平台。
    1.2 公开请求不需要携带 token, 需要用户登陆的接口需要携带 token
    1.3 注意响应拦截器里实现了用户无感知刷新
    import 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
    }
    全局挂载 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

  1. 当没有携带 token 时,抛出UnauthorizedHttpException异常。
  2. 令牌过期时,抛出TokenExpiredException异常。
  3. 无法解析的令牌,抛出TokenInvalidException异常。
  4. 令牌已在黑名单或超出刷新时间上限或内部错误,抛出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 协议》,转载必须注明作者和本文链接
welcome come back
《L01 基础入门》
我们将带你从零开发一个项目并部署到线上,本课程教授 Web 开发中专业、实用的技能,如 Git 工作流、Laravel Mix 前端工作流等。
《L03 构架 API 服务器》
你将学到如 RESTFul 设计风格、PostMan 的使用、OAuth 流程,JWT 概念及使用 和 API 开发相关的进阶知识。
讨论数量: 2

不知道为什么代码失去格式,成一团了

3年前 评论

刷新上限时间是不是token已经过期了,这期间(2周)没有刷新就要登陆了

3年前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!
未填写
文章
94
粉丝
24
喜欢
156
收藏
346
排名:325
访问:2.9 万
私信
所有博文
社区赞助商