Skip to content

mall整合SpringSecurity和JWT实现认证和授权

主要是整合SpringSecurity和JWT实现后台用户的认证和授权功能,同时改造Swagger的配置使其可以自动记住登录令牌进行发送。

SpringSecurity简介

SpringSecurity是一个强大的可高度定制的认证和授权框架,对于Spring应用来说它是一套Web安全标准。

SpringSecurity注重于为Java应用提供认证和授权功能,像所有的Spring项目一样,它对自定义需求具有强大的扩展性。

JWT入门

JWT是JSON WEB TOKEN的缩写,它是基于 RFC 7519 标准定义的一种可以安全传输的的JSON对象,由于使用了数字签名,所以是可信任和安全的。

JWT的组成

  • JWT token的格式:

    header.payload.signature
  • header中用于存放签名的生成算法:

    json
    {"alg": "HS512"}
  • payload中用于存放用户名、token的生成时间和过期时间:

    json
    {"sub":"admin","created":1489079981393,"exp":1489684781}
  • signature为以header和payload生成的签名,一旦header和payload被篡改,验证将失败。

    java
    //secret为加密算法的密钥
    String signature = HMACSHA512(base64UrlEncode(header) + "." +base64UrlEncode(payload),secret)

JWT实例

  • 这是一个JWT的字符串的实例:

    eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIsImNyZWF0ZWQiOjE1NTY3NzkxMjUzMDksImV4cCI6MTU1NzM4MzkyNX0.d-iki0193X0bBOETf2UN3r3PotNIEAV7mzIxxeI5IxFyzzkOZxS0PGfF_SK6wxCv2K8S0cZjMkv6b5bCqc0VBw
  • 可以在该网站上获得解析结果:https://jwt.io/

image.png

JWT实现认证和授权的原理

  1. 用户调用登录接口,登录成功后获取到JWT的token;
  2. 之后用户每次调用接口都在http的header中添加一个叫Authorization的头,值为JWT的token;
  3. 后台程序通过对Authorization头中信息的解码及数字签名校验来获取其中的用户信息,从而实现认证和授权。

整合SpringSecurity和JWT

整合JWT

  • 在pom.xml中添加相关依赖:

    xml
    <dependencies>
        <!--SpringSecurity依赖配置-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <!--JWT(Json Web Token)登录支持-->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>${jjwt.version}</version>
        </dependency>
    </dependencies>
  • 修改配置文件application.yml,添加JWT相关配置:

    yaml
    # 自定义jwt key
    jwt:
      tokenHeader: Authorization #JWT存储的请求头
      secret: mySecret #JWT加解密使用的密钥
      expiration: 604800 #JWT的超期限时间(60*60*24)
      tokenHead: Bearer  #JWT负载中拿到开头
  • 添加JWT token的工具类,用于生成和解析JWT token的工具类:

    java
    /**
     * @auther macrozheng
     * @description JwtToken生成的工具类
     * JWT token的格式:header.payload.signature
     * header的格式(算法、token的类型):
     * {"alg": "HS512","typ": "JWT"}
     * payload的格式(用户名、创建时间、生成时间):
     * {"sub":"wang","created":1489079981393,"exp":1489684781}
     * signature的生成算法:
     * HMACSHA512(base64UrlEncode(header) + "." +base64UrlEncode(payload),secret)
     * @date 2018/4/26
     * @github https://github.com/macrozheng
     */
    @Component
    public class JwtTokenUtil {
        private static final Logger LOGGER = LoggerFactory.getLogger(JwtTokenUtil.class);
        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;
    
        /**
         * 根据负载生成JWT的token
         */
        private String generateToken(Map<String, Object> claims) {
            return Jwts.builder()
                    .setClaims(claims)
                    .setExpiration(generateExpirationDate())
                    .signWith(SignatureAlgorithm.HS512, secret)
                    .compact();
        }
    
        /**
         * 从token中获取JWT中的负载
         */
        private Claims getClaimsFromToken(String token) {
            Claims claims = null;
            try {
                claims = Jwts.parser()
                        .setSigningKey(secret)
                        .parseClaimsJws(token)
                        .getBody();
            } catch (Exception e) {
                LOGGER.info("JWT格式验证失败:{}",token);
            }
            return claims;
        }
    
        /**
         * 生成token的过期时间
         */
        private Date generateExpirationDate() {
            return new Date(System.currentTimeMillis() + expiration * 1000);
        }
    
        /**
         * 从token中获取登录用户名
         */
        public String getUserNameFromToken(String token) {
            String username;
            try {
                Claims claims = getClaimsFromToken(token);
                username =  claims.getSubject();
            } catch (Exception e) {
                username = null;
            }
            return username;
        }
    
        /**
         * 验证token是否还有效
         *
         * @param token       客户端传入的token
         * @param userDetails 从数据库中查询出来的用户信息
         */
        public boolean validateToken(String token, UserDetails userDetails) {
            String username = getUserNameFromToken(token);
            return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
        }
    
        /**
         * 判断token是否已经失效
         */
        private boolean isTokenExpired(String token) {
            Date expiredDate = getExpiredDateFromToken(token);
            return expiredDate.before(new Date());
        }
    
        /**
         * 从token中获取过期时间
         */
        private Date getExpiredDateFromToken(String token) {
            Claims claims = getClaimsFromToken(token);
            return claims.getExpiration();
        }
    
        /**
         * 根据用户信息生成token
         */
        public String generateToken(UserDetails userDetails) {
            Map<String, Object> claims = new HashMap<>();
            claims.put(CLAIM_KEY_USERNAME, userDetails.getUsername());
            claims.put(CLAIM_KEY_CREATED, new Date());
            return generateToken(claims);
        }
    
        /**
         * 判断token是否可以被刷新
         */
        public boolean canRefresh(String token) {
            return !isTokenExpired(token);
        }
    
        /**
         * 刷新token
         */
        public String refreshToken(String token) {
            Claims claims = getClaimsFromToken(token);
            claims.put(CLAIM_KEY_CREATED, new Date());
            return generateToken(claims);
        }
    }
  • 几个常用方法说明如下:

    • generateToken(UserDetails userDetails) :用于根据登录用户信息生成token
    • getUserNameFromToken(String token):从token中获取登录用户的信息
    • validateToken(String token, UserDetails userDetails):判断token是否还有效

