To-Do Calendar - Day29 Spring Security 的認證與授權(四)

前面已經實作出了 token 校驗過濾器和登入、登出的功能,最後補充一下授權的部分。

授權

不同的用戶可以使用不同的功能,這就是權限系統要去實現的效果。
只依賴前端去判斷用戶的權限來選擇顯示哪些菜單哪些按鈕是不夠的。因為如果有人知道了對應功能的接口地址就可以不通過前端,直接去發送請求來實現相關功能操作。所以我們還需要進行用戶權限的判斷,判斷當前用戶是否有相應的權限,必須具有所需權限才能進行相應的操作。

授權基本流程

image
  • 在Spring Security中,會使用默認的 FilterSecurityInterceptor 來進行權限校驗。在 FilterSecurityInterceptor 中會從 SecurityContextHolder 獲取其中的 Authentication,然後獲取其中的權限信息,判斷當前用戶是否擁有訪問當前資源所需的權限。
  • 所以我們只需要把當前登入使用者的權限信息也存入 Authentication,​然後設置我們的資源所需要的權限即可。

授權實現

限制訪問資源所需權限

  • Spring Security 提供了基於註解的權限控制方案,我們可以使用註解去指定訪問對應的資源所需的權限。
  • 另外一種是基於配置的方案,主要是用來配置靜態的資源,用的相對比較少。

開啟相關配置

  • 在 SecurityConfig 設定檔加上開啟配置的註解
      @EnableGlobalMethodSecurity(prePostEnabled = true)
      @Configuration
      public class SecurityConfig extends WebSecurityConfigurerAdapter {
        ...
      }
    
  • 接著就可以在接口的方法上加上角色訪問限制的註解(這裡以便利貼牆查詢功能為例)
    @RestController
      public class NotesController {
    
          @Autowired
          private NotesService notesService;
    
          @PreAuthorize("hasAuthority('test')")
          @GetMapping("/users/{userId}/notes")
          public ResponseEntity<Notes> getNotes(@PathVariable String userId) {
              Notes notes = notesService.getNotesByUserId(userId);
              if (notes != null) {
                  return ResponseEntity.status(HttpStatus.OK).body(notes);
              } else {
                  return ResponseEntity.status(HttpStatus.OK).build();
              }
          }
          ...
      }
    

封裝權限信息

  • 前面在實作 UserDetailsServiceImpl 時,留了一個 TODO,在查詢出使用者後還要獲取對應的權限信息,封裝到 UserDetails 中返回。
    (因授權非本次需求,並未規劃對應的資料結構與畫面設計,所以這裡直接把權限信息寫死封裝到 UserDetails,正確做法是從資料庫查詢權限訊息)
    @Component
    public class UserDetailsServiceImpl implements UserDetailsService {
    
      @Autowired
      private UserDao userDao;
    
      @Override
      public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
          // 查詢使用者資料
          User user = userDao.getUserByEmail(username);
    
          // 如果使用者不存在就拋出異常
          if (Objects.isNull(user)) {
              throw new AccountNotFoundException("帳號或密碼錯誤");
          }
    
          // 查詢對應的權限訊息
          List<String> list = new ArrayList<>(Arrays.asList("admin"));
    
          // 把資料封裝成 UserDetails 返回
          return new LoginUser(user, list);
      }
    }
    
  • 修改 LoginUser class
      @Data
      @NoArgsConstructor
      public class LoginUser implements UserDetails {
          private User user;
          private List<String> permissions;
    
          @JSONField(serialize = false)
          private List<GrantedAuthority> authorities;
    
          public LoginUser(User user, List<String> permissions) {
              this.user = user;
              this.permissions = permissions;
          }
    
          @Override
          public Collection<? extends GrantedAuthority> getAuthorities() {
              if (authorities != null) {
                  return authorities;
              }
              // 把 permissions 中字串類型的權限信息轉型成 SimpleGrantedAuthority 物件存入 authorities 中
              authorities = permissions.stream()
                      .map(SimpleGrantedAuthority::new)
                      .collect(Collectors.toList());
              return authorities;
          }
          ...
      }
    
  • 在 token 校驗過濾器中,也留了一個 TODO,當時只是把 loginUser 封裝到 authentication,我們還須把權限信息也封裝進去。
      @Component
      public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
    
          @Autowired
          RedisCache redisCache;
    
          @Override
          protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
              // 取得 request header 中的 token
              String authHeader  = request.getHeader(HttpHeaders.AUTHORIZATION);
    
              if (StringUtils.isEmpty(authHeader)) {
                  // 放行
                  filterChain.doFilter(request, response);
                  return;
              }
    
              // 解析 token 取得 userId
              String token = authHeader.replace("Bearer ", "");
              String userId;
              try {
                  Claims claims = JwtUtil.parseJWT(token);
                  userId = claims.getSubject();
              } catch (Exception e) {
                  e.printStackTrace();
                  throw new RuntimeException("token解析失敗");
              }
    
              // 通過 userId 從 redis 中取得 LoginUser
              String redisKey = "login:" + userId;
              LoginUser loginUser = redisCache.getCacheObject(redisKey);
              if (Objects.isNull(loginUser)) {
                  throw new RuntimeException("使用者未登入");
              }
              //TODO: 獲取權限信息封裝到 authentication 中
    
              // 如果能從 redis 中取得 loginUser -> 存入 SecurityContextHolder
              if (Objects.nonNull(loginUser)) {
                  UsernamePasswordAuthenticationToken authentication =
                          new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
                  SecurityContextHolder.getContext().setAuthentication(authentication);
              }
              filterChain.doFilter(request, response);
          }
      }
    

