SpringBoot+Vue先後端分離,使用SpringSecurity完美處理權限問題(二)

當先後端分離時,權限問題的處理也和咱們傳統的處理方式有一點差別。筆者前幾天恰好在負責一個項目的權限管理模塊,如今權限管理模塊已經作完了,我想經過5-6篇文章,來介紹一下項目中遇到的問題以及個人解決方案,但願這個系列可以給小夥伴一些幫助。本系列文章並非手把手的教程,主要介紹了核心思路並講解了核心代碼,完整的代碼小夥伴們能夠在GitHub上star並clone下來研究。另外,本來計劃把項目跑起來放到網上供小夥伴們查看,可是以前買服務器爲了省錢,內存只有512M,兩個應用跑不起來(已經有一個V部落開源項目在運行),所以小夥伴們只能將就看一下下面的截圖了,GitHub上有部署教程,部署到本地也能夠查看完整效果。html


項目地址:https://github.com/lenve/vhr 前端

上篇文章咱們對項目作了一個總體的介紹,從本文開始,咱們就來實現咱們的權限管理模塊。因爲先後端分離,所以咱們先來完成後臺接口,完成以後,能夠先用POSTMAN或者RESTClient等工具進行測試,測試成功以後,咱們再來着手開發前端。 java

本文是本系列的第二篇,建議先閱讀前面的文章有助於更好的理解本文: mysql

1.SpringBoot+Vue先後端分離,使用SpringSecurity完美處理權限問題(一)git

建立SpringBoot項目

在IDEA中建立SpringBoot項目,建立完成以後,添加以下依賴:github

<dependencies>
    <dependency>
        <groupId>org.mybatis.spring.boot</groupId>
        <artifactId>mybatis-spring-boot-starter</artifactId>
        <version>1.3.1</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>druid</artifactId>
        <version>1.0.29</version>
    </dependency>
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
    </dependency>
</dependencies>

這些都是常規的依賴,有SpringBoot、SpringSecurity、Druid數據庫鏈接池,還有數據庫驅動。 web

而後在application.properties中配置數據庫,以下:spring

spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/vhr?useUnicode=true&characterEncoding=UTF-8
spring.datasource.username=root
spring.datasource.password=123

server.port=8082

OK,至此,咱們的工程就建立好了。sql

建立Hr和HrService

首先咱們須要建立Hr類,即咱們的用戶類,該類實現了UserDetails接口,該類的屬性以下:數據庫

public class Hr implements UserDetails {
    private Long id;
    private String name;
    private String phone;
    private String telephone;
    private String address;
    private boolean enabled;
    private String username;
    private String password;
    private String remark;
    private List<Role> roles;
    private String userface;
    //getter/setter省略
}
若是小夥伴對屬性的含義有疑問,能夠參考 1.權限數據庫設計.

UserDetails接口默認有幾個方法須要實現,這幾個方法中,除了isEnabled返回了正常的enabled以外,其餘的方法我都統一返回true,由於我這裏的業務邏輯並不涉及到帳戶的鎖定、密碼的過時等等,只有帳戶是否被禁用,所以只處理了isEnabled方法,這一塊小夥伴能夠根據本身的實際狀況來調整。另外,UserDetails中還有一個方法叫作getAuthorities,該方法用來獲取當前用戶所具備的角色,可是小夥伴也看到了,個人Hr中有一個roles屬性用來描述當前用戶的角色,所以個人getAuthorities方法的實現以下:

public Collection<? extends GrantedAuthority> getAuthorities() {
    List<GrantedAuthority> authorities = new ArrayList<>();
    for (Role role : roles) {
        authorities.add(new SimpleGrantedAuthority(role.getName()));
    }
    return authorities;
}

即直接從roles中獲取當前用戶所具備的角色,構造SimpleGrantedAuthority而後返回便可。

建立好Hr以後,接下來咱們須要建立HrService,用來執行登陸等操做,HrService須要實現UserDetailsService接口,以下:

@Service
@Transactional
public class HrService implements UserDetailsService {

    @Autowired
    HrMapper hrMapper;

    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        Hr hr = hrMapper.loadUserByUsername(s);
        if (hr == null) {
            throw new UsernameNotFoundException("用戶名不對");
        }
        return hr;
    }
}

這裏最主要是實現了UserDetailsService接口中的loadUserByUsername方法,在執行登陸的過程當中,這個方法將根據用戶名去查找用戶,若是用戶不存在,則拋出UsernameNotFoundException異常,不然直接將查到的Hr返回。HrMapper用來執行數據庫的查詢操做,這個不在本系列的介紹範圍內,全部涉及到數據庫的操做都將只介紹方法的做用。