整合SpringSecurity

  • 添加SpringSecurity的配置类:

    java
    /**
     * @auther macrozheng
     * @description SpringSecurity的配置
     * @date 2018/4/26
     * @github https://github.com/macrozheng
     */
    @Configuration
    @EnableWebSecurity
    @EnableGlobalMethodSecurity(prePostEnabled=true)
    public class SecurityConfig {
        @Autowired
        private RestfulAccessDeniedHandler restfulAccessDeniedHandler;
        @Autowired
        private RestAuthenticationEntryPoint restAuthenticationEntryPoint;
        @Autowired
        private IgnoreUrlsConfig ignoreUrlsConfig;
    
        @Bean
        SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
            ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry = httpSecurity
                    .authorizeRequests();
            //不需要保护的资源路径允许访问
            for (String url : ignoreUrlsConfig.getUrls()) {
                registry.antMatchers(url).permitAll();
            }
            //允许跨域请求的OPTIONS请求
            registry.antMatchers(HttpMethod.OPTIONS)
                    .permitAll();
            httpSecurity.csrf()// 由于使用的是JWT,我们这里不需要csrf
                    .disable()
                    .sessionManagement()// 基于token,所以不需要session
                    .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                    .and()
                    .authorizeRequests()
                    .anyRequest()// 除上面外的所有请求全部需要鉴权认证
                    .authenticated();
            // 禁用缓存
            httpSecurity.headers().cacheControl();
            // 添加JWT filter
            httpSecurity.addFilterBefore(jwtAuthenticationTokenFilter(), UsernamePasswordAuthenticationFilter.class);
            //添加自定义未授权和未登录结果返回
            httpSecurity.exceptionHandling()
                    .accessDeniedHandler(restfulAccessDeniedHandler)
                    .authenticationEntryPoint(restAuthenticationEntryPoint);
            return httpSecurity.build();
        }
    
        @Bean
        public PasswordEncoder passwordEncoder() {
            return new BCryptPasswordEncoder();
        }
    
        @Bean
        public JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter(){
            return new JwtAuthenticationTokenFilter();
        }
    }
  • 配置中相关方法及依赖说明如下:

    • filterChain(HttpSecurity httpSecurity):用于配置SecurityFilterChain实例,SpringSecurity的核心配置类,可以SpringSecurity进行路径授权配置、过滤器配置等;
    • RestfulAccessDeniedHandler:当用户没有访问权限时的处理器,用于返回JSON格式的处理结果;
    • RestAuthenticationEntryPoint:当未登录或token失效时,返回JSON格式的结果;
    • UserDetailsService:SpringSecurity定义的核心接口,用于根据用户名获取用户信息,需要自行实现;
    • UserDetails:SpringSecurity定义用于封装用户信息的类(主要是用户信息和权限),需要自行实现;
    • PasswordEncoder:SpringSecurity定义的用于对密码进行编码及比对的接口,目前使用的是BCryptPasswordEncoder;
    • JwtAuthenticationTokenFilter:在用户名和密码校验前添加的过滤器,如果有jwt的token,会自行根据token信息进行登录。
  • 添加RestfulAccessDeniedHandler:

    java
    /**
     * @auther macrozheng
     * @description 当访问接口没有权限时,自定义的返回结果
     * @date 2018/4/26
     * @github https://github.com/macrozheng
     */
    @Component
    public class RestfulAccessDeniedHandler implements AccessDeniedHandler{
        @Override
        public void handle(HttpServletRequest request,
                           HttpServletResponse response,
                           AccessDeniedException e) throws IOException, ServletException {
            response.setCharacterEncoding("UTF-8");
            response.setContentType("application/json");
            response.getWriter().println(JSONUtil.parse(CommonResult.forbidden(e.getMessage())));
            response.getWriter().flush();
        }
    }
  • 添加RestAuthenticationEntryPoint:

java

  /**
   * @auther macrozheng
   * @description 当未登录或者token失效访问接口时,自定义的返回结果
   * @date 2018/5/14
   * @github https://github.com/macrozheng
   */
  @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(JSONUtil.parse(CommonResult.unauthorized(authException.getMessage())));
          response.getWriter().flush();
      }
  }
  • 添加AdminUserDetails:

    java
    /**
     * @auther macrozheng
     * @description SpringSecurity用户信息封装类
     * @date 2020/10/15
     * @github https://github.com/macrozheng
     */
    @Data
    @EqualsAndHashCode(callSuper = false)
    @Builder
    public class AdminUserDetails implements UserDetails {
        private String username;
        private String password;
        private List<String> authorityList;
        @Override
        public Collection<? extends GrantedAuthority> getAuthorities() {
            return this.authorityList.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
        }
    
        @Override
        public String getPassword() {
            return this.password;
        }
    
        @Override
        public String getUsername() {
            return this.username;
        }
    
        @Override
        public boolean isAccountNonExpired() {
            return true;
        }
    
        @Override
        public boolean isAccountNonLocked() {
            return true;
        }
    
        @Override
        public boolean isCredentialsNonExpired() {
            return true;
        }
    
        @Override
        public boolean isEnabled() {
            return true;
        }
    }
  • 添加JwtAuthenticationTokenFilter,在用户名和密码校验前添加的过滤器,如果请求中有jwt的token且有效,会取出token中的用户名,然后调用SpringSecurity的API进行登录操作:

    java
    /**
     * @auther macrozheng
     * @description JWT登录授权过滤器
     * @date 2018/4/26
     * @github https://github.com/macrozheng
     */
    public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
        private static final Logger LOGGER = LoggerFactory.getLogger(JwtAuthenticationTokenFilter.class);
        @Autowired
        private UserDetailsService userDetailsService;
        @Autowired
        private JwtTokenUtil jwtTokenUtil;
        @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);
            if (authHeader != null && authHeader.startsWith(this.tokenHead)) {
                String authToken = authHeader.substring(this.tokenHead.length());// The part after "Bearer "
                String username = jwtTokenUtil.getUserNameFromToken(authToken);
                LOGGER.info("checking username:{}", username);
                if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                    UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
                    if (jwtTokenUtil.validateToken(authToken, userDetails)) {
                        UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                        authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                        LOGGER.info("authenticated user:{}", username);
                        SecurityContextHolder.getContext().setAuthentication(authentication);
                    }
                }
            }
            chain.doFilter(request, response);
        }
    }
  • 添加IgnoreUrlsConfig,进行白名单路径配置,符合的路径不会拦截:

    java
    /**
     * @auther macrozheng
     * @description 用于配置白名单资源路径
     * @date 2018/11/5
     * @github https://github.com/macrozheng
     */
    @Getter
    @Setter
    @Configuration
    @ConfigurationProperties(prefix = "secure.ignored")
    public class IgnoreUrlsConfig {
    
        private List<String> urls = new ArrayList<>();
    
    }
  • 还需修改application.yml文件,添加如下路径配置:

    yaml
    secure:
      ignored:
        urls: #安全路径白名单
          - /swagger-ui/
          - /swagger-resources/**
          - /**/v2/api-docs
          - /**/*.html
          - /**/*.js
          - /**/*.css
          - /**/*.png
          - /favicon.ico
          - /actuator/**
          - /druid/**
          - /admin/**
  • 添加自定义权限配置,配置好获取用户信息的服务:

    java
    /**
     * @auther macrozheng
     * @description 自定义配置,用于配置如何获取用户信息
     * @date 2022/5/20
     * @github https://github.com/macrozheng
     */
    @Configuration
    public class MallSecurityConfig {
    
        @Autowired
        private UmsAdminService adminService;
    
        @Bean
        public UserDetailsService userDetailsService() {
            //获取登录用户信息
            return username -> {
                AdminUserDetails admin = adminService.getAdminByUsername(username);
                if (admin != null) {
                    return admin;
                }
                throw new UsernameNotFoundException("用户名或密码错误");
            };
        }
    }

