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