基於RABC模型的SpringSecurity許可權控制能力

愛叨叨的程式狗發表於2020-12-20

RBAC許可權模型

全名為:Role-Based Access Control 譯為基於角色的訪問控制。

RBAC許可權框架基於角色進行鑑權,在該框架中具有三大模組:角色(Role)、使用者(User)、許可權(Permissions),

RBAC使用最小特權原則,當前請求訪問的使用者具備那些角色,該角色具備那些許可權,所具備的許可權中是否包含本次訪問所需的許可權?若具有,正常訪問返回,若不具有,給予使用者提示,所以,RBAC可以把許可權粒度做到方法級。

SpringSecurity是基於RBAC模型輕量級許可權控框架,與之對等的還有Apache Shiro,由於Spring的生態不斷完善、功能日益豐富,使得SpringSecurity越來越越受歡迎。
一般的,SpringSecurity的許可權控制設計思路為:User - User_Role -Role -Role_Menu -Menu,即:使用者屬於什麼角色,該角色具有什麼許可權,具有該許可權可以訪問那些頁面,如若把許可權控制在方法級別,可以使用SpringSecurity註解在後端方法上,從而做到按鈕級別的許可權控制,以上,便完成了許可權訪問控制。

資料庫設計便為:

  • User:使用者表
  • User_Role:使用者角色中間表
  • Role:角色表
  • Role_Menu:角色選單中間表
  • Menu:選單表

(使用者可能有多個角色,一個角色可能有多個使用者,所以使用者和角色是多對多的關係)

​ Menu可以理解為許可權,在Web中,選單中的顯示與否可以視為使用者是否具備該許可權

如此便完成了許可權控制的設計方案。

SpringSecurity使用步驟:

引入SpringSecurity模組

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

加入這個依賴後表示所有的介面都是被保護的狀態,訪問的時候被Security攔截。

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片儲存下來直接上傳(img-pXAO3H4e-1607867100666)(C:\Users\Liu-PC\Desktop\Security\引入Security1.png)]

在瀏覽器輸入該請求路徑,會自重定向到Spring Security的登入頁。預設的使用者名稱是user,密碼請去IDEA的Consolse去找專案每次啟動時隨機生成的字串:

Using generated security password: 5a38aea2-81d0-485d-bf5c-12c73b0aad27

(複製passwor後的內容即可訪問)

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片儲存下來直接上傳(img-euN9SLzJ-1607867100668)(C:\Users\Liu-PC\Desktop\Security\Security2.png)]

同時也支援在資料庫配置使用者名稱和密碼(正式專案一般處理方式)或在配置檔案配置使用者名稱密碼,本文使用的是yml配置,properties同理,如果不知道如何配置,請自行百度。配置後在Console中便不自動生成password

配置HTTP攔截規則

配置介面放行規則

