天天看點

【超詳細斷點級别講解 SpringSecurity】項目實戰:使用者認證、使用者授權

一句簡潔明了的權限開發的名句:要對Web資源進行保護,最好的辦法莫過于Filter;要想對方法調用進行保護,最好的辦法莫過于AOP

文章目錄

  • ​​1 Spring Security 介紹​​
  • ​​Spring Security 簡介​​
  • ​​Spring Security 對比 Apache Shiro​​
  • ​​SpringSecurity 架構​​
  • ​​2 項目實戰​​
  • ​​使用者認證​​
  • ​​流程分析​​
  • ​​編碼​​
  • ​​Debug 分析(超詳細)​​
  • ​​使用者授權​​

1 Spring Security 介紹

Spring Security 簡介

Spring 是非常流行和成功的 Java 應用開發架構,Spring Security 正是 Spring 家族中的成員。Spring Security 基于 Spring 架構,提供了一套 Web 應用安全性的完整解決方案。

Spring Security 對比 Apache Shiro

SpringSecurity 特點:

  • 和 Spring 無縫整合
  • 全面的權限控制
  • 專門為 Web 開發而設計。舊版本不能脫離 Web 環境使用。新版本對整個架構進行了分層抽取,分成了核心子產品和 Web 子產品。單獨引入核心子產品就可以脫離 Web 環境
  • 重量級

Apache Shiro 特點:

  • 輕量級。Shiro 主張的理念是把複雜的事情變簡單。針對對性能有更高要求的網際網路應用有更好表現
  • 通用性。好處:不局限于 Web 環境,可以脫離 Web 環境使用。缺陷:在 Web 環境下一些特定的需求需要手動編寫代碼定制

SpringSecurity 架構

Spring Security進行認證和鑒權的時候,就是利用的一系列的Filter來進行攔截的。如圖所示,一個請求想要通路到API就會從左到右經過藍線框裡的過濾器,其中綠色部分是負責認證的過濾器,藍色部分是負責異常處理,橙色部分則是負責授權。進過一系列攔截最終通路到我們的API。這裡面我們隻需要重點關注兩個過濾器即可:​

​UsernamePasswordAuthenticationFilter​

​​負責登入認證,​

​FilterSecurityInterceptor​

​負責權限授權。

【超詳細斷點級别講解 SpringSecurity】項目實戰:使用者認證、使用者授權

2 項目實戰

大緻流程如下圖

【超詳細斷點級别講解 SpringSecurity】項目實戰:使用者認證、使用者授權

引入依賴

<!-- Spring Security依賴 -->
<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>
    <scope>provided </scope>
</dependency>      

使用者認證

流程分析

【超詳細斷點級别講解 SpringSecurity】項目實戰:使用者認證、使用者授權

來到 UsernamePasswordAuthenticationFilter 的父類 AbstractAuthenticationProcessingFilter

【超詳細斷點級别講解 SpringSecurity】項目實戰:使用者認證、使用者授權

父類中的 doFilter 就是做上述過程的核心方法

【超詳細斷點級别講解 SpringSecurity】項目實戰:使用者認證、使用者授權

其中 attemptAuthentication 為主要認證過程,其細節依賴于 AbstractAuthenticationProcessingFilter 的最終子類是否重寫這個方法

【超詳細斷點級别講解 SpringSecurity】項目實戰:使用者認證、使用者授權

最終調用 Provider 的 authenticate 方法進行認證

【超詳細斷點級别講解 SpringSecurity】項目實戰:使用者認證、使用者授權

最後将認證結果放入上下文

【超詳細斷點級别講解 SpringSecurity】項目實戰:使用者認證、使用者授權

編碼

【超詳細斷點級别講解 SpringSecurity】項目實戰:使用者認證、使用者授權

1 自定義:加密處理元件

@Component
public class CustomMd5PasswordEncoder implements PasswordEncoder {

    public String encode(CharSequence rawPassword) {
        return MD5.encrypt(rawPassword.toString());
    }

    public boolean matches(CharSequence rawPassword, String encodedPassword) {
        return encodedPassword.equals(MD5.encrypt(rawPassword.toString()));
    }
}      

2 自定義:使用者實體類

public class CustomUser extends User {

