限:Java后端微信API對(duì)接的權(quán)限控制實(shí)戰(zhàn))
微信API對(duì)接這件事做Java后端的朋友應(yīng)該都有體會(huì)半數(shù)以上的聯(lián)調(diào)事故最后都出在Token上。不是微信返回的錯(cuò)誤碼看不懂而是自己這套權(quán)限體系在并發(fā)、續(xù)期、多端登錄面前先垮了。我前后接過公眾號(hào)、小程序、企業(yè)微信第三方應(yīng)用從單機(jī)內(nèi)存緩存到分布式Redis從裸Token到JWT再到雙Token續(xù)簽踩過的坑攢了不少。這篇就把微信API開發(fā)里Java后端的接口權(quán)限控制和Token管理完整梳理一遍很多細(xì)節(jié)是官方文檔不寫、但線上環(huán)境一定會(huì)遇到的。1. 微信API對(duì)接中你面對(duì)的根本不是一種Token先把三種身份憑證分清楚很多人做微信開發(fā)時(shí)把Token當(dāng)成一個(gè)籠統(tǒng)概念這是后續(xù)所有混亂的根源。實(shí)際上在微信API場景下Java后端至少要管理三類完全不同的憑證它們的生命周期、存儲(chǔ)位置、失效策略各不相同。1.1 平臺(tái)級(jí)全局憑證公眾號(hào)/小程序的access_token這一類是微信開放平臺(tái)發(fā)放的全局調(diào)用憑證公眾號(hào)和小程序都有調(diào)用任何業(yè)務(wù)接口發(fā)消息、上傳素材、生成二維碼、獲取用戶列表等都必須攜帶它。特點(diǎn)是全局唯一、有效期7200秒兩小時(shí)、每天有調(diào)用次數(shù)上限公眾號(hào)是2000次通過IP白名單等方式可以提升到更高額度。關(guān)鍵點(diǎn)在于全局唯一。整個(gè)應(yīng)用只有一個(gè)access_token所有服務(wù)器節(jié)點(diǎn)、所有線程都要共享同一個(gè)值。如果你用本地內(nèi)存緩存每個(gè)節(jié)點(diǎn)各拿各的就會(huì)出現(xiàn)我這臺(tái)機(jī)器調(diào)接口成功、那臺(tái)機(jī)器調(diào)接口報(bào)40001的詭異現(xiàn)象。我在下面的章節(jié)會(huì)專門講緩存策略這里先記住一個(gè)結(jié)論微信的access_token必須是全局共享的Java后端一定要用Redis或類似中間件統(tǒng)一存放不能用JVM內(nèi)存。1.2 用戶級(jí)身份憑證網(wǎng)頁授權(quán)access_token/用戶憑證公眾號(hào)網(wǎng)頁授權(quán)、小程序登錄流程中用戶在授權(quán)后會(huì)拿到一個(gè)用戶維度的憑證。公眾號(hào)的網(wǎng)頁授權(quán)access_token有效期更短默認(rèn)7200秒刷新用的refresh_token有效期長達(dá)30天而且每次刷新會(huì)作廢舊的refresh_token。小程序側(cè)的session_key則完全不公開給前端由code換回后只能保存在后端。這類憑證有個(gè)特點(diǎn)它和某個(gè)具體用戶綁定不是全局共享的。你可以把它存在Redis里key用userId維度設(shè)計(jì)設(shè)置與微信側(cè)一致的過期時(shí)間。很多人在這一步犯的錯(cuò)是把用戶憑證和平臺(tái)憑證放在一起統(tǒng)一管理過期策略混用最后用戶明明還登錄著后端刷新時(shí)卻拿不到合法憑證去換新的導(dǎo)致所有用戶集體掉線。1.3 自建系統(tǒng)登錄態(tài)你的應(yīng)用自己的Token第三類是你在自己的Java后端里簽發(fā)的登錄態(tài)Token跟前兩類無關(guān)。用戶通過微信授權(quán)后你的后端確認(rèn)了用戶身份然后自己發(fā)一個(gè)Token給前端后續(xù)所有請(qǐng)求都帶這個(gè)Token來訪問你的業(yè)務(wù)接口。這一類才是你在代碼里完全掌控的東西也是接口權(quán)限控制的主要抓手。我見過很多項(xiàng)目把這三類混為一談拿微信的access_token當(dāng)前端登錄態(tài)用的有拿session_key當(dāng)業(yè)務(wù)Token用的也有還有的自建Token直接照搬了微信7200秒的過期策略導(dǎo)致用戶每小時(shí)被迫重新登錄一次。正確的做法是三層隔離微信平臺(tái)憑證只用于后端調(diào)用微信API微信用戶憑證只用于換取用戶信息自建Token只用于你自家系統(tǒng)的會(huì)話管理。哪怕它們都叫Token存儲(chǔ)、續(xù)期、失效處理也要分開。2. 自建Token機(jī)制的設(shè)計(jì)取舍JWT、不透明Token和雙Token續(xù)簽自建Token是Java后端權(quán)限控制的基石但選型時(shí)很多人直接跟風(fēng)上了JWT或者干脆UUID一把梭上線后才發(fā)現(xiàn)問題。這套體系的核心指標(biāo)其實(shí)就三個(gè)可撤銷性、過期控制粒度、存儲(chǔ)開銷。2.1 JWT的適用邊界無狀態(tài)是優(yōu)點(diǎn)也是撤銷難的根源JWTJSON Web Token這幾年在Java圈熱度很高尤其是配合Spring Security的OAuth2體系。它最大的優(yōu)點(diǎn)是服務(wù)端無狀態(tài)Token本身攜帶用戶信息、角色、過期時(shí)間校驗(yàn)時(shí)只需要驗(yàn)簽不需要查數(shù)據(jù)庫或Redis。但微信API場景下JWT有一個(gè)致命軟肋無法主動(dòng)撤銷。用戶注銷、被踢下線、管理員封禁賬號(hào)時(shí)只要Token還沒過期且簽名合法服務(wù)端就無法拒絕它。典型的妥協(xié)方案是引入Token黑名單Redis里存一份已撤銷Token的標(biāo)識(shí)但這就等于把狀態(tài)又引入了無狀態(tài)的優(yōu)勢大打折扣。我在一個(gè)電商小程序項(xiàng)目里用過純JWT方案運(yùn)營后臺(tái)封禁一個(gè)惡意用戶后那家伙的Token還能再合法調(diào)用接口最多30分鐘直到我們把黑名單邏輯補(bǔ)上才解決。2.2 不透明Token一切盡在掌握中的常規(guī)選擇所謂不透明Token就是服務(wù)端生成一個(gè)隨機(jī)字符串UUID或SecureRandom生成存Rediskey是Token值value是用戶信息和權(quán)限快照過期時(shí)間由你自由設(shè)定。前端請(qǐng)求時(shí)后端從Redis查一次查到了就是合法請(qǐng)求查不到就是未登錄或已過期。這個(gè)方案在微信API場景里其實(shí)是最穩(wěn)的。理由很直接可以隨時(shí)刪除Redis里的key實(shí)現(xiàn)強(qiáng)制下線、踢人、封禁過期時(shí)間可以精確到秒還能通過Redis的TTL動(dòng)態(tài)續(xù)期不需要引入JWT庫、密鑰管理、簽名算法代碼量小、出問題面窄唯一缺點(diǎn)是多一次Redis網(wǎng)絡(luò)開銷但對(duì)絕大多數(shù)業(yè)務(wù)來說這個(gè)成本可以忽略表JWT與不透明Token在微信API場景下的能力對(duì)比能力維度JWT不透明TokenRedis服務(wù)端狀態(tài)依賴無狀態(tài)不依賴存儲(chǔ)強(qiáng)依賴Redis主動(dòng)撤銷踢人/封禁困難需要黑名單直接刪key即可過期時(shí)間精確控制依賴exp聲明不可動(dòng)態(tài)改Redis TTL隨時(shí)可調(diào)跨端登錄/多設(shè)備管理難以區(qū)分同一賬號(hào)的多個(gè)Token可通過綁定標(biāo)識(shí)精確管理攜帶用戶信息內(nèi)嵌payload無需查詢需要查Redis獲取用戶信息實(shí)現(xiàn)復(fù)雜度需要引入庫、配置密鑰低純Redis操作2.3 雙Token機(jī)制access_token refresh_token的正確打開方式我現(xiàn)在的常規(guī)做法是雙Token配合使用access_token短時(shí)效通常2小時(shí)和微信平臺(tái)token對(duì)齊便于記憶每次請(qǐng)求攜帶校驗(yàn)時(shí)查Redis驗(yàn)證有效性refresh_token長時(shí)效7天到30天不等只在access_token過期時(shí)使用換取新的access_token交互流程是這樣的用戶登錄成功后后端同時(shí)簽發(fā)access_token和refresh_tokenaccess_token有效期2小時(shí)refresh_token有效期7天并持久化存儲(chǔ)。前端在收到401或約定的續(xù)簽狀態(tài)碼時(shí)攜帶refresh_token請(qǐng)求刷新接口后端校驗(yàn)refresh_token有效后簽發(fā)新的access_token并返回給前端。如果refresh_token也過期了用戶才需要重新走微信授權(quán)登錄。這套機(jī)制的價(jià)值在于用戶體驗(yàn)上一次登錄保持一周而安全上真正的核心憑證access_token始終是短時(shí)效的就算被截獲破壞窗口也控制在2小時(shí)內(nèi)。refresh_token本身的存儲(chǔ)要嚴(yán)格管理前端最好放在HttpOnly的Cookie里避免JavaScript讀取降低XSS竊取風(fēng)險(xiǎn)。Java后端實(shí)現(xiàn)時(shí)refresh_token的簽發(fā)和校驗(yàn)代碼和access_token類似但是存儲(chǔ)的Redis key要區(qū)分比如// access_token的key String accessKey user:token:access: userId : accessToken; // refresh_token的key String refreshKey user:token:refresh: userId : refreshToken;校驗(yàn)邏輯上refresh_token的校驗(yàn)要多一步確認(rèn)它確實(shí)是被簽發(fā)給當(dāng)前用戶的同時(shí)對(duì)比Redis里存的refresh_token是否一致。這里有一個(gè)很隱蔽的坑如果你每次刷新時(shí)都重新生成了refresh_token也就是輪換那前一個(gè)refresh_token必須立即作廢否則就會(huì)有多個(gè)refresh_token同時(shí)有效用戶換設(shè)備時(shí)會(huì)出現(xiàn)連環(huán)失效的怪問題。我早期沒做輪換只刷新access_token不刷新refresh_token后來發(fā)現(xiàn)有些用戶的refresh_token被第三方截獲后長期有效嚇得我趕緊改成每次刷新都同時(shí)輪換兩個(gè)Token。3. 接口權(quán)限控制的落地代碼從攔截器到注解再到角色模型Token機(jī)制解決的是你是誰的問題權(quán)限控制解決的是你能干什么的問題。微信API后端里接口權(quán)限控制的常規(guī)實(shí)現(xiàn)路徑是攔截器解析Token → 注解聲明權(quán)限 → 權(quán)限校驗(yàn)器判定角色 → 不通過則拒絕訪問。下面我逐步拆解。3.1 攔截器層的Token校驗(yàn)Spring MVC里的統(tǒng)一入口Spring Boot下最直接的是實(shí)現(xiàn)HandlerInterceptor在preHandle里完成Token解析和用戶身份綁定。Component public class AuthInterceptor implements HandlerInterceptor { Autowired private StringRedisTemplate redisTemplate; private static final String USER_TOKEN_KEY user:token:access:; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 如果請(qǐng)求的是非接口資源比如靜態(tài)資源直接放行 if (!(handler instanceof HandlerMethod)) { return true; } // 從Header獲取Token約定自定義Header名稱避免與標(biāo)準(zhǔn)授權(quán)頭沖突 String token request.getHeader(X-Access-Token); if (StringUtils.isBlank(token)) { throw new BusinessException(401, 缺少訪問令牌); } // 從Redis查詢Token對(duì)應(yīng)的登錄態(tài)這一步同時(shí)完成了存在性和過期校驗(yàn) String userInfoJson redisTemplate.opsForValue().get(USER_TOKEN_KEY token); if (userInfoJson null) { throw new BusinessException(401, 登錄已過期請(qǐng)重新授權(quán)); } // 解析出用戶信息放入ThreadLocal或RequestAttribute供后續(xù)業(yè)務(wù)代碼使用 UserContext user JSON.parseObject(userInfoJson, UserContext.class); request.setAttribute(currentUser, user); return true; } }這段代碼有幾個(gè)容易漏掉的細(xì)節(jié)。第一HandlerMethod的判斷非常關(guān)鍵不加的話攔截器會(huì)把Spring Boot的/error端點(diǎn)或者其他非控制器請(qǐng)求也攔截掉導(dǎo)致報(bào)錯(cuò)頁都無法正常顯示。第二錯(cuò)誤處理上攔截器里拋出的異常要有一個(gè)全局異常處理器接收轉(zhuǎn)換成統(tǒng)一的JSON錯(cuò)誤響應(yīng)否則前端收到的可能是Spring默認(rèn)的500頁面而不是你約定的錯(cuò)誤結(jié)構(gòu)。第三把用戶信息塞進(jìn)request只是最基礎(chǔ)的做法追求性能的項(xiàng)目一般還會(huì)在解析成功后直接放入ThreadLocal但要注意在afterCompletion里清理ThreadLocal防止線程池復(fù)用導(dǎo)致用戶信息串號(hào)。3.2 自定義注解的權(quán)限聲明給接口打上權(quán)限標(biāo)簽光校驗(yàn)Token還不夠接口還必須聲明自己需要什么權(quán)限。我用自研注解的方式在Controller方法上標(biāo)注需要的角色或權(quán)限碼。Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface RequirePermission { String[] value(); // 要求的權(quán)限碼如 order:create boolean requireAll() default false; // 多個(gè)權(quán)限是全部滿足還是任一滿足 }Controller里的用法PostMapping(/order) RequirePermission({order:create}) public ApiResultString createOrder(RequestBody OrderCreateReq req) { // 業(yè)務(wù)代碼 }這個(gè)注解的含義是只有持有order:create權(quán)限碼的用戶才能訪問/order創(chuàng)建訂單接口。權(quán)限碼的命名我推薦使用資源:操作的格式比單純數(shù)字編號(hào)可讀性強(qiáng)得多微信小程序的管理后臺(tái)里做菜單權(quán)限配置時(shí)也直白。3.3 角色-權(quán)限模型不做用戶維度冗長的權(quán)限列表用角色中轉(zhuǎn)最常用的權(quán)限模型是RBAC基于角色的訪問控制用戶掛角色、角色掛權(quán)限碼。用戶登錄后簽發(fā)Token時(shí)我把該用戶擁有的權(quán)限碼列表直接緩存進(jìn)Redis這樣校驗(yàn)時(shí)不需要每次去查庫。Component public class PermissionAspect { Autowired private StringRedisTemplate redisTemplate; Around(annotation(requirePermission)) public Object checkPermission(ProceedingJoinPoint joinPoint, RequirePermission requirePermission) throws Throwable { // 從攔截器階段綁定的用戶信息中取出用戶ID ServletRequestAttributes attrs (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); HttpServletRequest request attrs.getRequest(); UserContext user (UserContext) request.getAttribute(currentUser); // 查出該用戶的權(quán)限碼集合優(yōu)先走Redis緩存兜底查數(shù)據(jù)庫 SetString userPermissions getCachedPermissions(user.getUserId()); String[] required requirePermission.value(); boolean pass requirePermission.requireAll() ? Arrays.stream(required).allMatch(userPermissions::contains) : Arrays.stream(required).anyMatch(userPermissions::contains); if (!pass) { throw new BusinessException(403, 無權(quán)限執(zhí)行該操作); } return joinPoint.proceed(); } }權(quán)限碼緩存需要確保一個(gè)核心邏輯用戶權(quán)限變更時(shí)必須能立即生效。我采用的方法是權(quán)限緩存key里帶一個(gè)版本號(hào)或直接在變更時(shí)刪除該用戶的權(quán)限緩存下次請(qǐng)求重新從數(shù)據(jù)庫加載。這里最大的教訓(xùn)是不要在權(quán)限緩存里加自動(dòng)過期時(shí)間來保證更新除非你能接受最長過期時(shí)間內(nèi)的權(quán)限延遲。實(shí)際運(yùn)營場景中給某用戶加個(gè)權(quán)限他10分鐘內(nèi)還是沒權(quán)限這種反饋已經(jīng)足夠讓你被投訴了。3.4 攔截器、切面、參數(shù)校驗(yàn)的協(xié)作順序我的建議是責(zé)任鏈?zhǔn)浇M合第一個(gè)環(huán)是AuthInterceptor負(fù)責(zé)解析Token并綁定用戶第二個(gè)環(huán)是PermissionAspect負(fù)責(zé)基于注解做權(quán)限判定第三個(gè)環(huán)是Spring自帶的參數(shù)校驗(yàn)。如果Token都沒有根本不需要做權(quán)限判定如果權(quán)限不夠也沒有必要做參數(shù)校驗(yàn)和業(yè)務(wù)邏輯。這個(gè)順序不只是性能考量更是錯(cuò)誤語義的分層——401表示你是誰的問題403表示你的權(quán)限夠不夠的問題400表示你的參數(shù)對(duì)不對(duì)的問題。前端可以根據(jù)狀態(tài)碼精確提示用戶進(jìn)行不同的下一步操作。4. 微信側(cè)access_token的緩存與刷新并發(fā)場景下最容易翻車的環(huán)節(jié)前面三類Token中微信平臺(tái)級(jí)的access_token雖然不參與你的接口權(quán)限控制但它絕對(duì)是導(dǎo)致線上事故的重災(zāi)區(qū)。這一章單獨(dú)展開。4.1 全局access_token的Redis緩存方案與key設(shè)計(jì)公眾號(hào)/小程序的access_token互不通用所以Redis的key至少要把應(yīng)用類型帶進(jìn)去。我的設(shè)計(jì)wechat:access_token:{appId}value直接存token字符串不存JSON因?yàn)闆]什么別的元信息。調(diào)用微信API前先檢查Redis里有沒有這個(gè)key有就直接用沒有再調(diào)用微信的getAccessToken接口拉取并寫入Redis。public String getWechatAccessToken(String appId) { String key wechat:access_token: appId; String token redisTemplate.opsForValue().get(key); if (StringUtils.isNotBlank(token)) { return token; } // 加分布式鎖避免多個(gè)線程同時(shí)去拉取導(dǎo)致token互相覆蓋 String lockKey wechat:access_token:lock: appId; boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, 1, Duration.ofSeconds(10)); if (!locked) { // 沒搶到鎖的線程自旋等待通常對(duì)方會(huì)在幾百毫秒內(nèi)完成寫入 // 這里建議最多重試3次每次等待200毫秒再取不到就報(bào)錯(cuò) } try { // 雙重檢查搶到鎖后再次確認(rèn)Redis里是否已有token token redisTemplate.opsForValue().get(key); if (StringUtils.isNotBlank(token)) { return token; } // 調(diào)用微信接口拉取新token token fetchFromWechat(appId); // 微信返回的expires_in通常是7200秒為了避免臨界點(diǎn)恰好過期 // 這里故意提前寫入比如實(shí)際TTL設(shè)置為7000秒 redisTemplate.opsForValue().set(key, token, Duration.ofSeconds(7000)); return token; } finally { redisTemplate.delete(lockKey); } }4.2 提前過期策略為什么把TTL設(shè)為7000秒而不是7200秒很多人把微信返回的expires_in原樣作為Redis的TTL這是隱患。原因有二一是網(wǎng)絡(luò)延遲和代碼執(zhí)行本身要消耗時(shí)間你從微信拿到token到寫入Redis之間可能已經(jīng)過去了零點(diǎn)幾秒二是當(dāng)token的實(shí)際剩余時(shí)間還有幾秒時(shí)你發(fā)起的請(qǐng)求如果恰好落在過期點(diǎn)上微信會(huì)返回40001錯(cuò)誤碼invalid credential這時(shí)候你的程序還沒到觸發(fā)重新拉取的節(jié)點(diǎn)就會(huì)連續(xù)報(bào)錯(cuò)。所以我會(huì)統(tǒng)一把TTL設(shè)置成比微信給定值少200秒相當(dāng)于一個(gè)安全余量。這不是精密的計(jì)算純粹是實(shí)戰(zhàn)中為了減少邊界條件問題而做的保守選擇。4.3 獲取token時(shí)的這個(gè)隱藏細(xì)節(jié)多個(gè)服務(wù)節(jié)點(diǎn)同時(shí)去拉取的連鎖反應(yīng)微信官方文檔里明確警告不可頻繁調(diào)用獲取token的接口否則會(huì)被封禁IP。線上應(yīng)用如果是多節(jié)點(diǎn)部署每個(gè)節(jié)點(diǎn)啟動(dòng)時(shí)都會(huì)拉一次token寫進(jìn)各自的內(nèi)存或同一個(gè)Redis里后寫的會(huì)把先寫的覆蓋掉。最危險(xiǎn)的是如果你在不同節(jié)點(diǎn)上做了本地內(nèi)存緩存那么節(jié)點(diǎn)A拿到的token和節(jié)點(diǎn)B拿到的不一致當(dāng)微信側(cè)檢測到token被覆蓋獲取新token后舊token立即失效節(jié)點(diǎn)A的所有請(qǐng)求都會(huì)瞬間變成40001。處理辦法就是我上面代碼里展示的分布式鎖雙重檢查模式讓同一時(shí)間只有一個(gè)請(qǐng)求去微信拉token其他人復(fù)用。這個(gè)坑我在早期的單體應(yīng)用里沒遇到過一旦上了K8s多副本立刻就暴露了。4.4 與自建Token的關(guān)系你們之間少一個(gè)邊界還有一個(gè)很多人忽略的點(diǎn)微信的access_token和你自建的用戶Token雖然獨(dú)立管理但在接口上的調(diào)用時(shí)機(jī)是關(guān)聯(lián)的。用戶發(fā)起一次需要調(diào)微信API的操作時(shí)前端先帶自建Token請(qǐng)求你的后端你的后端再去調(diào)微信接口。如果微信access_token過期了哪怕用戶的自建Token完全正常這次業(yè)務(wù)也失敗了。所以我在后端封裝的微信API調(diào)用層里做了一個(gè)統(tǒng)一處理凡是調(diào)用微信接口返回40001且錯(cuò)誤信息是access_token無效時(shí)自動(dòng)刪除Redis里的舊token拉取新token并用新token重試一次這次請(qǐng)求。這個(gè)重試機(jī)制看似簡單實(shí)戰(zhàn)中能救回大量偶發(fā)的過期窗口問題。public String callWechatApi(String url, String body) { String token getWechatAccessToken(appId); String fullUrl url ?access_token token; ApiResult result httpClient.post(fullUrl, body); if (result.getErrcode() 40001) { // token失效清除緩存并重試 redisTemplate.delete(wechat:access_token: appId); token getWechatAccessToken(appId); fullUrl url ?access_token token; result httpClient.post(fullUrl, body); } return result; }但注意重試必須要有次數(shù)上限和日志記錄。如果微信側(cè)真的封禁了你的IP或者AppID重試一百次只會(huì)加重封禁程度。我通常只重試一次第二次仍是40001就直接拋錯(cuò)并告警讓值班的人去查原因。5. 疑難雜癥排查實(shí)錄那些線上環(huán)境下才會(huì)遇到的Token問題最后一個(gè)章節(jié)集中復(fù)盤我在微信APIJava后端項(xiàng)目里真實(shí)遇到過的幾個(gè)疑難問題每一條都是排查半天甚至通宵換來的經(jīng)驗(yàn)。5.1 現(xiàn)象用戶登錄后接口偶發(fā)401刷新頁面又正常復(fù)現(xiàn)路徑用戶通過微信小程序授權(quán)登錄后訪問一個(gè)接口偶發(fā)返回401用戶投訴后你讓前端強(qiáng)刷一次又正常了。這種偶發(fā)問題排查起來最惡心因?yàn)闊o法穩(wěn)定復(fù)現(xiàn)。根因前端在同一個(gè)頁面里并發(fā)發(fā)起了2-3個(gè)請(qǐng)求后端簽發(fā)的access_token只有一個(gè)而你在Token校驗(yàn)過程中可能用了校驗(yàn)后立即刪除Redis key的模式有些人為了嚴(yán)格確保Token只能用一次。并發(fā)請(qǐng)求同時(shí)到達(dá)第一個(gè)請(qǐng)求刪除了key第二個(gè)請(qǐng)求再查就查不到了于是401。正確的做法Token校驗(yàn)不能有一次性語義除非你做的是一次性的掃碼接口。常規(guī)登錄態(tài)校驗(yàn)只讀取Redis的值做比對(duì)絕不刪除。如果要做單點(diǎn)登錄只允許一個(gè)設(shè)備在線應(yīng)該用userId維度綁定最新的token而不是在每次請(qǐng)求時(shí)刪掉舊的。5.2 現(xiàn)象管理后臺(tái)操作正常但微信小程序端登錄總是提示登錄狀態(tài)已過期復(fù)現(xiàn)路徑后臺(tái)管理端用Web登錄一切正常小程序端用戶在7天周期內(nèi)頻繁被提示重新授權(quán)而且看起來沒什么規(guī)律。根因我在小程序端的登錄邏輯里要求前端必須在access_token過期前主動(dòng)調(diào)用刷新接口而刷新接口依賴refresh_token但refresh_token的Redis過期時(shí)間設(shè)置成了和access_token一樣的2小時(shí)。前端拿著7天有效期的refresh_token去換新access_token時(shí)Redis里的refresh_token早就沒了所以刷新失敗只能重新走微信授權(quán)。這類問題的高發(fā)點(diǎn)在于代碼里出現(xiàn)了多個(gè)過期時(shí)間的字面常量寫的時(shí)候沒統(tǒng)一。我的規(guī)避辦法是在項(xiàng)目里建一個(gè)TokenPolicy配置類把所有過期時(shí)間集中管理并注釋標(biāo)明每一類Token的過期策略。一旦出現(xiàn)問題可以先查配置類而不是在代碼里到處找魔法數(shù)字。5.3 現(xiàn)象微信服務(wù)器回調(diào)通知處理失敗Java后端收不到事件推送復(fù)現(xiàn)路徑公眾號(hào)配置了服務(wù)器回調(diào)URL可是微信服務(wù)器推送的事件如用戶關(guān)注、支付成功通知后端一直沒收到或者收到了但是校驗(yàn)簽名不通過。根因Token在這里指的不是前面說的認(rèn)證Token而是你在微信公眾平臺(tái)配置的服務(wù)器配置里的Token它用于參與簽名校驗(yàn)。這個(gè)Token是你自己設(shè)定的任意字符串微信推送消息時(shí)會(huì)在URL上帶上timestamp、nonce、signature參數(shù)你的后端需要用Tokentimestampnonce拼接后做SHA1哈希對(duì)比signature是否一致。很多人在這里把自建的用戶Token跟這個(gè)回調(diào)Token搞混把回調(diào)校驗(yàn)邏輯里用的Token從配置文件里換掉了然后所有回調(diào)都被拒。排查思路如果你確認(rèn)URL和事件推送都正常但簽名一直校驗(yàn)失敗先看你是不是真的從請(qǐng)求參數(shù)里拿了timestamp和nonce而不是從配置文件里的某個(gè)定時(shí)任務(wù)里拿的值。另外微信簽名校驗(yàn)有個(gè)比較坑的地方timestamp和nonce參數(shù)名與你自己的參數(shù)有可能沖突如果你在框架層做了統(tǒng)一參數(shù)處理可能把它們改名了導(dǎo)致校驗(yàn)時(shí)拿到的不是微信原始值。5.4 現(xiàn)象JWT Token過期后前端拿refresh_token去續(xù)期偶爾成功偶爾失敗復(fù)現(xiàn)路徑使用雙Token機(jī)制前端在access_token過期后調(diào)用刷新接口發(fā)現(xiàn)大約10%的概率會(huì)失敗失敗時(shí)返回401用戶重新登錄后恢復(fù)。根因我用的是JWT作為access_tokenrefresh_token存儲(chǔ)在Redis。刷新接口的邏輯是先校驗(yàn)refresh_token在Redis中有效然后簽發(fā)一個(gè)新的JWT access_token同時(shí)對(duì)refresh_token也做了輪換重新生成并更新Redis。輪換后的舊refresh_token會(huì)被刪除。問題出在并發(fā)場景如果前端在兩個(gè)請(qǐng)求里幾乎同時(shí)調(diào)用了刷新接口比如頁面恢復(fù)了多個(gè)掛起的請(qǐng)求第一個(gè)請(qǐng)求輪換了refresh_token第二個(gè)請(qǐng)求帶著舊refresh_token再來舊的已經(jīng)被刪了于是校驗(yàn)失敗。解決方案有幾個(gè)層面前端必須對(duì)刷新接口做唯一化處理同一時(shí)刻只允許一個(gè)刷新請(qǐng)求在途其他請(qǐng)求等待其結(jié)果后復(fù)用新的access_token后端可以引入短期并發(fā)容忍比如刷新接口在檢測到舊refresh_token已失效時(shí)再查一下Redis里是否有新refresh_token與當(dāng)前用戶關(guān)聯(lián)如果新Token剛生成時(shí)間戳在幾秒內(nèi)允許拿著舊token也換取一次同樣的新token這個(gè)問題的根本矛盾是輪換語義和并發(fā)容忍之間的沖突。微信的refresh_token機(jī)制里也明確說了能多次使用就是為了容忍網(wǎng)絡(luò)重試。我當(dāng)時(shí)把刷新接口改成校驗(yàn)通過則必定返回當(dāng)前有效的access_token而不是必須接受攜帶的那個(gè)refresh_token才算數(shù)徹底解決了偶發(fā)失敗。5.5 調(diào)試接口時(shí)怎么快速驗(yàn)證你的Token體系是否健康最后分享一個(gè)調(diào)試技巧微信API開發(fā)里接口權(quán)限控制和Token管理的問題很多時(shí)候不是邏輯錯(cuò)了而是不知道當(dāng)前系統(tǒng)里的Token處于什么狀態(tài)。我的做法是在后端寫一個(gè)只有運(yùn)維權(quán)限能訪問的診斷接口一鍵輸出當(dāng)前登錄用戶數(shù)、Token過期分布、Redis命中率、微信access_token剩余有效期、最近1小時(shí)401/40001錯(cuò)誤統(tǒng)計(jì)。這個(gè)接口不需要太花哨用幾個(gè)Redis命令加一個(gè)簡單聚合就能完成但排查問題時(shí)效率翻倍。GetMapping(/internal/auth/health) public ApiResultMapString, Object authHealth() { MapString, Object report new HashMap(); // 自建Token總數(shù) report.put(onlineUserCount, redisTemplate.keys(user:token:access:*).size()); // 微信access_token剩余有效期 String token redisTemplate.opsForValue().get(wechat:access_token: appId); Long ttl redisTemplate.getExpire(wechat:access_token: appId); report.put(wechatTokenTtl, ttl); // 最近N小時(shí)的401錯(cuò)誤統(tǒng)計(jì)從日志聚合此處省略實(shí)現(xiàn) return ApiResult.success(report); }這個(gè)診斷接口一定要限制在內(nèi)網(wǎng)或加獨(dú)立的強(qiáng)Token保護(hù)否則等于把系統(tǒng)內(nèi)部狀態(tài)暴露給攻擊者。我一般直接禁止外網(wǎng)映射訪問。微信API和Java后端配合下的Token管理說到底是一個(gè)分層分工的問題微信自己的憑證管好時(shí)效和緩存自建Token管好安全與權(quán)限兩者通過刷新重試機(jī)制銜接。把每一層的邊界畫清楚很多看似玄學(xué)的問題其實(shí)都能從某一層的Token生命周期管理不嚴(yán)謹(jǐn)里找到答案。