老涂的咁仔店

記載生活點滴、敗家記事、程式撰寫...etc

spring boot + spring security + jwt(json web token) 實作登入認証(authentication)與授權(authorization)

2021-03-12

Tags: 程式語言 , java , spring

在開始之前

建議先搞懂 spring security 裡的認証(authentication)與援權(authorization)功能的程式碼如何運作,不懂的可以看一下我之前寫的 spring security 裡的認証&援權原理說明 ,把細節搞懂。不然看完實作範例後很可能不知道程式為何這樣寫就可以弄出 token base auth 功能,知其然不知其所以然。

實作教學

別人的教學文已經超詳細,直接看下列這些別人的文章吧 :)

我改寫出來的教學範例程式

不過,別人寫的教學範例程式提到的細節太多了,常會用到 NoSQL DB 或是 RMDB 來存放用戶個人資料,導致範例程式複雜化。所以我改寫出了一個簡化版的教學範例,放在我的 github。程式裡關鍵處都有加上註解說明,有興趣的人可以看看。


spring security 裡的認証(authentication) & 援權(authorization) 原理說明

2021-03-12

Tags: 程式語言 , java , spring

前言

要實作 spring security 裡面認証(authentication) & 援權(authorization) 功能時,大多數文件只會說要實作 UserDetailsService、UserDetails、WebSecurityConfigurerAdapter 這儿個 class 或 inteface 就可以搞定一切。但這儿個 class 間到底怎麼交互作用通常都不提,讓人覺得實作參考的範例程式碼很簡單易懂、但背後原理卻不知怎麼回事。程式這樣改一改就可以跑讓人覺得很像黑魔法,哪天要微調時很可能因為不知背後原理就改不動了。

因此我決定把背後原理搞清楚並整理出重點摘要。既然是摘要只會挑關鍵重點,建議搭配 spring security 裡的 source code 一起看,比較好理解內容。

認証與援權功能的程式碼如何運作

程式碼依照下列順序進行認証動作

  1. 用戶使用帳號和密碼進行登錄。
  2. 在 Filter 裡將獲取到的用戶名和密碼封裝成一個實現了 Authentication interface 的 UsernamePasswordAuthenticationToken。
  3. 將上述產生的 token 傳遞給 AuthenticationManager 進行登錄認證。
  4. AuthenticationManager 認證成功後將會返回一個 封裝了用戶權限等資料 的 Authentication 實例。而且這個實例內容裡 不帶用戶憑証或密碼
  5. 通過呼叫 SecurityContextHolder.getContext().setAuthentication(...) method 將 AuthenticationManager 在認證成功後產生的 Authentication 實例賦予給當前的 SecurityContext。

上述流程項次 1,2,3,5 實作程式碼可參考 BasicAuthenticationFilter.doFilterInternal(...) method,source code 與關鍵說明如下

    @Override
    protected void doFilterInternal(HttpServletRequest request,HttpServletResponse response, FilterChain chain)
                    throws IOException, ServletException {
        ...略

        try {
            String[] tokens = extractAndDecodeHeader(header, request);

            String username = tokens[0];

            if (authenticationIsRequired(username)) {
                // step1. 將用戶輸入的帳號密碼轉換成 UsernamePasswordAuthenticationToken DTO
                UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, tokens[1]);
                authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
                
                // step2. 利用 AuthenticationManager.authenticate(...) method 進行用戶認証
                //        認証失敗時丟出 Exception,認証成功時將用戶資料(指 UserDetails)、權限存
                //        成一個新的 Authentication DTO
                //
                //        AuthenticationManager.authenticate(...) method 裡的 "尋找用戶資料" 程式片段
                //        通常是呼叫 UserDetailsService.loadUserByUsername(...) method
                Authentication authResult = this.authenticationManager.authenticate(authRequest);

                // step3. SecurityContextHolder 存放授權成功的 Authentication DTO
                SecurityContextHolder.getContext().setAuthentication(authResult);
            }

        }
        catch (AuthenticationException failed) {
            // 授權失敗時,要把 SecurityContextHolder 裡的 Authentication DTO 清除
            SecurityContextHolder.clearContext();
        }

        ...略
    }

