Spring Security 動態url許可權控制(三)

  • 2019 年 10 月 19 日
  • 筆記

一、前言

本篇文章將講述Spring Security 動態分配url許可權,未登錄許可權控制,登錄過後根據登錄用戶角色授予訪問url許可權

基本環境
  1. spring-boot 2.1.8
  2. mybatis-plus 2.2.0
  3. mysql 資料庫
  4. maven項目
Spring Security入門學習可參考之前文章:
  1. SpringBoot集成Spring Security入門體驗(一)
    https://blog.csdn.net/qq_38225558/article/details/101754743
  2. Spring Security 自定義登錄認證(二)
    https://blog.csdn.net/qq_38225558/article/details/102542072

二、資料庫建表

在這裡插入圖片描述

表關係簡介:
  1. 用戶表t_sys_user 關聯 角色表t_sys_role 兩者建立中間關係表t_sys_user_role
  2. 角色表t_sys_role 關聯 許可權表t_sys_permission 兩者建立中間關係表t_sys_role_permission
  3. 最終體現效果為當前登錄用戶所具備的角色關聯能訪問的所有url,只要給角色分配相應的url許可權即可

溫馨小提示:這裡邏輯根據個人業務來定義,小編這裡講解案例只給用戶對應的角色分配訪問許可權,像其它的 直接給用戶分配許可權等等可以自己實現

表模擬數據如下:

在這裡插入圖片描述

三、Spring Security 動態許可權控制

1、未登錄訪問許可權控制

自定義AdminAuthenticationEntryPoint類實現AuthenticationEntryPoint

這裡是認證許可權入口 -> 即在未登錄的情況下訪問所有介面都會攔截到此(除了放行忽略介面)

溫馨小提示ResponseUtilsApiResult是小編這裡模擬前後端分離情況下返回json格式數據所使用工具類,具體實現可參考文末給出的demo源碼

@Component  public class AdminAuthenticationEntryPoint implements AuthenticationEntryPoint {      @Override      public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) {          ResponseUtils.out(response, ApiResult.fail("未登錄!!!"));      }  }

2、自定義過濾器MyAuthenticationFilter繼承OncePerRequestFilter實現訪問鑒權

每次訪問介面都會經過此,我們可以在這裡記錄請求參數、響應內容,或者處理前後端分離情況下,以token換用戶許可權資訊,token是否過期,請求頭類型是否正確,防止非法請求等等

  1. logRequestBody()方法:記錄請求消息體
  2. logResponseBody()方法:記錄響應消息體

【註:請求的HttpServletRequest流只能讀一次,下一次就不能讀取了,因此這裡要使用自定義的MultiReadHttpServletRequest工具解決流只能讀一次的問題,響應同理,具體可參考文末demo源碼實現】

