Spring Security 動態url許可權控制(三)
- 2019 年 10 月 19 日
- 筆記
一、前言
本篇文章將講述Spring Security 動態分配url許可權,未登錄許可權控制,登錄過後根據登錄用戶角色授予訪問url許可權
基本環境
- spring-boot 2.1.8
- mybatis-plus 2.2.0
- mysql 資料庫
- maven項目
Spring Security入門學習可參考之前文章:
- SpringBoot集成Spring Security入門體驗(一)
https://blog.csdn.net/qq_38225558/article/details/101754743 - Spring Security 自定義登錄認證(二)
https://blog.csdn.net/qq_38225558/article/details/102542072
二、資料庫建表
表關係簡介:
- 用戶表
t_sys_user
關聯 角色表t_sys_role
兩者建立中間關係表t_sys_user_role
- 角色表
t_sys_role
關聯 許可權表t_sys_permission
兩者建立中間關係表t_sys_role_permission
- 最終體現效果為當前登錄用戶所具備的角色關聯能訪問的所有url,只要給角色分配相應的url許可權即可
溫馨小提示:這裡邏輯根據個人業務來定義,小編這裡講解案例只給用戶對應的角色分配訪問許可權,像其它的 直接給用戶分配許可權等等可以自己實現
表模擬數據如下:
三、Spring Security 動態許可權控制
1、未登錄訪問許可權控制
自定義AdminAuthenticationEntryPoint
類實現AuthenticationEntryPoint
類
這裡是認證許可權入口 -> 即在未登錄的情況下訪問所有介面都會攔截到此(除了放行忽略介面)
溫馨小提示:
ResponseUtils
和ApiResult
是小編這裡模擬前後端分離情況下返回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是否過期,請求頭類型是否正確,防止非法請求等等
logRequestBody()
方法:記錄請求消息體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時
六、總結
- 自定義未登錄許可權處理器
AdminAuthenticationEntryPoint
– 自定義未登錄時訪問無許可權url響應內容 - 自定義訪問鑒權過濾器
MyAuthenticationFilter
– 記錄請求響應日誌、是否合法訪問,驗證token過期等 - 自定義
UrlFilterInvocationSecurityMetadataSource
– 獲取訪問該url所需要的角色許可權 - 自定義
UrlAccessDecisionManager
– 對訪問url進行許可權認證處理 - 自定義
UrlAccessDeniedHandler
– 登錄過後訪問無許可權url失敗處理器 – 自定義403無許可權響應內容 - 在
Security核心配置類
中配置以上處理器和過濾器
Security動態許可權相關程式碼:
本文案例demo源碼
https://gitee.com/zhengqingya/java-workspace