運行結果

image

目前訪問查詢便利貼牆會因為權限不足而被擋下,但測試後發現有個問題,就是目前認證失敗與權限驗證失敗都是回傳403,這兩者應該要有不同的錯誤處理與提示。

解決方法

自定義失敗處理器

在 Spring Security 中,如果在認證或者授權的過程中出現了例外會被 ExceptionTranslationFilter 捕獲。在 ExceptionTranslationFilter 中會去判斷是認證失敗還是授權失敗出現的例外。

  • 如果是認證過程中出現的例外,會被封裝成 AuthenticationException,然後調用 AuthenticationEntryPoint 物件的方法去進行例外處理。
    @Component
    public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
        @Override
        public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
            response.setStatus(HttpStatus.UNAUTHORIZED.value());
            response.setCharacterEncoding("UTF-8");
            response.setHeader("Content-type", "text/html;charset=UTF-8");
            response.getWriter().write("認證失敗");
        }
    }
    
  • ​如果是授權過程中出現的例外,會被封裝成 AccessDeniedException,然後調用 AccessDeniedHandler 物件的方法去進行例外處理。
    @Component
    public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
        @Override
        public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException, ServletException {
            response.setStatus(HttpStatus.FORBIDDEN.value());
            response.setCharacterEncoding("UTF-8");
            response.setHeader("Content-type", "text/html;charset=UTF-8");
            response.getWriter().write("權限不足");
        }
    }
    

配置給 Spring Security

  • 注入對應的處理器
  • 使用 HttpSecurity 物件的方法去配置
    @EnableGlobalMethodSecurity(prePostEnabled = true)
    @Configuration
    public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
        @Autowired
        private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
    
        @Autowired
        private AccessDeniedHandler accessDeniedHandler;
    
        @Autowired
        private AuthenticationEntryPoint authenticationEntryPoint;
    
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http
                    // 關閉CSRF
                    .csrf().disable()
                    // 不通過 Session 取得 SecurityContext
                    .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                    .and()
                    .authorizeRequests()
                    // 對登入接口允許匿名訪問
                    .antMatchers("/users/login").anonymous()
                    // 對註冊接口允許匿名訪問
                    .antMatchers("/users/register").anonymous()
                    // 剩下的所有請求
                    .anyRequest().authenticated();
            // 將token校驗過濾器加到過濾器鏈中
            http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
            //  配置錯誤處理器
            http.exceptionHandling()
                    .accessDeniedHandler(accessDeniedHandler)
                    .authenticationEntryPoint(authenticationEntryPoint);
        }
    
        @Bean
        @Override
        public AuthenticationManager authenticationManagerBean() throws Exception {
            return super.authenticationManagerBean();
        }
    
        @Bean
        public PasswordEncoder passwordEncoder() {
            return new BCryptPasswordEncoder();
        }
    }
    

運行結果

image

認證與授權有不同的錯誤處理



到此所有功能與安全認證都完成啦!!!:;(∩´﹏`∩);: 可喜可賀~~

延伸閱讀

參考資料

comments

comments powered by Disqus