    /**
     * 我們自己的使用者實體對象,要調取使用者資訊時直接擷取這個實體對象
     */
    private SysUser sysUser;

    public CustomUser(SysUser sysUser, Collection<? extends GrantedAuthority> authorities) {
        super(sysUser.getUsername(), sysUser.getPassword(), authorities);
        this.sysUser = sysUser;
    }

    public SysUser getSysUser() {
        return sysUser;
    }

    public void setSysUser(SysUser sysUser) {
        this.sysUser = sysUser;
    }      

3 自定義:根據使用者名查使用者資訊

@Component
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private SysUserService sysUserService;
 
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        SysUser sysUser = sysUserService.getByUsername(username);
        if(null == sysUser) {
            throw new UsernameNotFoundException("使用者名不存在!");
        }

        if(sysUser.getStatus().intValue() == 0) {
            throw new RuntimeException("賬号已停用");
        }
        return new CustomUser(sysUser, Collections.emptyList());
    }
}      

4 自定義:登入過濾器

/**
 * <p>
 * 登入過濾器,繼承UsernamePasswordAuthenticationFilter,對使用者名密碼進行登入校驗
 * </p>
 *
 */
public class TokenLoginFilter extends UsernamePasswordAuthenticationFilter {

    public TokenLoginFilter(AuthenticationManager authenticationManager) {
        this.setAuthenticationManager(authenticationManager);
        this.setPostOnly(false);
        //指定登入接口及送出方式,可以指定任意路徑
        this.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/admin/system/index/login","POST"));
    }

    /**
     * 登入認證
     * @param req
     * @param res
     * @return
     * @throws AuthenticationException
     */
    @Override
    public Authentication attemptAuthentication(HttpServletRequest req, HttpServletResponse res)
            throws AuthenticationException {
        try {
            LoginVo loginVo = new ObjectMapper().readValue(req.getInputStream(), LoginVo.class);

            Authentication authenticationToken = new UsernamePasswordAuthenticationToken(loginVo.getUsername(), loginVo.getPassword());
            return this.getAuthenticationManager().authenticate(authenticationToken);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }

    }

    /**
     * 登入成功
     * @param request
     * @param response
     * @param chain
     * @param auth
     * @throws IOException
     * @throws ServletException
     */
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
                                            Authentication auth) throws IOException, ServletException {
        CustomUser customUser = (CustomUser) auth.getPrincipal();
        String token = JwtHelper.createToken(customUser.getSysUser().getId(), customUser.getSysUser().getUsername());

        Map<String, Object> map = new HashMap<>();
        map.put("token", token);
        ResponseUtil.out(response, Result.ok(map));
    }

    /**
     * 登入失敗
     * @param request
     * @param response
     * @param e
     * @throws IOException
     * @throws ServletException
     */
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
                                              AuthenticationException e) throws IOException, ServletException {
        
        if(e.getCause() instanceof RuntimeException) {
            ResponseUtil.out(response, Result.build(null, 204, e.getMessage()));
        } else {
            ResponseUtil.out(response, Result.build(null, ResultCodeEnum.LOGIN_MOBLE_ERROR));
        }
    }
}      

工具類 ResponseUtil

public class ResponseUtil {

    public static void out(HttpServletResponse response, Result r) {
        ObjectMapper mapper = new ObjectMapper();
        response.setStatus(HttpStatus.OK.value());
        response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
        try {
            mapper.writeValue(response.getWriter(), r);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}      

5 自定義:認證過濾器

/**
 * <p>
 * 認證解析token過濾器
 * </p>
 */
public class TokenAuthenticationFilter extends OncePerRequestFilter {

    public TokenAuthenticationFilter() {

    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        logger.info("uri:"+request.getRequestURI());
        //如果是登入接口,直接放行
        if("/admin/system/index/login".equals(request.getRequestURI())) {
            chain.doFilter(request, response);
            return;
        }

        UsernamePasswordAuthenticationToken authentication = getAuthentication(request);
        if(null != authentication) {
            SecurityContextHolder.getContext().setAuthentication(authentication);
            chain.doFilter(request, response);
        } else {
            ResponseUtil.out(response, Result.build(null, ResultCodeEnum.PERMISSION));
        }
    }

    private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) {
        // token置于header裡
        String token = request.getHeader("token");
        logger.info("token:"+token);
        if (!StringUtils.isEmpty(token)) {
            String useruame = JwtHelper.getUsername(token);
            logger.info("useruame:"+useruame);
            if (!StringUtils.isEmpty(useruame)) {
                return new UsernamePasswordAuthenticationToken(useruame, null, Collections.emptyList());
            }
        }
        return null;
    }
}      

6 自定義:SpringSecurity 配置類

@Configuration
@EnableWebSecurity //@EnableWebSecurity是開啟SpringSecurity的預設行為
@EnableGlobalMethodSecurity(prePostEnabled = true)//開啟注解功能,預設禁用注解
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private CustomMd5Password customMd5PasswordEncoder;

