回調(diào)與賬號綁定實戰(zhàn))
簡介面向Discuz!論壇站長與社區(qū)運營者的QQ快捷登錄插件改進版2.4由北岸團隊對原版登錄流程進行優(yōu)化主要解決論壇注冊步驟繁瑣、QQ用戶轉(zhuǎn)化率偏低以及登錄入口兼容性不足等問題。壓縮包共61個文件整體約1MB內(nèi)含24個php文件承載授權(quán)回調(diào)、賬號綁定、安裝卸載與移動端登錄邏輯12個jpg和11個png用于界面與按鈕素材8個htm模板配合4個xml插件配置另有gif、html等輔助文件覆蓋PC與移動端接入場景。已有430人學習下載在Discuz!插件使用者中具備一定參考度。資料提供多編碼插件配置、QQ回調(diào)與會員綁定處理、移動端登錄類及幫助頁模板便于管理員按QQ互聯(lián)要求填入IDKEY后完成對接也方便開發(fā)者排查授權(quán)回調(diào)、賬號綁定和頁面展示問題減少從零改造登錄模塊的時間適合需要快速上線QQ登錄、減少重復開發(fā)的站長與二次開發(fā)者參考。1. 按鈕點下去白屏問題多半不在插件本身上周幫人看一個 DZ 論壇前臺 QQ 登錄按鈕點下去跳授權(quán)頁、點同意、回來一片白。站長說換過三套插件都一樣最后發(fā)現(xiàn)是回調(diào)地址末尾多了一個斜杠。這套 DZ QQ 登錄插件改進版 2.4就是在反復踩這類坑之后攢出來的東西授權(quán)、回調(diào)、綁定、解綁四條鏈路收在一個插件目錄里后臺填好 appid 和回調(diào)地址就能跑適合還在用 Discuz! X3.4/X3.5 搭垂直社區(qū)、又不想讓新用戶卡在郵箱驗證這一步的站長和二次開發(fā)者。下面按“鏈路怎么走—文件怎么放—參數(shù)怎么填—數(shù)據(jù)怎么寫—坑在哪”拆開講能抄的部分直接抄抄完把域名換成自己的就行。2. OAuth2.0 授權(quán)碼流程與插件目錄先把鏈路和文件看明白2.1 授權(quán)碼模式在這套插件里是怎么跑通的QQ 互聯(lián)走的是標準授權(quán)碼模式authorization code四步握手跳授權(quán)頁拿code、用code換access_token、拿access_token換openid、拿openid和 token 拉用戶資料。這四步里只有第一步是用戶在瀏覽器里完成的后三步全部在服務端用 curl 走所以插件真正容易出錯的地方全在服務端而不是前端按鈕。第一步的 URL 由插件主類拼出來邏輯大致是這樣?php // source/plugin/qqlogin/qqlogin.class.php 片段生成授權(quán)地址 $state substr(md5($_G[uid] . TIMESTAMP . random(8)), 0, 16); // state 同時落 cookie 和插件表回調(diào)時兩邊都要對得上 dsetcookie(qqlogin_state, $state, 600); C::t(#qqlogin#qqlogin_state)-insert(array( state $state, uid (int)$_G[uid], dateline TIMESTAMP, ), false, true); $params array( response_type code, client_id $this-appid, // 開放平臺分配的應用 ID純數(shù)字 redirect_uri $this-callback, // 必須與開放平臺后臺登記的一字不差 state $state, // 防 CSRF也用來區(qū)分是哪個入口發(fā)起的 scope get_user_info, ); $authorize_url https://graph.qq.com/oauth2.0/authorize? . http_build_query($params);state這一段是整個流程里最容易被偷懶省掉的部分。省掉之后功能也能跑但你會丟掉兩樣東西一是 CSRF 防護二是“這次登錄是從 PC 端還是手機端發(fā)起的”這個上下文。改進版 2.4 把 state 存成了獨立表pre_plugin_qqlogin_state而不是塞進$_SESSION原因是 DZ 的 session 在跨域回調(diào)回來時經(jīng)常已經(jīng)換了 session id尤其是站點開了 CDN 又沒做 cookie 透傳的時候讀 session 讀到空的概率相當高。第二步換 token 的請求在callback.inc.php里參數(shù)順序不重要但redirect_uri必須和第一步完全一致連 URL 編碼方式都要一致。第三步取 openid 返回的是一段callback( {client_id:xxx,openid:yyy} );的包裹文本插件里統(tǒng)一加了fmtjson省掉正則解析這個細節(jié)后面第 3 章會用 curl 單獨驗證。2.2 解包看目錄哪些文件在干活把壓縮包解開核心就是一個source/plugin/qqlogin/目錄外面還有一段后臺配置用的 XML。文件清單和職責對應關系如下文件/目錄作用二次開發(fā)改動頻率discuz_plugin_qqlogin.xml安裝描述文件定義插件變量、模塊、鉤子掛載點低除非要加新配置項qqlogin.class.php主類繼承plugin_qqlogin管鉤子和入口分發(fā)高callback.inc.php回調(diào)入口處理 code、換 token、寫綁定高bind.inc.php首次登錄的綁定頁邏輯處理用戶名沖突中unbind.inc.php解綁清common_member_connect對應記錄低table/table_qqlogin_state.phpstate 表的 DB 層封裝低template/按鈕模板、綁定頁模板、移動端模板中static/js/qqlogin.js彈窗與跳轉(zhuǎn)控制移動端 touch 綁定也在這里中看清這個結(jié)構(gòu)有個實際好處出問題的時候能立刻定位是“配置層”還是“代碼層”。比如授權(quán)頁能打開但回調(diào) 500問題一定在callback.inc.php之后按鈕點了沒反應問題在static/js或模板跟 OAuth 一點關系都沒有。我一般排查前先做這個二分能省掉一半時間。discuz_plugin_qqlogin.xml里的var節(jié)點定義了后臺可見的配置項常見的就四個appid、appkey、callback、allownewuser。其中allownewuser控制“沒綁定過的 QQ 是否允許自動創(chuàng)建新賬號”社區(qū)類站點一般設 1內(nèi)部論壇建議設 0只允許已有賬號綁定防止外人隨便注冊進來。2.3 安裝與后臺配置的落地步驟裝之前先備份source/plugin/和數(shù)據(jù)庫這一步別省。DZ 插件裝壞了會導致后臺應用列表白屏恢復起來比重裝還麻煩。上傳和權(quán)限給到位# 在論壇根目錄執(zhí)行假設包已解壓到 /tmp/qqlogin cp -r /tmp/qqlogin/source/plugin/qqlogin ./source/plugin/ # 插件目錄給 web 用戶可讀可執(zhí)行不要 777 chown -R www:www ./source/plugin/qqlogin find ./source/plugin/qqlogin -type d -exec chmod 755 {} \; find ./source/plugin/qqlogin -type f -exec chmod 644 {} \; # 清一遍模板與緩存目錄避免舊緩存打架 rm -rf ./data/template/* ./data/cache/*命令本身沒什么玄學重點是最后那兩行。DZ 的模板緩存是按插件標識緩存的第二次裝同標識插件時如果不清理前臺很可能還是舊按鈕的 HTML你會誤以為是插件沒生效。chown也別圖省事用 777插件目錄里有 PHP 文件777 等于給人留了寫 shell 的口子。上傳完成后進后臺「應用 → 插件 → 未安裝插件」找到 QQ 登錄點安裝。安裝過程會建兩張表pre_plugin_qqlogin_state和綁定關系表。裝完在插件設置里填四個參數(shù)參數(shù)填什么常見填錯appid開放平臺應用詳情頁的 APP ID純數(shù)字誤填成 APP Keyappkey應用密鑰字母數(shù)字混合不要帶空格復制時帶進了換行callback完整回調(diào)地址含協(xié)議和域名少了https://或末尾多了/allownewuser1 允許自動注冊0 僅允許綁定與站點注冊策略沖突填完必須點一次「更新緩存」DZ 的插件變量是緩存在pre_common_cache里的不更新緩存你改十遍后臺前臺讀到的還是舊值。這個坑我在第 5 章還會再提一次因為它出現(xiàn)頻率實在太高。最后一步是跑到前臺實際點一次。別用管理員賬號測管理員登錄會被 DZ 的connect邏輯特殊處理測出來的結(jié)果和普通用戶不一致。開一個瀏覽器無痕窗口用沒注冊過的普通身份走一遍完整流程能看到綁定頁、能填用戶名、能登錄成功才算真裝好了。3. 回調(diào)地址與 appid 配置三個參數(shù)決定成敗3.1 redirect_uri 的三處一致性校驗回調(diào)地址是這個插件里最容易翻車的參數(shù)沒有之一。它同時存在于三個地方開放平臺后臺登記的授權(quán)回調(diào)域、DZ 插件后臺配置的callback、以及callback.inc.php里拼給 token 接口的那個值。這三處必須完全一致任何一處不同都會在換 token 那一步拿到error100010之類的返回?!耙恢隆钡呐卸ū榷鄶?shù)人想的嚴格。http和https算兩個地址帶www和不帶www算兩個末尾有沒有斜杠算兩個帶不帶非標準端口比如:8080算兩個。測試環(huán)境從http://test.xxx.com換到https://www.xxx.com三處都得改漏一處就白屏。我的做法是把這個地址做成唯一來源插件后臺填一次代碼里全部讀配置不硬編碼?php // callback.inc.php 開頭回調(diào)地址統(tǒng)一從插件變量取不做任何拼接 $this-callback $_G[cache][plugin][qqlogin][callback]; if (empty($this-callback)) { showmessage(qqlogin_callback_missing, , array(), array(handle false)); } // 換 token 時原樣帶上不 urldecode、不重新拼參數(shù) $token_url https://graph.qq.com/oauth2.0/token?grant_typeauthorization_code . client_id . $this-appid . client_secret . $this-appkey . code . urlencode($code) . redirect_uri . urlencode($this-callback) . fmtjson;注意urlencode只加在參數(shù)值上整個 URL 不能再編一次否則https://里的冒號和斜杠會被編成%3A%2F%2F服務端解出來是個壞地址。這個錯誤的表現(xiàn)很迷惑授權(quán)頁能正常打開因為第一步瀏覽器會自動處理編碼只有回調(diào)那一步失敗很多人會誤判成 appkey 錯了。3.2 appid/appkey 與簽名參數(shù)appid和appkey一個是對外的身份標識一個是對內(nèi)的密鑰性質(zhì)完全不同。appid出現(xiàn)在前端跳轉(zhuǎn) URL 里泄露了無所謂appkey只在服務端換 token 時用絕不能出現(xiàn)在任何前端可見的地方。我見過有模板把appkey寫進 JS 變量方便“調(diào)試”這等于把應用密鑰公開了。state的校驗邏輯也在這層。改進版 2.4 用“cookie 數(shù)據(jù)表”雙份比對?php // 回調(diào)第一件事校驗 state兩個來源必須同時命中 $state isset($_GET[state]) ? trim($_GET[state]) : ; $cookie_state isset($_COOKIE[qqlogin_state]) ? $_COOKIE[qqlogin_state] : ; if (empty($state) || empty($cookie_state) || !hash_equals($cookie_state, $state)) { showmessage(qqlogin_state_invalid, , array(), array(handle false)); } // 命中后立刻刪掉state 一次性使用 C::t(#qqlogin#qqlogin_state)-delete_by_state($state); dsetcookie(qqlogin_state, , -1);用hash_equals而不是是防止時序攻擊雖然在這個場景下風險很低但成本幾乎為零沒理由不用。刪除也必須在校驗通過后立刻做否則同一個 state 可以被重放攻擊者拿到一次回調(diào) URL 就能反復觸發(fā)綁定動作。access_token的緩存策略也值得說一句。token 默認有效期 90 天refresh_token30 天內(nèi)可以續(xù)期。插件把 token 存在common_member_connect.conuintoken里每次登錄前先查有沒有可用 token沒有才重新走授權(quán)。這樣用戶第二次點登錄時是靜默的不會反復彈授權(quán)頁。但要注意用戶在開放平臺那邊“取消授權(quán)”之后本地緩存的 token 會失效插件必須能識別error100016這類返回并清掉本地記錄否則用戶會卡在“點了沒反應”的狀態(tài)里出不來。3.3 用 curl 先跑通授權(quán)鏈路再回插件排查這類問題我習慣先繞開 DZ 和瀏覽器用 curl 把手動流程走一遍。這樣能明確區(qū)分是“平臺側(cè)配置問題”還是“插件代碼問題”。# 變量先準備好值從插件后臺和開放平臺后臺抄過來 APPID你的appid APPKEY你的appkey REDIRECT_URIhttps://bbs.example.com/plugin.php?idqqlogin:callback CODE瀏覽器回調(diào)地址里 code 后面的那串 # 第 2 步code 換 token curl -s https://graph.qq.com/oauth2.0/token?grant_typeauthorization_codeclient_id${APPID}client_secret${APPKEY}code${CODE}redirect_uri${REDIRECT_URI}fmtjson # 第 3 步token 換 openid curl -s https://graph.qq.com/oauth2.0/me?access_token${ACCESS_TOKEN}fmtjson # 第 4 步拉用戶資料看 nickname 能不能取到 curl -s https://graph.qq.com/user/get_user_info?access_token${ACCESS_TOKEN}oauth_consumer_key${APPID}openid${OPENID}第二步返回里如果帶error基本可以斷定是三處redirect_uri不一致把返回的error_description讀一遍比在插件里瞎猜快得多。第三步返回openid說明 appkey 是對的。第四步返回的nickname里可能帶表情符號DZ 的老表結(jié)構(gòu)是utf8不是utf8mb4直接入庫會把表情截斷甚至報錯插件里做了strip_emoji過濾這個在第 4 章會講到。code是一次性的有效期很短通常幾分鐘。如果第二步報code expired回瀏覽器重新點一次登錄拿新 code 就行別對著舊 code 反復試。curl 驗證通過之后再回插件點一次如果插件還是失敗而 curl 成功問題就一定在插件的代碼路徑上重點看callback.inc.php有沒有對參數(shù)做了多余處理。4. 賬號綁定與用戶表寫入從 openid 到 uid 的落地邏輯4.1 connect 表與 common_member 的對應關系OAuth 鏈路走通只是拿到身份真正把“這個 QQ 是誰”落到“這個人是論壇的哪個 uid”靠的是common_member_connect這張表。它和common_member是一對一關系uid做主鍵conopenid上必須有唯一索引CREATE TABLE IF NOT EXISTS pre_common_member_connect ( uid int(10) unsigned NOT NULL, conisbind tinyint(1) unsigned NOT NULL DEFAULT 0, conopenid char(32) NOT NULL DEFAULT , conuin char(40) NOT NULL DEFAULT , conuintoken char(32) NOT NULL DEFAULT , conqqnick varchar(100) NOT NULL DEFAULT , condeletetime int(10) unsigned NOT NULL DEFAULT 0, PRIMARY KEY (uid), UNIQUE KEY conopenid (conopenid), KEY conisbind (conisbind) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;conopenid上的唯一索引不是可選項。缺了它并發(fā)情況下同一個 QQ 可能綁出兩個 uid用戶下次登錄隨機命中其中一個表現(xiàn)為“我的帖子怎么少了一半”。conuintoken存的是 access_token注意長度給夠別用char(16)這種拍腦袋的長度。condeletetime是軟刪除標記用戶解綁時不物理刪除記錄只置這個字段。這么設計的原因是防止“解綁后馬上重綁”觸發(fā)唯一索引沖突同時也留了一條審計痕跡。真要徹底清理由定時任務處理超過一定天數(shù)的記錄。4.2 綁定、解綁與重名沖突的處理代碼首次登錄的判斷順序很關鍵必須“先查 openid再決定是登錄還是注冊”?php // callback.inc.php拿到 openid 之后的核心分發(fā)邏輯 $openid daddslashes($openid); $bind C::t(#qqlogin#qqlogin_bind)-fetch_by_openid($openid); if ($bind $bind[uid]) { // 已綁定直接寫登錄態(tài)更新 token C::t(#qqlogin#qqlogin_bind)-update_token($bind[uid], $access_token); setloginstatus(array(uid $bind[uid], username $bind[username]), 2592000); showmessage(qqlogin_succeed, dreferer(), array(), array(handle false)); } // 未綁定allownewuser 為 0 時直接拒絕 if (!$_G[cache][plugin][qqlogin][allownewuser]) { showmessage(qqlogin_not_bind, , array(), array(handle false)); } // 允許注冊則跳到綁定頁由用戶確認用戶名 include template(qqlogin:bind);setloginstatus的第三個參數(shù)是 cookie 有效期單位秒2592000是 30 天。這個值別設太長社區(qū)類站點一般 7 到 30 天比較合適。重名沖突發(fā)生在自動注冊那一步。QQ 昵稱里有大量重復直接拿昵稱當用戶名注冊第二次就會撞common_member的用戶名唯一索引。插件的做法是加后綴遞增?php // bind.inc.php用戶名去重最多試 20 次 $base preg_replace(/[^\x{4e00}-\x{9fa5}a-zA-Z0-9_]/u, , $nickname); $base $base ? qquser : mb_substr($base, 0, 12, UTF-8); $username $base; for ($i 1; $i 20; $i) { if (!C::t(common_member)-fetch_by_username($username)) { break; } $username $base . _ . $i; } if ($i 20) { showmessage(qqlogin_username_conflict, , array(), array(handle false)); }preg_replace那行把昵稱里的表情和標點全部濾掉只留中文、字母、數(shù)字、下劃線。不過濾的話utf8表直接報Incorrect string value。mb_substr限長 12 是照顧 DZ 默認用戶名長度限制改過站點配置的按自己的來。4.3 老用戶遷移把歷史賬號掛到 openid 上站點從別的方式切過來時會有一批“已經(jīng)在論壇注冊過、但沒綁定過 QQ”的老用戶。這批人第一次點 QQ 登錄會走到注冊分支結(jié)果多出一個新賬號帖子還在老賬號上用戶會直接罵人。處理辦法是在綁定頁給一個“已有賬號”入口讓用戶輸入用戶名密碼驗證一次驗證通過后把 openid 掛到老 uid 上-- 遷移腳本把已知對應關系的用戶批量補綁定記錄 INSERT INTO pre_common_member_connect (uid, conisbind, conopenid, conuin, conuintoken, conqqnick, condeletetime) SELECT m.uid, 1, t.openid, , , t.nickname, 0 FROM pre_common_member m INNER JOIN pre_plugin_qqlogin_migrate t ON t.username m.username WHERE m.uid 0 ON DUPLICATE KEY UPDATE conisbind 1, conqqnick VALUES(conqqnick);ON DUPLICATE KEY UPDATE讓腳本可以重復跑跑第二遍不會報主鍵沖突也不會把已有記錄改壞。pre_plugin_qqlogin_migrate是臨時對照表跑完就刪。執(zhí)行前先在從庫或者備份上跑一遍SELECT版本確認行數(shù)和預期對得上血淚經(jīng)驗是這類腳本一定要先看結(jié)果再落庫。5. 避坑與排查五條踩坑記錄現(xiàn)象一回調(diào)后整頁白屏日志里沒有任何 DZ 報錯。原因通常是 PHP 致命錯誤被display_errors關掉了或者插件文件帶 BOM 頭導致headers already sent。解決臨時在config/config_global.php里開$_config[debug] 1去data/log/下看當天的xxx_log.phpBOM 用grep -rl $\xEF\xBB\xBF source/plugin/qqlogin/一次就能找出來批量轉(zhuǎn)成無 BOM 的 UTF-8。現(xiàn)象二授權(quán)頁能打開點同意后回到站點提示參數(shù)錯誤。原因是三處redirect_uri不一致或者callback參數(shù)被二次 URL 編碼。解決按第 3 章的辦法先用 curl 單獨驗證看返回的error_description。改完配置記得更新緩存插件變量讀的是緩存不是數(shù)據(jù)庫。現(xiàn)象三用戶每次點登錄都變成新注冊賬號越積越多。原因是common_member_connect缺conopenid唯一索引或者判斷邏輯寫反了先查 uid 再查 openid。解決先SHOW INDEX FROM pre_common_member_connect確認唯一索引在再檢查callback.inc.php的分支順序必須是 openid 優(yōu)先。已經(jīng)產(chǎn)生的重復賬號要按 openid 分組人工合并別寫腳本自動刪。現(xiàn)象四PC 端正常手機端點了完全沒反應。原因多半是模板里按鈕綁的是click事件而移動端瀏覽器在某些情況下會吞掉這個事件另一種是本站已全站 HTTPS但插件模板里硬編碼了http://的跳轉(zhuǎn)地址被瀏覽器按混合內(nèi)容攔掉。解決事件改成touchstart和click都綁檢查模板里所有 URL 是否走$_G[siteurl]。用手機瀏覽器遠程調(diào)試看一眼 Console有沒有 blocked 提示一目了然。現(xiàn)象五換了域名或上了 HTTPS 之后所有老用戶全部掉登錄。原因是 cookie 域、后臺站點 URL、開放平臺回調(diào)三處沒同步導致 state 校驗失敗或者 token 換不出來。解決換域名時把這三處列成清單逐項改改完讓用戶重新授權(quán)一次。這一步?jīng)]有后悔藥唯一的辦法是換域名前就做好清單。6. 移動端與模板兼容的進階調(diào)優(yōu)一次回歸驗證清單改完模板和綁定邏輯之后別急著放開給用戶用。我一般跑一遍固定清單把六種組合都點一遍任一項不對就不上線場景預期結(jié)果重點檢查PC 未登錄點按鈕跳授權(quán)頁回來進綁定頁state cookie 是否寫入成功PC 已綁定用戶點按鈕靜默登錄不彈授權(quán)頁conuintoken是否被復用手機瀏覽器點按鈕跳轉(zhuǎn)或喚起授權(quán)回來正常按鈕事件綁定、siteurl協(xié)議微信內(nèi)置瀏覽器打開至少給出明確提示不白屏UA 判斷分支是否存在已綁定用戶解綁再綁能正常解綁重綁不報沖突軟刪除字段是否生效后臺關閉自動注冊新用戶看到提示而非報錯allownewuser分支模板這塊改動最小、收益最大的一個點是按鈕的降級。移動端很多時候喚起授權(quán)會失敗如果沒有兜底用戶看到的就是一個死按鈕!-- template/qqlogin/button.htm移動端同時綁 touchstart 和 click防止事件被吞 -- a href{$qqlogin_url} classqqlogin-btn idqqloginBtn relnofollow span classqqlogin-icon/spanQQ 登錄 /a script typetext/javascript var btn document.getElementById(qqloginBtn); var jumping false; function go(e) { if (jumping) { return; } jumping true; if (e e.preventDefault) { e.preventDefault(); } window.location.href btn.getAttribute(href); } btn.addEventListener(touchstart, go, {passive: false}); btn.addEventListener(click, go, false); /scriptjumping這個標志位防的是移動端同時觸發(fā)touchstart和click導致連跳兩次第二次帶上來的 state 已經(jīng)失效用戶看到的就是一次莫名其妙的白屏。{passive: false}是為了在touchstart里能調(diào)preventDefault不加的話部分瀏覽器會忽略這個調(diào)用。還有一個容易被忽略的細節(jié)relnofollow。登錄入口被搜索引擎爬到會產(chǎn)生大量無效的授權(quán)頁索引加上這個屬性省事。最后說個習慣。這套插件我從第一版跟到現(xiàn)在每次升級前都會做同一件事把當前source/plugin/qqlogin/整個目錄打包留一份命名為qqlogin_日期_版本.rar放到獨立目錄然后才動手覆蓋。因為插件升級失敗時后臺應用列表會直接白屏進不去后臺就沒法回滾只能靠文件覆蓋回去。這個習慣讓我至少兩次在十分鐘內(nèi)恢復了一個正在做活動的論壇。從那以后我每次動插件目錄都強制走一遍“打包—記錄改動點—再覆蓋”的流程希望幫到你。本文還有配套的精品資源點擊獲取