昨天我們以循序圖描繪了整個登入流程中各組件溝通的狀況,並簡單介紹了 JWT 的相關概念,這樣的資訊應該足夠做為我們今天實作登入功能的基底。
這兩天我想會以說明實作程式碼,跟釐清一些觀念為主,功能測試則放到後天進行。
為什麼是後天?因為今天寫完發現篇幅實在太長了 XD,特別是 JWT Token 建立的部分。
第一次實作,不希望匆匆帶過這個過程,因此我決定將 JWT 實作內容留到明天。
讓我們先從很久之前就介紹過的 UserDetailsService
開始吧!
第三天介紹 Spring Security 時,我們提到 UserDetailsService
這個 interface 會定義如何驗證使用者身分。實作這個介面等於是在告訴系統,當接收到使用者資訊時,要去哪裡找出驗證使用者身分。
現在讓我們在 service
/Impl
package 中建立檔案 UserDetailsServiceImpl
類別來實作 UserDetailsService
這個介面:
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
return userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("User Not Found with email: " + email));
}
}
在此類別中,我們覆寫 loadUserByUsername
方法,告訴系統應該到哪個資料表查詢使用者資訊。若找不到使用者,拋出 UsernameNotFoundException
;若找到使用者,則回傳 UserDetails
物件。
還記得當時我們將 UserDetailsService
比喻作保全人員,但實作完這個環節,我會說 UserDetailsService
看起來更像一本「手冊」,用來告訴保全人員如何驗證使用者身分。
真正去使用這本手冊的「保全人員」,會比較像 Spring 預設元件 ** DaoAuthenticationProvider
**
AuthenticationManager
與DaoAuthenticationProvider
的關聯是什麼?AuthenticationManager
是一個介面,其最主要的預設實作是 ProviderManager
。ProviderManager
內部維護了一個 AuthenticationProvider
的列表,它可以管理多種不同的認證方式。UserDetailsService
和 PasswordEncoder
時,它會自動配置一個 DaoAuthenticationProvider
。DaoAuthenticationProvider
會被註冊到 ProviderManager
維護的 AuthenticationProvider
列表中。AuthenticationManager
了解來龍去脈後,我們只需將 AuthenticationManager
註冊到 Spring 容器中,就能讓其他元件(如 Controller
)注入並使用它來進行驗證。因此,我們要在 SecurityConfig
中,新增獲取 AuthenticationManager
的方法:
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
定義好驗證的方式,也配置好驗證的人員後,我們可以進一步來定義登入的 API 端點請求與回應的內容。
在 dto package 下建立以 record 宣告的 LoginRequest
類別,包含 email
和 password
欄位,並加上 @NotBlank
等驗證。
public record LoginRequest(
@NotBlank @Email
String email,
@NotBlank
String password
) {}
在 dto package 下建立 以 record 宣告的 LoginResponse
類別,包含 jwtToken
、id
、email
等成功登入後需要回傳給前端的資訊。
public record LoginResponse(
String token,
Long id,
String email,
List<String> roles
) {}
跳過 JwtUtils
,先來撰寫登入的/login
端點,實作細節如下:
...
@PostMapping("/login")
public ResponseEntity<?> authenticateUser(@Valid @RequestBody LoginRequest loginRequest) {
// 1. 使用 AuthenticationManager 進行認證
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(loginRequest.email(), loginRequest.password()));
// 2. 將認證成功的資訊存入 SecurityContext
SecurityContextHolder.getContext().setAuthentication(authentication);
// 3. 產生 JWT Token
String jwt = jwtUtils.generateJwtToken(authentication);
// 4. 從 Authentication 物件中取得 UserDetails
UserEntity userDetails = (UserEntity) authentication.getPrincipal();
List<String> roles = userDetails.getAuthorities().stream()
.map(item -> item.getAuthority())
.toList();
// 5. 回傳 JWT 和使用者資訊
return ResponseEntity.ok(new LoginResponse(jwt,
userDetails.getId(),
userDetails.getUsername(),
roles));
}
...
雖然跳過 JwtUtils
,先來撰寫登入的/login
端點,可能導致目前服務還無法正常運行,但我們可以想像此處使用到 JwtUtils
的情況,就是傳入 Authentication
物件,並取得工具回傳的 JwtToken。
整體流程就像 Day 12 的循序圖:呼叫 AuthenticationManager
進行認證,認證成功後使用 JwtUtils
產生 Token,最後將 Token 與使用者資訊打包回傳。
SecurityContext 在這次登入請求存入使用者資訊其實沒有特別的作用,省略也不影響功能,但在未來有其他請求需要驗證使用者權限資訊時,就會需要將驗證結果存入 context 中。