
1. 手搓之前先拆穿Spring Security的底褲如果你只用過Spring Security的配置類可能會覺得這東西像個黑盒一引入依賴登錄頁有了密碼校驗有了接口保護有了。出了問題之后報錯日志一大堆根本不知道哪一層攔截了你。我最初學安全框架時也有這個感覺直到自己動手做了一個極簡版本才徹底搞明白它到底在做什么。先說結論Spring Security本質上就是一段Servlet過濾器鏈只不過它把“認證”、“授權”、“防攻擊”這些事拆成了很多個小Filter按順序一個個執(zhí)行。你配置的SecurityFilterChain說人話就是“你允許哪些過濾器參與、按什么順序、放行哪些路徑”。這套東西完全基于Servlet的Filter機制脫離Spring Boot也能跑所以想“手搓一個Spring Security”第一步就是理解過濾器鏈而不是去看那些花里胡哨的注解。1.1 Spring Security的本質是一串Filter不是魔法Servlet容器收到一個請求后會按照注冊順序依次調用Filter每個Filter可以選擇放行、直接返回響應或者先處理再放行。Spring Security就是往這個鏈路里塞了十幾個Filter比如UsernamePasswordAuthenticationFilter負責處理表單登錄FilterSecurityInterceptor負責做最終的授權判斷。手搓版本不需要那么多但要抓住最核心的三個職責從請求里提取身份憑證Token、用戶名密碼、Cookie等校驗憑證并把身份信息放進上下文請求進入業(yè)務方法之前判斷這個身份有沒有權限這三件事對應到代碼里就是一個認證過濾器、一個上下文持有器、一個權限攔截器。搞清楚這三樣Spring Security的主干就摸透了。1.2 認證、授權、上下文三件套缺一不可很多人把認證和授權混在一起其實它們是兩個階段。認證是“你是誰”授權是“你能做什么”。兩者之間需要傳遞一個載體也就是SecurityContext里的Authentication對象。類比一下認證就是刷門禁卡看你是不是公司員工授權是進到樓里之后看你的門禁權限能不能打開某個辦公室。Spring Security用Authentication對象同時裝這兩個信息它里面有principal身份主體、credentials憑證一般存原始密碼或Token、authorities權限列表。手寫迷你版時我也照這個思路設計登錄接口校驗用戶名密碼之后構造一個AuthUser對象塞進ThreadLocal同時帶一個權限集合后續(xù)的攔截器從ThreadLocal里取人判斷權限。難點不在這些概念而在過濾器的順序和放行規(guī)則這才是坑最多的地方。2. 手搓一個能用的認證過濾器我選擇先實現(xiàn)一個最簡的Token認證過濾器原因很簡單它不依賴Session邏輯清晰適合講原理。真實項目中用JWT也是一樣的套路只是加密驗證細節(jié)更多。2.1 從最簡單的Token認證開始我的極簡方案長這樣客戶端登錄后服務端發(fā)一個隨機UUID當Token存內存Map里后續(xù)請求在Header里帶上Token。認證過濾器負責查這個Token查不到就代表未登錄。public class TokenAuthenticationFilter implements Filter { private final TokenStore tokenStore; public TokenAuthenticationFilter(TokenStore tokenStore) { this.tokenStore tokenStore; } Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest (HttpServletRequest) request; HttpServletResponse httpResponse (HttpServletResponse) response; String token httpRequest.getHeader(X-Auth-Token); if (token ! null tokenStore.contains(token)) { AuthUser user tokenStore.getUser(token); // 把用戶放進上下文供后續(xù)使用 SecurityContextHolder.setCurrentUser(user); } chain.doFilter(request, response); // 請求結束后清理防止ThreadLocal泄漏 SecurityContextHolder.clear(); } }關鍵點是chain.doFilter執(zhí)行完之后一定要清理當前線程的上下文。我曾經(jīng)在線程池環(huán)境下忘了做清理結果高并發(fā)時A用戶拿到了B用戶的數(shù)據(jù)這種事故排查起來極其痛苦。2.2 把SecurityContext存起來還要解決跨線程問題ThreadLocal看起來簡單但真實項目里容易踩雷。比如說你在一個Filter里放入了用戶到了Service層想拿用戶信息用的卻是異步線程那ThreadLocal就取不到了。Spring Security的SecurityContextHolder默認也基于ThreadLocal但它提供了兩種模式MODE_THREADLOCAL和MODE_INHERITABLETHREADLOCAL。后者可以讓子線程繼承父線程的上下文但也有并發(fā)覆蓋的風險。實際用例中RPC調用、MQ消費這類場景我建議把用戶ID顯式傳參不要依賴上下文傳遞。我在手搓版本里也故意做成單線程模型并留下一個清晰接口方便以后替換成別的實現(xiàn)public class SecurityContextHolder { private static final ThreadLocalAuthUser CONTEXT new ThreadLocal(); public static void setCurrentUser(AuthUser user) { CONTEXT.set(user); } public static AuthUser getCurrentUser() { return CONTEXT.get(); } public static void clear() { CONTEXT.remove(); } }這個類雖然簡單但它很好地反映了Spring Security的核心抽象。你甚至可以把這個類理解成“全局變量”只是這個全局變量是線程隔離的。在處理每個請求時過濾器負責往里面塞內容業(yè)務代碼負責取內容最后過濾器再負責清空。2.3 放行規(guī)則與異常處理最容易出錯的地方很多人寫安全框架時第一個坑就是登錄接口也被攔截了。這就像一個門禁系統(tǒng)把“進門本身”也當成需要門禁才能做的操作邏輯上說不通。所以放行規(guī)則必須放在過濾器鏈的最前面處理。我在TokenAuthenticationFilter里加了一個放行名單private final ListString permitAllPath List.of(/login, /public, /health); Override public void doFilter(...) { String path httpRequest.getRequestURI(); if (permitAllPath.stream().anyMatch(path::startsWith)) { chain.doFilter(request, response); return; } // 其余邏輯... }注意這里的坑permitAll不等于“不經(jīng)過過濾器”。真正的Spring Security里放行只是跳過“認證要求”過濾器鏈還是會繼續(xù)執(zhí)行。所以我在手搓版里也保持這個語義只是跳過認證判斷而不是直接把請求放給Servlet。異常處理也要提防。過濾器拋出AuthenticationException后不能直接就500應該轉成401狀態(tài)碼而且要確保響應格式統(tǒng)一。我在過濾器外層包了一個try-catchtry { // 認證邏輯 } catch (AuthenticationFailedException e) { httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED); httpResponse.setContentType(application/json;charsetUTF-8); httpResponse.getWriter().write({\message\:\未登錄或登錄已過期\}); return; }不這么做的后果是前端的axios統(tǒng)一報錯捕獲不到401反而拿到一個Html的500頁面聯(lián)調時非常難受。3. 授權模仿PreAuthorize的攔截邏輯認證做完接著是授權。Spring Security的授權有兩種常用方式基于URL的和基于注解的。手搓版本里我選擇實現(xiàn)一個簡化版RequireRole注解因為它最直觀地體現(xiàn)了“在方法執(zhí)行前做檢查”的思想。3.1 在攔截器里做權限判斷要攔截方法注解第一反應是用Spring AOP。思路是寫一個切面攔截所有帶有RequireRole注解的方法在方法執(zhí)行前取出當前用戶再看用戶權限列表里有沒有要求的角色。Aspect Component public class PermissionAspect { Before(annotation(requireRole)) public void checkRole(JoinPoint joinPoint, RequireRole requireRole) { AuthUser currentUser SecurityContextHolder.getCurrentUser(); if (currentUser null) { throw new ForbiddenException(未登錄); } String requiredRole requireRole.value(); boolean hasRole currentUser.getRoles().contains(requiredRole); if (!hasRole) { throw new ForbiddenException(缺少角色: requiredRole); } } }這個切面其實復刻了Spring Security的PreAuthorize的核心思路。PreAuthorize支持SpEL表達式能寫hasRole(admin)這種復雜判斷我們手搓版本不需要那么強大但原理是一樣的在方法執(zhí)行前拿到當前身份、判斷權限、沒有就拋異常。3.2 讓注解真正生效還要考慮優(yōu)先級只是加一個切面還不夠需要明確這個切面應該在哪一層執(zhí)行我建議放在Controller層之前或之后一段時間比如直接切在RestController的方法上。如果切在Service層弊端是權限檢查會晚于參數(shù)校驗一部分流程而且一個Controller方法調用多個Service方法時權限語義會分散。我自己的經(jīng)驗是權限注解放在Controller方法上因為它是面向接口粒度的正好對應“某個URL需要什么角色”。這樣做也天然契合前后端交互模型一個請求就是一個動作。還有一個細節(jié)如果有多個校驗類注解比如ValidateToken和RequireRole它們的執(zhí)行順序可能影響結果??梢酝ㄟ^Order來控制但更穩(wěn)妥的辦法是不要重復發(fā)明輪子直接在一個注解里表達“登錄且擁有角色”。3.3 RBAC模型的精簡實現(xiàn)完整版RBAC基于角色的訪問控制涉及五張表用戶表、角色表、權限表、用戶角色關聯(lián)表、角色權限關聯(lián)表。手搓版本不用那么重的表結構用兩張簡化表就能跑用戶表id、username、password、roles角色權限表role、permission在內存模型里我直接給AuthUser加一個roles屬性登錄時查詢用戶表把角色列表塞進去。判斷權限時先找角色再通過角色找權限。public class AuthUser { private Long id; private String username; private SetString roles; private SetString permissions; // getter setter 略 }這樣在切面里可以做兩級判斷if (requireRole.value() ! null) { // 檢查角色 } if (requirePermission.value() ! null !currentUser.getPermissions().contains(requirePermission.value())) { throw new ForbiddenException(缺少權限: requirePermission.value()); }實際項目中權限粒度做到“接口”級就夠了不用“按鈕”級否則維護成本會指數(shù)級上升。這是我做了多個后臺系統(tǒng)后的體會。4. 注冊過濾鏈與適配Spring Boot 3過濾器寫好了切面寫好了怎么讓它跑起來在Spring Boot里只需要把過濾器注冊成Bean。但這里有個版本差異特別值得說就是Spring Boot 3的配置遷移問題。4.1 用FilterRegistrationBean接入Servlet容器最傳統(tǒng)的做法是定義FilterRegistrationBean手動指定過濾器順序和URL模式Configuration public class SecurityConfig { Bean public FilterRegistrationBeanTokenAuthenticationFilter tokenFilterRegistration(TokenStore tokenStore) { FilterRegistrationBeanTokenAuthenticationFilter registration new FilterRegistrationBean(); registration.setFilter(new TokenAuthenticationFilter(tokenStore)); registration.addUrlPatterns(/*); registration.setOrder(1); return registration; } }Order很關鍵。如果手搓過濾器是1Spring Security自身的過濾器鏈可能是-100那你自己過濾器的位置就在Spring Security之后導致一些請求還沒到你的認證邏輯就被Spring Security拒了。如果你根本沒引入Spring Security就沒這個問題一旦同時存在要仔細核對順序。我調試時發(fā)現(xiàn)最簡單的方式是給過濾器一個較小的Order比如-200讓它靠前執(zhí)行。但注意太靠前可能導致靜態(tài)資源全部走一遍認證邏輯雖然也能放行但性能上不劃算。最好還是用路徑匹配來控制。4.2 Spring Boot 3配置遷移的坑從WebSecurityConfigurerAdapter說起很多老項目從Spring Boot 2升級到Spring Boot 3最大的坑就是WebSecurityConfigurerAdapter過時。Spring Security 5.7.0起就標記過時6.0里直接移除。所以Spring Boot 3時代安全配置要寫成一個SecurityFilterChain的Bean完全用Lambda DSL風格。這是老寫法Configuration EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests() .antMatchers(/login).permitAll() .anyRequest().authenticated() .and() .formLogin(); } }這是新寫法Configuration EnableWebSecurity public class SecurityConfig { Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth - auth .requestMatchers(/login, /public).permitAll() .anyRequest().authenticated() ) .formLogin(Customizer.withDefaults()); return http.build(); } }我最早自己遷的時候照著老教程寫發(fā)現(xiàn)antMatchers方法直接報錯因為新版本改成了requestMatchers。and()鏈式也廢棄了必須用Lambda風格配置每一塊。這不僅是語法變化更是一種思維切換老版中每個配置塊都依賴and()來拼接新版中每個配置項接收一個自定義器更清晰不容易把配置順序搞亂。4.3 配置類里如何定義安全規(guī)則兼談過濾器放行與security放行的區(qū)別你可能會問既然我已經(jīng)手搓了過濾器還需要Spring Security的SecurityFilterChain嗎如果是我為了演示會先關掉Spring Security只用自己的過濾器。但生產(chǎn)環(huán)境中同時存在的情況很常見比如你引入spring-boot-starter-security后Boot會自動配置默認的SecurityFilterChain它會把所有請求都攔下來。此時的解決思路不是去刪依賴而是用Spring Security提供的能力來管理請求級權限同時讓它“放行”你手搓過濾器需要處理的路徑。這里有個常見誤區(qū)在Spring Security里配置permitAll()只是說明Spring Security不做認證授權你的手搓過濾器還是會跑。反過來如果你手搓過濾器也沒放行請求照樣會被攔下來。兩者是疊加關系。我實際調試中遇到過這種場景/api/public在Spring Security配置里permitAll了但瀏覽器請求還是被自己寫的TokenAuthenticationFilter攔截。排查了半天才發(fā)現(xiàn)我自己的過濾器是按全局/*注冊的它在Spring Security之前接管了所有請求。最后把那幾個公共路徑也加入手搓過濾器的放行列表才解決。所以這里需要想清楚你引入Spring Security是為了利用它成熟的FilterChain機制而不是為了對抗它。如果你要手搓一個學習版建議單獨建一個demo工程不要跟Spring Security混在一起否則你會被“誰攔截了誰”的問題繞暈。5. 常見問題與排查技巧實錄手搓安全框架的過程其實是一個排查問題的過程。下面整理幾個我踩過的坑每個都很典型。5.1 過濾器沒執(zhí)行八成是Order問題明明注冊了FilterRegistrationBean但請求就是不走過濾器。檢查點有三個Bean是否被掃描到有沒有加Configuration或ComponentURL Pattern是否匹配/*和/含義不同/*匹配所有內容/匹配根路徑Order是否被其他過濾器覆蓋實戰(zhàn)里最常見的是項目里有多個過濾器你的Token過濾器Order是2結果排在它前面Order是1的過濾器直接拋異常返回了請求根本到不了你的過濾器。看日志時不要只看有沒有你的Filter執(zhí)行還要看有沒有別的Filter短路了。5.2 CSRF和Session固定攻擊的簡化處理Spring Security默認開啟CSRF保護會在表單提交時校驗一個隱藏Token。手搓版不需要照搬全部機制但要理解真正的項目如果是前后端分離且使用JWTCSRF風險相對較小因為Token放在Header里攻擊者很難構造請求頭但如果用Cookie做認證就必須要防CSRF。我在手搓Demo里沒有加CSRF但特意留了一個接口注釋“生產(chǎn)環(huán)境必須處理CSRF和Session固定攻擊”。對于Cookie方案最簡單的防護是每次登錄后更換SESSIONID避免攻擊者提前種下一個已知Session。這就像你家里的鎖被撬過一次后換一把新鎖而不是繼續(xù)用同一把。5.3 密碼存儲別再用明文BCrypt才是老朋友手搓安全框架時總有人圖方便把密碼存明文。我可以明確說正式項目這么做等于裸奔。Spring Security自帶BCryptPasswordEncoder它是一種帶隨機鹽的哈希算法同一密碼每次加密結果都不一樣而且計算成本可控暴力破解成本很高。如果你在手搓版本里也要做密碼校驗不要自己發(fā)明加密算法直接引用spring-security-cryptoBCryptPasswordEncoder encoder new BCryptPasswordEncoder(); String hash encoder.encode(password123); boolean matches encoder.matches(password123, hash);這里有個細節(jié)BCryptPasswordEncoder生成的字符串長度固定60位每次都不一樣。你在數(shù)據(jù)庫建字段時長度要大于60位否則入庫被截斷等查詢時發(fā)現(xiàn)匹配不上那是性能問題和數(shù)據(jù)問題的雙重折磨。5.4 排查SecurityContext被覆蓋的問題有一次Demo上線后有用戶反饋A賬號調接口返回的卻是B賬號的數(shù)據(jù)。最后排查出原因是由于我在異步線程里做業(yè)務邏輯父線程的SecurityContextHolder沒被子線程繼承子線程里重新從數(shù)據(jù)庫查用戶信息時查詢條件寫成了靜態(tài)變量上的登錄用戶ID導致并發(fā)狀態(tài)下互相覆蓋。真實項目里有一個非常實用的技巧進入異步線程前先把用戶ID用局部變量保存好傳入子線程不要在子線程里依賴任何上下文。這雖然“不夠安全框架”但非常安全工程。5.5 從日志視角看整個請求路徑最后推薦一下排查思路手搓安全框架后你最好在過濾器里加一行日志輸出請求路徑、是否放行、當前用戶。比如log.info( [AuthFilter] path{}, token{}, user{}, result{}, path, token, username, result);這樣一來當一個請求被莫名其妙的401或403攔截時打開日志看自己的過濾器日志和Spring Security的過濾器日志基本就能定位是哪一個環(huán)節(jié)出了問題。合理的日志是全鏈路排查的基礎這一點比寫100行安全配置還重要。6. 手搓之后對Spring Security的理解就不一樣了個人體會是手搓一遍之后你再回去看Spring Security的官方文檔很多原來抽象的概念都會具體化。比如它為什么默認有十三條過濾器為什么順序不能亂為什么SecurityContext要在請求結束后清理這些在我實現(xiàn)過一個迷你版后全都通了。后續(xù)想繼續(xù)擴展的話可以按這幾個方向做給這個迷你框架加上“記住我”功能或者支持Session方式認證又或者把權限判斷從角色升級成細粒度的權限碼。每加一個功能你就越接近Spring Security的一小塊能力。最終你會發(fā)現(xiàn)Spring Security并沒有想象中那么神秘它只是把很多安全細節(jié)打磨得極其扎實而已。如果你也在用Spring Boot 3做項目建議先把手頭老舊的WebSecurityConfigurerAdapter配置遷到新風格感受一下Lambda DSL的清晰度再決定要不要繼續(xù)手搓。畢竟理解原理的目的是更好地用手里的工具不是為了重復造輪子。