1. 程式人生 > >SpringBoot使用SpringSecurity搭建基於非對稱加密的JWT及前後端分離的搭建

SpringBoot使用SpringSecurity搭建基於非對稱加密的JWT及前後端分離的搭建

安全問題是一個比較複雜的問題,之前使用過Shiro這個安全框架,確實挺簡單的,後來使用SpringSecurity,SpringSecurity更細粒度可控,現在做專案基本都使用前後端分離的,很少再使用Thymeleaf這類模板引擎,而基於前後端分離的許可權問題,則需要使用JWT(json web token)
本次搭建基於JWT的SpringSecurity,並搭建前後端分離的安全許可權的開發環境,希望讀者有一點springsecurity的基礎
程式碼放在GitHub上

https://github.com/lhc0512/springsecurity-jwt

在pom.xml引入jar包

        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.7.0</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId
>
<artifactId>spring-security-jwt</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency
>
<dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.47</version> </dependency>

寫一個繼承於WebSecurityConfigurerAdapter的配置類,在重寫帶參httpsecurity,注入自定義的各種返回json的Handler


@Configuration
public class MySecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private AjaxLogoutSuccessHandler logoutSuccessHandler;

    @Autowired
    private AjaxAuthenticationEntryPoint authenticationEntryPoint;

    @Autowired
    private AjaxAccessDeniedHandler accessDeniedHandler;

    @Autowired
    private AjaxAuthenticationFailureHandler authenticationFailureHandler;

    @Autowired
    private AjaxAuthenticationSuccessHandler authenticationSuccessHandler;

    @Autowired
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;


    @Override
    protected void configure(HttpSecurity http) throws Exception {

           //取消session
        http
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
           .httpBasic().authenticationEntryPoint(authenticationEntryPoint)
            .and()
            .authorizeRequests()
            .anyRequest()
            //使用rbac 角色繫結資源的方式
            .access("@rbacauthorityservice.hasPermission(request,authentication)")
           //.authenticated()
            .and()
            //該url比較特殊,需要和login.html的form的action的的url一致
            .formLogin().loginPage("/login").successHandler(authenticationSuccessHandler).failureHandler(authenticationFailureHandler).permitAll()
            .and()
            .logout().logoutSuccessHandler(logoutSuccessHandler).permitAll()
            .and()
            .csrf().disable();
        http.rememberMe().rememberMeParameter("remember-me")
           .userDetailsService(myUserDetailsService).tokenValiditySeconds(300);
        http.exceptionHandling().accessDeniedHandler(accessDeniedHandler);
        //使用jwt的Authentication
        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
        // 禁用headers快取
       http.headers().cacheControl();

    }
}

這些handler的寫法基本一樣,你需要先寫一個返回json的類
包含狀態碼,狀態資訊,返回物件,以及token


@Component
public class AjaxResponseBody implements Serializable {
    private String status;
    private String msg;
    private Object result;
    private String jwtToken;

以登陸的處理為例,你需要配置好返回的json,使用fastjson進行轉換為json,最後返回給前端


@Component
public class AjaxAuthenticationSuccessHandler implements AuthenticationSuccessHandler {


    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
           AjaxResponseBody responseBody = new AjaxResponseBody();

        responseBody.setStatus("00");
        responseBody.setMsg("Login Success!");

        MyUserDetails myUserDetails = (MyUserDetails) authentication.getPrincipal();


        String jwtToken = JwtTokenUtil.generateToken(myUserDetails.getUsername(), 300);
        responseBody.setJwtToken(jwtToken);

        response.getWriter().write(JSON.toJSONString(responseBody));
    }
}

接下來在springsecurity的核心配置類中新增和資料庫及密碼加密的相關配置,注入自定義userDetailsService
使用BCryptPasswordEncoder進行加密

    @Autowired
    private MyUserDetailsService myUserDetailsService;


  @Override
        protected void configure (AuthenticationManagerBuilder auth) throws Exception {

            //使用資料庫
            auth.userDetailsService(myUserDetailsService).passwordEncoder(new BCryptPasswordEncoder());
        }

自定義一個UserDetails類,

@Component
public class MyUserDetails implements UserDetails ,Serializable {
    private String username;
    private String password;
    private Collection<? extends GrantedAuthority> authorities;

自定義一個UserDitailsService,為了方便起見,我就不使用mybatis了,在程式碼中模擬從加密的資料庫中查詢使用者資訊,你註冊使用者資訊的時候就該作如下加密

@Component
public class MyUserDetailsService implements UserDetailsService,Serializable {

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        MyUserDetails myUserDetails = new MyUserDetails();
        myUserDetails.setUsername(username);
        //模擬從資料庫取出的密碼
        myUserDetails.setPassword(new BCryptPasswordEncoder().encode("12345"));

        //模擬從資料庫取出的許可權
        HashSet<SimpleGrantedAuthority> set = new HashSet<>();
      //  set.add(new SimpleGrantedAuthority("ROLE_ADMIN"));
        set.add(new SimpleGrantedAuthority("ROLE_USER"));
        myUserDetails.setAuthorities(set);
        return myUserDetails;
    }
}

