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