間信任建立與鑒權(quán)實(shí)戰(zhàn))
“trust me”這句話放在人和人之間是情感表達(dá)放在系統(tǒng)與系統(tǒng)之間就是一道道需要被驗(yàn)證的技術(shù)關(guān)卡。前端跟后端說“我是登錄用戶”網(wǎng)關(guān)跟業(yè)務(wù)服務(wù)說“這個(gè)請(qǐng)求已經(jīng)校驗(yàn)過了”服務(wù) A 跟服務(wù) B 說“我代表某個(gè)用戶在操作”——每一句“請(qǐng)相信我”背后都必須有一個(gè)可驗(yàn)證的憑證否則整條鏈路就是裸奔。本文圍繞系統(tǒng)間信任關(guān)系的建立與驗(yàn)證展開從認(rèn)證和授權(quán)這兩個(gè)容易混淆的概念講起逐步拆解 JWT 憑證的結(jié)構(gòu)、簽名算法、Spring Security 過濾器鏈的執(zhí)行順序最后落到一套可以跑起來的完整代碼登錄簽發(fā) Token、請(qǐng)求攜帶 Token、服務(wù)端校驗(yàn) Token、微服務(wù)之間透?jìng)餍湃?。適合有 Java/Spring 基礎(chǔ)、正在做登錄鑒權(quán)模塊或微服務(wù)改造的開發(fā)者閱讀也適合剛開始接觸 Spring Security 的同學(xué)按步驟動(dòng)手。文中代碼都標(biāo)注了文件路徑可以直接復(fù)制到項(xiàng)目里跑通。1. 從 “trust me” 說起系統(tǒng)信任關(guān)系的本質(zhì)1.1 認(rèn)證與授權(quán)兩個(gè)最容易被混用的概念很多項(xiàng)目代碼里到處都是auth這個(gè)詞但認(rèn)證Authentication和授權(quán)Authorization解決的是兩個(gè)完全不同的問題。認(rèn)證回答的是“你是誰(shuí)”它關(guān)心身份的真實(shí)性比如用戶提交用戶名和密碼服務(wù)端確認(rèn)這個(gè)身份確實(shí)存在且憑證匹配。授權(quán)回答的是“你能做什么”它發(fā)生在認(rèn)證之后關(guān)心權(quán)限邊界比如這個(gè)用戶是普通員工還是管理員能不能刪除訂單。為什么一定要把這兩者拆開理解因?yàn)樗鼈兊氖√幚矸绞酵耆煌?。認(rèn)證失敗是“我不認(rèn)識(shí)你”通常返回 401 未授權(quán)狀態(tài)碼前端應(yīng)該跳轉(zhuǎn)登錄頁(yè)授權(quán)失敗是“我認(rèn)識(shí)你但你不能這么做”返回 403 禁止訪問前端應(yīng)該提示無權(quán)操作。如果代碼里把兩者揉在一起就會(huì)出現(xiàn)“權(quán)限不足卻把用戶踢回登錄頁(yè)”這種體驗(yàn)很差的交互排查問題時(shí)也很難區(qū)分到底是憑證無效還是權(quán)限不夠。在實(shí)際項(xiàng)目里我建議把認(rèn)證和授權(quán)放在兩套代碼里分別處理認(rèn)證交給過濾器Filter在請(qǐng)求進(jìn)入業(yè)務(wù)邏輯之前完成授權(quán)交給方法注解或路徑規(guī)則在業(yè)務(wù)層完成。這樣責(zé)任清晰日志也好打。1.2 信任鏈一次請(qǐng)求究竟經(jīng)過幾道關(guān)卡用戶點(diǎn)擊一次“查看我的訂單”這個(gè)請(qǐng)求在到達(dá)業(yè)務(wù)代碼之前其實(shí)要走過一條信任鏈。第一關(guān)是入口層可能由網(wǎng)關(guān)做初步的 Token 解析和格式校驗(yàn)第二關(guān)是應(yīng)用的過濾器鏈校驗(yàn)簽名、有效期、簽發(fā)方第三關(guān)是方法級(jí)的權(quán)限判斷確認(rèn)當(dāng)前身份是否有order:read權(quán)限第四關(guān)是數(shù)據(jù)層確認(rèn)這條數(shù)據(jù)確實(shí)屬于當(dāng)前用戶防止越權(quán)訪問這個(gè)叫水平越權(quán)是很多線上事故的源頭。這條鏈上任何一關(guān)被跳過整條鏈路就不再可信。這也是為什么“前端隱藏了按鈕”永遠(yuǎn)不能算作權(quán)限控制——請(qǐng)求可以直接用工具構(gòu)造繞過所有前端邏輯。理解信任鏈的意義在于你需要在設(shè)計(jì)階段就想清楚每一關(guān)校驗(yàn)什么、失敗后怎么響應(yīng)、日志記錄哪些字段。否則等接口上線之后再來補(bǔ)往往會(huì)出現(xiàn)“同一個(gè) Token 在網(wǎng)關(guān)能過、在業(yè)務(wù)服務(wù)過不了”這類讓人抓狂的不一致問題。1.3 為什么用 Token 承載信任而不是 Session傳統(tǒng)的 Session 方案是服務(wù)端保存會(huì)話狀態(tài)客戶端只拿一個(gè)無意義的 SessionId。這個(gè)方案在單機(jī)應(yīng)用里非常成熟但到了多實(shí)例部署就有了麻煩會(huì)話數(shù)據(jù)要么共享存儲(chǔ)引入 Redis 依賴要么做粘性會(huì)話負(fù)載均衡變得不靈活。Token 方案反過來把會(huì)話狀態(tài)編碼進(jìn)憑證本身服務(wù)端只負(fù)責(zé)校驗(yàn)簽名不需要保存任何狀態(tài)。這帶來了兩個(gè)直接好處水平擴(kuò)展變得簡(jiǎn)單任意實(shí)例都能獨(dú)立完成校驗(yàn)跨服務(wù)傳遞變得自然服務(wù) A 拿到 Token 可以直接交給服務(wù) B。但代價(jià)同樣明顯Token 一旦簽發(fā)在有效期內(nèi)就是有效的服務(wù)端無法“主動(dòng)撤銷”單個(gè) Token。所以必須配合較短的有效期和刷新機(jī)制關(guān)鍵操作還要額外校驗(yàn)。這一點(diǎn)在后面講最佳實(shí)踐時(shí)會(huì)重點(diǎn)展開。2. 環(huán)境準(zhǔn)備與項(xiàng)目結(jié)構(gòu)2.1 技術(shù)棧與版本說明本文示例基于 Spring Boot 3.x Spring Security 6.x JDK 17 編寫。這個(gè)組合是目前新項(xiàng)目比較常見的選擇其中 Spring Security 6 相比 5.x 有較大變化最典型的是WebSecurityConfigurerAdapter被移除改為聲明SecurityFilterChainBean 的方式進(jìn)行配置方法鏈?zhǔn)秸{(diào)用也做了調(diào)整。需要特別說明的是不同版本之間 API 差異較大請(qǐng)按你項(xiàng)目的實(shí)際版本調(diào)整本文示例。如果你還在 Spring Boot 2.7 及以下版本配置寫法需要用WebSecurityConfigurerAdapter繼承方式或者用SecurityFilterChain但換用authorizeRequests()而不是authorizeHttpRequests()。JWT 庫(kù)方面本文使用jjwt0.12.x 版本它在 0.11.x 的基礎(chǔ)上重命名了大量方法setSubject改為subject、parserBuilder改為parser等如果你的工程里是舊版本編譯時(shí)方法找不到基本都是這個(gè)原因。其他環(huán)境要求JDK 17 及以上Spring Boot 3 強(qiáng)制要求構(gòu)建工具 Maven 3.8 或 Gradle 7.5IDE 用 IntelliJ IDEA 或 VS Code 都可以接口調(diào)試用 Postman 或 curl 命令。2.2 依賴引入新建一個(gè) Spring Boot 工程在pom.xml中引入下面幾項(xiàng)依賴。注意jjwt需要同時(shí)引入 api、impl、jackson 三個(gè)坐標(biāo)只引 api 會(huì)在運(yùn)行時(shí)找不到序列化實(shí)現(xiàn)。!-- 文件路徑pom.xml -- dependencies !-- Web 基礎(chǔ)能力提供內(nèi)嵌容器與 MVC 支持 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- 安全框架提供過濾器鏈、密碼加密、權(quán)限注解 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency !-- JWT 核心 API用于構(gòu)建和解析 Token -- dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.12.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.12.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.12.5/version scoperuntime/scope /dependency !-- 參數(shù)校驗(yàn)登錄接口用得上 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency /dependencies版本號(hào)如果和你項(xiàng)目其他依賴有沖突可以用dependencyManagement統(tǒng)一管理避免出現(xiàn)NoClassDefFoundError這類穿透到運(yùn)行期才暴露的問題。2.3 項(xiàng)目目錄結(jié)構(gòu)為了避免后面代碼貼出來找不到位置先把整體結(jié)構(gòu)列清楚。包名你可以按自己公司規(guī)范調(diào)整本文示例統(tǒng)一用com.example.trust。src/main/java/com/example/trust ├── TrustApplication.java // 啟動(dòng)類 ├── config │ ├── SecurityConfig.java // 過濾器鏈配置 │ └── JwtProperties.java // 密鑰與有效期配置綁定 ├── controller │ ├── AuthController.java // 登錄接口 │ └── OrderController.java // 受保護(hù)的示例業(yè)務(wù)接口 ├── filter │ └── JwtAuthenticationFilter.java // Token 校驗(yàn)過濾器 ├── service │ ├── JwtService.java // Token 簽發(fā)與解析 │ └── UserService.java // 用戶查詢示例內(nèi)存實(shí)現(xiàn) └── model ├── LoginRequest.java // 登錄入?yún)?└── LoginResponse.java // 登錄返回 src/main/resources └── application.yml // 密鑰、過期時(shí)間等配置這個(gè)結(jié)構(gòu)刻意把“簽發(fā)/解析能力”抽成JwtService“校驗(yàn)時(shí)機(jī)”放進(jìn)過濾器“權(quán)限規(guī)則”放進(jìn)配置類三個(gè)關(guān)注點(diǎn)分離。后面如果要做密鑰輪換或者多簽發(fā)方支持只需要改動(dòng)JwtService其他代碼不受影響。3. JWT 與信任憑證的核心原理3.1 三段式結(jié)構(gòu)它到底存了什么JWTJSON Web Token是一個(gè)用點(diǎn)號(hào)分隔的三段字符串形如xxxxx.yyyyy.zzzzz。三段分別是 Header、Payload 和 Signature每一段都是 Base64URL 編碼。Header 描述簽名算法和類型通常是{alg:HS256,typ:JWT}。Payload 存放聲明Claims分為標(biāo)準(zhǔn)聲明和自定義聲明標(biāo)準(zhǔn)的有sub主體通常是用戶標(biāo)識(shí)、iat簽發(fā)時(shí)間、exp過期時(shí)間、iss簽發(fā)方自定義的可以放用戶角色、租戶 ID 等業(yè)務(wù)信息。Signature 是對(duì)前兩段內(nèi)容的簽名用于防篡改。這里有一個(gè)新手最常見的誤區(qū)JWT 的 Payload 只是 Base64 編碼不是加密。任何人拿到 Token隨便找個(gè)在線工具就能解出里面的內(nèi)容。所以絕對(duì)不要往 Payload 里塞手機(jī)號(hào)、身份證號(hào)、密碼這類敏感信息。Token 能保證的是“沒被改過”而不是“別人看不到”。另一個(gè)常見誤區(qū)是把角色、權(quán)限全部塞進(jìn) Token導(dǎo)致 Token 體積巨大每次請(qǐng)求都要帶上幾百字節(jié)甚至上 KB 的頭部。更麻煩的是權(quán)限變更后舊 Token 里的權(quán)限還是舊的必須等它過期才生效。我的建議是 Token 里只放最少量的標(biāo)識(shí)用戶 ID、租戶 ID權(quán)限實(shí)時(shí)從緩存或數(shù)據(jù)庫(kù)查。3.2 簽名算法與密鑰長(zhǎng)度本文使用的是 HMAC-SHA256HS256對(duì)稱加密簽發(fā)和校驗(yàn)用同一個(gè)密鑰。它的優(yōu)點(diǎn)是性能好、實(shí)現(xiàn)簡(jiǎn)單缺點(diǎn)是所有校驗(yàn)方都持有密鑰等于所有校驗(yàn)方都能簽發(fā)。如果你的系統(tǒng)里調(diào)用方很多、安全邊界復(fù)雜應(yīng)該考慮 RS256 這種非對(duì)稱算法私鑰簽發(fā)、公鑰校驗(yàn)校驗(yàn)方拿不到簽發(fā)能力。密鑰長(zhǎng)度是一個(gè)必須重視的點(diǎn)。Keys.hmacShaKeyFor()對(duì)密鑰有最小長(zhǎng)度要求HS256 要求至少 256 位32 字節(jié)。如果你圖省事寫一句secret運(yùn)行時(shí)就會(huì)拋WeakKeyException。這不是框架在刁難你而是短密鑰確實(shí)容易被暴力破解。密鑰的管理方式更需要強(qiáng)調(diào)絕對(duì)不能硬編碼在代碼里提交到倉(cāng)庫(kù)。正確做法是通過環(huán)境變量、配置中心或密鑰管理服務(wù)注入并且區(qū)分環(huán)境。生產(chǎn)密鑰一旦泄露攻擊者可以偽造任意用戶的 Token這是最高級(jí)別的安全事故。所以下面示例里我會(huì)通過application.yml占位符 環(huán)境變量注入的方式來寫同時(shí)提醒你在生產(chǎn)環(huán)境做替換。3.3 Spring Security 過濾器鏈的執(zhí)行順序Spring Security 的核心是一條過濾器鏈請(qǐng)求按順序穿過這些過濾器每個(gè)過濾器負(fù)責(zé)一類事情。跟本文相關(guān)的關(guān)鍵是認(rèn)證過濾器要在UsernamePasswordAuthenticationFilter之前執(zhí)行這樣自定義的 Token 校驗(yàn)才能在默認(rèn)表單登錄邏輯之前接管請(qǐng)求。請(qǐng)求進(jìn)來后的處理順序大致是這樣的SecurityContextHolderFilter負(fù)責(zé)清理和加載安全上下文。我們的JwtAuthenticationFilter解析請(qǐng)求頭校驗(yàn) Token把身份信息寫入SecurityContext。AuthorizationFilter根據(jù)配置的路徑規(guī)則判斷當(dāng)前身份是否有權(quán)訪問。進(jìn)入 DispatcherServlet執(zhí)行 Controller 邏輯。這個(gè)順序解釋了為什么很多同學(xué)遇到“明明 Token 是對(duì)的接口還是返回 401”——如果你的過濾器加在了錯(cuò)誤的位置比如加在AuthorizationFilter之后那么權(quán)限判斷的時(shí)候SecurityContext還是空的自然判定為未認(rèn)證。還有一個(gè)隱含細(xì)節(jié)SecurityContext默認(rèn)是基于 ThreadLocal 的一次請(qǐng)求一個(gè)線程所以過濾器里寫入的身份信息在 Controller 里能讀到。但如果你在業(yè)務(wù)代碼里開了新線程做異步處理是讀不到這個(gè)上下文的需要手動(dòng)傳遞。4. 完整實(shí)戰(zhàn)從登錄到校驗(yàn)的閉環(huán)4.1 配置項(xiàng)與屬性綁定先在application.yml中定義 JWT 相關(guān)配置。注意${JWT_SECRET:...}這種寫法表示優(yōu)先讀環(huán)境變量讀不到再用默認(rèn)值方便本地開發(fā)。# 文件路徑src/main/resources/application.yml server: port: 8080 jwt: # 生產(chǎn)環(huán)境請(qǐng)通過環(huán)境變量 JWT_SECRET 注入不要提交到倉(cāng)庫(kù) secret: ${JWT_SECRET:local-dev-only-secret-key-please-change-32bytes} # 訪問令牌有效期單位秒示例為 30 分鐘 expire-seconds: 1800 # 簽發(fā)方標(biāo)識(shí)便于多系統(tǒng)區(qū)分 issuer: trust-demo對(duì)應(yīng)的屬性綁定類如下。使用ConfigurationProperties比散落的Value更好維護(hù)字段多了以后尤其明顯。// 文件路徑src/main/java/com/example/trust/config/JwtProperties.java package com.example.trust.config; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; Component ConfigurationProperties(prefix jwt) public class JwtProperties { /** 簽名密鑰長(zhǎng)度至少 32 字節(jié) */ private String secret; /** 訪問令牌有效期秒 */ private long expireSeconds; /** 簽發(fā)方標(biāo)識(shí) */ private String issuer; public String getSecret() { return secret; } public void setSecret(String secret) { this.secret secret; } public long getExpireSeconds() { return expireSeconds; } public void setExpireSeconds(long expireSeconds) { this.expireSeconds expireSeconds; } public String getIssuer() { return issuer; } public void setIssuer(String issuer) { this.issuer issuer; } }4.2 Token 簽發(fā)與解析服務(wù)這是整套邏輯的核心。簽發(fā)時(shí)把用戶 ID 放進(jìn)subject角色放進(jìn)自定義聲明解析時(shí)先驗(yàn)簽再取聲明任何異常都統(tǒng)一處理成“Token 無效”。// 文件路徑src/main/java/com/example/trust/service/JwtService.java package com.example.trust.service; import com.example.trust.config.JwtProperties; import io.jsonwebtoken.Claims; import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import org.springframework.stereotype.Service; import javax.crypto.SecretKey; import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.Date; import java.util.List; Service public class JwtService { private final JwtProperties properties; private final SecretKey key; public JwtService(JwtProperties properties) { this.properties properties; // 密鑰長(zhǎng)度不足 32 字節(jié)時(shí)會(huì)拋出 WeakKeyException這里提前暴露問題 this.key Keys.hmacShaKeyFor( properties.getSecret().getBytes(StandardCharsets.UTF_8)); } /** * 簽發(fā)訪問令牌 * * param userId 用戶唯一標(biāo)識(shí) * param roles 角色列表 * return 簽名后的 JWT 字符串 */ public String issueToken(String userId, ListString roles) { Instant now Instant.now(); Instant expireAt now.plusSeconds(properties.getExpireSeconds()); return Jwts.builder() .subject(userId) .issuer(properties.getIssuer()) .issuedAt(Date.from(now)) .expiration(Date.from(expireAt)) .claim(roles, roles) .signWith(key) .compact(); } /** * 解析并校驗(yàn)令牌簽名錯(cuò)誤、過期、格式不對(duì)都會(huì)拋出 JwtException */ public Claims parse(String token) throws JwtException { return Jwts.parser() .verifyWith(key) .requireIssuer(properties.getIssuer()) .build() .parseSignedClaims(token) .getPayload(); } }幾處值得說明的地方。signWith(key)會(huì)依據(jù)密鑰長(zhǎng)度自動(dòng)選擇算法32 字節(jié)對(duì)應(yīng) HS25664 字節(jié)對(duì)應(yīng) HS512不用手動(dòng)指定。requireIssuer()是校驗(yàn)簽發(fā)方的關(guān)鍵多系統(tǒng)共存時(shí)能擋住“別的系統(tǒng)簽發(fā)的 Token 拿來用”這種越權(quán)場(chǎng)景。parseSignedClaims()一次性完成驗(yàn)簽、過期檢查、簽發(fā)方檢查比手動(dòng)逐項(xiàng)判斷更不容易遺漏。如果你項(xiàng)目里是jjwt0.11.x等價(jià)寫法是Jwts.parserBuilder().setSigningKey(key).requireIssuer(...).build().parseClaimsJws(token).getBody()方法名不一樣邏輯完全一致。4.3 密碼加密與用戶查詢密碼絕對(duì)不能用明文或 MD5 存儲(chǔ)。Spring Security 提供的BCryptPasswordEncoder是推薦選擇它自帶隨機(jī)鹽同一個(gè)密碼每次加密結(jié)果都不一樣并且計(jì)算成本可調(diào)能顯著提高離線爆破的代價(jià)。// 文件路徑src/main/java/com/example/trust/service/UserService.java package com.example.trust.service; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import java.util.List; import java.util.Map; import java.util.Optional; /** * 示例用的內(nèi)存用戶存儲(chǔ)真實(shí)項(xiàng)目請(qǐng)?zhí)鎿Q為數(shù)據(jù)庫(kù)查詢 */ Service public class UserService { private final PasswordEncoder passwordEncoder; /** 用戶名 - {加密后的密碼, 角色列表} */ private final MapString, UserRecord users; public UserService(PasswordEncoder passwordEncoder) { this.passwordEncoder passwordEncoder; // 啟動(dòng)時(shí)把示例密碼加密避免代碼里出現(xiàn)明文 this.users Map.of( admin, new UserRecord( passwordEncoder.encode(admin123), List.of(ADMIN)), alice, new UserRecord( passwordEncoder.encode(alice123), List.of(USER)) ); } public OptionalUserRecord findByUsername(String username) { return Optional.ofNullable(users.get(username)); } public boolean matches(String rawPassword, String encodedPassword) { return passwordEncoder.matches(rawPassword, encodedPassword); } public record UserRecord(String encodedPassword, ListString roles) { } }這里用record承載用戶數(shù)據(jù)代碼更緊湊。真實(shí)項(xiàng)目里findByUsername應(yīng)該走數(shù)據(jù)庫(kù)或緩存并且要注意無論用戶是否存在都不要在響應(yīng)里暴露差異否則攻擊者可以枚舉出哪些用戶名是有效的。登錄失敗統(tǒng)一返回“用戶名或密碼錯(cuò)誤”。4.4 認(rèn)證過濾器把 Token 變成安全上下文這個(gè)過濾器是整個(gè)機(jī)制的中樞。它做三件事從請(qǐng)求頭取 Token、校驗(yàn)并解析、把身份寫入上下文。注意它不能拋異常中斷流程而是要在校驗(yàn)失敗時(shí)保持上下文為空交給后續(xù)的授權(quán)環(huán)節(jié)去拒絕。// 文件路徑src/main/java/com/example/trust/filter/JwtAuthenticationFilter.java package com.example.trust.filter; import com.example.trust.service.JwtService; import io.jsonwebtoken.Claims; import io.jsonwebtoken.JwtException; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; import java.util.List; Component public class JwtAuthenticationFilter extends OncePerRequestFilter { private static final Logger log LoggerFactory.getLogger(JwtAuthenticationFilter.class); private static final String HEADER Authorization; private static final String PREFIX Bearer ; private final JwtService jwtService; public JwtAuthenticationFilter(JwtService jwtService) { this.jwtService jwtService; } Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String header request.getHeader(HEADER); // 沒有攜帶令牌時(shí)直接放行由后續(xù)授權(quán)規(guī)則決定是否拒絕 if (header null || !header.startsWith(PREFIX)) { chain.doFilter(request, response); return; } String token header.substring(PREFIX.length()).trim(); try { Claims claims jwtService.parse(token); SuppressWarnings(unchecked) ListString roles claims.get(roles, List.class); ListSimpleGrantedAuthority authorities roles null ? List.of() : roles.stream() .map(r - new SimpleGrantedAuthority(ROLE_ r)) .toList(); UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken( claims.getSubject(), null, authorities); SecurityContextHolder.getContext().setAuthentication(authentication); log.debug(令牌校驗(yàn)通過, userId{}, claims.getSubject()); } catch (JwtException | IllegalArgumentException e) { // 令牌無效時(shí)不寫入上下文交由授權(quán)環(huán)節(jié)返回 401 SecurityContextHolder.clearContext(); log.warn(令牌校驗(yàn)失敗, reason{}, e.getMessage()); } chain.doFilter(request, response); } }繼承OncePerRequestFilter而不是直接實(shí)現(xiàn)Filter是為了保證同一個(gè)請(qǐng)求內(nèi)只執(zhí)行一次避免異步轉(zhuǎn)發(fā)forward時(shí)重復(fù)解析。日志里只記錄失敗原因不要把完整 Token 打出來因?yàn)槿罩鞠到y(tǒng)往往權(quán)限更寬Token 泄露等同于身份泄露。4.5 安全配置路徑規(guī)則與無狀態(tài)會(huì)話// 文件路徑src/main/java/com/example/trust/config/SecurityConfig.java package com.example.trust.config; import com.example.trust.filter.JwtAuthenticationFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; Configuration public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) { this.jwtAuthenticationFilter jwtAuthenticationFilter; } Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http // 前后端分離場(chǎng)景下無狀態(tài)接口不使用 Cookie 會(huì)話可關(guān)閉 CSRF .csrf(AbstractHttpConfigurer::disable) .sessionManagement(session - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth - auth .requestMatchers(/auth/login, /health).permitAll() .requestMatchers(HttpMethod.GET, /orders/**).hasRole(USER) .requestMatchers(/admin/**).hasRole(ADMIN) .anyRequest().authenticated()) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }關(guān)于csrf().disable()需要說明清楚只有在你完全不依賴 Cookie 傳遞會(huì)話憑證的前提下才可以關(guān)閉。如果系統(tǒng)同時(shí)用 Cookie 做會(huì)話且關(guān)閉了 CSRF 防護(hù)就會(huì)暴露跨站請(qǐng)求偽造風(fēng)險(xiǎn)。前后端分離 Bearer Token 模式之所以能關(guān)是因?yàn)闉g覽器不會(huì)自動(dòng)攜帶 Token攻擊者構(gòu)造的跨站請(qǐng)求帶不上憑證。如果你的系統(tǒng)是傳統(tǒng)服務(wù)端渲染加表單提交務(wù)必保持 CSRF 開啟。4.6 登錄接口與受保護(hù)接口// 文件路徑src/main/java/com/example/trust/controller/AuthController.java package com.example.trust.controller; import com.example.trust.service.JwtService; import com.example.trust.service.UserService; import jakarta.validation.constraints.NotBlank; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.Map; RestController RequestMapping(/auth) public class AuthController { private final UserService userService; private final JwtService jwtService; public AuthController(UserService userService, JwtService jwtService) { this.userService userService; this.jwtService jwtService; } PostMapping(/login) public ResponseEntity? login(RequestBody LoginRequest request) { return userService.findByUsername(request.username()) .filter(u - userService.matches( request.password(), u.encodedPassword())) .map(u - { String token jwtService.issueToken( request.username(), u.roles()); return ResponseEntity.ok(Map.of( token, token, tokenType, Bearer)); }) // 用戶不存在與密碼錯(cuò)誤返回同一提示避免用戶名枚舉 .orElseGet(() - ResponseEntity.status(401) .body(Map.of(message, 用戶名或密碼錯(cuò)誤))); } public record LoginRequest(NotBlank String username, NotBlank String password) { } }// 文件路徑src/main/java/com/example/trust/controller/OrderController.java package com.example.trust.controller; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.List; import java.util.Map; RestController RequestMapping(/orders) public class OrderController { GetMapping public MapString, Object list(Authentication authentication) { // 從安全上下文中取當(dāng)前用戶不要從請(qǐng)求參數(shù)里取 userId String userId String.valueOf(authentication.getPrincipal()); return Map.of( userId, userId, orders, List.of( Map.of(orderId, A1001, amount, 199), Map.of(orderId, A1002, amount, 88) )); } }注意OrderController里取當(dāng)前用戶的方式從Authentication里拿而不是從請(qǐng)求參數(shù)里拿。如果接口設(shè)計(jì)成GET /orders?userIdxxx那就是典型的水平越權(quán)漏洞——只要改一下參數(shù)就能看到別人的訂單。真實(shí)業(yè)務(wù)里查詢條件必須強(qiáng)制附加當(dāng)前用戶 ID 作為過濾條件。4.7 運(yùn)行與驗(yàn)證啟動(dòng)應(yīng)用后用 curl 依次驗(yàn)證三個(gè)場(chǎng)景。先拿一個(gè)合法 Token# 1. 登錄獲取令牌 curl -X POST http://localhost:8080/auth/login \ -H Content-Type: application/json \ -d {username:alice,password:alice123}預(yù)期返回類似下面的結(jié)構(gòu)token字段就是三段式的 JWT{ token: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbGljZSIs..., tokenType: Bearer }然后帶 Token 訪問受保護(hù)接口# 2. 攜帶令牌訪問訂單接口 curl http://localhost:8080/orders \ -H Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbGljZSIs...預(yù)期返回當(dāng)前用戶和訂單列表。接著驗(yàn)證失敗場(chǎng)景# 3. 不帶令牌預(yù)期 401 curl -i http://localhost:8080/orders # 4. 篡改令牌最后一段預(yù)期 401驗(yàn)簽失敗 curl -i http://localhost:8080/orders -H Authorization: Bearer xxx.yyy.zzz # 5. 用 alice 的令牌訪問管理員接口預(yù)期 403認(rèn)證通過但授權(quán)不足 curl -i http://localhost:8080/admin/users \ -H Authorization: Bearer alice的令牌這三個(gè)失敗的用例很關(guān)鍵很多同學(xué)只測(cè)通了一條成功路徑就認(rèn)為做完了。401 和 403 的區(qū)分、簽名篡改能否被攔住才是判斷鑒權(quán)是否真的生效的標(biāo)準(zhǔn)。5. 服務(wù)間信任微服務(wù)調(diào)用如何傳遞身份5.1 Token 透?jìng)髯詈?jiǎn)單也最容易踩坑的方案服務(wù) A 收到用戶請(qǐng)求后要調(diào)用服務(wù) B最直接的做法是把原始 Token 放進(jìn)請(qǐng)求頭繼續(xù)往下傳。服務(wù) B 用同一個(gè)密鑰或公鑰校驗(yàn)就能拿到同一個(gè)身份。這種方式實(shí)現(xiàn)成本低鏈路清晰。但它有兩個(gè)必須注意的點(diǎn)。第一Token 的有效期在鏈路中持續(xù)消耗如果 A 調(diào)用 B 時(shí) Token 只剩 10 秒B 處理稍慢就會(huì) 401。建議在調(diào)用前檢查剩余有效期不足時(shí)提前刷新。第二Token 會(huì)流經(jīng)多個(gè)服務(wù)任何一個(gè)服務(wù)的日志、鏈路追蹤系統(tǒng)如果全量記錄請(qǐng)求頭Token 就會(huì)散落在各處。所以要在日志配置里對(duì)Authorization頭做脫敏。5.2 內(nèi)部服務(wù)簽名不依賴用戶身份的調(diào)用有些調(diào)用并不代表某個(gè)用戶比如定時(shí)任務(wù)觸發(fā)數(shù)據(jù)同步。這時(shí)不應(yīng)該偽造用戶 Token而是用服務(wù)身份。常見做法是給每個(gè)服務(wù)分配一對(duì)clientId/clientSecret調(diào)用時(shí)用密鑰簽名請(qǐng)求簽名內(nèi)容包含時(shí)間戳、請(qǐng)求體和隨機(jī)數(shù)被調(diào)方校驗(yàn)簽名并檢查時(shí)間戳窗口比如 ±5 分鐘防止重放。這塊實(shí)現(xiàn)相對(duì)復(fù)雜工程上更常見的替代方案是引入統(tǒng)一的服務(wù)網(wǎng)格或內(nèi)部網(wǎng)關(guān)統(tǒng)一處理業(yè)務(wù)代碼不用關(guān)心。無論用哪種方式原則是一致的內(nèi)部網(wǎng)絡(luò)不等于可信網(wǎng)絡(luò)涉及到數(shù)據(jù)變更的接口仍然需要校驗(yàn)調(diào)用方身份。5.3 密鑰輪換讓信任關(guān)系可以平滑更新密鑰一旦固定不變泄露風(fēng)險(xiǎn)就隨使用時(shí)長(zhǎng)線性上升。輪換的難點(diǎn)在于簽發(fā)方換新密鑰的瞬間舊 Token 還沒過期校驗(yàn)方如果只認(rèn)新密鑰所有在線用戶會(huì)集體掉線。解決辦法是讓校驗(yàn)方同時(shí)信任多個(gè)密鑰通過 Header 里的kid密鑰標(biāo)識(shí)字段選擇對(duì)應(yīng)的密鑰。輪換流程是先讓所有校驗(yàn)方上線“雙密鑰支持”再切換簽發(fā)方使用新密鑰等舊 Token 全部過期超過一個(gè)完整有效期后移除舊密鑰。整個(gè)過程用戶無感知。如果你的系統(tǒng)規(guī)模還不大至少要做到密鑰可以通過配置中心熱更新而不是改代碼重新發(fā)版。6. 常見問題與排查清單鑒權(quán)模塊的問題表現(xiàn)形式很集中但原因往往不止一種。下面這張表按現(xiàn)象歸類遇到報(bào)錯(cuò)時(shí)可以逐條比對(duì)。問題現(xiàn)象常見原因解決思路啟動(dòng)報(bào)WeakKeyException密鑰長(zhǎng)度不足 32 字節(jié)換用足夠長(zhǎng)度的密鑰或改用Keys.secretKeyFor(SignatureAlgorithm.HS256)生成所有請(qǐng)求都 401過濾器未注冊(cè)或順序錯(cuò)誤檢查addFilterBefore是否加在UsernamePasswordAuthenticationFilter之前所有請(qǐng)求都 401Authorization頭缺少Bearer前綴前端拼接時(shí)統(tǒng)一封裝服務(wù)端嚴(yán)格按前綴截取明明登錄成功但訪問接口 403hasRole(USER)與授權(quán)值不匹配過濾器里加的權(quán)限前綴要和hasRole約定一致hasRole(USER)實(shí)際校驗(yàn)ROLE_USER上線后部分用戶頻繁掉線多實(shí)例密鑰不一致密鑰統(tǒng)一從配置中心下發(fā)不寫死在各自配置文件時(shí)間一過零點(diǎn)就大面積 401服務(wù)器時(shí)區(qū)或時(shí)間不同步統(tǒng)一使用 UTC 時(shí)間部署 NTP 時(shí)間同步舊 Token 在權(quán)限變更后仍有舊權(quán)限權(quán)限被寫進(jìn)了 Token Payload改為 Token 只放用戶 ID權(quán)限實(shí)時(shí)查詢緩存報(bào)InvalidClaimException: iss多系統(tǒng)共用密鑰但簽發(fā)方不同簽發(fā)時(shí)設(shè)置 issuer校驗(yàn)時(shí)用requireIssuer限制日志出現(xiàn)大量驗(yàn)簽失敗可能是掃描器探測(cè)或密鑰被誤換結(jié)合來源 IP 和錯(cuò)誤分布判斷必要時(shí)做頻率限制排查時(shí)建議按“請(qǐng)求頭是否正確 → 過濾器是否執(zhí)行 → 上下文是否寫入 → 授權(quán)規(guī)則是否匹配”這個(gè)順序走。一個(gè)很實(shí)用的小技巧是在過濾器里打一行 debug 日志把SecurityContext里的權(quán)限列表輸出出來能快速定位是認(rèn)證沒通過還是授權(quán)沒配對(duì)。7. 最佳實(shí)踐與工程建議有效期設(shè)計(jì)要有層次。訪問令牌有效期短比如 30 分鐘刷新令牌有效期長(zhǎng)比如 7 天刷新令牌一次性使用并且可撤銷。這樣既控制了泄露后的風(fēng)險(xiǎn)窗口又不會(huì)讓用戶頻繁重新登錄。刷新令牌必須存儲(chǔ)服務(wù)端狀態(tài)否則就失去了“可撤銷”這個(gè)核心價(jià)值。密鑰管理納入發(fā)布流程。密鑰不要放在代碼倉(cāng)庫(kù)、不要放在前端、不要出現(xiàn)在日志。生產(chǎn)環(huán)境通過環(huán)境變量或密鑰管理服務(wù)注入不同環(huán)境使用不同密鑰并且把密鑰輪換寫進(jìn)運(yùn)維手冊(cè)。這條做不好前面所有代碼都是白寫。錯(cuò)誤響應(yīng)要統(tǒng)一但不要泄露細(xì)節(jié)。對(duì)外統(tǒng)一返回“用戶名或密碼錯(cuò)誤”“登錄狀態(tài)已失效”不要把“用戶不存在”“簽名不匹配”“Token 已過期”這些內(nèi)部原因直接透給前端。詳細(xì)原因?qū)懭罩窘涌谥唤o用戶能理解的信息。日志脫敏要做成默認(rèn)行為。請(qǐng)求頭、請(qǐng)求體、響應(yīng)體里都可能帶憑證。建議在日志框架層面統(tǒng)一配置敏感字段過濾而不是靠每個(gè)開發(fā)者記得手動(dòng)脫敏——靠人記的事情遲早會(huì)漏。權(quán)限校驗(yàn)要有兜底策略。路徑規(guī)則容易寫漏建議默認(rèn)拒絕anyRequest().authenticated()作為兜底而不是默認(rèn)放行。新增接口時(shí)如果忘記配置規(guī)則默認(rèn)是受保護(hù)的這比默認(rèn)開放安全得多。避免過度依賴前端控制。按鈕隱藏、菜單不渲染都只是體驗(yàn)優(yōu)化不是安全措施。所有涉及數(shù)據(jù)讀取和狀態(tài)變更的接口都必須在服務(wù)端做身份和歸屬校驗(yàn)尤其是“根據(jù) ID 查詢?cè)斍椤边@類接口必須校驗(yàn)數(shù)據(jù)是否屬于當(dāng)前用戶。給測(cè)試留出可驗(yàn)證的失敗路徑。寫單元測(cè)試時(shí)除了驗(yàn)證成功場(chǎng)景至少要覆蓋過期 Token、篡改簽名、缺少 Token、權(quán)限不足這四種。這幾條用例能在重構(gòu)過濾器鏈時(shí)第一時(shí)間發(fā)現(xiàn)回歸問題。生產(chǎn)變更要穩(wěn)妥。修改鑒權(quán)邏輯屬于高風(fēng)險(xiǎn)變更建議先在測(cè)試環(huán)境完整驗(yàn)證采用灰度發(fā)布保留快速回滾方案。涉及用戶數(shù)據(jù)的權(quán)限規(guī)則調(diào)整最好提前通知相關(guān)方避免上線后出現(xiàn)大面積無法訪問的情況。8. 繼續(xù)深入的方向把上面的代碼跑通之后你已經(jīng)具備了一套可用的無狀態(tài)認(rèn)證基礎(chǔ)。接下來值得繼續(xù)研究的方向有幾個(gè)一是權(quán)限模型升級(jí)從簡(jiǎn)單的角色RBAC走向基于資源和操作的細(xì)粒度權(quán)限如order:read、order:delete配合方法級(jí)注解PreAuthorize使用二是單點(diǎn)登錄與多端統(tǒng)一研究 OAuth 2.1 和 OIDC 的授權(quán)碼流程理解第三方登錄場(chǎng)景下信任是如何建立的三是令牌的主動(dòng)失效通過維護(hù)一個(gè)短期的失效列表或版本號(hào)解決“用戶改密碼后舊 Token 仍然有效”的問題。如果你想現(xiàn)在就動(dòng)手加固手上的項(xiàng)目有三個(gè)動(dòng)作性價(jià)比最高把硬編碼的密鑰挪到環(huán)境變量檢查所有“按 ID 查詳情”的接口是否做了歸屬校驗(yàn)給鑒權(quán)模塊補(bǔ)上過期、篡改、越權(quán)三類測(cè)試用例。這三件事花不了多少時(shí)間但能擋掉大部分真實(shí)存在的風(fēng)險(xiǎn)。信任這件事在系統(tǒng)里從來不是靠一句“請(qǐng)相信我”成立的它靠的是每一個(gè)環(huán)節(jié)都把該做的校驗(yàn)老老實(shí)實(shí)做完。代碼寫到位了別人才敢信。