昨日啟用了Spring Security針對特定規則路由進行保護,但目前驗證功能尚未完整,還欠缺了身分驗證的方式,今天來套用之前寫好的JWT工具類,添加自訂過濾器實現身分驗證
去銀行或郵局辦事情時,都會需要抽號碼牌排隊,要辦理業務的窗口會叫號,臨櫃出示抽到的號碼,必須與當窗口服務的號碼相符合,才可以執行相關業務,若要執行要本人親洽才能辦理的業務,此時就要另外出示證明本人的文件,來識別申請人是否有權可以執行相關業務,在進一步做後續操作
身分驗證
授權
每個窗口都有專職的業務範疇,會根據申請者的資料,決定是否可以辦理業務
,轉成程式邏輯來看就是,當前使用者有相應資料,可以操作當前API,能執行後續操作
銜接上個段落提到的情境,程式並不知道「已經被授權」的使用者狀態,此時可以建立一個類別,用來當前使用者請求的狀態,Spring Security建立下列元件,用來記錄使用者及請求驗證資訊
UserDetails
負責存放當前請求的使用資訊
UsernamePasswordAuthenticationToken
封裝當前請求使用者資訊(UserDetails),以及請求是否被授權的狀態
此外 Spring Security是基於Filter,實現進入核心業務邏輯前,進行相關邏輯處理,實際操作流程是先撰寫過濾器,並實作身分驗證邏輯,在將自訂過濾器嵌入登入驗證過濾鏈中
依照使用規範,必須將系統紀錄使用者資料,轉換為 UserDetails物件,Spring Security提供了UserDetailsService 介面,規範要實作的抽象方法 loadUserByUsername,根據識別User唯一名稱來產生 UserDetails
由於是用 account 當做唯一鍵,取得資料依據會以 account為主,先來做前置作業
public Member getByAccount(String account);
<select id="getByAccount" resultType="com.app.demo.dto.Member">
SELECT * FROM members WHERE account = #{account} LIMIT 1
</select>
系統互動的元件建立後,在來就是新增 Service依照介面UserDetailsService,來實作相應方法
@Service
@Service
public class DBUserDetailsService implements UserDetailsService {
@Autowired
private MemberDao memberMapper;
@Override
public UserDetails loadUserByUsername(String account) throws UsernameNotFoundException {
Member member = memberMapper.getByAccount(account);
if (member == null) {
throw new UsernameNotFoundException("User not found");
}
return new User(
member.getAccount(),
member.getPassword(),
// 這裡先套空陣列,後面會說實際用途
Collections.emptyList()
);
}
}
有了Service Layer測試好後,來針對 DBUserDetailsService進行測試
@SpringBootTest
@ActiveProfiles("test") // 定義測試的環境設定套用 application-test.properties
public class UserDetailsServiceTest {
@Autowired
private DBUserDetailsService userDetialServ;
@Test
void checkUserByAccount() {
// 記得先在資料庫添加相應 account為test的資料
UserDetails member = userDetialServ.loadUserByUsername("test");
assertEquals("test", member.getUsername());
}
}
執行 mvn test -Dtest=UserDetailsServiceTest#checkUserByAccount
檢查將使用者資料封裝UserDetails是否正常
身分過濾器的實現邏輯就是,利用請求中攜帶的 Token 套用到,套用到寫好的模組中,根據實際需求決定是否需要阻止請求
@Component
public class UserAuthFilter extends OncePerRequestFilter {
@Autowired
private JwtUtil jwtUtil;
@Autowired
private DBUserDetailsService userDetialServ;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String authToken = request.getHeader("Authorization");
String headerPrefix = "Bearer ";
if (authToken != null && authToken.startsWith(headerPrefix)) {
try {
authToken = authToken.substring(headerPrefix.length());
jwtUtil.validateToken(authToken);
Claims payload = jwtUtil.parseToken(authToken);
// 取得 Token綁定識別身分資料
String account = payload.getSubject();
System.out.printf("account is %s\n", account);
// 根據 stage 判斷查找前台還是後台使用者
UserDetails userDetials = userDetialServ.loadUserByUsername(account);
System.out.printf("UserDetails Username %s\n", userDetials.getUsername());
if (userDetials != null) {
Authentication authenticationToken = new UsernamePasswordAuthenticationToken(
userDetials, null, userDetials.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
} catch (Exception ex) {
System.out.println(ex.getMessage());
SecurityContextHolder.getContext().setAuthentication(null);
}
}
filterChain.doFilter(request, response);
}
}
在 Spring Security Config 嵌入自訂過濾鍊並建立新的 Bean
@Bean
public UserAuthFilter jwtAuthenticationFilter() {
return new UserAuthFilter();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authorize) -> {
authorize
.requestMatchers("/api/member/**").authenticated()
.anyRequest().permitAll();
})
// 構造器添加 addFilterBefore 配置
.addFilterBefore(
jwtAuthenticationFilter(), // 此處要用Bean註釋的方法取得自定義 Filter
UsernamePasswordAuthenticationFilter.class // Spring Security 預設
);
return http.build();
}
建立一個API 取得登入使用者資料
@GetMapping("/member")
public Map getMmeber() {
Authentication authUser = SecurityContextHolder.getContext().getAuthentication();
Map<String, Object> responseData = new HashMap<>();
responseData.put("account", authUser.getName());
return responseData;
}
現在來調整getMember的測試,並套用Bearer Toekn
@Test
public void getMember() throws Exception {
// 為了掌控篇幅,這裡省略透過登入取得Token的流程,直接用JwtTuil產生新Token
String token = jwtUtil.generateToken("test");
String bearerToken = String.format("Bearer %s", token);
MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders
.get("/api/member") // 測試取得當前使用者資料的接口
.header("authorization", bearerToken);
mockMvc.perform(requestBuilder)
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers
.jsonPath("$.account")
.value("test"));
}
從運行測試打印的內容查看Token的資料,與期望的帳號符合