Appearance
主要是整合SpringSecurity和JWT实现后台用户的认证和授权功能,同时改造Swagger的配置使其可以自动记住登录令牌进行发送。
SpringSecurity简介
SpringSecurity是一个强大的可高度定制的认证和授权框架,对于Spring应用来说它是一套Web安全标准。
SpringSecurity注重于为Java应用提供认证和授权功能,像所有的Spring项目一样,它对自定义需求具有强大的扩展性。
JWT入门
JWT是JSON WEB TOKEN的缩写,它是基于 RFC 7519 标准定义的一种可以安全传输的的JSON对象,由于使用了数字签名,所以是可信任和安全的。
JWT的组成
JWT token的格式:
header.payload.signatureheader中用于存放签名的生成算法:
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/

JWT实现认证和授权的原理
- 用户调用登录接口,登录成功后获取到JWT的token;
- 之后用户每次调用接口都在http的header中添加一个叫Authorization的头,值为JWT的token;
- 后台程序通过对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):用于根据登录用户信息生成tokengetUserNameFromToken(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文件,添加如下路径配置:
yamlsecure: 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)); } }
认证与授权流程演示
- 运行项目,访问Swagger API文档,访问地址:http://localhost:8080/swagger-ui/
- 直接访问需要登录认证的接口,将返回未登录信息;
- 此时访问登录接口,输入账号密码
admin:123456进行token获取; - 然后点击右上角Authorize按钮,输入tokenHead token组合后的token;

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