上述流程項次 4 實作程式碼可參考 AbstractUserDetailsAuthenticationProvider.authenticate(...) method,source code 與關鍵說明如下

    public Authentication authenticate(Authentication authentication)throws AuthenticationException {
        ...略

        // Determine username
        String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED": authentication.getName();

        boolean cacheWasUsed = true;
        UserDetails user = this.userCache.getUserFromCache(username);

        if (user == null) {
            cacheWasUsed = false;

            try {
                // step1. retrieveUser(...) method 呼叫 UserDetailsService.loadUserByUsername(...) method 
                //        查出用戶資料(指 UserDetails)
                user = retrieveUser(username,(UsernamePasswordAuthenticationToken) authentication);
            }
            catch (UsernameNotFoundException notFound) {
                ...略

                throw notFound;
            }

        }

        try {
            ...略

            // step2. 用戶資料(指 UserDetails) 跟 UsernamePasswordAuthenticationToken 內容比對
            //        進行用戶認証,認証失敗時丟出 Exception。
            additionalAuthenticationChecks(user,(UsernamePasswordAuthenticationToken) authentication);
        }
        catch (AuthenticationException exception) {
            ...略

            throw exception;
        }

        ...略

        // step3. 認証成功時將用戶資料(指 UserDetails)、權限存成一個新的 Authentication DTO
        return createSuccessAuthentication(principalToReturn, authentication, user);
    }

認証與援權功能對應的關鍵 class 與 interface

  • Authentication

    Authentication interface 用來存放用戶認證資料,在用戶登錄認證之前相關資料會封裝為一個 Authentication instance,在登錄認證成功之後又會生成一個資料更全面,包含用戶權限等資料的 Authentication instance,然後把它保存在 SecurityContextHolder 所持有的 SecurityContext 中,供後續的程序進行呼叫,如訪問權限的鑑定等。

    Authentication.getPrincipal() : 認証成功前存放 "用戶登入id",認証成功後存放 "用戶對應的 UserDetails"
    Authentication.getCredentials() : 認証成功前存放 "用戶登入密碼",認証成功後通常不帶任何資料
    Authentication.getAuthorities() : 認証成功前不帶任何資料,認証成功後存放 "用戶權限"
    
  • AuthenticationManager 和 AuthenticationProvider

    AuthenticationManager 是一個用來處理認證(Authentication)請求的 interface,裡面只定義了 authenticate() 方法,該方法只接收一個代表認證請求的 Authentication 實例作為參數,如果認證成功,則會返回一個封裝了當前用戶權限等資料的 Authentication 實例進行返回。

    AuthenticationManager 的預設實現是 ProviderManager。認證是由 AuthenticationManager 來管理的,但是真正進行認證的是 AuthenticationManager 中定義的 AuthenticationProvider。AuthenticationManager 中可以定義有多個 AuthenticationProvider。如果沒有指定對應關聯的 AuthenticationProvider 實例,Spring Security 預設會使用 DaoAuthenticationProvider。DaoAuthenticationProvider 在進行認證的時候需要一個 UserDetailsService 來獲取用戶的資料 UserDetails。

    檢核認證請求最常用的方法是根據請求的用戶名產生對應的 UserDetails,然後比對 UserDetails 的密碼與認證請求的密碼是否一致,一致則表示認證通過。Spring Security 內部的 DaoAuthenticationProvider 就是使用的這種方式。其內部使用 UserDetailsService 來負責產生 UserDetails。在認證成功以後會產生包含用戶權限等資料的的 UserDetails 實例,並將它封裝在返回的認證成功 Authentication 實例。認證成功返回的 Authentication 實例將會保存在當前的 SecurityContext 中。預設情況下,在認證成功後 ProviderManager 將清除返回的 Authentication 中的憑證資料,如密碼。

  • UserDetailsService

    通過 Authentication.getPrincipal() 的返回類型是 Object,但很多情況下其返回的其實是一個 UserDetails 實例。UserDetails interface 定義了一些可以獲取用戶名、密碼、權限等與認證相關的資料的方法。登錄認證的時候 Spring Security 會通過 UserDetailsService 的 loadUserByUsername() 方法獲取對應的 UserDetails 進行認證,認證通過後會將該 UserDetails 放到認證通過的 Authentication 的 principal,然後再把該 Authentication 存入到 SecurityContext 中。之後如果需要使用用戶資料的時候就是通過 SecurityContextHolder 獲取存放在 SecurityContext 中的 Authentication 的 principal。

  • GrantedAuthority

    Authentication 的 getAuthorities() 可以返回當前 Authentication 實例擁有的權限,即當前用戶擁有的權限。其返回型別是 Collection<? extends GrantedAuthority>,每一個 GrantedAuthority 實例代表賦予給當前用戶的一種權限。

  • SecurityContextHolder

    SecurityContextHolder 是用來保存 SecurityContext,SecurityContext 中含有當前正在訪問系統的用戶的詳細資料。預設情況下,SecurityContextHolder 將使用 ThreadLocal 來保存 SecurityContext,這也就意味著在處於同一線程中的方法中我們可以從 ThreadLocal 中獲取到當前的 SecurityContext。

    通過 SecurityContextHolder.getContext().getAuthentication().getPrincipal() 可以獲取到代表當前已通過認証與授權的用戶資料,這個實例通常是 UserDetails 實例。

