spring boot整合jwt 实现前后端分离登录认证及授权
一丶 基本介绍
前后端分离的认证及授权有两种方式,
第一种是使用jwt 也就是(Json Web Token),客户端请求服务端,完成账号密码的认证以后,由服务端生成一个带有过期时间的token,返回给客户端,后续每次请求客户端都要带上这个token,服务端从请求中拿到token 进行解析 判断是否过期,然后构建spring security的安全对象,交由spring security框架进行后续的认证等处理.这种方式相比于传统的session方式不同,是无状态的,服务端没有保存和每个客户端对应的session对象,而是由客户端每次请求带上token,服务端进行解析 来判断客户端的身份,这相比传统方式对服务端的压力非常小,不需要保存和每个客户端对应的session对象,而且由于前后端分离,后端更加倾向于提供接口,很多业务逻辑前移,后端只需要认证请求的身份,提供好对应的接口,剩下的权限控制,跳转页面等就交由前端实现.
第二种 就是spring cloud的OAuth2认证方式,我这里没有去研究,所以就不细说了.
我这里也不过多介绍jwt了 百度相关的文章很多,我就直接介绍spring boot怎么整合jwt实现登录认证及授权
首先贴出maven依赖
1 <!-- jwt依赖 -->
<dependency> 2 <groupId>io.jsonwebtoken</groupId> 3 <artifactId>jjwt</artifactId> 4 <version>0.9.0</version> 5 </dependency>
<!--spring security的依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
暂时就引入这两个依赖吧,一个是jwt,另外一个呢是spring security的依赖
二丶 代码实现
操作jwt生成token有一个现成的工具类,已经写好了常用方法,比如根据用户名生成token,计算token过期时间等方法,我这里先把这个工具类贴上来
1 import io.jsonwebtoken.Claims; 2 import io.jsonwebtoken.Jwts; 3 import io.jsonwebtoken.SignatureAlgorithm; 4 import org.springframework.beans.factory.annotation.Value; 5 import org.springframework.security.core.userdetails.UserDetails; 6 import org.springframework.stereotype.Component; 7 8 import java.io.Serializable; 9 import java.util.Date; 10 import java.util.HashMap; 11 import java.util.Map; 12 import java.util.function.Function; 13 14 /** 15 * @Description: JwtTokenUtil,JWT工具类,生成/验证/是否过期token 。 16 * @Author: Tan 17 * @CreateDate: 2019/12/2 18 **/ 19 @Component 20 public class JwtTokenUtil implements Serializable { 21 private static final long serialVersionUID = -2550185165626007488L; 22 23 //token有效期 24 @Value("${jwt.validity}") 25 private Long tokenValidity; 26 27 28 //加密秘钥 29 @Value("${jwt.secret}") 30 private String secret; 31 32 //通过token返回用户名 33 public String getUsernameFromToken(String token) { 34 return getClaimFromToken(token, Claims::getSubject); 35 } 36 37 //通过token得到token过期时间 38 public Date getExpirationDateFromToken(String token) { 39 return getClaimFromToken(token, Claims::getExpiration); 40 } 41 42 //从token中获得用户信息 43 public <T> T getClaimFromToken(String token, Function<Claims, T> claimsResolver) { 44 final Claims claims = getAllClaimsFromToken(token); 45 return claimsResolver.apply(claims); 46 } 47 48 //从token中解密 获得用户信息 49 private Claims getAllClaimsFromToken(String token) { 50 return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody(); 51 } 52 53 //验证token是否过期 54 private Boolean isTokenExpired(String token) { 55 final Date expiration = getExpirationDateFromToken(token); 56 return expiration.before(new Date()); 57 } 58 59 //根据用户生成token 60 public String generateToken(UserDetails userDetails) { 61 Map<String, Object> claims = new HashMap<>(); 62 return doGenerateToken(claims, userDetails.getUsername()); 63 } 64 65 //生成token 66 private String doGenerateToken(Map<String, Object> claims, String subject) { 67 return Jwts.builder().setClaims(claims).setSubject(subject).setIssuedAt(new Date(System.currentTimeMillis())) 68 .setExpiration(new Date(System.currentTimeMillis() + (tokenValidity * 1000))) 69 .signWith(SignatureAlgorithm.HS512, secret).compact(); 70 } 71 72 //验证token 73 public Boolean validateToken(String token, UserDetails userDetails) { 74 final String userName = getUsernameFromToken(token); 75 return (userName.equals(userDetails.getUsername()) && !isTokenExpired(token)); 76 } 77 78 }
tokenValidity这个token有效期和secret加密秘钥,这两个变量是通过读取spring boot的application.yml配置文件中定义的,在spring Ioc容器实例化这个类的实例的时候就会从配置文件中读取,这样不写死,也方便后续修改
到这里关于jwt的代码实现其实已经结束了,已经可以生成token,和验证token了,接下来就是关于spring security的配置部分了,其实spring security相比于另外一个安全框架shiro来说绝对算是重量级,也比较复杂,但是呢由于是spring提供,搭配整个spring生态使用应该还是可以的
首先spring security对用户的操作,比如登录判断用户名密码是否正确,访问某个资源是否有对应的权限,定义了一个接口 或许也有类吧 但是我是实现了接口 重写了那些方法 就算是满足了spring security要求的安全用户对象,在它内部的实现机制就会用到,我们只需要传参构建好这个对象即可
1 import org.springframework.security.core.GrantedAuthority; 2 import org.springframework.security.core.authority.SimpleGrantedAuthority; 3 import org.springframework.security.core.userdetails.UserDetails; 4 5 import java.util.Collection; 6 import java.util.List; 7 import java.util.stream.Collectors; 8 9 /** 10 * @Description: 实现 UserDetails 重写方法 就是满足spring security安全要求的用户 11 * spring security验证用户必须要使用实现UserDetails的类,的实例 12 * 所以构建这个类 将我们自身实体类中的一些字段 赋值到这个类 用于校验 13 * 也是由于我们自身的用户实体类 字段比较多 14 * @Author: Tan 15 * @CreateDate: 2019/12/6 16 **/ 17 public class SecurityUser implements UserDetails { 18 //用户名 19 private String userName; 20 //密码 21 private String passWord; 22 //权限集合 23 private Collection<? extends GrantedAuthority> authoritys; 24 //是否可用 25 private boolean enabled; 26 27 /** 28 * @Description: 这个构造方法 从用户实体对象中给这个安全用户赋值 29 * @Author: Tan 30 * @Date: 2019/12/6 31 * @param userName: 用户账号 32 * @param passWord: 用户密码 33 * @param authority: 用户权限集合 34 * @param enabled: 用户是否可用 35 * @return: null 36 **/ 37 public SecurityUser (String userName, String passWord, List<String> authority,boolean enabled ){ 38 this.userName=userName; 39 this.passWord=passWord; 40 this.enabled=enabled; 41 this.authoritys =authority.stream().map(item->new SimpleGrantedAuthority(item)).collect(Collectors.toList()); 42 } 43 44 @Override 45 public Collection<? extends GrantedAuthority> getAuthorities() { 46 return this.authoritys; 47 } 48 49 @Override 50 public String getPassword() { 51 return this.passWord; 52 } 53 54 @Override 55 public String getUsername() { 56 return this.userName; 57 } 58 59 @Override 60 public boolean isAccountNonExpired() { 61 return true; 62 } 63 64 @Override 65 public boolean isAccountNonLocked() { 66 return true; 67 } 68 69 @Override 70 public boolean isCredentialsNonExpired() { 71 return true; 72 } 73 74 @Override 75 public boolean isEnabled() { 76 return this.enabled; 77 } 78 }
这个类只定义了用户名,密码,拥有的权限和是否可用,其实还可以定义几个属性,比如该用户是否未锁定,是否未过期,密码是否未过期,这里我就没有写了 在重写的方法里面默认返回都是true,这个类写好了,在别的地方会实例化的.
其实为什么不使用和数据库对应的User实体类来实现这个接口,因为和数据库对应的User实体类肯定是有很多无关的字段,所以还是单独建一个类,将用户名,密码这些值传进来进行构建比较好.
接下来编写一个类实现一个接口重写一个方法,后续spring security框架会将得到的用户名调用这个方法,我们可以在这个方法里面将得到的用户名去数据库查询出是否有对应的记录,如果有记录就构建上面这个类的对象,传入用户名,密码,权限集合,是否可用然后返回
如果不存在这个记录,直接抛出一个用户名不存在异常 UsernameNotFoundException,我们这里返回了一个有用户名,密码,权限集合的对象,如果是登录的话,spring security框架会将这个用户名密码和从请求里面得到的token中解析出来的用户进行匹配 如果匹配失败
就会响应401错误,把代码贴出来
1 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; 2 import com.tqq.eggchat.dao.UserMapper; 3 import com.tqq.eggchat.entity.SecurityUser; 4 import com.tqq.eggchat.entity.User; 5 import lombok.extern.slf4j.Slf4j; 6 import org.springframework.beans.factory.annotation.Autowired; 7 import org.springframework.security.core.userdetails.UserDetails; 8 import org.springframework.security.core.userdetails.UserDetailsService; 9 import org.springframework.security.core.userdetails.UsernameNotFoundException; 10 import org.springframework.stereotype.Service; 11 12 import java.util.Arrays; 13 14 /** 15 * @Description: 这个类实现UserDetailsService接口 成为满足spring security标准的用户业务类 16 * 提供根据用户名 返回 UserDetails对象的方法 17 * 这里可以注入dao类对象 查询数据库 对应的用户信息 然后构造UserDetails对象 18 * @Author: Tan 19 * @CreateDate: 2019/12/6 20 **/ 21 @Slf4j 22 @Service 23 public class SecurityService implements UserDetailsService { 24 25 @Autowired 26 private UserMapper userMapper; 27 28 /** 29 * @Description: 根据用户名去数据库查询对应的用户信息 30 * @Author: Tan 31 * @Date: 2019/12/6 32 * @param userName: 用户名 33 * @return: org.springframework.security.core.userdetails.UserDetails 34 **/ 35 @Override 36 public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException { 37 //根据用户名查询用户信息 38 User user = userMapper.selectOne(new QueryWrapper<User>().eq("s_account", userName)); 39 if(user!=null){ 40 //这里暂时没有权限的概念 就默认个user权限 41 return new SecurityUser(user.getS_account(),user.getS_password(), Arrays.asList("USER"),user.getI_status()==1?true:false); 42 }else{ 43 log.info("查询数据库,该账号{}不存在",userName); 44 throw new UsernameNotFoundException(String.format("%s 该账号不存在",userName)); 45 } 46 } 47 }
我这个@Slf4j是lombok框架提供的一个功能,相当于是一个日志对象,省得重复写了,直接在代码中就可以用,在编译以后会加上的.要想使用这个功能除了要引用lombok框架的依赖,使用的IDE也要装插件才能使用
客户端每次请求都会带上token.那么就需要一个过滤器,从请求对象中获取token 然后进行解析等,把过滤器代码贴出来
1 /** 2 * @Description: 这个过滤器用于判断请求中是否有token 如果有就进行登录到spring security中 3 * 继承OncePerRequestFilter 这个类是spring 对filter的封装 可以实现 4 * 一次请求 只会执行一次过滤器 5 * @Author: Tan 6 * @CreateDate: 2019/12/6 7 **/ 8 @Component 9 public class JwtRequestFilter extends OncePerRequestFilter { 10 11 @Autowired 12 private SecurityService securityService; 13 14 @Autowired 15 private JwtTokenUtil jwtTokenUtil; 16 17 @Override 18 protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { 19 String tokenHead=request.getHeader("Authorization"); 20 //token头不等于空 并且以Bearer 开头进行token验证登录处理 21 if(tokenHead!=null&&tokenHead.startsWith("Bearer ")){ 22 //从请求头中截取token 23 String token=tokenHead.substring(7); 24 //通过token得到用户名 如果token已过期或者错误 会抛出异常,并被spring security捕获 调我们自定义的登录失败处理方法 25 String userName = jwtTokenUtil.getUsernameFromToken(token); 26 //用户名不等于空 并且当前上下文环境中没有认证过 就进行登录验证 27 if(userName!=null&& SecurityContextHolder.getContext().getAuthentication()==null){ 28 //通过用户名查询数据库 构建符合spring security要求的安全用户对象 29 UserDetails userDetails = securityService.loadUserByUsername(userName); 30 //验证token和用户对象 31 if(jwtTokenUtil.validateToken(token,userDetails)){ 32 //通过安全用户对象 构建一个登录对象 33 UsernamePasswordAuthenticationToken login=new UsernamePasswordAuthenticationToken(userDetails,null,userDetails.getAuthorities()); 34 //传入当前http请求对象 35 login.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); 36 //将登录对象 写入到当前上下文环境中 后续的判断 权限控制就由spring Security做 37 SecurityContextHolder.getContext().setAuthentication(login); 38 } 39 //调用下一个过滤器 如果有token已经在此完成登录 没有登录的话 会被后续拦截器处理 40 filterChain.doFilter(request,response); 41 } 42 43 }
在这个过滤器里面有注入了操作jwt的工具类和之前定义的用于根据用户名查询数据库构建spring security要求的用户对象的类,
过滤器写好以后,怎么样使用,并且安全框架实现认证拦截控制都是通过,过滤器来实现的,过滤器一般都是一个链,我们这个截取token完成解析登录,就必须要在spring security所有过滤器之前,而且由于我们这个是spring boot项目已经抛弃了xml配置文件
对于框架的配置全部采用配置类,在这个spring security配置类中可以配置那些资源或者url需要登录,或者需要什么权限,这也是比较重要的一个类,我把代码贴出来
1 /** 2 * @Description: spring security核心配置类 功能都在此配置 3 * @Author: Tan 4 * @CreateDate: 2019/12/8 5 **/ 6 @EnableGlobalMethodSecurity(prePostEnabled = true) 7 @EnableWebSecurity 8 @Configuration 9 public class WebSecurityConfig extends WebSecurityConfigurerAdapter { 10 11 @Autowired 12 private SecurityService securityService; 13 14 @Autowired 15 private JwtRequestFilter jwtRequestFilter; 16 17 @Autowired 18 private UserLoginFailurceConfig userLoginFailurceConfig; 19 20 @Autowired 21 private UserNotAuthorityConfig userNotAuthorityConfig; 22 23 24 25 @Override 26 protected void configure(AuthenticationManagerBuilder auth) throws Exception { 27 //配置密码的加密方式 28 auth.userDetailsService(securityService).passwordEncoder(new BCryptPasswordEncoder()); 29 } 30 31 @Bean 32 @Override 33 public AuthenticationManager authenticationManagerBean() throws Exception { 34 //向spring容器中注入 认证管理器 35 return super.authenticationManagerBean(); 36 } 37 38 @Bean 39 public PasswordEncoder passwordEncoder(){ 40 return new BCryptPasswordEncoder(); 41 } 42 43 44 45 @Override 46 protected void configure(HttpSecurity http) throws Exception { 47 //关闭csrf防护器 48 http.csrf().disable() 49 //session管理器为无状态 50 .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) 51 .and() 52 //登录和注册所有人可以访问 53 .authorizeRequests().antMatchers("/user/userLogin","/user/userRegister","/user/checkUserAccount").permitAll() 54 //放行swagger2 55 .antMatchers("/swagger-resources/**", "/webjars/**", "/v2/**", "/swagger-ui.html/**").permitAll() 56 //所有请求需要认证 57 .anyRequest().authenticated() 58 .and() 59 //添加我们实现的过滤器到spring security过滤器链的第一个 进行过滤 60 .addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class); 61 //禁用缓存 62 http.headers().cacheControl(); 63 64 //设置异常处理 authenticationEntryPoint是身份验证失败的处理,accessDeniedHandler是没有权限访问处理 65 http.exceptionHandling().authenticationEntryPoint(userLoginFailurceConfig).accessDeniedHandler(userNotAuthorityConfig); 66 } 67 68 69 }
这里配置了那些url可以放行,还将前面写的过滤器添加到了过滤链的第一个,并且还设置了两个异常处理类,就是身份验证失败处理,和访问没有权限的资源处理,这两个类呢主要是可以进行一些定制化的响应,比如和前端约定响应状态码为10003代表无权限访问
这样前端就可以重定向到指定的页面,否则就都是403,把这个两个异常处理类贴出来.
1 /** 2 * @Description: 当spring security验证用户身份失败时 会调用 这个类的commence方法 3 * 有两种情况 第一张用户名密码错误 第二种 就是token过期或者错误 4 * @Author: Tan 5 * @CreateDate: 2019/12/14 6 **/ 7 @Component 8 public class UserLoginFailurceConfig implements AuthenticationEntryPoint { 9 @Override 10 public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException { 11 12 //将自定义响应结果转json字符串 默认是用户名密码错误的提示信息 13 String resultJsonString = JSONUtil.toJsonStr(ResponseResult.failure(ResponseCodeEnum.USER_LOGIN_ERROR)); 14 15 16 //从请求对象中获取token 17 String tokenHead = httpServletRequest.getHeader("Authorization"); 18 //如果token不是空 并且Bearer 开头 那么就是token错误或者过期了 19 if(tokenHead!=null&&tokenHead.startsWith("Bearer ")){ 20 //将token错误或过期提示 转换成字符串 21 resultJsonString = JSONUtil.toJsonStr(ResponseResult.failure(ResponseCodeEnum.USER_TOKEN_ERROR)); 22 //设置响应对象状态码 23 httpServletResponse.setStatus(ResponseCodeEnum.USER_TOKEN_ERROR.getCode()); 24 } 25 26 //设置响应内容类型 27 httpServletResponse.setContentType("application/json;charset=utf-8"); 28 //输出结果 29 httpServletResponse.getWriter().print(resultJsonString); 30 } 31 }
其实重写的这个方法就和servlet里面的那个doPost差不多,调用的时候,会把请求和响应对象都给你传进来,你写你自己的逻辑即可,其实通过重写这个类,我就可以响应前端,这次到底是用户名密码错误还是token过期或者错误,如果不写则都是401响应
把没有权限访问的类也贴出来
1 /** 2 * @Description: 用户已登录 请求没有权限的接口 响应信息 3 * @Author: Tan 4 * @CreateDate: 2019/12/14 5 **/ 6 @Component 7 public class UserNotAuthorityConfig implements AccessDeniedHandler { 8 @Override 9 public void handle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AccessDeniedException e) throws IOException, ServletException { 10 //将自定义响应结果转json字符串 默认是用户名密码错误的提示信息 11 String resultJsonString = JSONUtil.toJsonStr(ResponseResult.failure(ResponseCodeEnum.USER_NOT_AUTHORITY)); 12 13 //设置响应对象状态码 14 httpServletResponse.setStatus(ResponseCodeEnum.USER_NOT_AUTHORITY.getCode()); 15 16 //设置响应内容类型 17 httpServletResponse.setContentType("application/json;charset=utf-8"); 18 //输出结果 19 httpServletResponse.getWriter().print(resultJsonString); 20 } 21 }
到这里基本上spring security整合jwt基本上完了 还要讲一下权限控制,在SecurityService这个类的loadUserByUsername方法中,查询数据库然后构建SecurityUser这个对象,可以传入这个所拥有的权限集合,这个集合一般也是查询数据库查出来
这里默认都是使用spring mvc 那么在对应的controller类上的方法上可以加一个注解 @PreAuthorize("权限名") 那么当请求这个方法的url地址时,spring security会判断是否登录,已登录后是否拥有该权限.这样就实现了权限控制
这里还要单独讲一下登录和注册方法,我把service类方法贴出来
1 /** 2 * @Description: 用户服务类 3 * @Author: Tan 4 * @CreateDate: 2019/12/8 5 **/ 6 @Service 7 public class UserServiceImpl implements UserService { 8 9 @Autowired 10 private UserMapper userMapper; 11 12 @Autowired 13 private JwtTokenUtil jwtTokenUtil; 14 15 @Autowired 16 private AuthenticationManager authenticationManager; 17 18 @Autowired 19 private SecurityService securityService; 20 21 22 @Override 23 public User userRegister(User user) { 24 if(user.getS_account()!=null){ 25 BCryptPasswordEncoder passwordEncoder=new BCryptPasswordEncoder(); 26 user.setS_password(passwordEncoder.encode(user.getS_password())); 27 user.setD_register_date(new Date()); 28 int insert = userMapper.insert(user); 29 return insert==1?user:null; 30 } 31 return null; 32 } 33 34 @Override 35 public String userLogin(String userName, String passWord) { 36 //根据输入的用户名和密码创建一个 用户名密码token 37 UsernamePasswordAuthenticationToken userNamepassWordToken = new UsernamePasswordAuthenticationToken( userName, passWord ); 38 //创建认证对象 传入用户名密码token 然后spring security会根据用户名去调用loadUserByUsername方法 39 //这个方法是由我们重写了 是根据用户名去数据库查询 只要查询到记录 交由spring security进行密码比较 40 //如果没有查询到对应记录 或者密码不正确 就会直接响应403 这里后续代码不会执行 41 Authentication authentication = authenticationManager.authenticate(userNamepassWordToken); 42 //执行到这一步 代表用户名密码已经校验成功了 将认证对象写入到spring security上下文环境中 43 SecurityContextHolder.getContext().setAuthentication(authentication); 44 //根据用户名查询结果 然后生成token 并返回 45 UserDetails userDetails = securityService.loadUserByUsername( userName ); 46 return jwtTokenUtil.generateToken(userDetails); 47 } 48 49 }
userRegister 这个方法是注册 也没什么特别的 接收了User对象以后使用BCryptPasswordEncoder对密码进行加密,然后保存到数据库,
userLogin 这个方法是登录,首先将接收到的用户名密码构建成一个spring security框架定义的用户名密码token对象 然后根据这个对象去创建 Authentication这个对象,在创建这个对象时,会通过传入用户名密码token对象得到用户名然后去调用我们之前那个loadUserByUsername方法
从数据库查询构建出一个对象来进行比较,如果比较失败就会是登录失败会调用前面写的登录失败处理类,这个方法里面的后续代码也不会执行了.还有一点就是如果同一个用户多次登录 每次生成的token都不会相同,因为token里面包含了时间信息,后续也是通过解析token来判断是否过期
三丶 总结
看了前面这一大堆,这么多类 方法 可能会对整个执行流程很懵,这里我在结合我的理解说一下我认为的整个执行流程,可能不对,欢迎指正.
首先如果你是请求登录的url,最开始先进入到上面的JwtRequestFilter这个过滤器,先判断你的请求是否有token,如果你的请求携带了token,并且对token的进行解析校验,没问题就会登录到spring security的上下文中,然后调用下一个过滤器,我觉得spring security剩下的过滤器都会
判断上下文中是否已登录,如果已登录都会放行调用下一个过滤器直到调用到要访问的url.如果你没有携带token,并且你访问的url是需要登录的,那么你就会被拦截. 这是关于登录的
如果是权限管理,在解析token进行登录,或者有用户名密码进行登录都会调用SecurityService类重写的loadUserByUsername方法,在这个方法里面我们可以查询数据库中这个用户的信息,并且也可以查询这个用户所拥有的权限,生成一个String类型的集合,然后构建一个我们自定义的类实现了
spring security要求的用户对象给spring security进行认证和判断是否有权限,然后具体某个controller的方法,只需要加上@PreAuthorize("权限名") 注解 剩下的判断交由spring security即可
其实关于前端的部分我就没有写出来了,我前端使用的是vue,vue中请求统一都是用axios,axios可以配置两个拦截器,请求拦截器和响应拦截器,在发请求的时候可以带上token,响应拦截器里面可以判断后台响应值对页面进行跳转,比如后台没有登录会响应一个什么状态码,在响应拦截器里面判断如果是
这个状态码就操作路由跳转页面啥的
大致差不多就这样吧 网上看了很多关于spring security整合jwt的 很多方法 我这个是我自己参考了别人的文章 和我自己慢慢尝试的 后续有更多在补充吧