這個BCryptPasswordEncoder很強大,每次加密產生的密碼都不一樣,而認證的使用它又能識別出來,也是現在較為主流的加密演算法,像MD5和SHA256等演算法都被淘汰了

在springsecurity的核心配置有jwtAuthenticationTokenFilter,其配置如下,作用就是把傳過來的token解析為username,再從資料庫中查詢使用者資訊放在authentication中

@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
    @Autowired
    MyUserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {

        //請求頭為 Authorization
        //請求體為 Bearer token

        String authHeader = request.getHeader("Authorization");

        if (authHeader != null && authHeader.startsWith("Bearer ")) {

            final String authToken = authHeader.substring("Bearer ".length());

            String username = JwtTokenUtil.parseToken(authToken);
            if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                UserDetails userDetails = userDetailsService.loadUserByUsername(username);
                if (userDetails != null) {

                    UsernamePasswordAuthenticationToken authentication =
                            new UsernamePasswordAuthenticationToken(userDetails, userDetails.getPassword(), userDetails.getAuthorities());
                    authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                }
            }
        }
        chain.doFilter(request, response);
    }
}

注意UsernamePasswordAuthenticationToken的第一個引數有兩種方式,建議傳使用者的整個資訊,因為現在比較流行使用RBAC角色繫結資源的細粒度許可權控制,該方式較為靈活,而不是硬編碼在程式碼中,而使用該方式需要用到使用者的許可權資訊

前面的配置有.access(“@rbacauthorityservice.hasPermission(request,authentication)”)

下面介紹如何使用RBAC

@Component("rbacauthorityservice")
public class RbacAuthorityService {
    public boolean hasPermission(HttpServletRequest request, Authentication authentication) {

        //得到的principal的資訊是使用者名稱還是整個使用者資訊取決於在自定義的authenticationProvider中傳參的方式
        Object userInfo = authentication.getPrincipal();

        boolean hasPermission = false;

        if (userInfo instanceof UserDetails) {

            String username = ((UserDetails) userInfo).getUsername();

            Collection<? extends GrantedAuthority> authorities = ((UserDetails) userInfo).getAuthorities();
            Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
            for (GrantedAuthority authority : authorities) {
                if (authority.getAuthority().equals("ROLE_ADMIN")) {

                    //admin 可以訪問的資源
                    Set<String> urls = new HashSet();
                    urls.add("/sys/**");
                    urls.add("/test/**");
                    AntPathMatcher antPathMatcher = new AntPathMatcher();
                    for (String url : urls) {
                        if (antPathMatcher.match(url, request.getRequestURI())) {
                            hasPermission = true;
                            break;
                        }
                    }
                }
            }
            //user可以訪問的資源
            Set<String> urls = new HashSet();
            urls.add("/test/**");
            AntPathMatcher antPathMatcher = new AntPathMatcher();
            for (String url : urls) {
                if (antPathMatcher.match(url, request.getRequestURI())) {
                    hasPermission = true;
                    break;
                }
            }
            return hasPermission;
        } else {
            return false;
        }
    }
}

接下來說說非對稱加密的token怎樣產生和解析的,你可以使用jdk自帶的keytool工具,注意配置好JAVA_HOME,
輸入,如下內容

keytool -genkey -alias jwt -keyalg  RSA -keysize 1024 -validity 365 -keystore jwt.jks

意思是使用keytool生成金鑰,別名為jwt,演算法為RSA,有效期為365天,檔名為jwt,jks,把檔案儲存在當前開啟cmd的路徑下,它提示輸入密碼,我就輸入lhc123吧
接下的輸入可以忽略,回車pass
把生成的檔案複製到resources目錄下,寫一個JwtTokenUtil 的生成和解析兩個方法

public class JwtTokenUtil {
    //載入jwt.jks檔案
    private static InputStream inputStream = Thread.currentThread().getContextClassLoader().getResourceAsStream("jwt.jks");
    private static PrivateKey privateKey = null;
    private static PublicKey publicKey = null;

    static {
        try {
            KeyStore keyStore = KeyStore.getInstance("JKS");
            keyStore.load(inputStream, "lhc123".toCharArray());
            privateKey = (PrivateKey) keyStore.getKey("jwt", "lhc123".toCharArray());
            publicKey = keyStore.getCertificate("jwt").getPublicKey();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public static String generateToken(String subject, int expirationSeconds) {
        return Jwts.builder()
                .setClaims(null)
                .setSubject(subject)
                .setExpiration(new Date(System.currentTimeMillis() + expirationSeconds * 1000))
                .signWith(SignatureAlgorithm.RS256, privateKey)
                .compact();
    }

    public static String parseToken(String token) {
        String subject = null;
        try {
            Claims claims = Jwts.parser()
                    .setSigningKey(publicKey)
                    .parseClaimsJws(token).getBody();
            subject = claims.getSubject();
        } catch (Exception e) {
        }
        return subject;
    }
}

好了專案搭建完畢,內容比較多,我也儘可能減少篇幅,但給大家一個清晰的思路,需要注意的是,每次請求後臺,後臺都需要重新整理token,上名設定的token的有效期是5分鐘,5分鐘不做任何操作就需要重新登入,最標準的做法是把token儲存到redis中,並且設定其有效時間