---
# System prepended metadata

title: Spring Boot 後端登入 — Spring Security

---

# 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();
    }
```