@Slf4j  @Component  public class MyAuthenticationFilter extends OncePerRequestFilter {        private final UserDetailsServiceImpl userDetailsService;        protected MyAuthenticationFilter(UserDetailsServiceImpl userDetailsService) {          this.userDetailsService = userDetailsService;      }        @Override      protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {          System.out.println("請求頭類型: " + request.getContentType());          if ((request.getContentType() == null && request.getContentLength() > 0) || (request.getContentType() != null && !request.getContentType().contains(Constants.REQUEST_HEADERS_CONTENT_TYPE))) {              filterChain.doFilter(request, response);              return;          }            MultiReadHttpServletRequest wrappedRequest = new MultiReadHttpServletRequest(request);          MultiReadHttpServletResponse wrappedResponse = new MultiReadHttpServletResponse(response);          StopWatch stopWatch = new StopWatch();          try {              stopWatch.start();              // 記錄請求的消息體              logRequestBody(wrappedRequest);    //            String token = "123";              // 前後端分離情況下,前端登錄後將token儲存在cookie中,每次訪問介面時通過token去拿用戶許可權              String token = wrappedRequest.getHeader(Constants.REQUEST_HEADER);              log.debug("後台檢查令牌:{}", token);              if (StringUtils.isNotBlank(token)) {                  // 檢查token                  SecurityUser securityUser = userDetailsService.getUserByToken(token);                  if (securityUser == null || securityUser.getCurrentUserInfo() == null) {                      throw new AccessDeniedException("TOKEN已過期,請重新登錄!");                  }                  UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(securityUser, null, securityUser.getAuthorities());                  // 全局注入角色許可權資訊和登錄用戶基本資訊                  SecurityContextHolder.getContext().setAuthentication(authentication);              }              filterChain.doFilter(wrappedRequest, wrappedResponse);          } finally {              stopWatch.stop();              long usedTimes = stopWatch.getTotalTimeMillis();              // 記錄響應的消息體              logResponseBody(wrappedRequest, wrappedResponse, usedTimes);          }        }        private String logRequestBody(MultiReadHttpServletRequest request) {          MultiReadHttpServletRequest wrapper = request;          if (wrapper != null) {              try {                  String bodyJson = wrapper.getBodyJsonStrByJson(request);                  String url = wrapper.getRequestURI().replace("//", "/");                  System.out.println("-------------------------------- 請求url: " + url + " --------------------------------");                  Constants.URL_MAPPING_MAP.put(url, url);                  log.info("`{}` 接收到的參數: {}",url , bodyJson);                  return bodyJson;              } catch (Exception e) {                  e.printStackTrace();              }          }          return null;      }        private void logResponseBody(MultiReadHttpServletRequest request, MultiReadHttpServletResponse response, long useTime) {          MultiReadHttpServletResponse wrapper = response;          if (wrapper != null) {              byte[] buf = wrapper.getBody();              if (buf.length > 0) {                  String payload;                  try {                      payload = new String(buf, 0, buf.length, wrapper.getCharacterEncoding());                  } catch (UnsupportedEncodingException ex) {                      payload = "[unknown]";                  }                  log.info("`{}`  耗時:{}ms  返回的參數: {}", Constants.URL_MAPPING_MAP.get(request.getRequestURI()), useTime, payload);              }          }      }    }

3、自定義UserDetailsServiceImpl實現UserDetailsService 和 自定義SecurityUser實現UserDetails 認證用戶詳情

這個在上一篇文章中也提及過,但上次未做角色許可權處理,這次我們來一起加上吧

@Service("userDetailsService")  public class UserDetailsServiceImpl implements UserDetailsService {        @Autowired      private UserMapper userMapper;      @Autowired      private RoleMapper roleMapper;      @Autowired      private UserRoleMapper userRoleMapper;        /***       * 根據帳號獲取用戶資訊       * @param username:       * @return: org.springframework.security.core.userdetails.UserDetails       */      @Override      public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {          // 從資料庫中取出用戶資訊          List<User> userList = userMapper.selectList(new EntityWrapper<User>().eq("username", username));          User user;          // 判斷用戶是否存在          if (!CollectionUtils.isEmpty(userList)) {              user = userList.get(0);          } else {              throw new UsernameNotFoundException("用戶名不存在!");          }          // 返回UserDetails實現類          return new SecurityUser(user, getUserRoles(user.getId()));      }        /***       * 根據token獲取用戶許可權與基本資訊       *       * @param token:       * @return: com.zhengqing.config.security.dto.SecurityUser       */      public SecurityUser getUserByToken(String token) {          User user = null;          List<User> loginList = userMapper.selectList(new EntityWrapper<User>().eq("token", token));          if (!CollectionUtils.isEmpty(loginList)) {              user = loginList.get(0);          }          return user != null ? new SecurityUser(user, getUserRoles(user.getId())) : null;      }        /**       * 根據用戶id獲取角色許可權資訊       *       * @param userId       * @return       */      private List<Role> getUserRoles(Integer userId) {          List<UserRole> userRoles = userRoleMapper.selectList(new EntityWrapper<UserRole>().eq("user_id", userId));          List<Role> roleList = new LinkedList<>();          for (UserRole userRole : userRoles) {              Role role = roleMapper.selectById(userRole.getRoleId());              roleList.add(role);          }          return roleList;      }    }

這裡再說下自定義SecurityUser是因為Spring Security自帶的 UserDetails (存儲當前用戶基本資訊) 有時候可能不滿足我們的需求,因此我們可以自己定義一個來擴展我們的需求
在這裡插入圖片描述
getAuthorities()方法:即授予當前用戶角色許可權資訊

@Data  @Slf4j  public class SecurityUser implements UserDetails {      /**       * 當前登錄用戶       */      private transient User currentUserInfo;      /**       * 角色       */      private transient List<Role> roleList;        public SecurityUser() { }        public SecurityUser(User user) {          if (user != null) {              this.currentUserInfo = user;          }      }        public SecurityUser(User user, List<Role> roleList) {          if (user != null) {              this.currentUserInfo = user;              this.roleList = roleList;          }      }        /**       * 獲取當前用戶所具有的角色       *       * @return       */      @Override      public Collection<? extends GrantedAuthority> getAuthorities() {          Collection<GrantedAuthority> authorities = new ArrayList<>();          if (!CollectionUtils.isEmpty(this.roleList)) {              for (Role role : this.roleList) {                  SimpleGrantedAuthority authority = new SimpleGrantedAuthority(role.getCode());                  authorities.add(authority);              }          }          return authorities;      }        @Override      public String getPassword() {          return currentUserInfo.getPassword();      }        @Override      public String getUsername() {          return currentUserInfo.getUsername();      }        @Override      public boolean isAccountNonExpired() {          return true;      }        @Override      public boolean isAccountNonLocked() {          return true;      }        @Override      public boolean isCredentialsNonExpired() {          return true;      }        @Override      public boolean isEnabled() {          return true;      }  }

4、自定義UrlFilterInvocationSecurityMetadataSource實現FilterInvocationSecurityMetadataSource重寫getAttributes()方法 獲取訪問該url所需要的角色許可權資訊

執行完之後到 下一步 UrlAccessDecisionManager 中認證許可權

@Component  public class UrlFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {        @Autowired      PermissionMapper permissionMapper;      @Autowired      RolePermissionMapper rolePermissionMapper;      @Autowired      RoleMapper roleMapper;        /***       * 返回該url所需要的用戶許可權資訊       *       * @param object: 儲存請求url資訊       * @return: null:標識不需要任何許可權都可以訪問       */      @Override      public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {          // 獲取當前請求url          String requestUrl = ((FilterInvocation) object).getRequestUrl();          // TODO 忽略url請放在此處進行過濾放行          if ("/login".equals(requestUrl) || requestUrl.contains("logout")) {              return null;          }            // 資料庫中所有url          List<Permission> permissionList = permissionMapper.selectList(null);          for (Permission permission : permissionList) {              // 獲取該url所對應的許可權              if (requestUrl.equals(permission.getUrl())) {                  List<RoleMenu> permissions = rolePermissionMapper.selectList(new EntityWrapper<RoleMenu>().eq("permission_id", permission.getId()));                  List<String> roles = new LinkedList<>();                  if (!CollectionUtils.isEmpty(permissions)){                      Integer roleId = permissions.get(0).getRoleId();                      Role role = roleMapper.selectById(roleId);                      roles.add(role.getCode());                  }                  // 保存該url對應角色許可權資訊                  return SecurityConfig.createList(roles.toArray(new String[roles.size()]));              }          }          // 如果數據中沒有找到相應url資源則為非法訪問,要求用戶登錄再進行操作          return SecurityConfig.createList(Constants.ROLE_LOGIN);      }        @Override      public Collection<ConfigAttribute> getAllConfigAttributes() {          return null;      }        @Override      public boolean supports(Class<?> aClass) {          return FilterInvocation.class.isAssignableFrom(aClass);      }  }

5、自定義UrlAccessDecisionManager實現AccessDecisionManager重寫decide()方法 對訪問url進行許可權認證處理

此處小編的處理邏輯是只要包含其中一個角色即可訪問

@Component  public class UrlAccessDecisionManager implements AccessDecisionManager {        /**       * @param authentication: 當前登錄用戶的角色資訊       * @param object: 請求url資訊       * @param collection: `UrlFilterInvocationSecurityMetadataSource`中的getAttributes方法傳來的,表示當前請求需要的角色(可能有多個)       * @return: void       */      @Override      public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> collection) throws AccessDeniedException, AuthenticationException {          // 遍歷角色          for (ConfigAttribute ca : collection) {              // ① 當前url請求需要的許可權              String needRole = ca.getAttribute();              if (Constants.ROLE_LOGIN.equals(needRole)) {                  if (authentication instanceof AnonymousAuthenticationToken) {                      throw new BadCredentialsException("未登錄!");                  } else {                      throw new AccessDeniedException("未授權該url!");                  }              }                // ② 當前用戶所具有的角色              Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();              for (GrantedAuthority authority : authorities) {                  // 只要包含其中一個角色即可訪問                  if (authority.getAuthority().equals(needRole)) {                      return;                  }              }          }          throw new AccessDeniedException("請聯繫管理員分配許可權!");      }        @Override      public boolean supports(ConfigAttribute configAttribute) {          return true;      }        @Override      public boolean supports(Class<?> aClass) {          return true;      }  }

6、自定義無許可權處理器 UrlAccessDeniedHandler實現AccessDeniedHandler重寫handle()方法

在這裡自定義403無許可權響應內容,登錄過後的許可權處理
:要和未登錄時的許可權處理區分開哦~ 】

@Component  public class UrlAccessDeniedHandler implements AccessDeniedHandler {      @Override      public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException, ServletException {          ResponseUtils.out(response, ApiResult.fail(403, e.getMessage()));      }  }

7、最後在Security 核心配置類中配置以上處理

@Configuration  @EnableWebSecurity  @EnableGlobalMethodSecurity(prePostEnabled = true)  public class SecurityConfig extends WebSecurityConfigurerAdapter {        /**       * 訪問鑒權 - 認證token、簽名...       */      private final MyAuthenticationFilter myAuthenticationFilter;      /**       * 訪問許可權認證異常處理       */      private final AdminAuthenticationEntryPoint adminAuthenticationEntryPoint;      /**       * 用戶密碼校驗過濾器       */      private final AdminAuthenticationProcessingFilter adminAuthenticationProcessingFilter;        // 上面是登錄認證相關  下面為url許可權相關 - ========================================================================================        /**       * 獲取訪問url所需要的角色資訊       */      private final UrlFilterInvocationSecurityMetadataSource urlFilterInvocationSecurityMetadataSource;      /**       * 認證許可權處理 - 將上面所獲得角色許可權與當前登錄用戶的角色做對比,如果包含其中一個角色即可正常訪問       */      private final UrlAccessDecisionManager urlAccessDecisionManager;      /**       * 自定義訪問無許可權介面時403響應內容       */      private final UrlAccessDeniedHandler urlAccessDeniedHandler;        public SecurityConfig(MyAuthenticationFilter myAuthenticationFilter, AdminAuthenticationEntryPoint adminAuthenticationEntryPoint, AdminAuthenticationProcessingFilter adminAuthenticationProcessingFilter, UrlFilterInvocationSecurityMetadataSource urlFilterInvocationSecurityMetadataSource, UrlAccessDeniedHandler urlAccessDeniedHandler, UrlAccessDecisionManager urlAccessDecisionManager) {          this.myAuthenticationFilter = myAuthenticationFilter;          this.adminAuthenticationEntryPoint = adminAuthenticationEntryPoint;          this.adminAuthenticationProcessingFilter = adminAuthenticationProcessingFilter;          this.urlFilterInvocationSecurityMetadataSource = urlFilterInvocationSecurityMetadataSource;          this.urlAccessDeniedHandler = urlAccessDeniedHandler;          this.urlAccessDecisionManager = urlAccessDecisionManager;      }          /**       * 許可權配置       * @param http       * @throws Exception       */      @Override      protected void configure(HttpSecurity http) throws Exception {          ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry = http.antMatcher("/**").authorizeRequests();            // 禁用CSRF 開啟跨域          http.csrf().disable().cors();            // 未登錄認證異常          http.exceptionHandling().authenticationEntryPoint(adminAuthenticationEntryPoint);          // 登錄過後訪問無許可權的介面時自定義403響應內容          http.exceptionHandling().accessDeniedHandler(urlAccessDeniedHandler);            // url許可權認證處理          registry.withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {              @Override              public <O extends FilterSecurityInterceptor> O postProcess(O o) {                  o.setSecurityMetadataSource(urlFilterInvocationSecurityMetadataSource);                  o.setAccessDecisionManager(urlAccessDecisionManager);                  return o;              }          });            // 不創建會話 - 即通過前端傳token到後台過濾器中驗證是否存在訪問許可權  //        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);            // 標識訪問 `/home` 這個介面,需要具備`ADMIN`角色  //        registry.antMatchers("/home").hasRole("ADMIN");          // 標識只能在 伺服器本地ip[127.0.0.1或localhost] 訪問 `/home` 這個介面,其他ip地址無法訪問          registry.antMatchers("/home").hasIpAddress("127.0.0.1");          // 允許匿名的url - 可理解為放行介面 - 多個介面使用,分割          registry.antMatchers("/login", "/index").permitAll();  //        registry.antMatchers("/**").access("hasAuthority('admin')");          // OPTIONS(選項):查找適用於一個特定網址資源的通訊選擇。 在不需執行具體的涉及數據傳輸的動作情況下, 允許客戶端來確定與資源相關的選項以及 / 或者要求, 或是一個伺服器的性能          registry.antMatchers(HttpMethod.OPTIONS, "/**").denyAll();          // 自動登錄 - cookie儲存方式          registry.and().rememberMe();          // 其餘所有請求都需要認證          registry.anyRequest().authenticated();          // 防止iframe 造成跨域          registry.and().headers().frameOptions().disable();            // 自定義過濾器在登錄時認證用戶名、密碼          http.addFilterAt(adminAuthenticationProcessingFilter, UsernamePasswordAuthenticationFilter.class)              .addFilterBefore(myAuthenticationFilter, BasicAuthenticationFilter.class);      }        /**       * 忽略攔截url或靜態資源文件夾 - web.ignoring(): 會直接過濾該url - 將不會經過Spring Security過濾器鏈       *                             http.permitAll(): 不會繞開springsecurity驗證,相當於是允許該路徑通過       * @param web       * @throws Exception       */      @Override      public void configure(WebSecurity web) throws Exception {          web.ignoring().antMatchers(HttpMethod.GET,                  "/favicon.ico",                  "/*.html",                  "/**/*.css",                  "/**/*.js");      }    }

四、編寫測試程式碼

控制層:

@Slf4j  @RestController  public class IndexController {        @GetMapping("/")      public ModelAndView showHome() {          return new ModelAndView("home.html");      }        @GetMapping("/index")      public String index() {          return "Hello World ~";      }        @GetMapping("/login")      public ModelAndView login() {          return new ModelAndView("login.html");      }        @GetMapping("/home")      public String home() {          String name = SecurityContextHolder.getContext().getAuthentication().getName();          log.info("登陸人:" + name);          return "Hello~ " + name;      }        @GetMapping(value ="/admin")      // 訪問路徑`/admin` 具有`ADMIN`角色許可權   【這種是寫死方式】  //    @PreAuthorize("hasPermission('/admin','ADMIN')")      public String admin() {          return "Hello~ 管理員";      }        @GetMapping("/test")      public String test() {          return "Hello~ 測試許可權訪問介面";      }    }

頁面和其它相關程式碼這裡就不貼出來了,具體可參考文末demo源碼

五、運行訪問測試效果

1、未登錄時

在這裡插入圖片描述

2、登錄過後如果有許可權則正常訪問

在這裡插入圖片描述

3、登錄過後,沒有許可權

這裡我們可以修改資料庫角色許可權關聯表t_sys_role_permission來進行測試哦 ~

Security 動態url許可權也就是依賴這張表來判斷的,只要修改這張表分配角色對應url許可權資源,用戶訪問url時就會動態的去判斷,無需做其他處理,如果是將許可權資訊放在了快取中,修改表數據時及時更新快取即可!
在這裡插入圖片描述

在這裡插入圖片描述

4、登錄過後,訪問資料庫中沒有配置的url 並且 在Security中沒有忽略攔截的url時

在這裡插入圖片描述

六、總結

  1. 自定義未登錄許可權處理器AdminAuthenticationEntryPoint – 自定義未登錄時訪問無許可權url響應內容
  2. 自定義訪問鑒權過濾器MyAuthenticationFilter – 記錄請求響應日誌、是否合法訪問,驗證token過期等
  3. 自定義UrlFilterInvocationSecurityMetadataSource – 獲取訪問該url所需要的角色許可權
  4. 自定義UrlAccessDecisionManager – 對訪問url進行許可權認證處理
  5. 自定義UrlAccessDeniedHandler – 登錄過後訪問無許可權url失敗處理器 – 自定義403無許可權響應內容
  6. Security核心配置類中配置以上處理器和過濾器
Security動態許可權相關程式碼:

在這裡插入圖片描述

本文案例demo源碼

https://gitee.com/zhengqingya/java-workspace