SpringBoot使用SpringSecurity搭建基于非对称加密的JWT及前后端分离的搭建

时间:2022-08-28 13:46:54

安全问题是一个比较复杂的问题,之前使用过Shiro这个安全框架,确实挺简单的,后来使用SpringSecurity,SpringSecurity更细粒度可控,现在做项目基本都使用前后端分离的,很少再使用Thymeleaf这类模板引擎,而基于前后端分离的权限问题,则需要使用JWT(json web token)
本次搭建基于JWT的SpringSecurity,并搭建前后端分离的安全权限的开发环境,希望读者有一点springsecurity的基础

在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中,并且设置其有效时间