iT邦幫忙

2023 iThome 鐵人賽

DAY 26
0
Software Development

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

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

  • 分享至 

  • xImage
  •  

此篇已更新,歡迎到以下文章繼續閱讀:
【Spring Boot】第12.4課-從 Security Context 取得 API 存取方的認證資訊
【Spring Boot】第12.6課-實作 Spring Security 的認證 Filter(以 JWT 為例)

前兩篇的進度是完成帳密認證,並核發 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,並注入 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 中,我們也採取同樣的包裝方式。

(二)配置到 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) }}
直播中

尚未有邦友留言

立即登入留言