Swagger安全配置

想要让Swagger发送认证请求头,还需进行如下配置。

java
/**
 * @auther macrozheng
 * @description Swagger文档的配置(带认证)
 * @date 2022/11/22
 * @github https://github.com/macrozheng
 */
@Configuration
public class Swagger2Config {
    @Bean
    public Docket createRestApi() {
        return new Docket(DocumentationType.SWAGGER_2)
                .apiInfo(apiInfo())
                .select()
                .apis(RequestHandlerSelectors.basePackage("com.macro.mall.tiny.controller"))
                .paths(PathSelectors.any())
                .build()
                //添加登录认证
                .securitySchemes(securitySchemes())
                .securityContexts(securityContexts());
    }
    
    private List<SecurityScheme> securitySchemes() {
        //设置请求头信息
        List<SecurityScheme> result = new ArrayList<>();
        ApiKey apiKey = new ApiKey("Authorization", "Authorization", "header");
        result.add(apiKey);
        return result;
    }

    private List<SecurityContext> securityContexts() {
        //设置需要登录认证的路径
        List<SecurityContext> result = new ArrayList<>();
        result.add(getContextByPath("/brand/.*"));
        return result;
    }

    private SecurityContext getContextByPath(String pathRegex) {
        return SecurityContext.builder()
                .securityReferences(defaultAuth())
                .forPaths(PathSelectors.regex(pathRegex))
                .build();
    }

    private List<SecurityReference> defaultAuth() {
        List<SecurityReference> result = new ArrayList<>();
        AuthorizationScope authorizationScope = new AuthorizationScope("global", "accessEverything");
        AuthorizationScope[] authorizationScopes = new AuthorizationScope[1];
        authorizationScopes[0] = authorizationScope;
        result.add(new SecurityReference("Authorization", authorizationScopes));
        return result;
    }
}

接口权限配置

  • 给PmsBrandController接口中的方法添加访问权限的配置:

    • 给查询接口添加brand:list权限;
    • 给查询全部接口添加brand:listAll权限;
    • 给修改接口添加brand:update权限;
    • 给删除接口添加brand:delete权限;
    • 给添加接口添加brand:create权限。
  • 具体配置代码如下:

    java
    /**
     * @auther macrozheng
     * @description 品牌管理Controller
     * @date 2019/4/19
     * @github https://github.com/macrozheng
     */
    @Controller
    @Api(tags = "PmsBrandController")
    @Tag(name = "PmsBrandController", description = "商品品牌管理")
    @RequestMapping("/brand")
    public class PmsBrandController {
        @ApiOperation("分页查询品牌列表")
        @RequestMapping(value = "/list", method = RequestMethod.GET)
        @ResponseBody
        @PreAuthorize("hasAuthority('brand:list')")
        public CommonResult<CommonPage<PmsBrand>> listBrand(@RequestParam(value = "pageNum", defaultValue = "1")
                                                            @ApiParam("页码") Integer pageNum,
                                                            @RequestParam(value = "pageSize", defaultValue = "3")
                                                            @ApiParam("每页数量") Integer pageSize) {
            List<PmsBrand> brandList = brandService.listBrand(pageNum, pageSize);
            return CommonResult.success(CommonPage.restPage(brandList));
        }
    }

认证与授权流程演示

  1. 运行项目,访问Swagger API文档,访问地址:http://localhost:8080/swagger-ui/
  2. 直接访问需要登录认证的接口,将返回未登录信息;
  3. 此时访问登录接口,输入账号密码admin:123456进行token获取;
  4. 然后点击右上角Authorize按钮,输入tokenHead token组合后的token;

image.png

  1. 接下来访问需要认证的接口,就可以正常访问了;
  2. 然后使用macro:123456进行访问,该账号只有访问/brand/listAll接口的权限,访问下其他接口会返回没有相关权限的信息。