自定義FilterInvocationSecurityMetadataSource

FilterInvocationSecurityMetadataSource有一個默認的實現類DefaultFilterInvocationSecurityMetadataSource,該類的主要功能就是經過當前的請求地址,獲取該地址須要的用戶角色,咱們照貓畫虎,本身也定義一個FilterInvocationSecurityMetadataSource,以下:

@Component
public class UrlFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {
    @Autowired
    MenuService menuService;
    AntPathMatcher antPathMatcher = new AntPathMatcher();

    @Override
    public Collection<ConfigAttribute> getAttributes(Object o) throws IllegalArgumentException {
        //獲取請求地址
        String requestUrl = ((FilterInvocation) o).getRequestUrl();
        if ("/login_p".equals(requestUrl)) {
            return null;
        }
        List<Menu> allMenu = menuService.getAllMenu();
        for (Menu menu : allMenu) {
            if (antPathMatcher.match(menu.getUrl(), requestUrl)&&menu.getRoles().size()>0) {
                List<Role> roles = menu.getRoles();
                int size = roles.size();
                String[] values = new String[size];
                for (int i = 0; i < size; i++) {
                    values[i] = roles.get(i).getName();
                }
                return SecurityConfig.createList(values);
            }
        }
        //沒有匹配上的資源,都是登陸訪問
        return SecurityConfig.createList("ROLE_LOGIN");
    }

    @Override
    public Collection<ConfigAttribute> getAllConfigAttributes() {
        return null;
    }

    @Override
    public boolean supports(Class<?> aClass) {
        return FilterInvocation.class.isAssignableFrom(aClass);
    }
}

關於自定義這個類,我說以下幾點:

1.一開始注入了MenuService,MenuService的做用是用來查詢數據庫中url pattern和role的對應關係,查詢結果是一個List集合,集合中是Menu類,Menu類有兩個核心屬性,一個是url pattern,即匹配規則(好比/admin/**),還有一個是List<Role>,即這種規則的路徑須要哪些角色才能訪問。

2.咱們能夠從getAttributes(Object o)方法的參數o中提取出當前的請求url,而後將這個請求url和數據庫中查詢出來的全部url pattern一一對照,看符合哪個url pattern,而後就獲取到該url pattern所對應的角色,固然這個角色可能有多個,因此遍歷角色,最後利用SecurityConfig.createList方法來建立一個角色集合。

3.第二步的操做中,涉及到一個優先級問題,好比個人地址是/employee/basic/hello,這個地址既能被/employee/**匹配,也能被/employee/basic/**匹配,這就要求咱們從數據庫查詢的時候對數據進行排序,將/employee/basic/**類型的url pattern放在集合的前面去比較。

4.若是getAttributes(Object o)方法返回null的話,意味着當前這個請求不須要任何角色就能訪問,甚至不須要登陸。可是在個人整個業務中,並不存在這樣的請求,我這裏的要求是,全部未匹配到的路徑,都是認證(登陸)後可訪問,所以我在這裏返回一個ROLE_LOGIN的角色,這種角色在個人角色數據庫中並不存在,所以我將在下一步的角色比對過程當中特殊處理這種角色。

5.若是地址是/login_p,這個是登陸頁,不須要任何角色便可訪問,直接返回null。

6.getAttributes(Object o)方法返回的集合最終會來到AccessDecisionManager類中,接下來咱們再來看AccessDecisionManager類。

自定義AccessDecisionManager

自定義UrlAccessDecisionManager類實現AccessDecisionManager接口,以下:

@Component
public class UrlAccessDecisionManager implements AccessDecisionManager {
    @Override
    public void decide(Authentication authentication, Object o, Collection<ConfigAttribute> collection) throws AccessDeniedException, AuthenticationException {
        Iterator<ConfigAttribute> iterator = collection.iterator();
        while (iterator.hasNext()) {
            ConfigAttribute ca = iterator.next();
            //當前請求須要的權限
            String needRole = ca.getAttribute();
            if ("ROLE_LOGIN".equals(needRole)) {
                if (authentication instanceof AnonymousAuthenticationToken) {
                    throw new BadCredentialsException("未登陸");
                } else
                    return;
            }
            //當前用戶所具備的權限
            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;
    }
}

關於這個類,我說以下幾點:

1.decide方法接收三個參數,其中第一個參數中保存了當前登陸用戶的角色信息,第三個參數則是UrlFilterInvocationSecurityMetadataSource中的getAttributes方法傳來的,表示當前請求須要的角色(可能有多個)。

2.若是當前請求須要的權限爲ROLE_LOGIN則表示登陸便可訪問,和角色沒有關係,此時我須要判斷authentication是否是AnonymousAuthenticationToken的一個實例,若是是,則表示當前用戶沒有登陸,沒有登陸就拋一個BadCredentialsException異常,登陸了就直接返回,則這個請求將被成功執行。

3.遍歷collection,同時查看當前用戶的角色列表中是否具有須要的權限,若是具有就直接返回,不然就拋異常。

4.這裏涉及到一個all和any的問題:假設當前用戶具有角色A、角色B,當前請求須要角色B、角色C,那麼是要當前用戶要包含全部請求角色纔算受權成功仍是隻要包含一個就算受權成功?我這裏採用了第二種方案,即只要包含一個便可。小夥伴可根據本身的實際狀況調整decide方法中的邏輯。

自定義AccessDeniedHandler

經過自定義AccessDeniedHandler咱們能夠自定義403響應的內容,以下:

@Component
public class AuthenticationAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest httpServletRequest, HttpServletResponse resp, AccessDeniedException e) throws IOException, ServletException {
        resp.setStatus(HttpServletResponse.SC_FORBIDDEN);
        resp.setCharacterEncoding("UTF-8");
        PrintWriter out = resp.getWriter();
        out.write("{\"status\":\"error\",\"msg\":\"權限不足,請聯繫管理員!\"}");
        out.flush();
        out.close();
    }
}

配置WebSecurityConfig

最後在webSecurityConfig中完成簡單的配置便可,以下:

@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    HrService hrService;
    @Autowired
    UrlFilterInvocationSecurityMetadataSource urlFilterInvocationSecurityMetadataSource;
    @Autowired
    UrlAccessDecisionManager urlAccessDecisionManager;
    @Autowired
    AuthenticationAccessDeniedHandler authenticationAccessDeniedHandler;

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

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/index.html", "/static/**");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
                    @Override
                    public <O extends FilterSecurityInterceptor> O postProcess(O o) {
                        o.setSecurityMetadataSource(urlFilterInvocationSecurityMetadataSource);
                        o.setAccessDecisionManager(urlAccessDecisionManager);
                        return o;
                    }
                }).and().formLogin().loginPage("/login_p").loginProcessingUrl("/login").usernameParameter("username").passwordParameter("password").permitAll().failureHandler(new AuthenticationFailureHandler() {
            @Override
            public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
                httpServletResponse.setContentType("application/json;charset=utf-8");
                PrintWriter out = httpServletResponse.getWriter();
                StringBuffer sb = new StringBuffer();
                sb.append("{\"status\":\"error\",\"msg\":\"");
                if (e instanceof UsernameNotFoundException || e instanceof BadCredentialsException) {
                    sb.append("用戶名或密碼輸入錯誤,登陸失敗!");
                } else if (e instanceof DisabledException) {
                    sb.append("帳戶被禁用,登陸失敗,請聯繫管理員!");
                } else {
                    sb.append("登陸失敗!");
                }
                sb.append("\"}");
                out.write(sb.toString());
                out.flush();
                out.close();
            }
        }).successHandler(new AuthenticationSuccessHandler() {
            @Override
            public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
                httpServletResponse.setContentType("application/json;charset=utf-8");
                PrintWriter out = httpServletResponse.getWriter();
                ObjectMapper objectMapper = new ObjectMapper();
                String s = "{\"status\":\"success\",\"msg\":" + objectMapper.writeValueAsString(HrUtils.getCurrentHr()) + "}";
                out.write(s);
                out.flush();
                out.close();
            }
        }).and().logout().permitAll().and().csrf().disable().exceptionHandling().accessDeniedHandler(authenticationAccessDeniedHandler);
    }
}

關於這個配置,我說以下幾點:

1.在configure(HttpSecurity http)方法中,經過withObjectPostProcessor將剛剛建立的UrlFilterInvocationSecurityMetadataSource和UrlAccessDecisionManager注入進來。到時候,請求都會通過剛纔的過濾器(除了configure(WebSecurity web)方法忽略的請求)。

2.successHandler中配置登陸成功時返回的JSON,登陸成功時返回當前用戶的信息。

3.failureHandler表示登陸失敗,登陸失敗的緣由可能有多種,咱們根據不一樣的異常輸出不一樣的錯誤提示便可。

OK,這些操做都完成以後,咱們能夠經過POSTMAN或者RESTClient來發起一個登陸請求,看到以下結果則表示登陸成功:

圖片描述

關注公衆號,能夠及時接收到最新文章:
圖片描述

相關文章
相關標籤/搜索