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