SecurityConfig.class

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private UserService userService;

    @Autowired
    private RedisService redisUtil;


    @Autowired
    private SecurityFilter securityFilter;

    @Autowired
    private OwnAccessDecisionManager ownAccessDecisionManager;


    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userService);
    }

    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }


    @Bean
    /**
     * 定義角色繼承
     */
    RoleHierarchy roleHierarchy() {
        RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
        roleHierarchy.setHierarchy("ROLE_dba> ROLE_admin > ROLE_user");
        return roleHierarchy;
    }

    /**
     * 配置HTTP請求規則
     * 什麼請求路徑需要什麼許可權才能訪問,
     * 登入介面,都可訪問
     *
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/admin/**").hasRole("admin")
                .antMatchers("user/**").hasAnyRole("admin", "user")
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .loginProcessingUrl("/doLogin")
                .usernameParameter("userName")
                .passwordParameter("passWord")
                .successHandler(new AuthenticationSuccessHandler() {
                    @Override
                    public void onAuthenticationSuccess(HttpServletRequest req, HttpServletResponse resp, Authentication authentication) throws IOException, ServletException {
                        //登陸成功處理控制程式碼,前後分離專案,給前端返回Json即可
                        resp.setContentType("application/json;charset=utf-8");
                        Map<String, Object> map = new HashMap<>();
                        map.put("status", HttpServletResponse.SC_OK);
                        User principal = (User) authentication.getPrincipal();
                        String token = JwtUtil.sign(principal.getUsername(), principal.getPassword());
                        map.put("msg", authentication.getPrincipal());
                        map.put("token", token);
                        map.put("userName", principal.getUsername());
                        redisUtil.setCacheObject(BusinessConstant.REDIS_RELATED.PREFIX + LocalDateUtils.getStartTimeOfDayStr() + principal.getId(), map);
                        ResponseUtil.responseJson(resp, HttpStatus.OK.value(), map);
                    }
                })
                .failureHandler(new AuthenticationFailureHandler() {
                    @Override
                    public void onAuthenticationFailure(HttpServletRequest req, HttpServletResponse resp, AuthenticationException e) throws IOException, ServletException {
                        //登入失敗處理  AuthenticationException:鎖定異常問題
                        resp.setContentType("application/json;charset=utf-8");
                        PrintWriter out = resp.getWriter();
                        Map<String, Object> map = new HashMap<>();
                        map.put("status", HttpServletResponse.SC_UNAUTHORIZED);
                        if (e instanceof LockedException) {
                            map.put("msg", ResponseEnum.USER_ACCOUNT_LOCKED.getMessage());
                        } else if (e instanceof BadCredentialsException) {
                            map.put("msg", ResponseEnum.USER_NOT_EXIST_OR_ERROR.getMessage());
                        } else {
                            map.put("msg", ResponseEnum.LOGIN_FILURE.getMessage());
                        }
                        out.write(new ObjectMapper().writeValueAsString(map));
                        out.flush();
                        out.close();
                    }
                })
                .permitAll()
                .and()
                .logout()
                .logoutUrl("/logout")
                .logoutSuccessHandler(new LogoutSuccessHandler() {
                    @Override
                    public void onLogoutSuccess(HttpServletRequest req, HttpServletResponse resp, Authentication authentication) throws IOException, ServletException {
                        resp.setContentType("application/json;charset=utf-8");
                        PrintWriter out = resp.getWriter();
                        out.write(new ObjectMapper().writeValueAsString("登出成功!"));
                        out.flush();
                        out.close();
                    }
                })
                .withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
                    @Override
                    public <O extends FilterSecurityInterceptor> O postProcess(O o) {
                        o.setAccessDecisionManager(ownAccessDecisionManager);
                        o.setSecurityMetadataSource(securityFilter);
                        return o;
                    }
                })
                .and()
                .csrf().disable()
                .exceptionHandling()
            
            //沒有許可權時返回Json,而不是重定向到登入頁,方便前後端分離專案使用
                .authenticationEntryPoint(new AuthenticationEntryPoint() {
                    @Override
                    public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException authException) throws IOException, ServletException {
                        httpServletResponse.setContentType("application/json;charset=utf-8");
                        PrintWriter out = httpServletResponse.getWriter();
              
                        if (authException instanceof InsufficientAuthenticationException) {
                            throw new RuntimeException(ResponseEnum.SYSTEM_INNER_ERROR.getMessage());
                        }
//                        ResponseEnum.SYSTEM_INNER_ERROR.assertException(authException);
                        out.write(new ObjectMapper().writeValueAsString(authException));
                        out.flush();
                        out.close();
                    }
                });
    }

SecurityFilter.class

根據請求地址分析出該地址需要那些角色,並檢視請求的使用者是否具備該角色

@Component
public class SecurityFilter implements FilterInvocationSecurityMetadataSource{

    @Autowired
    private MenuService menuService;

    @Autowired
    private RedisService redisService;

    /**
     * Ant規則匹配符
     */
    AntPathMatcher pathMatcher = new AntPathMatcher();

    @Override
    public Collection<ConfigAttribute> getAttributes(Object o) throws IllegalArgumentException {
        String requestUrl = ((FilterInvocation) o).getRequestUrl();
        List<Menu> allMenus;
        allMenus = redisService.getCacheObject(BusinessConstant.REDIS_RELATED.MENU_ALL);
        if (CollectionUtils.isEmpty(allMenus)) {
            allMenus = menuService.getAllMenus();
        }
        for (Menu menu : allMenus) {
            if (pathMatcher.match(menu.getPattern(), requestUrl)) {
                List<Role> roles = menu.getRoles();
                String[] rolesStr = new String[roles.size()];
                for (int i = 0; i < roles.size(); i++) {
                    rolesStr[i] = roles.get(i).getName();
                }
                return SecurityConfig.createList(rolesStr);
            }
        }
        //需要的角色都不滿足條件,非法請求
        return SecurityConfig.createList("ROLE_login");
    }

    @Override
    /**
     *根據需要的角色檢視當前使用者是否具有該角色
     */
    public Collection<ConfigAttribute> getAllConfigAttributes() {
        return null;
    }

    @Override
    public boolean supports(Class<?> aClass) {
        return true;
    }


}

OwnAccessDecisionManager.class

為當前的訪問規則進行決策,是否給予訪問的許可權。

@Component
public class OwnAccessDecisionManager implements AccessDecisionManager {
    @Override
    public void decide(Authentication authentication, Object o, Collection<ConfigAttribute> collection) throws AccessDeniedException, InsufficientAuthenticationException {
        for (ConfigAttribute attribute : collection) {
            if ("ROLE_login".equals(attribute.getAttribute())) {
                if (authentication instanceof AnonymousAuthenticationToken) {
                    throw new AccessDeniedException(ResponseEnum.PERMISSION_NOT_SAFE.getMessage());
                } else {
                    return;
                }
            }
            //查詢訪問所需角色,當前登入使用者是否具備所需角色的其中的一個
            Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
            for (GrantedAuthority authority : authorities) {
                if (authority.getAuthority().equals(attribute.getAttribute())) {
                    return;
                }
            }
            throw new AccessDeniedException(ResponseEnum.USER_ROLE_PERMISSION_ERROR.getMessage());
        }
    }

    @Override
    public boolean supports(ConfigAttribute configAttribute) {
        return true;
    }

    @Override
    public boolean supports(Class<?> aClass) {
        return true;
    }
}
使用postman測試,所以關閉CSRF攻擊,正式環境請開啟
記得要刪掉super.configure(http);  不然會報錯IllegalStateException: Can't configure anyRequest after itself
ObjectMapper類是Jackson庫的主要類。它提供一些功能將轉換成Java物件匹配JSON結構,反之亦然。它使用JsonParser和JsonGenerator的例項實現JSON實際的讀/寫。

表單登入測試

在這裡插入圖片描述

使用post請求構造表單登入,SpringSecurity已做密碼脫敏,許可權中預設使用"ROLE_"為字首。

表單登出測試

在這裡插入圖片描述

登出配置如上程式碼,構造get請求即可。

使用資料庫的方式做登入認證

由於篇幅原因,不宜過長,所以我是分開書寫的,許可權功能需要整合資料庫相關,在我的另一篇文章中:

SpringBoot整合Redis、MyBatis-Plus

因為敲完這個demo,時間不是很充足,所以沒有更新文章,SpringBoot下與資料庫互動使用許可權認證請去我的github去尋找原始碼,思路根據江南一點雨(鬆哥)的許可權認證思路而來。

專案原始碼Git地址

鬆哥Github地址

相關文章