所有 Filter 簡述

Spring Security 底層是通過一系列的 Filter 來管理,每個 Filter 都有其自身的功能,而且各個 Filter 在功能上還有關聯關係,所以它們的順序也是非常重要的。Spring Security 對 FilterChain 中 Filter 順序有嚴格的規定的,Spring Security 對那些預設的 Filter 指定了它們的位置。

Spring Security 已經定義了一些 Filter,不管實際應用中你用到了哪些,它們應當保持如下順序。

  1. ChannelProcessingFilter

    如果你訪問的 channel 錯了,那首先就會在 channel 之間進行跳轉,如 http 變為 https。

  2. SecurityContextPersistenceFilter

    開始進行 request 的時候就可以在 SecurityContextHolder 中建立一個 SecurityContext,然後在請求結束的時候,任何對 SecurityContext 的改變都可以被 copy 到 HttpSession。

  3. ConcurrentSessionFilter

    它使用 SecurityContextHolder 的功能,而且更新對應 session 的最後更新時間,以及通過 SessionRegistry 獲取當前的 SessionInformation 以檢查當前的 session 是否已經過期,過期則會呼叫 LogoutHandler。

  4. 認證處理機制

    如 UsernamePasswordAuthenticationFilter,CasAuthenticationFilter,BasicAuthenticationFilter 等,以至於 SecurityContextHolder 可以被更新為包含一個有效的 Authentication 請求。

  5. SecurityContextHolderAwareRequestFilter

    它將會把 HttpServletRequest 封裝成一個繼承自 HttpServletRequestWrapper 的 SecurityContextHolderAwareRequestWrapper,同時使用 SecurityContext 實現了 HttpServletRequest 中與安全相關的方法。

  6. JaasApiIntegrationFilter

    如果 SecurityContextHolder 中擁有的 Authentication 是一個 JaasAuthenticationToken,那麼該 Filter 將使用包含在 JaasAuthenticationToken 中的 Subject 繼續執行 FilterChain。

  7. RememberMeAuthenticationFilter

    如果之前的認證處理機制沒有更新 SecurityContextHolder,並且用戶請求包含了一個 Remember-Me 對應的 cookie,那麼一個對應的 Authentication 將會設給 SecurityContextHolder。

  8. AnonymousAuthenticationFilter

    如果之前的認證機制都沒有更新 SecurityContextHolder 擁有的 Authentication,那麼一個 AnonymousAuthenticationToken 將會設給 SecurityContextHolder。

  9. ExceptionTransactionFilter

    用於處理在 FilterChain 範圍內拋出的 AccessDeniedException 和 AuthenticationException,並把它們轉換為對應的 Http 錯誤碼返回或者對應的頁面。

  10. FilterSecurityInterceptor,保護 Web URI,並且在訪問被拒絕時拋出異常。

