iT邦幫忙

2023 iThome 鐵人賽

DAY 26
0
Software Development

救救我啊我救我!CRUD 工程師的惡補日記系列 第 26

【Spring Security】透過 Security Context 得知誰在存取 API

  • 分享至 

  • xImage
  •  

前兩篇的進度是完成帳密認證,並核發 JWT 做為 access token。而本文想做到的,則是因應前端的 request 攜帶 access token 到達後端時,要讓 Spring Security 知道此人的身份。最終目的是從業務邏輯中輕鬆得知該使用者的資訊,並加以利用。

此篇亦轉載到個人部落格


一、讓後端知道這個人是誰

(一)背景

這次 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) {
        // ...
    }
}

為了示範如何透過身份元件取得當前使用者的資訊,於業務邏輯加以利用,筆者設計了一點情境。

  • 建立使用者:若我是高級會員,就將自身 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);
    }
}

二、用 Filter 解析 Access Token

在第一節的 controller 中,我們馬上就會使用身份元件了。那麼在 request 抵達 controller 之前,勢必就要讓後端知道這個人是誰。這個問題會使用 Filter 來處理。

(一)實作 Filter

請實作以下的 Filter,取名為 JwtAuthenticationFilter,並注入 TokenServiceUserDetailsService

簡單來說,此 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 中,我們也採取同樣的包裝方式。

(二)配置到 Security 機制

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 進行存取,請求成功。
https://ithelp.ithome.com.tw/upload/images/20231006/201311073fT2RSIJtB.jpg

下圖是使用沒有該權限的 access token 存取,得到 HTTP 403(Forbidden)的狀態碼。
https://ithelp.ithome.com.tw/upload/images/20231006/20131107xrVGXfGfrw.jpg

本文的完成專案:
https://github.com/ntub46010/SpringBootTutorial/tree/Ch19-new


今日文章到此結束!
最後推廣一下自己的部落格,我是「新手工程師的程式教室」的作者,請多指教/images/emoticon/emoticon41.gif


上一篇
【Spring Security】核發 JWT 並結合帳密認證(下)
下一篇
【Elasticsearch】導入到 Spring Boot 並使用 Java API Client 實作 CRUD
系列文
救救我啊我救我!CRUD 工程師的惡補日記50
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言