PHP程式碼篇(九)PHP介面開發如何使用JWT進行驗證身份
- 前言
事情是這樣的,在我進入目前公司的時候。因為公司是一家創業公司,所以在我進去的時候,裡面的開發配置就是web前端,ui設計,加我PHP後臺各一個。接手的是一個公益小程式,業務倒是不怎麼複雜,負責人說這個專案是之前委託外包公司開發的,用的是uniapp開發的小程式,基於ThinkPHP6.0做的介面開發,和一個CatchAdmin開發的管理後臺。公司主要業務這個上面都已經開發完了,但是由於微信小程式的一些功能的限制,我們想把目前的這個專案轉移到微信公眾號上面,就是改成H5開發的模式。
- 八月的雨
當時負責人,說前端可能需要重寫,因為之前是小程式,現在是H5嘛,前端是一個剛服役退伍的年輕小夥子,看了下說,用uniapp開發,到時候打包成H5就可以了,基本問題不大,於是就卡卡的開始寫靜態頁面了,說一週寫完靜態頁面。
到後臺這邊,就有兩個選擇了。因為不管怎麼變,最終還是做介面開發。所以有兩個選擇,要麼在原來外包公司開發的api上修改,做二開,這樣應該基本改下登陸註冊方式,和部分業務邏輯即可;聽取負責人的態度和想法目前對外包公司開發的api專案主要有如下不滿點:
1、說這個外包公司開發的專案,裡面有很多業務邏輯和現在他們想要不一樣;
2、有些功能業務,實際上還沒開發完,或者有些開發完了,還沒測試,有很多問題;
3、對這個專案的完成度,負責人,自己也不是完全清楚;
根據負責人的描述,再結合自己這幾天看專案原始碼,發現外包寫的程式碼確實比較差,看出來,雖然是用的tp框架,但是寫的基本上非常趕。有些介面,基本上是寫完,就沒有具體測試。還有最主要的就是,基本所有的業務程式碼邏輯承載都在一個controller裡面,非常不適合後期的迭代維護。所以第二種選擇就是,直接進行重寫,根據負責人對與專案的業務要求,直接重新設計資料庫,重寫api介面。
- 八月中下旬的選擇
當時我的想法,當然是重寫是最好,原因如下:
1、這個原有專案真實邏輯沒有開發人員真實瞭解(就是我來的時候,都沒有對接人);
2、這個專案本身業務邏輯比較單一,而且二期改版有十分大;
3、如果二開,我既要去熟悉舊有程式碼,還要修改,對於新功能的開發,還需要切合舊有邏輯;
想到如上,確實還是直接重寫最好,這樣,一個是程式碼的標準,質量,邏輯都在自己的掌握之中,也便於後期的工作。
- 我承認自己有賭的成分
重寫的原因說來,一條一條的,清晰完整。但是在做的時候,因為之前一般進入公司的,去的時候,公司專案基本上已經上線了,我們主要是做維護,和調優;自己主導寫一個完整的專案,還沒有;而且之前大多是做前後端不分離的方式,像這種做介面開發,前後端分離的方式,也有點欠缺理解。
於是入職這一週時間,我一直在重複一個流程:
1、想一個自己能說的通的開發方案,在腦子裡過幾遍;
2、如果感覺沒問題,就下載相應框架原始碼,搭建demo,開始按方案寫程式碼邏輯。在寫的過程中,如果出現方案外的問題,就想辦法解決;
3、如果方案外的問題實在,無力解決,就只能推倒當前方案;
最後又回到流程1,這樣我記得有很多次了,當時感覺已經很奔潰了,有點後悔,因為我目前的問題主要是對於這個介面驗證JWT不是特理解,雖然現在寫的時候,已經明白。但是當時是真的,有點暈(今天是10月14日,入職是8月12日)。這裡說後悔是什麼意思呢,因為在上一家公司做的時候,我們有做過一個新專案,就是前後端分離的模式。前端用的是vue,後端純介面開發,介面驗證用的就是jwt。但是當時基礎部分是另外一個同事寫的,我只負責寫業務端,像登入註冊,驗證,板塊設計都是他做的。記得當時是有在看他驗證這塊是怎麼寫的,有不懂的也有當面問過,但是還是沒有具體弄明白,後來想著如果後面需要自己處理這塊,再說,我承認當時確實有賭的成份,現在果然賭輸了。
人生很多事,就是這樣,有的時候,不知道珍惜,到後來需要自己獨自面對的時候,就要多勞力了。經歷了上面的不斷的試錯和嘗試,時間在一天一天過去,但是我這邊進展,還是一直沒有。每次專案進度開會,明顯感覺到,像是就我這邊進度緩慢。但是有一點可以確認的是,每次在自己的演示中,雖然方案行不通,回到了最初點,但是感覺對於jwt的理解和架構的設計,有了一些實際的理解。我有一種感覺,應該快出來了。果然在一個現在已經忘記了時間上,出來了。
- 言歸正傳
PHP通過jwt實現介面驗證,設計思路如下:
1、先定義controller控制器基類Base.php,作用是繼承改類的,都需要進行token驗證;
2、在定義一個前端api基類IndexBase.php,作為一箇中間層,裡面存放驗證後token裡面的使用者資訊
3、書寫PHP實現jwt基類PhpJwt.php,改類主要是獲取token,和驗證token;
上面三者的關係是,IndexBase.php繼承Base.php繼承PhpJwt.php。
前端與後端驗證邏輯如下:
1、前端請求介面,後臺驗證token;
2、沒有token,給出提示,前端輸入賬號密碼(微信公眾號類,直接通過非靜默授權,獲取使用者openid),進行驗證使用者資訊。驗證通過後,將使用者uid載入到jwt載荷中,生成token。一個驗證toekn(過期時間比較短),一個重新整理token(過期時間較長,用於避免每次段時間內,使用者重複登入)。
3、前端通過登入拿到兩個token後,存起來。然後在請求需要驗證的介面時,在header頭部加入引數authorization:使用者toekn;來進行驗證,後臺通過token來解析出當前請求使用者的資訊。
- 核心程式碼
1、PhpJwt.php
<?php /** * PHP實現jwt * */ namespace lib; class PhpJwt { //頭部 private static $header = array( 'alg'=>'HS256', //生成signature的演算法 'typ'=>'JWT' //型別 ); //使用HMAC生成資訊摘要時所使用的金鑰 md5('jjgw2021') private static $key = '99dc2d62ab85bcd9185f3e9324db5567'; //md5('jjgw2021admin') private static $admin_key = '12d44e568140bf62d84d9cb3e20b1103'; //請求jwt 過期時間 2小時(上線後改為10分鐘) private static $request_expect = 3600; //重新整理jwt 過期時間 24小時 private static $refresh_expect = 86400; private static $admin_request_expect = 1800; private static $admin_refresh_expect = 7200; //判斷是否後端token private static $is_admin = 'is_admin'; /*** 獲取jwt token * @param array $payload jwt載荷 格式如下非必須 * [ * 'iss'=>'jwt_admin', //該JWT的簽發者 * 'iat'=>time(), //簽發時間 * 'exp'=>time()+7200, //過期時間 * 'nbf'=>time()+60, //該時間之前不接收處理該Token * 'sub'=>'www.admin.com', //面向的使用者 * 'jti'=>md5(uniqid('JWT').time()) //該Token唯一標識 * ] * @param int $refresh 是否重新整理token 1是 * @param int $is_admin 是否後臺呼叫 1是 0 admin * @return string */ public static function getToken(array $payload, $refresh = 0, $is_admin = 0) { $exp = $refresh ? ($is_admin ? self::$admin_refresh_expect : self::$refresh_expect) : ($is_admin ? self::$admin_request_expect : self::$request_expect); $load = [ 'iat' => time(), 'exp' => time() + $exp, 'jti' => md5(uniqid('JWT').time()) ]; $payload = array_merge($load, $payload); $key = ($is_admin == 1 ? self::$admin_key : self::$key); $base64header = self::base64UrlEncode(json_encode(self::$header,JSON_UNESCAPED_UNICODE)); $base64payload = self::base64UrlEncode(json_encode($payload,JSON_UNESCAPED_UNICODE)); $token = $base64header.'.'.$base64payload.'.'.self::signature($base64header.'.'.$base64payload,$key,self::$header['alg']); return $token; } /** * 驗證token是否有效,預設驗證exp,nbf,iat時間 * @param string $Token 需要驗證的token * @return array */ public static function verifyToken($Token) { $tokens = explode('.', $Token); if (count($tokens) != 3){ return [ 'code' => 100, 'msg' => '驗證失敗' ]; } list($base64header, $base64payload, $sign) = $tokens; //獲取jwt演算法 $base64decodeheader = json_decode(self::base64UrlDecode($base64header), JSON_OBJECT_AS_ARRAY); if (empty($base64decodeheader['alg'])){ return [ 'code' => 100, 'msg' => '驗證失敗' ]; } $payload = json_decode(self::base64UrlDecode($base64payload), JSON_OBJECT_AS_ARRAY); $key = !empty($payload[self::$is_admin]) ? self::$admin_key : self::$key; //簽名驗證 if (self::signature($base64header . '.' . $base64payload, $key, $base64decodeheader['alg']) !== $sign){ return [ 'code' => 100, 'msg' => '簽名驗證失敗' ]; } //簽發時間大於當前伺服器時間驗證失敗 if (isset($payload['iat']) && $payload['iat'] > time()) { return [ 'code' => 100, 'msg' => '簽發時間大於當前伺服器時間,驗證失敗' ]; } //過期時間小宇當前伺服器時間驗證失敗 if (isset($payload['exp']) && $payload['exp'] < time()) { return [ 'code' => 200, 'msg' => '已過期' ]; } //該nbf時間之前不接收處理該Token if (isset($payload['nbf']) && $payload['nbf'] > time()) { return [ 'code' => 100, 'msg' => '驗證失敗' ]; } return [ 'code' => 0, 'msg' => '驗證成功', 'data' =>$payload ]; } /** * base64UrlEncode https://jwt.io/ 中base64UrlEncode編碼實現 * @param string $input 需要編碼的字串 * @return string */ private static function base64UrlEncode($input) { return str_replace('=', '', strtr(base64_encode($input), '+/', '-_')); } /** * base64UrlEncode https://jwt.io/ 中base64UrlEncode解碼實現 * @param string $input 需要解碼的字串 * @return bool|string */ private static function base64UrlDecode($input) { $remainder = strlen($input) % 4; if ($remainder) { $addlen = 4 - $remainder; $input .= str_repeat('=', $addlen); } return base64_decode(strtr($input, '-_', '+/')); } /** * HMACSHA256簽名 https://jwt.io/ 中HMACSHA256簽名實現 * @param string $input 為base64UrlEncode(header).".".base64UrlEncode(payload) * @param string $key * @param string $alg 演算法方式 * @return mixed */ private static function signature($input, $key, $alg ) { $alg_config = array( 'HS256'=>'sha256' ); return self::base64UrlEncode(hash_hmac($alg_config[$alg], $input, $key,true)); } }
2、Base.php
<?php /* * @Fun: 控制器基類 * @User: JessieK * @Date: 2021-08-19 18:06:47 */ namespace app\controller; use app\BaseController; use think\facade\Request; use lib\PhpJwt; class Base extends BaseController { //會員uid protected $uid; //會員unionid protected $unionid; //會員openid protected $openid; //jwt protected $payload; public function __construct() { //更新中 // $this->apiResult(-100, '網站正在火速更新中,請稍後---'); // $controller = strtolower(Request::controller()); $action = strtolower(Request::action()); if(!in_array($action, ['wechatlogin', 'index', 'coc', 'arealist', 'uploadimg', 'uploadimgstring'])){ //驗證token $this->checkToken(); } if(!in_array($action, ['wechatlogin', 'index', 'coc', 'arealist', 'uploadimg', 'getjwt', 'uploadimgstring'])){ //驗證sign // $this->verifySign(); } } /** * 驗證token */ public function checkToken() { $token = empty(Request::header()['authorization']) ? '' : Request::header()['authorization']; if(!$token){ $this->apiResult(-100, 'Authorization不能為空'); } $get_payload = PhpJwt::verifyToken($token); switch($get_payload['code']){ case 100: $this->apiResult(-100, 'token驗證失敗'); break; case 200: $this->apiResult(1000, 'token已過期'); } $this->uid = $get_payload['data']['uid']; $this->unionid = $get_payload['data']['unionid']; $this->openid = $get_payload['data']['openid']; $this->payload = $get_payload['data']; } /** * @name: 驗證簽名 * @param {*} * @return {*} */ public function verifySign() { $token = Request::header()['authorization']; list($base64header, $base64payload, $jwtsign) = explode('.', $token); $params = Request::post(); if(empty($params['sign'])){ $this->apiResult(-100 ,'sign不能為空'); } if(empty($params['timestamp'])){ $this->apiResult(-100, 'timestamp不能為空'); } //10分鐘有效 毫秒級 if (time() * 1000 - $params['timestamp'] > 600000) { // $this->apiResult(-100, '請求過期'); } $request_sign = $params['sign']; //對關聯陣列按照鍵名進行升序排序 unset($params['sign']); ksort($params); $param_str = ''; foreach ($params as $k => $v) { $v = is_array($v) ? json_encode($v, JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE) : $v; $param_str .= $k.$v; } $restr = $param_str.$jwtsign; $sign = md5($restr); if (strtolower($request_sign) != strtolower($sign)) { $this->apiResult(-100, '簽名驗證失敗'); } } public function apiResult($code, $msg, $data = []) { $result = [ 'code' => $code, 'msg' => $msg, 'data' => $data, ]; exit(json_encode($result, JSON_UNESCAPED_UNICODE)); } }
3、IndexBase.php
<?php /* * @Fun: 前臺api基類 * @User: JessieK * @Date: 2021-08-19 18:06:47 */ namespace app\controller; use app\model\Member; use think\facade\Request; class IndexBase extends Base { //會員資訊 protected $member_info; public function __construct() { parent::__construct(); if(!$this->openid){ $this->apiResult(-100, '缺少引數', ['msg' => 'openid為空']); } $memberModel = new Member(); $member_info = $memberModel->getUserInfoGather($this->openid); if(!$member_info){ $this->apiResult(1001, '會員資訊不存在'); } //鎖粉操作 $from_uid = Request::get('from_uid'); if(empty($member_info['from_uid']) && !empty($from_uid)){ $memberModel->setFromUid($member_info['uid'], $from_uid, Request::url(true)); } // addlog('errorlog/request/', 'pro', '請求url='.json_encode(request()->get(), JSON_UNESCAPED_UNICODE)); $this->member_info = $member_info; } }
- 最後書寫業務程式碼
1、對於需要驗證token的,只需要繼承IndexBase.php即可,基類裡面直接對前端傳過來的token進行驗證是否合法。
2、使用者在登入後,獲取到請求token,進行介面驗證;請求token過期後,不用重新登入,使用者用重新整理token重新整理,獲取到新的請求token,既可以重新獲取驗證,拿到使用者資訊,避免頻繁登入。
3、上述程式碼,還附帶verfySign簽名,這個主要是可以配合token進行一起使用。jwt實現驗證使用者身份,簽名實現介面請求是否合法。大致邏輯,在每次請求介面時帶上簽名和時間戳,具體簽名邏輯可看上述程式碼。
-----END
影子是一個會撒謊的精靈,它在虛空中流浪和等待被發現之間;在存在與不存在之間....