reference document


Guava Cache 使用說明

2018-08-29

Tags: 程式語言 , java , guava

基本用法

請參考下列文章

CacheBuilder class 裡的 expireAfterWrite, expireAfterAccess, refreshAfterWrite method 區別

  • expireAfterAccess

  • expireAfterWrite

    • 一定時間內沒有創建或覆寫時,會移除該 cache key,下次取的時候從 raw data (e.g. database) 裡取
    • 在更新 cache key 對應內容時,會 block 所有存取該 cache key 的所有 thread
    • expireAfterWrite 是時間到了就 expire。expireAfterAccess 則是以 上次取存的時間點 為基準來記算何時會 expire,也就是說只要一直被存取就不會 expire
  • refreshAfterWrite

    • 指定時間內沒有被創建或覆寫,在指定時間過後再次訪問時,會去更新 cache key 對應之值,在新值沒有到來之前,始終返回舊值。注意!是再次訪問,也就是未訪問前可能都不會更新。
    • 在更新 cache key 對應內容時,不會 block 所有存取該 cache key 的 thread
    • CacheBuilder 要產生 LoadingCache 時才可以叫用這個 method,不然會產生 refreshAfterWrite requires a LoadingCache exception
    • 跟 expireAfterAccess(或 expireAfterWrite) 區別
      • 指定時間過後,expireAfterAccess(或 expireAfterWrite) 是 remove cache key,下次訪問是 同步(sync) 去獲取返回新值。新值未取回前,所有要取新值 的 thread 都會被 block。
      • refresh 是指定時間後,不會 remove cache key,直到下次訪問 才以 非同步(async) 方式 refresh cache。在 refresh cache 動作完成前,大多數針對該 cache key 的請求 thread 會立刻返回舊值,不會被 block
    • refreshAfterWrite 行為分析請參考下列文章

CacheBuilder class 裡的 expireAfterWrite, refreshAfterWrite method 可以一起使用

二者一起使用時的行為請參考下列文章

  • 深入Guava Cache的refresh和expire刷新機制

    重點是這一段

    可以看出refreshAfterWrite和expireAfterWrite兩種方式各有優缺點,各有使用場景。那麼能否在refreshAfterWrite和expireAfterWrite找到一個折中?比如說控制緩存每1s進行refresh,如果超過2s沒有訪問,那麼則讓緩存失效,下次訪問時不會得到舊值,而是必須得待新值加載。由於guava官方文檔沒有給出一個詳細的解釋,查閱一些網上資料也沒有得到答案,因此只能對源碼進行分析,尋找答案。經過分析,當同時使用兩者的時候,可以達到預想的效果,這真是一個好消息吶!

  • 官方文件說明

    重點是這一段,不過我沒有很懂它表達的意思

    In contrast to expireAfterWrite, refreshAfterWrite will make a key eligible for refresh after the specified duration, but a refresh will only be actually initiated when the entry is queried. (If CacheLoader.reload is implemented to be asynchronous, then the query will not be slowed down by the refresh.) So, for example, you can specify both refreshAfterWrite and expireAfterWrite on the same cache, so that the expiration timer on an entry isn't blindly reset whenever an entry becomes eligible for a refresh, so if an entry isn't queried after it comes eligible for refreshing, it is allowed to expire.

reference document


如何在 spring 實作的 restful api 裡使用 validation annotation

2017-12-29

Tags: 程式語言 , java , spring

如何實作

spring 實作的 restful api,要針對輸入參數進行內容格式驗証,可分成下列儿類

針對 @RequestBody 進行 validation

實作程式碼重點如下

@RestController
@RequestMapping("/validateAnnotationDemo")
public class ValidateAnnotationDemoController {
    @RequestMapping(value = "/validateRequestBody", produces = MediaType.APPLICATION_JSON_VALUE)
    public User validateRequestBody(@Valid @RequestBody User user){
        ...etc;
    }

