SpringSecurity权限管理系统实战—六、SpringSecurity整合JWT
目录
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
进入正题
一、无状态登录
-
有状态登录
我们知道在原始的项目中我们是通过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接口,就会报如下错误
我们在header中添加上,之前登录成功控制台打印的token信息(因为我们添加了图片验证码,所以登录不是很方便用postman,我们可以在浏览器中登录或者先把验证码的拦截器去除)
加上了token信息之后再去访问http://localhost:8080/api/menu接口,发现已经可以正常访问了
我们再尝试用test用户登录后获取到jwt token访问该接口,会报如下错误
修改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测试了
前端适配
那么我们现在已经简单的实现了jwt的无状态登录功能,需要做的就是让前端的请求都带上jwt token。
。。。研究了半天没弄懂,所以暂时先搁置,下一章解决它。有知道怎么设置请求头的小伙伴也可以留言告诉我
所以本章结束的代码是不能正常在浏览器运行的,但是可以在postman和swagger中测试(如果想运行,在SpringSecurityConfig中添加上.rememberMe()即可)