:JWT+Filter+Interceptor實(shí)戰(zhàn)指南)
登錄校驗(yàn) JWT、Filter、Interceptor做了幾年后端登錄校驗(yàn)這塊踩過(guò)的坑比寫的業(yè)務(wù)代碼都多。從最開始用 Session 存登錄態(tài)到后來(lái)前后端分離、接口被各個(gè)端調(diào)用之后Session 那套越來(lái)越別扭跨域要配一堆東西、服務(wù)端要維護(hù)會(huì)話狀態(tài)、集群部署還得做 Session 同步。這時(shí)候 JWT 加 Filter 加 Interceptor 的組合成了主流解法。這篇文章就把我實(shí)際項(xiàng)目中這套登錄取證方案的完整思路、核心代碼和排坑經(jīng)驗(yàn)講清楚。適合剛接觸 Spring Boot 做登錄模塊的后端開發(fā)以及準(zhǔn)備從 Session 遷移到 JWT 的老項(xiàng)目維護(hù)者。1. 登錄校驗(yàn)的整體設(shè)計(jì)思路1.1 為什么放棄 Session 改成 JWTSession 模型的核心是服務(wù)端存儲(chǔ)用戶登錄后服務(wù)端生成一個(gè) sessionId 寫進(jìn) Cookie后續(xù)請(qǐng)求帶上這個(gè) ID服務(wù)端再去內(nèi)存或 Redis 里查會(huì)話記錄。單體應(yīng)用下沒毛病但到了前后端分離、接口要同時(shí)服務(wù) Web 端和移動(dòng)端的場(chǎng)景問題就來(lái)了第一移動(dòng)端對(duì) Cookie 的支持不如瀏覽器干凈很多客戶端框架處理 Cookie 得額外引庫(kù)。第二服務(wù)端會(huì)話是狀態(tài)化的部署多副本時(shí)得做 Session 共享引入 Redis 之后架構(gòu)復(fù)雜度直接上一個(gè)臺(tái)階。第三一旦會(huì)話數(shù)據(jù)膨脹內(nèi)存壓力也跟著上來(lái)。JWTJSON Web Token走的是另一條路服務(wù)端把用戶身份信息加密簽名后直接發(fā)給客戶端客戶端后續(xù)請(qǐng)求帶著這個(gè) Token服務(wù)端驗(yàn)簽通過(guò)就認(rèn)為身份合法。服務(wù)端不需要存任何會(huì)話天然無(wú)狀態(tài)多副本部署不需要額外同步。這點(diǎn)在現(xiàn)在的微服務(wù)和前后端分離架構(gòu)下非常關(guān)鍵。需要說(shuō)明的是JWT 不是銀彈。它犧牲了一個(gè)能力Token 在有效期內(nèi)無(wú)法主動(dòng)失效登出操作只能靠客戶端丟棄 Token。所以后面我會(huì)講到續(xù)簽和黑名單策略怎么配合把這套方案做得更完善。1.2 Filter 和 Interceptor 到底分工做什么很多新手搞不清 Filter 和 Interceptor 的區(qū)別其實(shí)它們對(duì)應(yīng)的層次和應(yīng)用場(chǎng)景完全不同。Filter 是 Servlet 規(guī)范里的東西在請(qǐng)求進(jìn)入 DispatcherServlet 之前執(zhí)行能拿到原始的 HttpServletRequest 和 HttpServletResponse也能攔截所有請(qǐng)求包括靜態(tài)資源。Interceptor 是 Spring MVC 的組件在 HandlerMapping 定位到具體的 Controller 方法之后執(zhí)行相當(dāng)于在請(qǐng)求進(jìn)入 Controller 之前做一個(gè)門衛(wèi)。放在登錄校驗(yàn)的場(chǎng)景里我的習(xí)慣做法是Filter 層處理 Token 的解析和校驗(yàn)決定這個(gè)請(qǐng)求是否放行。因?yàn)樗?Servlet 容器層面最早的入口可以統(tǒng)一處理請(qǐng)求頭解析完 Token 后把用戶信息寫進(jìn) ThreadLocal 或直接放到 request attribute 里。Interceptor 層處理更業(yè)務(wù)化的邏輯比如判斷當(dāng)前用戶有沒有訪問某個(gè)接口的權(quán)限、記錄操作日志、注入用戶上下文到業(yè)務(wù)代碼。但實(shí)際項(xiàng)目中很多人只用一個(gè)就夠。如果你只有登錄校驗(yàn)的需求單獨(dú)用 Filter 或 Interceptor 都能實(shí)現(xiàn)關(guān)鍵區(qū)別在于Filter 能攔到進(jìn)入 Spring MVC 之前的請(qǐng)求包括靜態(tài)資源和一些非 Controller 的轉(zhuǎn)發(fā)Interceptor 只能管到 Controller 這一層。另外 Filter 由容器管理天然能處理跨域場(chǎng)景下的預(yù)檢請(qǐng)求 OPTIONS這個(gè)在前后端分離項(xiàng)目中非常關(guān)鍵。2. JWT 的核心原理與安全細(xì)節(jié)2.1 三段式結(jié)構(gòu)拆解JWT 由三部分組成Header頭部、Payload載荷、Signature簽名用點(diǎn)號(hào)連接形如xxxxx.yyyyy.zzzzz。Header 里放的是簽名算法和 Token 類型Payload 里是業(yè)務(wù)聲明比如用戶 ID、用戶名、過(guò)期時(shí)間Signature 則是用密鑰對(duì)前兩部分做簽名后的結(jié)果。eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U這里的核心是 Signature 的生成過(guò)程把 Header 和 Payload 分別做 Base64Url 編碼再用點(diǎn)號(hào)拼接最后用 HMAC SHA256對(duì)應(yīng) HS256 算法或 RSA對(duì)應(yīng) RS256簽名。這個(gè)設(shè)計(jì)到底解決了什么問題Token 是發(fā)給客戶端的數(shù)據(jù)沒人能保證客戶端不篡改。如果用戶把自己的 userId 從 1001 改成 1002服務(wù)端驗(yàn)簽時(shí)就會(huì)發(fā)現(xiàn)簽名對(duì)不上直接拒絕。簽名等于給 Token 加了一把防偽鎖這是 JWT 比普通隨機(jī)字符串 Token 更優(yōu)雅的地方。2.2 密鑰管理和算法選擇簽名算法選擇上內(nèi)部系統(tǒng)用 HS256 足夠它是對(duì)稱密鑰生成和校驗(yàn)用同一個(gè) secret簡(jiǎn)單高效。但對(duì)面向第三方的開放平臺(tái)要用 RS256它用私鑰簽名、公鑰驗(yàn)證簽發(fā)方和驗(yàn)證方分離避免密鑰泄露導(dǎo)致所有 Token 可偽造。密鑰管理的幾個(gè)實(shí)操要點(diǎn)重點(diǎn)1HS256 的 secret 不要寫死在代碼里放到配置中心或環(huán)境變量長(zhǎng)度至少 32 字節(jié)建議 64 字節(jié)以上。有人用jwt或123456當(dāng)密鑰等于把鎖的鑰匙掛在門口。重點(diǎn)2歷史上出現(xiàn)過(guò)將算法從 RS256 降級(jí)為 HS256 的攻擊手法。攻擊者把 Token 的 Header 改成 HS256再用已知公鑰當(dāng)密鑰去簽名如果服務(wù)端校驗(yàn)時(shí)沒有指定算法就會(huì)驗(yàn)簽通過(guò)。解決方法是校驗(yàn)時(shí)固定算法類型不要直接讀取 Header 里的 alg。過(guò)期時(shí)間設(shè)置上我一般把登錄 Token 設(shè)成 2 小時(shí)刷新 Token 設(shè)成 7 天。太短體驗(yàn)差太長(zhǎng)安全性差。具體數(shù)字按業(yè)務(wù)調(diào)整但核心原則是最小有效時(shí)間。3. Filter 層實(shí)現(xiàn)登錄校驗(yàn)的落地代碼3.1 自定義 AuthFilter 的完整實(shí)現(xiàn)Filter 的正確打開方式是繼承OncePerRequestFilter而不是直接實(shí)現(xiàn)Filter接口。原因在于一次請(qǐng)求經(jīng)過(guò)轉(zhuǎn)發(fā)后普通 Filter 會(huì)執(zhí)行多次而這個(gè)抽象類保證了每個(gè)請(qǐng)求只執(zhí)行一次過(guò)濾邏輯。下面是我項(xiàng)目里沉淀下來(lái)的一套通用寫法Component public class AuthFilter extends OncePerRequestFilter { private static final String AUTH_HEADER Authorization; private static final String TOKEN_PREFIX Bearer ; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { // 放行預(yù)檢請(qǐng)求CORS OPTIONS if (OPTIONS.equalsIgnoreCase(request.getMethod())) { filterChain.doFilter(request, response); return; } String authHeader request.getHeader(AUTH_HEADER); if (authHeader null || !authHeader.startsWith(TOKEN_PREFIX)) { // 無(wú) Token 的直接放行交給 Interceptor 層判斷 filterChain.doFilter(request, response); return; } String token authHeader.substring(TOKEN_PREFIX.length()); try { Claims claims JwtUtil.parseToken(token); // 將用戶信息放入 request 屬性供后續(xù)使用 request.setAttribute(userId, claims.get(userId)); request.setAttribute(username, claims.get(username)); } catch (Exception e) { // Token 無(wú)效直接返回 401 response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\登錄狀態(tài)已失效請(qǐng)重新登錄\}); return; } filterChain.doFilter(request, response); } }這里有個(gè)設(shè)計(jì)上的取舍Filter 層只負(fù)責(zé)解析有效 Token不做強(qiáng)制攔截。對(duì)于沒有帶 Token 的請(qǐng)求選擇放行到后續(xù)的 Interceptor 再根據(jù)具體接口是否需要登錄來(lái)決定是否攔截。好處是 Filter 不用背哪些 URL 必須登錄這個(gè)配置清單把職責(zé)拆得更干凈。3.2 注冊(cè)方式和放行規(guī)則配置Filter 使用Component注解后會(huì)被 Spring Boot 自動(dòng)注冊(cè)但默認(rèn)攔截路徑是/*也就是所有請(qǐng)求。更重要的是多個(gè) Filter 的執(zhí)行順序由Order注解控制登錄校驗(yàn) Filter 應(yīng)該放在編碼過(guò)濾器之后但放在跨域過(guò)濾器之前或緊鄰其后。實(shí)際項(xiàng)目中容易踩的一個(gè)坑是Filter 中出現(xiàn)了非法 Token就返回 401。但有些接口是匿名可訪問的這時(shí)候即便客戶端莫名其妙傳了一個(gè)過(guò)期 Token 過(guò)來(lái)也不應(yīng)該直接攔死。所以更穩(wěn)的做法是Token 合法就解析并放入上下文Token 不合法但請(qǐng)求的接口允許匿名訪問就讓它繼續(xù)走由 Interceptor 做最終攔截判斷。放行規(guī)則配置方面我推薦將所有無(wú)需登錄的接口統(tǒng)一維護(hù)在一個(gè)配置類里public class PermitUrls { public static final ListString ANONYMOUS_URLS Arrays.asList( /api/auth/login, /api/auth/register, /api/captcha, /doc.html, /webjars/** ); }注意/doc.html和/webjars/**這類接口文檔資源也需要放行否則 Swagger 頁(yè)面會(huì)因?yàn)闊o(wú)法加載資源而白屏。這個(gè)細(xì)節(jié)我見過(guò)不少項(xiàng)目遺漏。4. Interceptor 的精細(xì)化控制4.1 攔截器注冊(cè)與路徑匹配Interceptor 的使用分兩步寫一個(gè)HandlerInterceptor實(shí)現(xiàn)類在WebMvcConfigurer里注冊(cè)。核心代碼如下Component public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 非接口請(qǐng)求直接放行可能是靜態(tài)資源或錯(cuò)誤頁(yè) if (!(handler instanceof HandlerMethod)) { return true; } Object userId request.getAttribute(userId); if (userId null) { response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\請(qǐng)先登錄\}); return false; } // 將用戶信息放入 ThreadLocal業(yè)務(wù)層可以直接取用 UserContext.setUserId(Long.valueOf(userId.toString())); return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { // 請(qǐng)求結(jié)束必須清理 ThreadLocal防止線程池復(fù)用導(dǎo)致數(shù)據(jù)串號(hào) UserContext.clear(); } }注冊(cè)配置類Configuration public class WebConfig implements WebMvcConfigurer { Autowired private LoginInterceptor loginInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .addPathPatterns(/api/**) .excludePathPatterns(/api/auth/login, /api/auth/register, /api/captcha); } }這個(gè)配置的含義是/api/開頭的所有請(qǐng)求都走攔截器但排除登錄、注冊(cè)、驗(yàn)證碼三個(gè)接口。路徑匹配用的是 Ant 風(fēng)格*匹配單級(jí)路徑**匹配多級(jí)路徑這個(gè)語(yǔ)義要記清楚。4.2 用戶上下文傳遞與 ThreadLocal 的正確清理上面代碼里我用了UserContext這個(gè) ThreadLocal 工具類目的是讓業(yè)務(wù)代碼優(yōu)雅獲取當(dāng)前登錄用戶不用每個(gè)方法都從 request 里取值傳參public class UserContext { private static final ThreadLocalLong USER_ID new ThreadLocal(); public static void setUserId(Long userId) { USER_ID.set(userId); } public static Long getUserId() { return USER_ID.get(); } public static void clear() { USER_ID.remove(); } }高并發(fā)下最容易出的問題就是ThreadLocal沒有及時(shí)清理。Tomcat 的工作線程是復(fù)用的這次請(qǐng)求往 ThreadLocal 里塞了 A 用戶的 ID下次請(qǐng)求如果是 B 用戶而 B 請(qǐng)求沒有走到setUserId比如匿名接口那getUserId拿到的就還是 A 的 ID這就是典型的越權(quán) Bug。所以我再三強(qiáng)調(diào)clear()方法必須放到afterCompletion里執(zhí)行。這個(gè)方法無(wú)論 preHandle 是否通過(guò)、Controller 是否拋異常只要進(jìn)入過(guò)攔截器就一定執(zhí)行是清理 ThreadLocal 最可靠的位置。4.3 Filter 和 Interceptor 的協(xié)作邊界回到最開始的問題既然我們有 Filter 解析 Token又用 Interceptor 攔截未登錄請(qǐng)求為什么不讓 Filter 一個(gè)干了原因在于職責(zé)分離帶來(lái)的靈活性。舉兩個(gè)真實(shí)的場(chǎng)景第一個(gè)場(chǎng)景接口級(jí)權(quán)限控制。系統(tǒng)里有/api/admin/**只有管理員能訪問這個(gè)判斷依賴用戶角色信息屬于業(yè)務(wù)邏輯放在 Interceptor 里比放在 Filter 里更自然因?yàn)樗浜?HandlerMethod 拿 Controller 方法上的RequireRole注解做判斷這個(gè)能力 Filter 做不到。第二個(gè)場(chǎng)景可插拔的校驗(yàn)策略。某些接口希望臨時(shí)放開登錄限制做個(gè)活動(dòng)用 Interceptor 的excludePathPatterns改一行配置就搞定不需要?jiǎng)?Filter 代碼。而 Filter 的路徑匹配能力本來(lái)就弱改起來(lái)還容易影響全局。所以我的結(jié)論是Filter 管Token 怎么驗(yàn)Interceptor 管哪些接口要驗(yàn)兩個(gè)配合使用各自職責(zé)內(nèi)聚后續(xù)擴(kuò)展權(quán)限、防刷、日志等功能時(shí)不會(huì)互相打架。5. Token 續(xù)簽、登出與常見問題排查5.1 Token 續(xù)簽的實(shí)現(xiàn)方案JWT 的無(wú)狀態(tài)是一把雙刃劍Token 在有效期內(nèi)永遠(yuǎn)有效但用戶操作到一半 Token 過(guò)期了體驗(yàn)非常差。續(xù)簽的主流方案有兩種。第一種是雙 Token 方案。登錄時(shí)同時(shí)返回 accessToken有效期短比如 30 分鐘和 refreshToken有效期長(zhǎng)比如 7 天??蛻舳嗣看握?qǐng)求帶 accessToken當(dāng)接口返回 401 時(shí)客戶端用 refreshToken 調(diào)用刷新接口換取新的 accessToken。這個(gè)方案實(shí)現(xiàn)清晰但客戶端需要處理異步重放邏輯稍微復(fù)雜一點(diǎn)。第二種是滑動(dòng)續(xù)期方案。每次請(qǐng)求時(shí)檢查 Token 剩余有效期如果低于某個(gè)閾值比如剩余不到一半時(shí)間就在響應(yīng)頭里返回一個(gè)新的 Token客戶端用新 Token 替換舊 Token。實(shí)現(xiàn)簡(jiǎn)單體驗(yàn)也順滑。我通常選第二種理由很實(shí)際它不需要客戶端改造太多邏輯響應(yīng)頭里放個(gè)X-Token字段前端 axios 攔截器加幾行代碼就能處理。不過(guò)要注意這種方式會(huì)讓短時(shí)間高頻請(qǐng)求頻繁刷新 Token所以續(xù)簽閾值別設(shè)太激進(jìn)比如 Token 有效期 2 小時(shí)剩余低于 20 分鐘才觸發(fā)續(xù)簽。5.2 登出與 Token 黑名單前面說(shuō)過(guò) JWT 無(wú)法真正失效那登出功能怎么做實(shí)際項(xiàng)目中最實(shí)用的方案是維護(hù)一個(gè)黑名單布隆過(guò)濾器或 Redis Set。登錄時(shí)給每個(gè) Token 一個(gè)jtiJWT ID字段登出時(shí)把這個(gè)jti塞進(jìn) Redis設(shè)置過(guò)期時(shí)間和 Token 剩余有效期一致。Filter 校驗(yàn) Token 時(shí)同時(shí)查一下黑名單查到了直接拒絕。借用 Redis 的過(guò)期機(jī)制黑名單數(shù)據(jù)不用手動(dòng)清理到點(diǎn)自動(dòng)消失。String jti UUID.randomUUID().toString(); // 生成 Token 時(shí)放入 claims.put(jti, jti); // 登出時(shí)加入黑名單 redisTemplate.opsForValue().set(logout: jti, 1, tokenExpireDuration, TimeUnit.MILLISECONDS); // 校驗(yàn)時(shí)查詢 boolean isLogout redisTemplate.hasKey(logout: jti);這個(gè)方案算是用一點(diǎn)有狀態(tài)換取關(guān)鍵安全性的折中在實(shí)際生產(chǎn)中足夠可靠。如果連 Redis 都不想引入至少也要把密鑰輪換機(jī)制做好出現(xiàn)問題時(shí)有快速讓所有 Token 失效的后手。5.3 常見問題速查表問題現(xiàn)象根因分析解決方案Filter 里解析 Token 成功但 Interceptor 拿不到用戶請(qǐng)求轉(zhuǎn)發(fā)導(dǎo)致 Filter 多次執(zhí)行改用 OncePerRequestFilter保證只執(zhí)行一次Token 能生成但接口一直 401算法混淆攻擊或密鑰不匹配校驗(yàn)時(shí)固定算法類型檢查簽名密鑰一致性登錄后訪問接口偶發(fā) 401Token 過(guò)期時(shí)間太短且沒有續(xù)簽機(jī)制引入滑動(dòng)續(xù)簽或雙 Token前端同步處理新 Token多個(gè) Filter 執(zhí)行順序錯(cuò)亂沒有配置 Order 或 Order 數(shù)值錯(cuò)誤明確編碼、跨域、登錄校驗(yàn) Filter 的優(yōu)先級(jí)并固定部署多個(gè)實(shí)例后登錄狀態(tài)不穩(wěn)定各實(shí)例用不同的簽名密鑰密鑰統(tǒng)一走配置中心或環(huán)境變量保證所有實(shí)例一致上傳文件接口 Token 解析正常但超時(shí)大文件上傳被 Filter 攔截處理時(shí)間過(guò)長(zhǎng)對(duì)上傳接口單獨(dú)放行或調(diào)整 Filter 的超時(shí)策略ThreadLocal 用戶信息串號(hào)請(qǐng)求結(jié)束未清理線程變量在 Interceptor 的 afterCompletion 中調(diào)用 clear()排查這類問題最有效的順序是先看請(qǐng)求頭里 Token 有沒有攜帶不對(duì)就查前端再用 JWT 官網(wǎng)的 Debugger 工具驗(yàn)證 Token 是否有效能解析說(shuō)明生成過(guò)程沒問題最后看服務(wù)端過(guò)濾鏈日志確認(rèn)請(qǐng)求走到哪一層被攔的。5.4 接口文檔與跨域這兩個(gè)附加坑Swagger 接口文檔掛/doc.html的一定要把資源路徑加到放行名單里不然前端拿著 token 去調(diào)文檔里的接口調(diào)試時(shí)頁(yè)面上一堆報(bào)錯(cuò)體驗(yàn)極差。開發(fā)環(huán)境可以在配置里單獨(dú)開關(guān)攔截器生產(chǎn)環(huán)境再嚴(yán)格放行。跨域問題上前后端分離項(xiàng)目最常見的 401 不是真的沒登錄而是 OPTIONS 預(yù)檢請(qǐng)求被攔截了。瀏覽器發(fā)起帶自定義 Header 的跨域請(qǐng)求時(shí)會(huì)先發(fā)一個(gè) OPTIONS 探路如果 Filter 直接對(duì)這個(gè)請(qǐng)求返回 401瀏覽器就認(rèn)為跨域失敗真實(shí)的 POST 請(qǐng)求根本發(fā)不出去。所以任何 Filter 里都要先判斷OPTIONS方法直接放行這是前提條件。6. 這套方案的擴(kuò)展空間做了基礎(chǔ)登錄校驗(yàn)之后我強(qiáng)烈建議把后續(xù)擴(kuò)展提前想好不然功能越加越亂。接口冪等和防重放可以基于 JWT 的jti字段做同一個(gè)jti出現(xiàn)兩次就拒絕能在不改接口簽名的情況下預(yù)防一部分重放攻擊。細(xì)粒度權(quán)限控制可以在 Interceptor 里配合HandlerMethod上的自定義注解實(shí)現(xiàn)比到處寫if (hasRole(ADMIN))要優(yōu)雅得多。操作日志直接復(fù)用 Filter 解析出來(lái)的用戶信息在afterCompletion里記錄接口耗時(shí)、狀態(tài)碼和操作人不需要侵入業(yè)務(wù)代碼。我還見過(guò)把登錄校驗(yàn)做到網(wǎng)關(guān)層的架構(gòu)網(wǎng)關(guān)統(tǒng)一驗(yàn)簽然后把用戶信息通過(guò) Header 傳給下游服務(wù)。本質(zhì)上思路都一樣Token 解析和校驗(yàn)前置業(yè)務(wù)服務(wù)只管從上下文中拿當(dāng)前用戶。這套模式在微服務(wù)架構(gòu)下更省事值得提前了解。寫到這里關(guān)于 JWT、Filter、Interceptor 的登錄校驗(yàn)方案基本上講全了。最后給還沒動(dòng)手改造項(xiàng)目的朋友一個(gè)建議不要一上來(lái)就追求雙 Token 加黑名單的完整方案先把 Filter 驗(yàn)簽加 Interceptor 攔截這套主鏈路跑通再根據(jù)業(yè)務(wù)需要逐步加上續(xù)簽和黑名單。登錄校驗(yàn)是整個(gè)系統(tǒng)的安全底座寧可多花一點(diǎn)時(shí)間設(shè)計(jì)清楚也別上線之后出現(xiàn)一次越權(quán)事故再改那個(gè)時(shí)候的代價(jià)可比寫這幾百行代碼大多了。