    private static class User{
        @NotBlank
        private String name;
        ...etc;
    }
}
@ControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity handleMethodArgumentNotValidException(HttpServletRequest req, MethodArgumentNotValidException e) {
        ...etc;
    }
}
  1. @RequestBody 前面要加上 @Valid
  2. pojo (在此例指 User class) 要加上 validation annotation (e.g. @NotBlank, @NotNull ...etc)
  3. validate fail 會丟出 MethodArgumentNotValidException,要自己寫 handler 決定哪些異常明細回傳給前端

針對 @RequestParam 進行 validation

實作程式碼重點如下

@Configuration
@ComponentScan
public class WebConfig {
    @Bean
    public MethodValidationPostProcessor methodValidationPostProcessor() {
        return new MethodValidationPostProcessor();
    }
}
@Validated
@RestController
@RequestMapping("/validateAnnotationDemo")
public class ValidateAnnotationDemoController {
    @RequestMapping(value = "/validateRequestParam", produces = MediaType.APPLICATION_JSON_VALUE)
    public Map<String,Object> validateRequestParam(@NotBlank(message = "message must not be blank") @RequestParam String message){
        ...etc;
    }
}
@ControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity handleConstraintViolationException(HttpServletRequest req, ConstraintViolationException e){
        ...etc;
    }
}
  1. 要注冊 MethodValidationPostProcessor bean,不然無法對 @RequestParam 進行 validation
  2. 在 @RestController 前面加上 @Validated
  3. 在 @RequestParam 前面加上 validation annotation (e.g. @NotBlank, @NotNull ...etc)
  4. validate fail 會丟出 ConstraintViolationException,要自己寫 handler 決定哪些異常明細回傳給前端

完整範例

WebConfig.java

@Configuration
@ComponentScan
public class WebConfig {

    /**
     * 注冊 MethodValidationPostProcessor bean 之後,Controller class 開頭加的 @Validated 與 method 裡針
     * 對 @RequestParam annotation 加的 validate annotation (e.g. @NotBlank, @NotNull ...etc)才會生效,
     * 沒注冊時就算程式碼裡有加這些 annotation 還是不會有作用
     *
     * @return
     */
    @Bean
    public MethodValidationPostProcessor methodValidationPostProcessor() {
        return new MethodValidationPostProcessor();
    }
}

ValidateAnnotationDemoController.java

@Validated
@RestController
@RequestMapping("/validateAnnotationDemo")
public class ValidateAnnotationDemoController {

    //在 class 開頭加了 @Validated 之後,針對 @RequestParam 加的 validation annotation (e.g. @NotBlank, @NotNull...etc) 才會生效
    @RequestMapping(value = "/validateRequestParam", produces = MediaType.APPLICATION_JSON_VALUE)
    public Map<String,Object> validateRequestParam(
            @NotBlank(message = "message must not be blank") @RequestParam String message){
        Map<String,Object> result = new HashMap<>();
        result.put("message", message);
        return result;
    }

    // 加對 @Valid 之後,會驗証 pojo (此例中指 User instance) 內容格式是否正確
    @RequestMapping(value = "/validateRequestBody", produces = MediaType.APPLICATION_JSON_VALUE)
    public User validateRequestBody(@Valid @RequestBody User user){
        return user;
    }

    private static class User{
        @NotBlank
        private String name;

        // 針對 list of pojo 的資料格式進行驗証,要加上 @NotEmpty, @Valid 二個 annotation
        @NotEmpty
        @Valid
        private List<ContactInfo> contactInfoList;

        public String getName() {
            return name;
        }

        public void setName(String name) {
            this.name = name;
        }

        public List<ContactInfo> getContactInfoList() {
            return contactInfoList;
        }

        public void setContactInfoList(List<ContactInfo> contactInfoList) {
            this.contactInfoList = contactInfoList;
        }
    }

    private static class ContactInfo{
        @NotBlank
        private String address;

        public String getAddress() {
            return address;
        }

        public void setAddress(String address) {
            this.address = address;
        }
    }
}

GlobalExceptionHandler.java

/**
 * Controller 發生 uncatch exception 情況時,會統一在這個 class 被處理。
 */
