来源 https://juejin.im/post/5cfa0933f265da1b8f1ab2da
JWT: 带签名的tocken用来做用户和权限的验证。替代session,因为现在前后端分离,涉及到跨域攻击。再者app端对session不友好。集群环境session共享问题
用户登录-》服务端生成tocken给客户端->客户端访问都携带tocken->服务端验证token->访问API
JWT的组成
JWT token的格式:header.payload.signature
- header(头),保存算法,类型: {"alg": "HS512"}
- payload(负载),用户的信息,如用户名,创建时间,过期时间 {"sub":"admin","created":1489079981393,"exp":1489684781}
- signature(签名),将生成的token编码(加密)//secret为加密算法的密钥 String signature = HMACSHA512(base64UrlEncode(header) + "." +base64UrlEncode(payload),secret)
一。代码
1.pom.xml
<!--SpringSecurity依赖配置--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <!--Hutool Java工具包--> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>4.5.7</version> </dependency> <!--JWT(Json Web Token)登录支持--> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.0</version> </dependency>
application.yml
# 自定义jwt key
jwt:
tokenHeader: Authorization #JWT存储的请求头
secret: mySecret #JWT加解密使用的密钥
expiration: 604800 #JWT的超期限时间(60*60*24)
tokenHead: Bearer #JWT负载中拿到开头
存储的请求头是: Bearer xjdkfjalkjdflkajlkdfjlka
2.jwt工具类
package com.yy.mallTiny.common.utils; import com.yy.mallTiny.service.RedisService; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Component; import java.util.Date; import java.util.HashMap; import java.util.Map; @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; RedisService redisService; /** * 根据负责生成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); } }
3.配置security框架拦截的url,过滤器等
package com.yy.mallTiny.config; import com.yy.mallTiny.component.JwtAuthenticationTokenFilter; import com.yy.mallTiny.component.RestAuthenticationEntryPoint; import com.yy.mallTiny.component.RestfulAccessDeniedHandler; import com.yy.mallTiny.dto.AdminUserDetails; import com.yy.mallTiny.model.UmsAdmin; import com.yy.mallTiny.model.UmsPermission; import com.yy.mallTiny.service.UmsAdminService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import java.util.List; @Configuration @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled=true) public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private UmsAdminService adminService; @Autowired private RestfulAccessDeniedHandler restfulAccessDeniedHandler; @Autowired private RestAuthenticationEntryPoint restAuthenticationEntryPoint; @Override /** * 配置拦截的URL,过滤器及security基本配置 */ protected void configure(HttpSecurity httpSecurity) throws Exception { httpSecurity.csrf()// 由于使用的是JWT,我们这里不需要csrf,不使用session .disable() .sessionManagement()// 基于token,所以不需要session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers(HttpMethod.GET, // 允许对于网站静态资源的无授权访问 "/", "/*.html", "/favicon.ico", "/**/*.html", "/**/*.css", "/**/*.js", "/swagger-resources/**", "/v2/api-docs/**" ) .permitAll() .antMatchers("/admin/login", "/admin/register")// 对登录注册要允许匿名访问 .permitAll() .antMatchers(HttpMethod.OPTIONS)//跨域请求会先进行一次options请求 .permitAll() // .antMatchers("/**")//测试时全部运行访问 // .permitAll() .anyRequest()// 除上面外的所有请求全部需要鉴权认证 .authenticated(); // 禁用缓存 httpSecurity.headers().cacheControl(); // 添加JWT filter httpSecurity.addFilterBefore(jwtAuthenticationTokenFilter(), UsernamePasswordAuthenticationFilter.class); //添加自定义未授权和未登录结果返回 httpSecurity.exceptionHandling() .accessDeniedHandler(restfulAccessDeniedHandler) .authenticationEntryPoint(restAuthenticationEntryPoint); } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailsService()) .passwordEncoder(passwordEncoder()); } /** * 密码加密 * @return */ @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } /** * 获取登录用户信息,实现 loadUserByUsername方法 * @return */ @Bean public UserDetailsService userDetailsService() { return username -> { UmsAdmin admin = adminService.getAdminByUsername(username); if (admin != null) { List<UmsPermission> permissionList = adminService.getPermissionList(admin.getId()); return new AdminUserDetails(admin,permissionList); } throw new UsernameNotFoundException("用户名或密码错误"); }; } @Bean public JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter(){ return new JwtAuthenticationTokenFilter(); } @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } }
4. AdminUserDetails.java SpringSecurity需要的用户详情,用户名,密码,所含权限等
package com.yy.mallTiny.dto; import com.yy.mallTiny.model.UmsAdmin; import com.yy.mallTiny.model.UmsPermission; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import java.util.Collection; import java.util.List; import java.util.stream.Collectors; /** * SpringSecurity需要的用户详情 * SpringSecurity定义用于封装用户信息的类(主要是用户信息和权限),需要自行实现; */ public class AdminUserDetails implements UserDetails { private UmsAdmin umsAdmin; private List<UmsPermission> permissionList; public AdminUserDetails(UmsAdmin umsAdmin, List<UmsPermission> permissionList) { this.umsAdmin = umsAdmin; this.permissionList = permissionList; } /** * 返回当前用户的权限 * @return */ @Override public Collection<? extends GrantedAuthority> getAuthorities() { return permissionList.stream() .filter(permission->permission.getValue()!=null) .map(permission-> new SimpleGrantedAuthority(permission.getValue())) .collect(Collectors.toList()); } @Override public String getPassword() { return umsAdmin.getPassword(); } @Override public String getUsername() { return umsAdmin.getUsername(); } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return umsAdmin.getStatus().equals(1); } }
5.登录实现,controller中进行验证并保存context,后面filter直接通过了
@ApiOperation("用户登录") @RequestMapping(value = "/login", method = RequestMethod.POST) public CommonResult login(@RequestBody UmsAdmin umsAdminParam){ String tocken = adminService.login(umsAdminParam.getUsername(), umsAdminParam.getPassword()); if(tocken == null){ return CommonResult.error("登录失败"); } Map<String, String> tockenMap = new HashMap<String, String>(); tockenMap.put("tocken", tocken); tockenMap.put("tockenHead", tockenHead); return CommonResult.success(tockenMap, "登录成功"); }
adminService.login
@Override public String login(String username, String password){ String tocken = null; try{ // 获取用户信息 UserDetails userDetails = userDetailsService.loadUserByUsername(username); // 校验用户名密码 if(!passwordEncoder.matches(password, userDetails.getPassword())){ throw new BadCredentialsException("密码错误"); } // 校验成功保存信息至context UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authenticationToken); tocken = jwtTokenUtil.generateToken(userDetails); } catch(Exception e){ logger.error("登录失败",e); } return tocken; }
6. 登录后再访问header携带token, 走 JWT授权过滤器。验证tocken并保存至context,后面filter就通过了
package com.yy.mallTiny.component; import com.yy.mallTiny.common.utils.JwtTokenUtil; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.web.filter.OncePerRequestFilter; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; /** * JWT登陆授权过滤器 * 在用户名和密码校验前添加的过滤器,如果请求中有jwt的token且有效, * 会取出token中的用户名,然后调用SpringSecurity的API进行登录操作。 */ public class JwtAuthenticationTokenFilter extends OncePerRequestFilter { @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 filterChain) throws ServletException, IOException { // 获取header中叫Authorization的头,值为JWT的tocken String authHeader = request.getHeader(this.tokenHeader); if(authHeader!=null && authHeader.startsWith(this.tokenHead)){ // 截取tocken String authTocken = authHeader.substring(this.tokenHead.length()); // 解码获取用户名 String username = jwtTokenUtil.getUserNameFromToken(authTocken); // 每次线程结束都会清空context中的authentication if(username!=null && SecurityContextHolder.getContext().getAuthentication() == null){ UserDetails userDetails = userDetailsService.loadUserByUsername(username); if(jwtTokenUtil.validateToken(authTocken, userDetails)){ UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } } filterChain.doFilter(request, response); } }
7.未登录或tocken失效返回
package com.yy.mallTiny.component; import cn.hutool.json.JSONUtil; import com.yy.mallTiny.common.CommonResult; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.stereotype.Component; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; /** * 当未登录或token失效时,返回JSON格式的结果 */ @Component public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException { response.setCharacterEncoding("UTF-8"); response.setContentType("application/json"); response.getWriter().println(JSONUtil.parse(CommonResult.authException(e.getMessage()))); response.getWriter().flush(); } }
8.未授权返回
package com.yy.mallTiny.component; import cn.hutool.json.JSONUtil; import com.yy.mallTiny.common.CommonResult; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.stereotype.Component; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; /** * 用户没有访问权限时的处理器,返回JSON格式的处理结果 */ @Component public class RestfulAccessDeniedHandler implements AccessDeniedHandler { @Override public void handle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AccessDeniedException e) throws IOException, ServletException { httpServletResponse.setCharacterEncoding("UTF-8"); httpServletResponse.setContentType("application/json"); httpServletResponse.getWriter().println(JSONUtil.parse(CommonResult.forbidden(e.getMessage()))); httpServletResponse.getWriter().flush(); } }
9.接口权限控制
hasAuthority会校验conext中保存的userDetails中的权限是否包含该权限
@ApiOperation("分页查询品牌列表") @RequestMapping(value = "page", method = RequestMethod.GET) @ResponseBody @PreAuthorize("hasAuthority('pms:brand:read')") public CommonResult<CommonPage<PmsBrand>> getPageBrand(int pageNum, int pageSize){ PageInfo<PmsBrand> pageInfo = brandService.listBrand(pageNum, pageSize); return CommonResult.success(CommonPage.restPage(pageInfo), "success"); }
9.swagger增加Authentication头
package com.yy.mallTiny.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import springfox.documentation.builders.ApiInfoBuilder; import springfox.documentation.builders.PathSelectors; import springfox.documentation.builders.RequestHandlerSelectors; import springfox.documentation.service.ApiInfo; import springfox.documentation.service.ApiKey; import springfox.documentation.service.AuthorizationScope; import springfox.documentation.service.SecurityReference; import springfox.documentation.spi.DocumentationType; import springfox.documentation.spi.service.contexts.SecurityContext; import springfox.documentation.spring.web.plugins.Docket; import springfox.documentation.swagger2.annotations.EnableSwagger2; import java.util.ArrayList; import java.util.List; /** * Swagger2API文档的配置 */ @Configuration @EnableSwagger2 public class Swagger2Config { @Bean public Docket createRestApi(){ return new Docket(DocumentationType.SWAGGER_2) .apiInfo(apiInfo()) .select() //为当前包下controller生成API文档 .apis(RequestHandlerSelectors.basePackage("com.yy.mallTiny.controller")) //为有@Api注解的Controller生成API文档 // .apis(RequestHandlerSelectors.withClassAnnotation(Api.class)) //为有@ApiOperation注解的方法生成API文档 // .apis(RequestHandlerSelectors.withMethodAnnotation(ApiOperation.class)) .paths(PathSelectors.any()) .build() //通过修改配置实现调用接口自带Authorization头,这样就可以访问需要登录的接口了。 .securitySchemes(securitySchemes()) .securityContexts(securityContexts()); } private ApiInfo apiInfo() { return new ApiInfoBuilder() .title("SwaggerUI演示") .description("mall-tiny") //.contact("macro") .version("1.0") .build(); } private List<ApiKey> securitySchemes() { //设置请求头信息 List<ApiKey> 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; } }
二。总结
截图显示security框架走过的11个filter
这里主要关注JwtAuthenticationTockenFilter 做授权认证。 服务端不保存登录状态,根据tocken信息来获取用户信息再保存至context。
问题1. 如何登出?? 看下面文字是将securit即tocken的加密密钥每次动态生成,并保存至redis,登出即删除
https://www.jianshu.com/p/d5ce890c67f7 ------------jwt+security好文章
2.UsernamePasswordAuthenticationFilter不见了?