前兩篇的進度是完成帳密認證,並核發 JWT 做為 access token。而本文想做到的,則是因應前端的 request 攜帶 access token 到達後端時,要讓 Spring Security 知道此人的身份。最終目的是從業務邏輯中輕鬆得知該使用者的資訊,並加以利用。
此篇在 2024 年於以下文章更新。
【Spring Boot】第17.4課-從 Security Context 取得 API 存取方的認證資訊
【Spring Boot】第17.6課-實作 Spring Security 的認證 Filter(以 JWT 為例)
這次 Spring Security 小系列的文章,其範例專案的業務邏輯,不外乎就是建立與取得使用者資料,DB 的 entity 只有「使用者」(AppUser
)這一種。
然而真正進行開發工作時,會有很多 DB 的 entity,我們通常會紀錄這些資料是誰建立的。比方說某人在網路上發表文章,朋友來按讚和留言,那麼 DB 中就會有「文章」、「讚」與「留言」這 3 張 table。並且都有個欄位用來紀錄「誰發的文」、「誰按的讚」和「誰留的言」,亦即資料的建立者。
雖說前端的網頁或 App 在發送請求到後端 API 時,可在 request body 提供自己的使用者 id,但若有心人士繞過前端,直接對 API 發送請求,不就可以攜帶他人的 id 進行假冒,甚至攜帶髒資料進而產生 bug 了嗎?
所以說,當前端存取受保護的 API,最好是透過攜帶 access token 的形式。一方面讓後端知道自己是誰,另一方面也能讓 Spring Security 判斷是否有權限存取。
Access token 與 refresh token 本身也是私密的資料,如果洩漏,那就有可能會被冒充身份了。
無論是上述情境的發表文章、按讚還是留言,若能在業務邏輯中得知 API 存取方的身份,這會有利於將資料的建立者一起儲存到 DB。這便是本文的目的。
為此,讓我們先設計一個元件,取名為 UserIdentity
(以下亦稱「身份元件」),藉此取得該人的資訊。
@Component
public class UserIdentity {
public String getId() { return null; }
public String getUsername() { return null; }
public UserAuthority getUserAuthority() { return null; }
public boolean isPremium() { return false; }
}
上面簡單地提供 4 個方法,回傳該使用者的 id、帳號、權限與是否為高級會員。後面會再回頭完成它們。
下面的 controller 列出了兩支 API,分別是建立使用者,以及本文新加的刪除使用者。並且將前面實作的 UserIdentity
元件注入進來。
@RestController
public class DemoController {
@Autowired
private UserIdentity userIdentity;
@Autowired
private UserRepository userRepository;
// ...
@PostMapping("/users")
public ResponseEntity<Void> createUser(@RequestBody AppUser user) {
// ...
}
@DeleteMapping("/users/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable String id) {
// ...
}
}
為了示範如何透過身份元件取得當前使用者的資訊,於業務邏輯加以利用,筆者設計了一點情境。
本文的目標就是像下面的範例程式這樣,即便 Spring Boot 應用程式處於多執行緒環境,但透過身份元件取得的資訊,都必定屬於當下所處理請求的 API 存取方。
@RestController
public class DemoController {
// ...
@PostMapping("/users")
public ResponseEntity<Void> createUser(@RequestBody AppUser user) {
if (userIdentity.isPremium()) {
user.setCreatorId(userIdentity.getId());
}
userRepository.insert(user);
return ResponseEntity.ok().build();
}
@DeleteMapping("/users/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable String id) {
AppUser deletingUser = userRepository.findById(id);
String myUserId = userIdentity.getId();
UserAuthority myAuthority = userIdentity.getUserAuthority();
if (id.equals(myUserId) ||
myUserId.equals(deletingUser.getCreatorId()) ||
myAuthority == UserAuthority.ADMIN) {
userRepository.deleteById(id);
return ResponseEntity.ok().build();
} else {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
}
}
基於上述的 API 調整,以下是所做出的其他改動。包含在使用者的 model 類別添加欄位,與在 repository 層宣告刪除資料的方法。
public class AppUser {
// ...
private String creatorId;
// getter, setter ...
}
@Repository
public class UserRepository {
private final Map<String, AppUser> idToUserMap = new HashMap<>();
// ...
public void deleteById(String id) {
idToUserMap.remove(id);
}
}
在第一節的 controller 中,我們馬上就會使用身份元件了。那麼在 request 抵達 controller 之前,勢必就要讓後端知道這個人是誰。這個問題會使用 Filter 來處理。
請實作以下的 Filter,取名為 JwtAuthenticationFilter
,並注入 TokenService
與 UserDetailsService
。
簡單來說,此 Filter 的目的,是從「Authorization」這個 request header 取出 access token。接著解析出裡面的 username
欄位,查詢出使用者資料。最後告訴 Spring Security 這個人是誰。
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private TokenService tokenService;
@Autowired
private UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 取得 request header 的值
String bearerToken = request.getHeader(HttpHeaders.AUTHORIZATION);
if (bearerToken != null) {
String accessToken = bearerToken.replace("Bearer ", "");
// 解析 token
Map<String, Object> tokenPayload = tokenService.parseToken(accessToken);
String username = (String) tokenPayload.get("username");
// 查詢使用者
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
// 將使用者身份與權限傳遞給 Spring Security
Authentication authentication = new UsernamePasswordAuthenticationToken(
userDetails,
userDetails.getPassword(),
userDetails.getAuthorities()
);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
// 將 request 送往 controller 或下一個 filter
filterChain.doFilter(request, response);
}
}
根據 RFC 6750 的標準,發送請求到受保護的 API 時,所攜帶的 access token 會以「Bearer」加一個空格做為前綴,故 Filter 一開始會先去除這個字眼。
接著查詢出 UserDetails
後,再連同它的權限資料一起包裝成 UsernamePasswordAuthenticationToken
,傳遞給 SecurityContext
,將認證身份告訴給 Spring Security。
讓我們回顧一下 UsernamePasswordAuthenticationToken
。在 Day 24 實作帳密認證時,也曾使用該物件過。對於通過認證的使用者,該物件裡面會包含 3 種資料。
principal
:型態為 Object,值為 UserDetails
。credentials
:型態為 Object,值為密碼字串的密文。authorities
:型態為 Collection<? extends GrantedAuthority>
。這與 Day 23 介紹的 API 授權規則有關,會影響是否能存取 API。而在這裡的 JwtAuthenticationFilter
中,我們也採取同樣的包裝方式。
Spring Security 底層會有一系列的 Filter,依照順序檢查自己是否要對這個 request 進行身分認證。若其中一個 Filter 通過了,則會將含有認證身份的 Authentication
物件帶進 SecurityContext
。
其中一個 Filter 叫做 UsernamePasswordAuthenticationFilter
,是用來處理基於帳密的認證,對應到 Day 23 所見到的 Spring Security 登入畫面。
而剛剛實作的 JwtAuthenticationFilter
勢必會有它執行的時機。請回到 Spring Security 的配置程式,將它的順序放在 UsernamePasswordAuthenticationFilter
前面。
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity http,
JwtAuthenticationFilter authFilter
) throws Exception {
http
...
.addFilterBefore(authFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
題外話,
JwtAuthenticationFilter
會繼承OncePerReqeustFilter
,是為了加強確保每個 request 只會被該 Filter 處理一次。
接著讓我們繼續完成第一節所設計的 UserIdentity
身份元件,這樣第一節在 controller 中的業務邏輯便能成真了。
@Component
public class UserIdentity {
private AppUserDetails getUserDetails() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
Object principal = authentication.getPrincipal();
return "anonymousUser".equals(principal)
? new AppUserDetails(new AppUser())
: (AppUserDetails) principal;
}
public String getId() { return getUserDetails().getId(); }
public String getUsername() { return getUserDetails().getUsername(); }
public UserAuthority getUserAuthority() { return getUserDetails().getUserAuthority(); }
public boolean isPremium() { return getUserDetails().isPremium(); }
}
上面的程式中,最關鍵的地方在於 getUserDetails
方法,它會從 Spring Security 取出第二節在 JwtAuthenticationFilter
放入的認證身份。而 UserIdentity
則是呼叫 SecurityContext.getAuthentication
來取出。
為什麼在多執行緒的環境下,每個 request 都能取得屬於自己的身份呢?這是因為
SecurityContext
底層使用了 Java 的ThreadLocal
,讓每個執行緒都能取得各自的物件。
取出認證身份後,呼叫 Authentication.getPrincipal
方法,便可得到在 JwtAuthenticationFilter
放入的 UserDetails
。若未攜帶 access token,則該身份的 principal
將會是「anonymousUser」字串,此處給予空的物件。
這裡給予空物件,是「Null Object Pattern」設計模式的應用。用來避免因為顧慮該物件可能是 null 或其他無意義的值,而寫出太多判斷,不便於閱讀或維護。
至於其他 4 個取得使用者資訊的方法,都能直接從 UserDetails(實作類別為 AppUserDetails
) 獲取資料。
最後示範一下在 Postman 要如何攜帶 access token。
只要到「Authorization」(Auth)頁籤,在左邊的「Type」選擇「Bearer Token」,並在右邊填入 access token 即可。Postman 會自動附加到 request header 的 Authorization 欄位,並補上 Bearer 的前綴。亦可在「Headers」頁籤查看。
這節以 GET /users
這支 API 為例,它被設計成只有具備管理員權限(UserAuthority.ADMIN
)的人存取。下圖是使用具有該權限的 access token 進行存取,請求成功。
下圖是使用沒有該權限的 access token 存取,得到 HTTP 403(Forbidden)的狀態碼。
本文的完成專案:
https://github.com/ntub46010/SpringBootTutorial/tree/Ch19-new
今日文章到此結束!
最後推廣一下自己的部落格,我是「新手工程師的程式教室」的作者,請多指教