一、問題背景
在日常開發中,我們通常只關注 ERROR 級別日誌,而會忽略 WARN(警告) 級別的信息。
然而,Spring Security 在登錄時產生的大量警告其實暗示了潛在問題。
最近,在處理分配的一個登錄警告修復任務時,我們發現如下日誌出現:
2025-10-16T21:27:07.411+08:00 WARN 3905 --- [nio-8080-exec-6] w.c.HttpSessionSecurityContextRepository : SPRING_SECURITY_CONTEXT did not contain a SecurityContext but contained: 'UsernamePasswordAuthenticationToken [Principal=User(username=13920618851, name=系統管理員, password=$2a$10$lDSKF/m7HWpw3tC2pR496.Yak3EcPw9J7.YZY4yU0SqkXA03NsIK., phone=null, status=1, wechatUser=null, roles=[Role(beSystem=true, name=系統管理員, code=systemAdmin, accesses=[Access(authorities=supplier:page,customer:page,outboundOrder:page,personalCenter,access:update,settlement,system,access:getByUuid,productSku:page,access:getAll,access:save,dashboard,finance,inboundOrder:page, description=所有權限, name=所有權限, code=systemAdmin, beSystem=true)], weight=-2147483648)], authorities=[club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@5c109170, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@1cf66757, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@4e42d403, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@7426a5e1, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@1a9e54d6, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@f8a6987, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@17167624, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@552af218, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@27984b6f, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@20e97bfe, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@27fb35e3, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@30e8abd0, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@42dddde4, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@1890218a, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@3e1da267]), Credentials=[PROTECTED], Authenticated=true, Details=null, Granted Authorities=[club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@1890218a, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@17167624, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@5c109170, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@27984b6f, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@3e1da267, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@1a9e54d6, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@42dddde4, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@27fb35e3, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@7426a5e1, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@1cf66757, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@30e8abd0, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@4e42d403, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@f8a6987, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@20e97bfe, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@552af218]]'; are you improperly modifying the HttpSession directly (you should always use SecurityContextHolder) or using the HttpSession attribute reserved for this class?
2025-10-16T21:27:07.412+08:00 DEBUG 3905 --- [nio-8080-exec-6] o.s.web.servlet.DispatcherServlet : GET "/saleGauge/todayGauge", parameters={}
2025-10-16T21:27:07.412+08:00 DEBUG 3905 --- [nio-8080-exec-6] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to club.yunzhi.minicrm.controller.SaleGaugeController#getTodayGauge()
2025-10-16T21:27:07.477+08:00 DEBUG 3905 --- [nio-8080-exec-6] org.hibernate.SQL : select sr1_0.id,sr1_0.`created_by_id`,sr1_0.created_time,sr1_0.last_settlement_entry_id,sr1_0.last_stock_transaction_id,sr1_0.previous_reconciliation_id,sr1_0.`updated_by_id`,sr1_0.updated_time,sr1_0.uuid from system_reconciliation sr1_0 where sr1_0.created_time<? order by sr1_0.id desc
2025-10-16T21:27:07.530+08:00 WARN 3905 --- [nio-8080-exec-7] w.c.HttpSessionSecurityContextRepository : SPRING_SECURITY_CONTEXT did not contain a SecurityContext but contained: 'UsernamePasswordAuthenticationToken [Principal=User(username=13920618851, name=系統管理員, password=$2a$10$lDSKF/m7HWpw3tC2pR496.Yak3EcPw9J7.YZY4yU0SqkXA03NsIK., phone=null, status=1, wechatUser=null, roles=[Role(beSystem=true, name=系統管理員, code=systemAdmin, accesses=[Access(authorities=supplier:page,customer:page,outboundOrder:page,personalCenter,access:update,settlement,system,access:getByUuid,productSku:page,access:getAll,access:save,dashboard,finance,inboundOrder:page, description=所有權限, name=所有權限, code=systemAdmin, beSystem=true)], weight=-2147483648)], authorities=[club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@18bf71da, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@7260a86a, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@59a3096e, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@22f5f618, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@5f86c989, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@312115d1, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@73058c94, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@6973f4a0, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@4dbaeace, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@47813f75, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@3c544202, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@5916ebef, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@3c2ff416, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@4bd6b46a, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@73e867b6]), Credentials=[PROTECTED], Authenticated=true, Details=null, Granted Authorities=[club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@4dbaeace, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@73e867b6, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@59a3096e, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@73058c94, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@6973f4a0, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@5916ebef, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@18bf71da, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@47813f75, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@312115d1, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@5f86c989, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@3c544202, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@7260a86a, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@4bd6b46a, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@22f5f618, club.yunzhi.minicrm.entity.User$$Lambda/0x0000000120ef81f8@3c2ff416]]'; are you improperly modifying the HttpSession directly (you should always use SecurityContextHolder) or using the HttpSession attribute reserved for this class?
2025-10-16T21:27:07.531+08:00 DEBUG 3905 --- [nio-8080-exec-7] o.s.web.servlet.DispatcherServlet : GET "/saleGauge/getRecentDaysGauges/7", parameters={}
儘管系統功能表面上正常,但這個警告反映出:Spring Security 的會話安全機制被“錯誤地使用”了。
二、問題定位
2.1 報錯信息解析
將報錯信息簡化一下,提取關鍵信息:
w.c.HttpSessionSecurityContextRepository :
SPRING_SECURITY_CONTEXT did not contain a SecurityContext but contained: 'UsernamePasswordAuthenticationToken[...]'; are you improperly modifying the HttpSession directly (you should always use SecurityContextHolder) or using the HttpSession attribute reserved for this class?
我們可以發現警告信息來自:HttpSessionSecurityContextRepository —— Spring Security 負責將認證信息(SecurityContext)與會話(HttpSession)關聯的組件。
日誌的含義可以翻譯為:
HttpSessionSecurityContextRepository:
SPRING_SECURITY_CONTEXT中未包含SecurityContext對象,但發現了'UsernamePasswordAuthenticationToken[...]'內容;你是否正在直接修改HttpSession(應始終通過SecurityContextHolder操作)或使用了為此類保留的HttpSession屬性?
換句話説:
Spring Security 框架期望從Session的 SPRING_SECURITY_CONTEXT 屬性中取出一個 SecurityContext 對象,但它卻發現裏面直接存放了一個 UsernamePasswordAuthenticationToken 對象。
2.2 相關名詞解釋
為了理解問題,我們需要先了解幾個關鍵概念:
| 名稱 | 作用 |
|---|---|
| UsernamePasswordAuthenticationToken | 代表了一次身份認證請求或一個已認證的主體,就是用户身份的載體,包含用户名、密碼、權限等信息。 |
| SecurityContext | 封裝了 Authentication,表示安全上下文。 |
| SecurityContextHolder | 管理當前線程的 SecurityContext,是獲取認證信息的統一入口。你應該始終通過它來訪問和修改安全上下文,而不是直接操作Session。 |
| HttpSession | HTTP 會話,用於在請求間存儲用户相關的數據,會為每個用户創建一個唯一的Session。 |
| SPRING_SECURITY_CONTEXT | Spring Security 在 Session 中保存 SecurityContext 的屬性名常量。 |
2.3 當前登錄流程及問題根源分析
讓我們通過登錄流程圖來理解問題的發生位置:
問題本質:存儲了錯誤類型的對象
這通常是因為有人繞過了 SecurityContextHolder,直接像下面這樣寫入了HttpSession:
// ❌ 錯誤示範!
// 直接存了Token,而不是存SecurityContext
UsernamePasswordAuthenticationToken authToken = ...;
httpSession.setAttribute("SPRING_SECURITY_CONTEXT", authToken);
為什麼功能正常但仍有警告?
- ✅ 功能正常:因為
TokenAuthenticationFilter正確地從Session中取出了Authentication並設置到SecurityContextHolder。 - ⚠️ 產生警告:
Spring Security檢測到Session中存儲的是直接序列化的Authentication(即UsernamePasswordAuthenticationToken) 而非SecurityContext,違反框架約定,導致每次請求處理時都會觸發警告日誌,提示可能存在對HttpSession的不當操作。 -
💥 潛在風險:可能導致內存浪費,
Spring Security可能因找不到SecurityContext而創建默認空上下文。三、解決方案及代碼實現
3.1 解決後登錄流程
按照正確的設計規範,修復後的登錄流程應該如下圖所示:
3.2 修復登錄接口
修改前:直接存儲 Authentication(UsernamePasswordAuthenticationToken)
// UserController.java
@PostMapping("login")
@JsonView(LoginJsonView.class)
UserDetails login(@RequestBody Map<String, String> loginRequest,
HttpServletRequest request) {
String username = loginRequest.get("username");
String password = loginRequest.get("password");
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(username, password);
Authentication authentication = authenticationManager.authenticate(authToken);
// 將session與認證信息相關聯 ❌ 問題在這裏:直接將 Authentication 存入 Session
request.getSession(true).setAttribute("SPRING_SECURITY_CONTEXT", authentication);
return (UserDetails) authentication.getPrincipal();
}
修改後:正確存儲 SecurityContext
// UserController.java
@PostMapping("login")
@JsonView(LoginJsonView.class)
UserDetails login(@RequestBody Map<String, String> loginRequest,
HttpServletRequest request) {
String username = loginRequest.get("username");
String password = loginRequest.get("password");
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(username, password);
Authentication authentication = authenticationManager.authenticate(authToken);
// 創建 SecurityContext 並設置認證信息 ✅
SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
securityContext.setAuthentication(authentication);
// 將 SecurityContext 存入 session ✅
request.getSession(true).setAttribute("SPRING_SECURITY_CONTEXT", securityContext);
return (UserDetails) authentication.getPrincipal();
}
3.3 修復認證過濾器
登錄時存儲的是 SecurityContext,過濾器讀取時也必須按相同結構解析。
修改前:錯誤地從 Session 獲取 Authentication
// TokenAuthenticationFilter.java
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
// 1. 從請求頭獲取 x-auth-token
// ...
// 2. 如果token不存在,直接放行(後續Security會處理未認證的情況)
// ...
// 3. 根據token查找session
// ...
// 4. 從session中獲取關聯的認證信息 ❌
Authentication authentication = session.getAttribute("SPRING_SECURITY_CONTEXT");
if (authentication == null) {
filterChain.doFilter(request, response);
return;
}
// 5. 設置認證信息到SecurityContext
SecurityContextHolder.getContext().setAuthentication(authentication);
// 6. 繼續過濾器鏈
filterChain.doFilter(request, response);
}
修改後:正確地從 SecurityContext 獲取 Authentication
// TokenAuthenticationFilter.java
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
// 1. 從請求頭獲取 x-auth-token
// ...
// 2. 如果token不存在,直接放行(後續Security會處理未認證的情況)
// ...
// 3. 根據token查找session
// ...
// 4. session 中獲取 SecurityContext ✅
SecurityContext securityContext = session.getAttribute("SPRING_SECURITY_CONTEXT");
if (securityContext == null) {
filterChain.doFilter(request, response);
return;
}
// 5. 從 SecurityContext 中獲取 Authentication ✅
Authentication authentication = securityContext.getAuthentication();
if (authentication == null) {
filterChain.doFilter(request, response);
return;
}
// 6. 設置認證信息到SecurityContext
SecurityContextHolder.getContext().setAuthentication(authentication);
// 7. 繼續過濾器鏈
filterChain.doFilter(request, response);
}
3.4 修復後後續請求流程
修復完成後,後續請求的處理流程變得更加規範和清晰,具體過程如下圖所示:
四、補充
為了確認修復效果,我們可以通過 Docker 中的 Redis 客户端界面來驗證數據存儲情況。具體操作:
- 如圖點擊
Docker界面,或者直接訪問http://localhost:5540/
- 再點擊/創建自己的數據庫
-
當登錄成功後就可以生成
HASH數據,如下圖所示
五、總結與心得
5.1 修復要點回顧:
存儲時:
Authentication→SecurityContext→Session
讀取時:Session→SecurityContext→Authentication
核心原則:始終通過SecurityContextHolder來操作安全上下文5.2 個人心得
通過最近時間的學習,明確了自己要學的東西還很多,當時對於Redis存儲Session的流程圖表達不太清晰。在學長的指導下,我學會去追源碼以及查看Redis中的Session數據。
所以,有些技術細節無法僅通過文檔查詢獲得,必須親自動手實踐——包括代碼調試、源碼閲讀和數據驗證。這樣通常會節省很大時間以及加強自己的實踐動手能力。