# Spring Boot 後端登入 — Spring Security ## 1. Spring Security Spring Security 是一套安全管理框架,主要用來保護服務與資料資源,避免被未授權存取。 它提供兩大核心功能: * **認證(Authentication)**:使用者登入,向系統表明身份。 * **授權(Authorization)**:系統判斷該使用者是否具備存取特定資源或 API 的權限。 --- ## 2. SecurityConfig 專案中可透過 `SecurityConfig` 調整 Spring Security 的相關設定,常見配置如下: 1. **UserDetailsService** * 專案中若 `UserDetailsService` 被註解,通常需要將註解移除並實作,否則登入功能無法啟動。 2. **HttpSecurity 設定** * `.anyRequest().permitAll()` → 所有 API 允許未登入存取。 * `.anyRequest().authenticated()` → 使用者需登入後才能存取 API。 3. **CORS 設定** * `corsConfigurationSource` 中的 `setAllowedOrigins()` 用來設定允許的前端來源。 * 注意:**正式打包時不能設為 `"*"`**,必須指定允許的來源 IP 或網域。 --- ## 3. UserDetailsService 登入功能必須實作 `UserDetailsService` 介面: * 建立 Service 並實作 `loadUserByUsername(String username)`。 * 在方法中透過 `username` 查詢資料庫,取得使用者資訊與密碼。 * 回傳實作 **UserDetails** 介面的物件(可使用 Spring Security 提供的 `User` 類)。 --- ## 4. 登入流程 中心專案針對 **Spring Security** 做了自訂流程,主要在 `modules/config` 目錄下,以 **Custom** 開頭的檔案為主。 1. **CustomLoginFilter** * 攔截 `/login` 請求。 * 在 `attemptAuthentication()` 進行驗證: * 確認請求方法是否為 POST。 * 從 `RequestBody` 或 `form-data` 取得 `account` 與 `password`。 * 驗證成功後,呼叫 **AuthenticationManager** 進行認證。 2. **CustomAuthenticationProvider**(繼承 `AbstractUserDetailsAuthenticationProvider`) * 實際的認證流程由此類別執行。 * 認證時會呼叫 `retrieveUser()`: * 在 `retrieveUser()` 中呼叫 `loadUserByUsername()`。 * `loadUserByUsername()` 從資料庫取得使用者資訊(含密碼)。 * 回傳符合 **UserDetails** 的物件。 * 在 `retrieveUser()` 進行密碼比對。 > 可以使用Debug Mode去了解整個登入流程的進行。 --- ## 5. 傳統登入 傳統登入方式即為使用者輸入帳號與密碼。 前端傳遞參數方式有兩種: 1. **application/json** 2. **form-data** 無論哪種方式,傳遞的參數都只有兩種:**帳號**與**密碼**。 --- ### 相關程式碼 以下為程式範例,可直接使用。 --- ### CustomLoginFilter ```java @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { // 傳統登入 if (request.getMethod().equalsIgnoreCase("POST")) { // 驗證請求方式是否為 POST // form-data 預設帳號、密碼的 key 為 username 與 password String account = obtainUsername(request); // 取得 form-data 帳號 String password = obtainPassword(request); // 取得 form-data 密碼 UsernamePasswordAuthenticationToken authentication; if (request.getContentType().startsWith("application/json")) { // 從 RequestBody 中取得帳號、密碼並封裝 authentication = resolveAuthenticationFromRequestBody(request); } else { // 封裝使用者帳號與密碼 authentication = new UsernamePasswordAuthenticationToken(account, password); } // 將 request 中的額外資訊放入 authentication setDetails(request, authentication); // 呼叫 AuthenticationManager 執行認證 return getAuthenticationManager().authenticate(authentication); } else { throw new AuthenticationServiceException("認證方法不支援:" + request.getMethod()); } } ``` ```java private UsernamePasswordAuthenticationToken resolveAuthenticationFromRequestBody(HttpServletRequest request) throws AuthenticationException { StringBuilder stringBuilder = new StringBuilder(); String line; try { BufferedReader reader = request.getReader(); while ((line = reader.readLine()) != null) { // 逐行讀取 JSON stringBuilder.append(line); } ObjectMapper mapper = new ObjectMapper(); JsonNode loginRequest = mapper.readTree(stringBuilder.toString()); return new UsernamePasswordAuthenticationToken( loginRequest.findValue("account").asText(), // 取 key = account 的值 loginRequest.findValue("password").asText() // 取 key = password 的值 ); } catch (IOException e) { e.printStackTrace(); throw new AuthenticationServiceException("登入失敗"); } } ``` --- ### UserDetailsServiceImpl 需實作 `UserDetailsService`。 ```java @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { // 建立一個固定的使用者,帳號 admin / 密碼 password return User.builder() .username("admin") // 使用者帳號 .password("password") // 使用者密碼 .authorities("管理員") // 使用者權限 .disabled(false) // 是否禁用,false = 啟用 .accountExpired(false) // 帳號是否過期,false = 未過期 .accountLocked(false) // 帳號是否鎖定,false = 未鎖定 .credentialsExpired(false)// 密碼是否過期,false = 未過期 .build(); } ``` --- ### CustomAuthenticationProvider ```java @Override protected UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { // 從 authentication 取得帳號與密碼(由 attemptAuthentication 封裝) String account = authentication.getName(); String password = authentication.getCredentials().toString(); // 透過 loadUserByUsername 從 DB 撈取使用者資訊(含密碼) UserDetails userDetails = userDetailsService.loadUserByUsername(account); // 驗證密碼(此範例使用明文比對) if (StringUtils.isNotEquals(userDetails.getPassword(), password)) { throw new AuthenticationServiceException("密碼錯誤"); } // 驗證使用者是否停權 else if (!userDetails.isEnabled()) { throw new AuthenticationServiceException("您的帳號已被停權"); } // 認證成功,回傳使用者資訊 return userDetails; } ``` --- ## Google 登入 ### 說明 由於是透過第三方登入,要先去Google Console中建立一個OAuth 2.0 Client ID。 會獲取到`客戶端ID`與`客戶端密碼` --- ### 流程 Step 1 ~ Step 4 為前端處理,不詳細解釋。 --- 1. **前端導向 Google OAuth2 認證網址** 2. **使用者選取 Google 帳號並授權** * 授權成功後 Google 會回傳 **Code**、**Scope** 等資訊。 3. **前端透過 Code 換取 Token** * 前端或後端使用 Code 交換取得 **access_token** 與 **id_token**。 4. **前端將 id_token 傳送至後端** * 放在 **Header:X-Client-Token** * 呼叫 `POST /login`。 5. **後端基本驗證** * 驗證請求 Method 是否為 **POST** * 驗證 `X-Client-Token` 是否存在。 6. **後端進階驗證** * 使用 **GoogleIdTokenVerifier** 驗證 `id_token` 是否有效。 7. **查詢或建立使用者** * 從 DB 撈使用者資料 * 若無對應使用者,則新增一筆。 8. **回傳使用者資訊 (UserDetails)** 9. **檢查使用者狀態** * 是否停權、是否過期、是否鎖定等。 10. **登入結果** * 驗證成功 → 登入成功 * 驗證失敗 → 登入失敗 --- ### 相關程式碼 ### application-local.yml ```yaml google: clientId: 71000000-hrg4bjsn7rwwwwokjq2f150g1j.apps.googleusercontent.com clientSecret: GOCSAA-RtKvfLjWWWW8Q99rmJXXXK ``` ### CustomLoginFilter ```java @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { // GOOGLE登入 // 比對請求method是否為POST且Header中X-Client-Token是否有值(中心專案的Google登入會將id_token放入Header中的X-Client-Token) if (request.getMethod().equalsIgnoreCase("POST") && request.getHeader("X-Client-Token") != null) { // 封裝用戶名密碼認證訊息的類,這邊的用戶名為id_token,密碼為null UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( request.getHeader("X-Client-Token"), null); // 將request中的額外資訊放入authentication中 setDetails(request, authentication); // 呼叫AuthenticationManager去執行認證的步驟 return getAuthenticationManager().authenticate(authentication); } else { throw new AuthenticationServiceException("認證方法不支援:" + request.getMethod()); } } ``` ### CustomAuthenticationProvider ```java @Override protected UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { //這裡傳的username為google頒發的id_token,我們要在loadUserByUsername中再次驗證此id_token UserDetails userDetails = userDetailsService.loadUserByUsername(username); if (!userDetails.isEnabled()) { throw new AuthenticationServiceException("您的帳號已被停權"); } return userDetails; } ``` ### UserDetailsServiceImpl ```java // 從.yml中獲得值 @Value("${google.clientId}") private String clientId; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { // 透過clientId去建立GoogleIdTokenVerifier GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), new GsonFactory()) .setAudience(Collections.singletonList(clientId)) .build(); // 宣告GoogleIdToken物件,可透過getPayload取得使用者各項資訊 GoogleIdToken googleIdToken; try { googleIdToken = verifier.verify(username); } catch (Exception e) { throw new AuthenticationServiceException("該Token不合法"); } GoogleIdToken.Payload payload = googleIdToken.getPayload(); System.out.println("Email: " + payload.getEmail()); // 如果沒有相關函式但其實有值可以使用.get("{key}")來獲得值 System.out.println("Name: " + payload.get("name")); return User.builder() .username(payload.get("name").toString()) // 使用者帳號 .password("") // 使用者密碼 .authorities("管理員") // 使用者權限 .disabled(false) // 是否禁用,false表示啟用的意思 .accountExpired(false) // 帳號是否過期,false表示還沒過期的意思 .accountLocked(false) // 帳號是否鎖定,false表示沒有鎖定的意思 .credentialsExpired(false) // 密碼是否過期,false表示沒有過期的意思 .build(); } ```