    @Autowired
    private RedisTemplate redisTemplate;

    @Autowired
    private LoginLogService loginLogService;

    @Bean
    @Override
    protected AuthenticationManager authenticationManager() throws Exception {
        return super.authenticationManager();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 這是配置的關鍵,決定哪些接口開啟防護,哪些接口繞過防護
        http
                //關閉csrf
                .csrf().disable()
                // 開啟跨域以便前端調用接口
                .cors().and()
                .authorizeRequests()
                // 指定某些接口不需要通過驗證即可通路。登陸接口肯定是不需要認證的
                //.antMatchers("/admin/system/index/login").permitAll()
                // 這裡意思是其它所有接口需要認證才能通路
                .anyRequest().authenticated()
                .and()
                //TokenAuthenticationFilter放到UsernamePasswordAuthenticationFilter的前面,
                // 這樣做就是為了除了登入的時候去查詢資料庫外,其他時候都用token進行認證。
                .addFilterBefore(new TokenAuthenticationFilter(redisTemplate), UsernamePasswordAuthenticationFilter.class)
                .addFilter(new TokenLoginFilter(authenticationManager(), redisTemplate,loginLogService));

        //禁用session
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 指定UserDetailService和加密器
        auth.userDetailsService(userDetailsService).passwordEncoder(customMd5PasswordEncoder);
    }

    /**
     * 配置哪些請求不攔截
     * 排除swagger相關請求
     * @param web
     * @throws Exception
     */
    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/favicon.ico","/swagger-resources/**", "/webjars/**", "/v2/**", "/swagger-ui.html/**", "/doc.html");
    }
}      

Debug 分析(超詳細)

再放一次流程拓撲圖

【超詳細斷點級别講解 SpringSecurity】項目實戰:使用者認證、使用者授權

發起登入請求

【超詳細斷點級别講解 SpringSecurity】項目實戰:使用者認證、使用者授權

首先來到我們自定義的認證過濾器,由于是管理者登入接口的請求路徑,是以直接放行,否則就走下面的擷取 header 中 token 的方法(從緩存中查此 token 中包含的 useruame 對應的權限清單),校驗是否正确并存在

【超詳細斷點級别講解 SpringSecurity】項目實戰:使用者認證、使用者授權

之後來到了登入過濾器,建立出 UsernamePasswordAuthenticationToken

【超詳細斷點級别講解 SpringSecurity】項目實戰:使用者認證、使用者授權

之後走 authenticate 方法對密碼進行加密

【超詳細斷點級别講解 SpringSecurity】項目實戰:使用者認證、使用者授權

之後來到之前自定義的根據使用者名查使用者資訊的實作類

【超詳細斷點級别講解 SpringSecurity】項目實戰:使用者認證、使用者授權

同時擷取了使用者權限的相關資訊(這裡是測試時用的代碼,可以删掉)

【超詳細斷點級别講解 SpringSecurity】項目實戰:使用者認證、使用者授權

之後才走密碼的校驗

【超詳細斷點級别講解 SpringSecurity】項目實戰:使用者認證、使用者授權

最後來到了登入過濾器的成功響應方法

【超詳細斷點級别講解 SpringSecurity】項目實戰:使用者認證、使用者授權

成功擷取到 token

【超詳細斷點級别講解 SpringSecurity】項目實戰:使用者認證、使用者授權

使用者授權

每次請求進來,進入認證過濾器,由于不是登入請求,是以會被攔截判斷 header 中的 token 是否正确,以及對應解析出來的使用者名中是否在緩存中對應權限清單

【超詳細斷點級别講解 SpringSecurity】項目實戰:使用者認證、使用者授權