SpringBoot:使用AOP對API請求授權驗證 - George

banq發表於2021-12-03

在今天的文章中,我將討論如何利用 Spring AOP 在端點級別授權 API 請求。

假設我們構建了一個 API 來跟蹤啟用了基本身份驗證的 Spring Security 的每月費用,並且我們希望根據經過身份驗證的使用者的許可權來授權請求​​。

簡而言之,身份驗證是驗證使用者身份以確定他們聲稱的身份的過程,授權 是驗證使用者的許可權/角色/許可權以訪問特定資源的過程。

為簡單起見,我們 只有兩個許可權:USER並且ADMIN我們可以考慮身份驗證過程已經根據使用者名稱/密碼組合使用正確的授予許可權填充 Spring Security Context。

public enum SecurityAuthorities {
  USER,
  ADMIN
}

我們的一些 API 端點需要USER許可權,而其他端點需要許可權ADMIN(用於使用者管理和其他管理要求)。

我們如何獲得自定義許可權的授權?

使用 Spring,我們可以@PreAuthorize在端點級別使用眾所周知的註釋:

@PreAuthorize("hasAuthority('USER')")
@GetMapping("/api/v1/expenses", produces = MediaType.APPLICATION_JSON_VALUE)
@ResponseStatus(OK)
public ResponseEntity<GetExpensesResponseDto> getExpenses(){
   // irrelevant code here
}

正如您所看到的,我們hasAuthority('USER')為@PreAuthorize註釋指定了 值,它轉換為:經過身份驗證的使用者必須具有USER訪問此端點的許可權。如果缺少此許可權,則將返回 403 Forbidden。

將許可權值指定為純文字的主要問題是可維護性以及容易出現拼寫錯誤和破壞性更改的事實。想象一下,我們在SecurityAuthorities想將許可權名稱從USER 重構為CUSTOMER.

這意味著您需要確保找到所有找到'USER'字串的位置並將其替換為'CUSTOMER'; 再加上你需要在 25 個不同的地方做這件事,這很快就會變得很痛苦。那麼為什麼不使用我們的列舉類呢?

@PreAuthorize("hasAuthority(T(com.example.SecurityAuthorities).USER)")
@GetMapping("/api/v1/expenses", produces = MediaType.APPLICATION_JSON_VALUE)
@ResponseStatus(OK)
public ResponseEntity<GetExpensesResponseDto> getExpenses(){
   // irrelevant code here
}

使用 enum 類比純文字更容易一些,因為在編譯時,您可以在完全限定的包名稱之後放置正確的值,而無需擔心拼寫錯誤。此外,如果您重新命名許可權名稱或將列舉移動到另一個包中,更改將反映在此處......但是如果您刪除列舉,編譯器根本不會抱怨,這是一個大問題,因為它隱藏了您的端點期望的授權事實上不再存在。

即使@PreAuthorize註釋解決了授權過程並且使用起來非常簡單,我們仍然需要一個更清晰、更易於維護的解決方案,以便直接使用我們的列舉值,而無需限定包名稱或硬編碼字串,同時確保編譯時安全。AOP 來拯救你了!

 

AOP 解決方案

長話短說AOP 允許您向現有程式碼新增額外的行為,而無需修改程式碼本身。

我們要做的基本上是實現一個方法,該方法將在新請求到達我們的端點時由 AOP 自動呼叫,並接收一組許可權以檢查經過身份驗證的使用者 (the Principal) 以決定授權的結果. 請記住,我們端點的現有程式碼只會發生一個小改動:用@PreAuthorize自定義的註釋替換註釋。讓我們繼續…

首先,我們需要一種方法來指定端點需要檢查自定義許可權的哪個子集。為此,我們將建立一個自定義註釋以直接使用我們的列舉而不是字串:

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Order(Ordered.HIGHEST_PRECEDENCE)
public @interface HasEndpointAuthorities {

    SecurityAuthorities[] authorities();

}

接下來,我們需要更新我們的端點,如下所示:

@HasEndpointAuthorities(authorities = { SecurityAuthorities.USER })
@GetMapping("/api/v1/expenses", produces = MediaType.APPLICATION_JSON_VALUE)
@ResponseStatus(OK)
public ResponseEntity<GetExpensesResponseDto> getExpenses(){
   // irrelevant code here
}

這個自定義註解的好處是,如果我們重構SecurityAuthorities列舉,所有更改都會立即反映,如果我們刪除它,編譯器會尖叫報錯。

在我們定義了我們的自定義註釋之後,我們需要建立一個方法,該方法將在針對我們用@HasEndpointAuthorities註釋的端點之一的每個請求上呼叫。

@Aspect
@Component
@Slf4j
public class HasEndpointAuthoritiesAspect {

    @Before("within(@org.springframework.web.bind.annotation.RestController *) && @annotation(authorities)")
    public void hasAuthorities(final JoinPoint joinPoint, final HasEndpointAuthorities authorities) {
        final SecurityContext securityContext = SecurityContextHolder.getContext();
        if (!Objects.isNull(securityContext)) {
            final Authentication authentication = securityContext.getAuthentication();
            if (!Objects.isNull(authentication)) {
                final String username = authentication.getName();

                final Collection<? extends GrantedAuthority> userAuthorities = authentication.getAuthorities();

                if (Stream.of(authorities.authorities()).noneMatch(authorityName -> userAuthorities.stream().anyMatch(userAuthority ->
                        authorityName.name().equals(userAuthority.getAuthority())))) {

                    log.error("User {} does not have the correct authorities required by endpoint", username);
                    throw new ApiException(DefaultExceptionReason.FORBIDDEN);
                }
            } else {
                log.error("The authentication is null when checking endpoint access for user request");
                throw new ApiException(DefaultExceptionReason.UNAUTHORIZED);
            }
        } else {
            log.error("The security context is null when checking endpoint access for user request");
            throw new ApiException(DefaultExceptionReason.FORBIDDEN);
        }
    }


}

注意類別的的註釋@Aspect和方法級別的@Before註釋:第一個註釋了 Spring AOP 將使用的類,第二個解釋如下:

@Before註釋是在被@hasAuthorities註釋了的方法倍呼叫之前呼叫, /api/v1/expenses端點如果被訪問,在真正呼叫該API對應的方法getExpenses 之前hasAuthorities 必須首先被呼叫。如果hasAuthorities 拋錯如403, 真正業務方法getExpenses方法也不會被呼叫,403將作為響應返回。

就是這樣!您現在可以通過自定義註釋使用一行程式碼來控制檢查每個端點的許可權,並且您還可以充分利用列舉類。

 

相關文章