Java — Spring Boot Todo List Built From Scratch (4)

使用 Spring Security 與自定義 Filter 完成身份驗證

Mask
12 min readMay 18, 2023

上篇完成 JWT 相關功能,本篇目標為使用 Spring Security 來做 Middleware 層的驗證,並會使用到上篇所完成的功能

Photo by James Harrison on Unsplash

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 ,在後續的流程中,就可以調用經過驗證的資訊

https://www.youtube.com/watch?v=YkA4cunsU9g

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

Photo by Marnhe du Plooy on Unsplash

--

--