美文网首页SpringBoot - 收录
SpringSecurity权限管理系统实战—六、SpringS

SpringSecurity权限管理系统实战—六、SpringS

作者: witmy | 来源:发表于2020-08-07 09:15 被阅读0次

    目录

    SpringSecurity权限管理系统实战—一、项目简介和开发环境准备
    SpringSecurity权限管理系统实战—二、日志、接口文档等实现
    SpringSecurity权限管理系统实战—三、主要页面及接口实现
    SpringSecurity权限管理系统实战—四、整合SpringSecurity(上)
    SpringSecurity权限管理系统实战—五、整合SpringSecurity(下)
    SpringSecurity权限管理系统实战—六、SpringSecurity整合jwt
    SpringSecurity权限管理系统实战—七、处理一些问题
    SpringSecurity权限管理系统实战—八、AOP记录用户、异常日志
    SpringSecurity权限管理系统实战—九、数据权限的配置

    前言

    最近是真的懒,感觉我每个月都有那么几天什么都不想干。。

    画风一转,前几天的lpl忍界大战是真的精彩,虚假的电竞春晚:RNG vs IG 。真正的电竞春晚 TES vs IG。TES自从阿水和kasra加入之后,状态直接起飞,在我看来TES将是s10夺冠热门之一。不过这一次木叶村战胜了晓组织。

    本以为会打满三局,没想到ig直接2:0带走。rookie线上压制了新皇knight,确实永远可以相信宋义进,或许是因为‍小钰采访吧。

    这两把我最没想到的是kasra被宁王压着打,几乎没有节奏,宝蓝在哪都是阿水的噩梦。这波啊,这波是盗版打赢了正版,puff小小的证明了自己。

    最后还是希望lpl的饭圈粉少一点,peace


    1.jpg

    进入正题

    一、无状态登录

    • 有状态登录

      我们知道在原始的项目中我们是通过session和cookie来实现用户的识别认证。但是这样做无疑会增加服务器的压力,服务的保存了大量的数据。如果业务需要扩展,搭建了集群的话,还需要将session共享。

    • 无状态登录

      而什么是无状态登录呢,简而言之,就是服务的不需要再保存任何的用户信息,而是用户自己携带者信息去访问服务端,服务端通过这些信息来识别客户端身份。这样一来,有状态登录的缺点都被解决了,但是这同样也会带来新问题。比如token信息无法在服务端注销,必须要等其自己过期,占用更多的空间(意味着需要更多带宽),修改密码后原本的token在没过期时仍然可用访问系统等。

    二、JWT介绍

    1、什么是jwt

    JWT是 Json Web Token 的缩写。它是基于 RFC 7519 标准定义的一种可以安全传输的 小巧 和 自包含 的JSON对象。由于数据是使用数字签名的,所以是可信任的和安全的。JWT可以使用HMAC算法对secret进行加密或者使用RSA的公钥私钥对来进行签名。

    我们来看一下jwt长什么样

    eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJjb2Rlcm15IiwiYXV0aG9yaXRpZXMiOiJhZG1pbiIsImV4cCI6MTU5NjA4MDM5OX0.rfDtzMus50uAFnqMw1tm3c_ZYbmUNkIRqMkeJ0510PAH-RCUWtZkfNPTDYAGVVfDU6jmdEkGyNYvGy3UrNq5pA
    

    JSON Web 令牌以紧凑的形式由三个部分组成,由点分隔,它们包括:

    • 头部
    • 负载
    • 签名

    头部(Header)

    jwt的头部承载两部分信息:

    • 声明类型,这里是jwt
    • 声明加密的算法 通常直接使用 HMAC SHA256

    像这样

    {
      'typ': 'JWT',
      'alg': 'HS256'
    }
    

    载荷(Payload)

    这个部分用来承载要传递的数据,他的默认字段有

    • iss:发行人
    • exp:到期时间
    • sub:主题
    • aud:用户
    • nbf:在此之前不可用
    • iat:发布时间
    • jti:JWT ID用于标识该JWT

    除以上默认字段外,我们还可以自定义私有字段,例如

    {
      "sub": "1234567890",
      "name": "John Doe",
      "admin": true
    }
    

    签名(Signature)

    Signature 部分是对前两部分的签名,防止数据篡改。

    2、JWT工作流程

    • 用户发起登录请求
    • 服务端验证身份,将用户信息,标识等信息打包成jwt token返回给客户端
    • 用户拿到token,携带token发送请求给服务端
    • 服务的验证token是否可用,可用便根据其y业务逻辑返回相应结果。

    3、简单实现

    首先我们在maven中引入以下依赖

            <!--jjwt-->
            <dependency>
                <groupId>io.jsonwebtoken</groupId>
                <artifactId>jjwt</artifactId>
                <version>0.9.1</version>
            </dependency>
    

    新建JwtTest来测试一下

    /**
     * @author codermy
     * @createTime 2020/7/30
     */
    public class JwtTest {
        public static void main(String[] args) {
            String token = Jwts.builder()
                    //用户名
                    .setSubject("codermy")
                    //自定义属性 放入用户拥有请求权限
                    .claim("authorities","admin")
                    // 设置失效时间为1分钟
                    .setExpiration(new Date(System.currentTimeMillis()+1000*60))
                    // 签名算法和密钥
                    .signWith(SignatureAlgorithm.HS512, "java")
                    .compact();
            System.out.println(token);
        }
    

    输出

    eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJjb2Rlcm15IiwiYXV0aG9yaXRpZXMiOiJhZG1pbiIsImV4cCI6MTU5NjA4MDM5OX0.rfDtzMus50uAFnqMw1tm3c_ZYbmUNkIRqMkeJ0510PAH-RCUWtZkfNPTDYAGVVfDU6jmdEkGyNYvGy3UrNq5pA
    

    我们再来解析

        //解析token
            Claims claims = Jwts.parser()
                    .setSigningKey("java")
                    .parseClaimsJws(token)
                    .getBody();
            System.out.println(claims);
            //获取用户名
            String username = claims.getSubject();
            System.out.println("username:"+username);
            //获取权限
            String authority = claims.get("authorities").toString();
            System.out.println("权限:"+authority);
            System.out.println("到期时间:" + claims.getExpiration());
    

    输出

    {sub=codermy, authorities=admin, exp=1596082316}
    username:codermy
    权限:admin
    到期时间:Thu Jul 30 12:11:56 CST 2020
    

    三、整合JWT

    后端实现

    其实jwt本身很好理解,无非就就是一把钥匙,可用打开对应的锁,这不过这把钥匙稍微特殊点,它还带了主人的一些信息。难理解的是要将它符合业务逻辑的整合进框架中。我自己就被绕了好久才明白。

    我这里写了一个Jwt的工具类,用于生成和解析jwt

    /**
     * @author codermy
     * @createTime 2020/7/23
     */
    @Component
    public class JwtUtils {
        private static final String CLAIM_KEY_USERNAME = "sub";
        private static final String CLAIM_KEY_CREATED = "created";
        @Value("${jwt.secret}")
        private String secret;
        @Value("${jwt.expiration}")
        private  Long expiration;
        // 创建token
        public  String generateToken(String username) {
            return Jwts.builder()
                    .signWith(SignatureAlgorithm.HS512, secret)
                    .setSubject(username)
                    .setIssuedAt(new Date())
                    .setExpiration(new Date(System.currentTimeMillis() + expiration * 1000))
                    .compact();
    
        }
        // 从token中获取用户名
        public  String getUserNameFromToken(String token){
            return getTokenBody(token).getSubject();
        }
    
        // 是否已过期
        public  boolean isExpiration(String token){
            return getTokenBody(token).getExpiration().before(new Date());
        }
    
        private  Claims getTokenBody(String token){
            return Jwts.parser()
                    .setSigningKey(secret)
                    .parseClaimsJws(token)
                    .getBody();
        }
    
    }
    
    

    然后我们可以将jwt的一些信息写在yml中,使得可以灵活的配置。application.yml中添加如下配置

    jwt:
      tokenHeader: Authorization #JWT存储的请求头
      secret: my-springsecurity-plus #JWT加解密使用的密钥
      expiration: 604800 #JWT的超期限时间(60*60*24*7)
      tokenHead: 'Bearer ' #JWT负载中拿到开头,空格别忘了
    

    我们照着jwt的工作流程来,首先是登录成功后客户端会返回一个jwt token

    所以我们首先自定义一个MyAuthenticationSuccessHandler继承AuthenticationSuccessHandler,这是登录成功后的处理器

    /**
     * @author codermy
     * @createTime 2020/8/1
     * 登录成功
     */
    @Component
    @Slf4j
    public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
        @Autowired
        private JwtUtils jwtUtils;
        @Value("${jwt.tokenHeader}")
        private String tokenHeader;
        @Value("${jwt.tokenHead}")
        private String tokenHead;
        @Override
        public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
    
            JwtUserDto userDetails = (JwtUserDto)authentication.getPrincipal();//拿到登录用户信息
            String jwtToken = jwtUtils.generateToken(userDetails.getUsername());//生成token
            Result result = Result.ok().message("登录成功").jwt(jwtToken);
            System.out.println(JSON.toJSONString(result));//用于测试
            httpServletResponse.setCharacterEncoding("utf-8");//修改编码格式
            httpServletResponse.setContentType("application/json");
            httpServletResponse.getWriter().write(JSON.toJSONString(result));//输出结果
            httpServletResponse.sendRedirect("/api/admin");//重定向到api/admin页面。我这里路由名取的不是很好
        }
    }
    
    

    然后我们再写一个jwt的拦截器,让每个请求都需要验证jwt token

    /**
     * @author codermy
     * @createTime 2020/7/30
     */
    @Component
    @Slf4j
    public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
        @Autowired
        private UserDetailsServiceImpl userDetailsService;
        @Autowired
        private JwtUtils jwtUtils;
        @Value("${jwt.tokenHeader}")
        private String tokenHeader;
    
        @Value("${jwt.tokenHead}")
        private String tokenHead;
    
        @Override
        protected void doFilterInternal(HttpServletRequest request,
                                        HttpServletResponse response,
                                        FilterChain chain) throws ServletException, IOException {
            String authHeader = request.getHeader(this.tokenHeader);//拿到requset中的head
            if (authHeader != null && authHeader.startsWith(this.tokenHead)) {
                String authToken = authHeader.substring(this.tokenHead.length());// The part after "Bearer "
                String username = jwtUtils.getUserNameFromToken(authToken);//解析token获取用户名
                log.info("checking username:{}", username);
                if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                    UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
                    if (userDetails != null) {//判断是否存在这个给用户
                        UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                        authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                        log.info("authenticated user:{}", username);
                        SecurityContextHolder.getContext().setAuthentication(authentication);
                    }
                }
            }
            chain.doFilter(request, response);
        }
        }
    

    这里为了之后结果更直观,自定义一个AuthenticationEntryPoint,用于在未登录是访问接口返回json而不是login.html

    /**
     * @author codermy
     * @createTime 2020/8/1
     * 当未登录或者token失效访问接口时,自定义的返回结果
     */
    @Component
    public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
        @Override
        public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
            response.setCharacterEncoding("UTF-8");//设置编码格式
            response.setContentType("application/json");
            response.getWriter().println(JSON.toJSONString(Result.error().message("尚未登录,或者登录过期   " + authException.getMessage())));
            response.getWriter().flush();
        }
    }
    

    将上述方法加入到SpringSecurityConfig中

    /**
     * @author codermy
     * @createTime 2020/7/15
     */
    @EnableWebSecurity
    @EnableGlobalMethodSecurity(prePostEnabled = true)
    public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
        @Autowired
        private UserDetailsServiceImpl userDetailsService;
        @Autowired
        private VerifyCodeFilter verifyCodeFilter;
        @Autowired
        MyAuthenticationSuccessHandler authenticationSuccessHandler;
        @Autowired
        private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
        @Autowired
        private RestAuthenticationEntryPoint restAuthenticationEntryPoint;
        @Autowired
        private RestfulAccessDeniedHandler accessDeniedHandler;
        @Bean
        public PasswordEncoder passwordEncoder() {
            return new BCryptPasswordEncoder(12);
        }
    
        /**
         * 身份认证接口
         */
        @Override
        protected void configure(AuthenticationManagerBuilder auth) throws Exception {
            auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
        }
    
    
        @Override
        public void configure(WebSecurity web) throws Exception {
            web.ignoring()
                    .antMatchers(HttpMethod.GET,
                            "/swagger-resources/**",
                            "/PearAdmin/**",
                            "/**/*.html",
                            "/**/*.css",
                            "/**/*.js",
                            "/swagger-ui.html",
                            "/webjars/**",
                            "/v2/**");//放行静态资源
        }
    
        /**
         * anyRequest          |   匹配所有请求路径
         * access              |   SpringEl表达式结果为true时可以访问
         * anonymous           |   匿名可以访问
         * denyAll             |   用户不能访问
         * fullyAuthenticated  |   用户完全认证可以访问(非remember-me下自动登录)
         * hasAnyAuthority     |   如果有参数,参数表示权限,则其中任何一个权限可以访问
         * hasAnyRole          |   如果有参数,参数表示角色,则其中任何一个角色可以访问
         * hasAuthority        |   如果有参数,参数表示权限,则其权限可以访问
         * hasIpAddress        |   如果有参数,参数表示IP地址,如果用户IP和参数匹配,则可以访问
         * hasRole             |   如果有参数,参数表示角色,则其角色可以访问
         * permitAll           |   用户可以任意访问
         * rememberMe          |   允许通过remember-me登录的用户访问
         * authenticated       |   用户登录后可访问
         */
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http.addFilterBefore(verifyCodeFilter, UsernamePasswordAuthenticationFilter.class);
            http.csrf().disable()//关闭csrf
                    .sessionManagement()// 基于token,所以不需要session
                    .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                    .and()
                    .httpBasic().authenticationEntryPoint(restAuthenticationEntryPoint)//未登陆时返回 JSON 格式的数据给前端,否则是html
                    .and()
                    .authorizeRequests()
                    .antMatchers("/captcha").permitAll()//任何人都能访问这个请求
                    .anyRequest().authenticated()
                    .and()
                    .formLogin()
                    .loginPage("/login.html")//登录页面 不设限访问
                    .loginProcessingUrl("/login")//拦截的请求
                    .successHandler(authenticationSuccessHandler) // 登录成功处理器
                    .permitAll()
                    // 防止iframe 造成跨域
                    .and()
                    .headers()
                    .frameOptions()
                    .disable()
                    .and();
    
            // 禁用缓存
            http.headers().cacheControl();
    
            // 添加JWT拦截器
            http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
    
    
    }
    

    我这里直接贴了完整的代码,因为有添加也有删除,不是很好描述,大家对比着之前的来看,都添加了注释。

    现在我们重启项目,用admin账号来登录。登录成功后发现页面并没有跳转到我们想去的页面,但是控制台打印出了我们想要的jwt信息

    {"code":200,"data":[],"jwt":"eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIsImlhdCI6MTU5NjI1OTgyOCwiZXhwIjoxNTk2ODY0NjI4fQ.Khn5t6WjOsuG6R2if1Q_gAeNq-zTamIAO32b1UVc6L8-6_IAHMaCeWr-v7H2-7Hob0SSmmK23dv71_da-YK8hw","msg":"登录成功","success":true}
    

    这是为什么呢?

    着很好理解,因为我们的jwt拦截器已经起了作用,而我们原本的前端页面是没有把jwt token添加在header上的,所以认为没有登录,重定向到了登录页面。

    但是我们现在可以借助postman来测试,postman是一个测试api的工具,大家可以自行百度,这里不做过多介绍。

    在我们未携带jwt token信息时,访问http://localhost:8080/api/menu接口,就会报如下错误

    2.png

    我们在header中添加上,之前登录成功控制台打印的token信息(因为我们添加了图片验证码,所以登录不是很方便用postman,我们可以在浏览器中登录或者先把验证码的拦截器去除)

    3.png

    加上了token信息之后再去访问http://localhost:8080/api/menu接口,发现已经可以正常访问了

    4.png

    我们再尝试用test用户登录后获取到jwt token访问该接口,会报如下错误


    5.png

    修改Swagger配置

    直接贴代码

    /**
     * @author codermy
     * @createTime 2020/7/10
     */
    @Configuration
    @EnableSwagger2
    public class SwaggerConfig {
        @Value("${jwt.tokenHeader}")
        private String tokenHeader;
    
        @Value("${jwt.tokenHead}")
        private String tokenHead;
    
    
        @Bean
        public Docket createRestApi() {
            ParameterBuilder ticketPar = new ParameterBuilder();
            List<Parameter> pars = new ArrayList<>();
            ticketPar.name(tokenHeader).description("token")
                    .modelRef(new ModelRef("string"))
                    .parameterType("header")
                    .defaultValue(tokenHead + " ")
                    .required(true)
                    .build();
            pars.add(ticketPar.build());
            return new Docket(DocumentationType.SWAGGER_2)
                    .apiInfo(webApiInfo())
                    .select()
                    .apis(RequestHandlerSelectors.basePackage("com.codermy.myspringsecurityplus.controller"))
                    .paths(PathSelectors.any())
                    .paths(Predicates.not(PathSelectors.regex("/error.*")))
    
                    .build()
                    .globalOperationParameters(pars);
        }
        /**
         * 该套 API 说明,包含作者、简介、版本、等信息
         * @return
         */
        private ApiInfo webApiInfo(){
            return new ApiInfoBuilder()
                    .title("my-springsecurity-plus-API文档")
                    .description("本文档描述了my-springsecurity-plus接口定义")
                    .version("1.0.5")
                    .build();
        }
    
    }
    

    现在再swagger中就可以添加token测试了


    6.png

    前端适配

    那么我们现在已经简单的实现了jwt的无状态登录功能,需要做的就是让前端的请求都带上jwt token。

    。。。研究了半天没弄懂,所以暂时先搁置,下一章解决它。有知道怎么设置请求头的小伙伴也可以留言告诉我

    所以本章结束的代码是不能正常在浏览器运行的,但是可以在postman和swagger中测试(如果想运行,在SpringSecurityConfig中添加上.rememberMe()即可)

    giteegithub中可获取源代码,与本系列文章同步更新

    相关文章

      网友评论

        本文标题:SpringSecurity权限管理系统实战—六、SpringS

        本文链接:https://www.haomeiwen.com/subject/kyhgrktx.html