1. 程式人生 > >SpringBoot+Vue前後端分離,使用SpringSecurity完美處理許可權問題(二)

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

當前後端分離時,許可權問題的處理也和我們傳統的處理方式有一點差異。筆者前幾天剛好在負責一個專案的許可權管理模組,現在許可權管理模組已經做完了,我想通過5-6篇文章,來介紹一下專案中遇到的問題以及我的解決方案,希望這個系列能夠給小夥伴一些幫助。本系列文章並不是手把手的教程,主要介紹了核心思路並講解了核心程式碼,完整的程式碼小夥伴們可以在GitHub上star並clone下來研究。另外,原本計劃把專案跑起來放到網上供小夥伴們檢視,但是之前買伺服器為了省錢,記憶體只有512M,兩個應用跑不起來(已經有一個V部落開源專案在執行),因此小夥伴們只能將就看一下下面的截圖了,GitHub上有部署教程,部署到本地也可以檢視完整效果。

上篇文章我們對專案做了一個整體的介紹,從本文開始,我們就來實現我們的許可權管理模組。由於前後端分離,因此我們先來完成後臺介面,完成之後,可以先用POSTMAN或者RESTClient等工具進行測試,測試成功之後,我們再來著手開發前端。

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

建立SpringBoot專案

在IDEA中建立SpringBoot專案,建立完成之後,新增如下依賴:

<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資料庫連線池,還有資料庫驅動。

然後在application.properties中配置資料庫,如下:

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,至此,我們的工程就建立好了。

建立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,即這種規則的路徑需要哪些角色才能訪問。

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來發起一個登入請求,看到如下結果則表示登入成功:

這裡寫圖片描述

關注公眾號,可以及時接收到最新文章:

這裡寫圖片描述