@ControllerAdvice
public class GlobalExceptionHandler {
    private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(Exception.class)
    public ResponseEntity handleException(HttpServletRequest req, Exception e){
        logger.error(e.getMessage(), e);
        String errorMsg = (e.getMessage() == null) ? e.getClass().getSimpleName() : e.getMessage();
        Map<String,Object> error = Collections.singletonMap("error", errorMsg);
        return ResponseEntity.status(500).body(error);
    }

    /**
     * Controller 裡標注 @RequestParam 的變數在 validate fail 時會丟出 ConstraintViolationException。這個 method
     * 專門處理此類 exception
     *
     * @param req
     * @param e
     *
     * @return
     */
    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity handleConstraintViolationException(HttpServletRequest req, ConstraintViolationException e){
        logger.error(e.getMessage(), e);
        // "@NotBlank @RequestParam String myArg" 這樣的 validate 寫法在 validate fail 時無法得知 "哪個輸入參數名稱" 驗証失敗,這是 java reflection 本身的限制。
        // 用這類語法時要改寫成 "@NotBlank(myArg must not be blank) @RequestParam String myArg",程式裡的 validate annotation 要寫出 "完整出錯明細",
        // 不然在處理 ConstraintViolationException 時只會知道驗証失敗的原因,卻不知道是哪個輸入參數名稱驗証失敗。
        List<String> errorMessages = e.getConstraintViolations()
                .stream()
                .map(ConstraintViolation::getMessage)
                .collect(Collectors.toList());
        Map<String,Object> error = Collections.singletonMap("error", errorMessages);
        return ResponseEntity.status(400).body(error);
    }

    /**
     * Controller 裡標注 @RequestBody 的變數在 validate fail 時會丟出 MethodArgumentNotValidException。這個 method
     * 專門處理此類 exception
     *
     * @param req
     * @param e
     *
     * @return
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity handleMethodArgumentNotValidException(HttpServletRequest req, MethodArgumentNotValidException e) {
        logger.error(e.getMessage(), e);
        List<String> errorMessages = e.getBindingResult().getFieldErrors()
                .stream()
                .map(fieldError -> fieldError.getField() + " " + fieldError.getDefaultMessage()) // 記錄 "fieldName + validateFailMessage"
                .collect(Collectors.toList());
        Map<String,Object> error = Collections.singletonMap("error", errorMessages);
        return ResponseEntity.status(400).body(error);
    }
}

reference document


MAC 新手入門筆記

2016-03-04

Tags: mac

儿個月前因為工作需要摸了一陣子 MAC 筆電,有一些簡單心得,所以做個筆記以備未來不時之需 :)

MAC OS 入門影片

  1. 英文
  2. 中文

MAC OS 入門文件

SSH 相關 console command

  1. 產生 SSH 公私鑰

    ssh-keygen -t rsa
    
  2. Storing Passphrases in the Keychain, 免除每次 ssh 連線都要指定 private key 所在位置

    ssh-add -K /path/to/private/key/file
    
  3. 相關參考文件

利用 homebrew 指令安裝軟體

homebrew 可以幫你用 console command 的方式安裝與管理各種軟體,可以讓你在這方面省掉很多時間,強烈推薦要用這個好物。下面列的是我常會利用 homebrew 安裝的軟體。

# install xcode command tool
xcode-select --install
# check xcode command tool
xcode-select -p

# install homebrew
ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"
# check homebrew install success or not
brew doctor
brew update

# install homebrew-cask
brew tap caskroom/cask
brew install brew-cask

# i install these soft below by brew & brew-cask

brew install wget
brew install curl
brew install git
brew install htop-osx

# install java7
brew cask install caskroom/versions/java7
# install java8
brew cask install java

brew cask install intellij-idea-ce
brew cask install eclipse-jee

brew install maven
brew install gradle

brew cask install sourcetree
brew cask install cyberduck
brew cask install dbeaver-enterprise

brew cask install google-chrome
brew cask install firefox
brew cask install flash

brew cask install bettertouchtool

其它實用的教學文件


較舊的文章在 archive 專區.