Java — Spring Boot Todo List Built From Scratch (4)
上篇完成 JWT 相關功能,本篇目標為使用 Spring Security 來做 Middleware 層的驗證,並會使用到上篇所完成的功能
Spring Security
是 Spring 中集成許多常用驗證、保護等功能的框架,在系統中擔任 Middleware 的位置,所有請求都須經過驗證才可以進到 Controller 處理,這個小專案使用的 JWT 將透過 Spring Security 來完成功能
Dependency
這個依賴一使用,在專案啟用時就會使用預設的各種 Filter ,所以在沒有進行完整的設定前,原先可以使用的 Requst 都會被擋住
<!--Spring Security-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
Security Config
要配置 Spring Security 需要通過兩個註解開始
- Configuration
- EnableWebSecurity
從兩個註解的依賴可以得知,第一個 Configuration
是 Spring 的註解,用於告訴 Spring 此類為配置類,在程序啟動時應用其中的配置
第二個 EnableWebSecurity
是 Spring Security 的註解,用於配置與啟用 Spring Security
@Configuration
@EnableWebSecurity
public class SecurityConfig {
// do something...
}
FilterChain
Spring Security 是通過一連串的 Filter 來驗證 http request,在經過所有驗證後,可以將驗證後的資訊放在 SecurityContextHolder
,在後續的流程中,就可以調用經過驗證的資訊
Spring Security 有預設的 Filter,參考 doc
通過設置 SecurityFilterChain
,可以設定自己需要的配置,後續在完成自定義的 JwtAuthFilter
後,會在這個設定內加入
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
String[] allowPath = { "/user/register", "/user/login" };
http
// 禁用CSRF(跨站請求偽造)保護
.csrf().disable()
// 不使用Sesseion
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
return http.build();
}
AuthenticationManager
Spring Security 中驗證身份的核心組件,如果有用到
UsernamePasswordAuthenticationManage
就會需要這個組件,但在這個專案中我沒使用,所以這個實例我不需要,不過貌似很常用的樣子,我參考的兩個專案都有用,所以做個紀錄
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig)
throws Exception {
return authConfig.getAuthenticationManager();
}
PasswordEncoder
加密明文密碼,在此實例可以做到在 Middleware 就對 Request 中的密碼加密,在這個專案中沒有用到,但也是常見,紀錄一下
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
根據之前的開發經驗,密碼在前端就會先加密一次,確保傳輸中的安全,後端做的是驗證加密後密碼的簽名,確保沒有被更改過,
以上完成基本的 Spring Security 設置,後續完成自定義的過濾器後,會再增加設定
JwtAuthToken
在進行 Filter 的實作之前,須先完成 AuthenticationToken
,這是將驗證完成的 Token 轉成可供後續流程調用使用者資訊,
比如UsernamePasswordAuthenticationToken
是 Spring Security 提供的,或是可以自定義,這個專案我只需要可以在 Controller 拿到我的 Token,所以我用自定義
AuthenticationToken
中兩個我比較需要用到的函式
- Credential — 用來放使用者提供的驗證資訊,以此例來說就是 Token
- Principal — 發出請求的身份,也就是 Token 內的身份信息
import org.springframework.security.authentication.AbstractAuthenticationToken;
public class JwtAuthToken extends AbstractAuthenticationToken {
private String token;
public JwtAuthToken(String token) {
super(null);
this.token = token;
}
/**
* 驗證資訊
*/
@Override
public Object getCredentials() {
return token;
}
/**
* 身份資訊
* 視情況放需要的資訊,此處以使用者編號為例
*/
@Override
public Object getPrincipal() {
return jwtUtil.extractID(token);
}
}
會需要實作這一個類是因為
SecurityContextHolder
只接受實作Authentication
介面的參數,
上方的類是繼承自抽象類AbstractAuthentication
,而該抽象類實作Authenication
,所以可以傳入 Context 中
JwtAuthFilter
這個自定義的過濾器要負責處理 Request 中的 token,調用前篇寫的 JwtUtil
對其進行驗證,通過驗證就存入 Context,如果有錯誤的話,直接就返回,不需進到 Controller
/**
* Jwt 驗證中間層
*/
@Component
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil = new JwtUtil();
/**
* 主要的中間層邏輯
*/
@Override
protected void doFilterInternal(
HttpServletRequest req,
HttpServletResponse res,
FilterChain filterChain) throws ServletException, IOException {
// 取得token
String token = jwtUtil.getToken(req);
// 跳過邏輯
if (SkipPath(req.getRequestURI())) {
filterChain.doFilter(req, res);
return;
}
// 驗證 Token
try {
boolean valid = jwtUtil.checkToken(token);
if (valid) {
// 建立完成驗證的 Authentication
JwtAuthToken auth = new JwtAuthToken(token);
// 存入 Context
SecurityContextHolder.getContext().setAuthentication(auth);
}
} catch (Exception e) {
// status code 401
res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
// 預設是ISO-8859,會編譯不了中文
res.setCharacterEncoding("UTF-8");
// application/json
res.setContentType(MediaType.APPLICATION_JSON_VALUE);
Response resBody = new Response().Error().ErrorMessage(e);
// 將Response轉成Json格式的字串
String resJson = new ObjectMapper()
.writeValueAsString(resBody);
// 取得輸出流,寫入回傳內容
res.getWriter().write(resJson);
// 回傳給Client
res.getWriter().flush();
// 關閉輸出流
res.getWriter().close();
return;
}
filterChain.doFilter(req, res);
}
public boolean SkipPath(String url) {
String[] needCheckURL = {
"/user/info",
};
List<String> list = Arrays.asList(needCheckURL);
return list.contains(url);
}
}
自定義的 Filter 需要在內部自行實作跳過的邏輯
Security Config
完成自定義的 Filter 後,要將它加入到 Spring Security 的 FilterChain中,才會起作用,承上述已提過程式碼,再加入以下
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// String[] allowPath = { "/user/register", "/user/login" };
http
// 禁用CSRF(跨站請求偽造)保護
.csrf().disable()
// 不使用Sesseion
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
// 設定完SecurityConfig時使用,並返回SecurityBuilder
/*
* 將自定義的過濾器放在User..Filter之前
* User...Filter是框架預設過濾器中的最後一個
*/
http.addFilterAfter(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
踩坑紀錄
WebSecurityConfigurerAdapter 已棄用
最一開始搜尋 Spring Security 的教學時,大多從繼承這個類開始,然後覆寫函式設定,但我的編輯器一直辨識不到這個類,一查才知道在 2022年2月被棄用,並且有官方建議的替代方式
Spring Security without the WebSecurityConfigurerAdapter
中文參考:點我
permitAll Not Working
permitAll() 源碼中的註釋說 Specify that URLs are allowed by anyone
,所以我的理解是該設定的路由將可被任何人通過,原本想在 SecurityConfig 中使用此函式,來讓特定的路由不用經過 JwtAuthFilter ,但用了之後,我的 /user/register
一直被自己寫的驗證失敗返回,但我明明對這個路徑用了permitAll
反覆跟 ChatGPT 問答後,得到了可能問題是
permitAll()不會 “跳過” filter,如果有自定義的 filter,需要自行處理跳過邏輯
在知道這個簡單的結論後,我補上了跳過邏輯,一切都成功了,但我需要知道原因,所以做了額外的查詢,針對問題有了個比較完整的結論
permitAll()只會讓 spring security 針對特定的URL跳過部分的 filter ,比如身份驗證的filter就不會跳過,但 Spring Security 預設的身份驗證只匹配 /login
URL,所以不影響我
而我在本篇做的 JwtAuthFilter
是繼承自 OncePerRequestFilter
,這個類的特性就是保證每一次 Request 都會執行此 Filter ,也就是 PermitAll() 對這個Filter不起作用
釐清後我就把 permitAll 相關的程式刪除了
參考:
Closed After adding custom filters, permitAll() does not work
Spring Security with filters permitAll not working