ToB企服应用市场:ToB评测及商务社交产业平台

标题: 前后端实现双Token无感刷新用户认证 [打印本页]

作者: 耶耶耶耶耶    时间: 2024-10-22 20:36
标题: 前后端实现双Token无感刷新用户认证
前后端实现双Token无感刷新用户认证

本文记录了利用双Token机制实现用户认证的详细步骤,前端利用的Vue,后端利用SpringSecurity和JWT
双Token分别指的是AccessToken和RefreshToken
AccessToken:每次请求需要携带AccessToken访问后端数据,有效期短,淘汰AccessToken泄露带来的风险
RefreshToken:有效期长,只用于AccessToken过期时天生新的AccessToken
利用双Token机制的好处:
无感刷新:利用单个Token时,若Token过期,会强制用户重新登录,影响用户体验。双Token可以实现无感刷新,当AccessToken过期,应用会自动通过RefreshToken天生新的AccessToken,不会打断用户的操作。
提高安全性:若AccessToken有效期很长,当AccessToken被窃取后,攻击者可以长期利用这个Token,因此AccessToken的有效期不易过长。而RefreshToken只用于请求新的AccessToken和RefreshToken,它平时不会直接暴漏在网络中。
双Token认证的根本流程如下图:
1、用户登录后,服务器天生一个短期的访问令牌和一个长期的刷新令牌,并将它们发送给客户端。
2、客户端在每次请求受保护的资源时,携带访问令牌进行身份验证。
3、当访问令牌过期时,客户端利用刷新令牌向服务器请求新的访问令牌。
4、假如刷新令牌有效,服务器天生并返回新的访问令牌;否则,要求用户重新登录。

代码实现:
本文完备代码保存在Github仓库:https://github.com/Bombtsti/DoubleTokenDemo
忽略依赖导入和设置文件,直接从代码部分开始。
首先,编写一个SpringSecurity设置类(SecurityConfig.java)进行SpringSecurity的设置。
  1. @Configuration
  2. @EnableWebSecurity
  3. public class SecurityConfig extends WebSecurityConfigurerAdapter {
  4.     //自定义JWT拦截器
  5.     @Autowired
  6.     private JwtLoginFilter jwtLoginFilter;
  7.     @Autowired
  8.     private UserDetailService userDetailService;
  9.     //自定义认证方案
  10.     @Autowired
  11.     private TokenAuthenticationEntryPoint tokenAuthenticationEntryPoint;
  12.     @Bean
  13.     @Override
  14.     protected AuthenticationManager authenticationManager() throws Exception {
  15.         return super.authenticationManager();
  16.     }
  17.     @Override
  18.     protected void configure(HttpSecurity http) throws Exception {
  19.         // 关闭csrf和frameOptions,如果不关闭会影响前端请求接口(这里不展开细讲了,感兴趣的自行了解)
  20.         http.csrf().disable();
  21.         http.headers().frameOptions().disable();
  22.         http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
  23.         // 开启跨域以便前端调用接口
  24.         http.cors();
  25.         // 这是配置的关键,决定哪些接口开启防护,哪些接口绕过防护
  26.         http.authorizeRequests()
  27.                 // 注意这里,是允许前端跨域联调的一个必要配置
  28.                 .requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
  29.                 // 指定某些接口不需要通过验证即可访问。登陆、注册接口肯定是不需要认证的
  30.                 .antMatchers("/api/login", "/login","/refreshToken").permitAll()
  31.                 // 这里意思是其它所有接口需要认证才能访问
  32.                 .anyRequest().authenticated();
  33.         //http.formLogin().loginPage("/login").defaultSuccessUrl("/").permitAll();
  34. //        http.exceptionHandling().authenticationEntryPoint(((httpServletRequest, httpServletResponse, e) -> {
  35. //            httpServletResponse.sendRedirect("/login");
  36. //        }));
  37.         http.exceptionHandling().authenticationEntryPoint(tokenAuthenticationEntryPoint);
  38.         http.addFilterBefore(jwtLoginFilter, UsernamePasswordAuthenticationFilter.class);
  39.     }
  40.     @Override
  41.     protected void configure(AuthenticationManagerBuilder auth) throws Exception {
  42.         // 指定UserDetailService和加密器
  43.         auth.userDetailsService(userDetailService).passwordEncoder(passwordEncoder());
  44.     }
  45.     @Bean
  46.     public PasswordEncoder passwordEncoder(){
  47.         return new BCryptPasswordEncoder();
  48.     }
  49.     @Bean
  50.     CorsConfigurationSource corsConfigurationSource() {
  51.         UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
  52.         CorsConfiguration configuration = new CorsConfiguration();
  53.         configuration.setAllowCredentials(true);
  54.         configuration.setAllowedOrigins(Arrays.asList("*"));
  55.         configuration.setAllowedMethods(Arrays.asList("*"));
  56.         configuration.setAllowedHeaders(Arrays.asList("*"));
  57.         configuration.setMaxAge(Duration.ofHours(1));
  58.         source.registerCorsConfiguration("/**",configuration);
  59.         return source;
  60.     }
  61. }
复制代码
我们需要自界说一个JWT的拦截器(JwtLoginFilter.java)
  1. @Component
  2. public class JwtLoginFilter extends OncePerRequestFilter {
  3.     @Autowired
  4.     private UserDetailService userDetailService;
  5.     @Override
  6.     protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
  7.         String accessToken = httpServletRequest.getHeader("accessToken");
  8.         if(!StringUtils.hasText(accessToken)){
  9.             filterChain.doFilter(httpServletRequest,httpServletResponse);
  10.             return;
  11.         }
  12.         boolean checkToken = JWTUtil.checkToken(accessToken);
  13.         if(!checkToken){
  14.             throw new RuntimeException("token无效");
  15.         }
  16.         String username = JWTUtil.getUsername(accessToken);
  17.         UserDetails userDetails = userDetailService.loadUserByUsername(username);
  18.         UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userDetails,null,null);
  19.         SecurityContextHolder.getContext().setAuthentication(authenticationToken);
  20.         filterChain.doFilter(httpServletRequest,httpServletResponse);
  21.     }
  22. }
复制代码
为了封装JWT相关的操作,可以编写了一个工具类(JWTUtil.java)
  1. public class JWTUtil {
  2.     //定义两个常量,1.设置过期时间 2.密钥(随机,由公司生成)
  3.     public static final String APP_SECRET = "ukc8BDbRigUDaY6pZFfWus2jZWLPHO";
  4.     /**
  5.      * 生成token
  6.      *
  7.      * @param username
  8.      * @param expirationTime
  9.      * @return
  10.      */
  11.     public static String getJwtToken(String username, long expirationTime) {
  12.         return Jwts.builder()
  13.                 //设置token的头信息
  14.                 .setHeaderParam("typ", "JWT")
  15.                 .setHeaderParam("alg", "HS256")
  16.                 //设置过期时间
  17.                 .setSubject("user")
  18.                 .setIssuedAt(new Date())
  19.                 //设置刷新
  20.                 .setExpiration(new Date(System.currentTimeMillis() + expirationTime))
  21.                 //设置token的主题部分
  22.                 .claim("username", username)
  23.                 //签名哈希
  24.                 .signWith(SignatureAlgorithm.HS256, APP_SECRET)
  25.                 .compact();
  26.     }
  27.     /**
  28.      * 判断token是否存在与有效
  29.      *
  30.      * @param jwtToken
  31.      * @return
  32.      */
  33.     public static boolean checkToken(String jwtToken) {
  34.         if (StringUtils.isEmpty(jwtToken)) {
  35.             return false;
  36.         }
  37.         try {
  38.             //验证是否有效的token
  39.             Jwts.parser().setSigningKey(APP_SECRET).parseClaimsJws(jwtToken);
  40.         } catch (Exception e) {
  41.             return false;
  42.         }
  43.         return true;
  44.     }
  45.     /**
  46.      * 根据token信息得到getUserId
  47.      *
  48.      * @param jwtToken
  49.      * @return
  50.      */
  51.     public static String getUsername(String jwtToken) {
  52.         //验证是否有效的token
  53.         Jws<Claims> claimsJws = Jwts.parser().setSigningKey(APP_SECRET).parseClaimsJws(jwtToken);
  54.         //得到字符串的主题部分
  55.         Claims claims = claimsJws.getBody();
  56.         return (String) claims.get("username");
  57.     }
  58.     /**
  59.      * 判断token是否存在与有效
  60.      *
  61.      * @param request
  62.      * @return
  63.      */
  64.     public static boolean checkToken(HttpServletRequest request) {
  65.         try {
  66.             String jwtToken = request.getHeader(TokenConstant.ACCESS_TOKEN);
  67.             if (StringUtils.isEmpty(jwtToken)) {
  68.                 return false;
  69.             }
  70.             Jwts.parser().setSigningKey(APP_SECRET).parseClaimsJws(jwtToken);
  71.         } catch (Exception e) {
  72.             e.printStackTrace();
  73.             return false;
  74.         }
  75.         return true;
  76.     }
  77. }
复制代码
另外,在利用SpringSecurity时,我们需要编写一个UserDetail类和一个UserDetailService类分别实现UserDetails和UserDetailsService接口
  1. @Data
  2. @AllArgsConstructor
  3. @NoArgsConstructor
  4. public class UserDetail implements UserDetails {
  5.     @Autowired
  6.     private User user;
  7.     @Override
  8.     public Collection<? extends GrantedAuthority> getAuthorities() {
  9.         return null;
  10.     }
  11.     @Override
  12.     public String getPassword() {
  13.         return user.getPassword();
  14.     }
  15.     @Override
  16.     public String getUsername() {
  17.         return user.getUsername();
  18.     }
  19.     @Override
  20.     public boolean isAccountNonExpired() {
  21.         return true;
  22.     }
  23.     @Override
  24.     public boolean isAccountNonLocked() {
  25.         return true;
  26.     }
  27.     @Override
  28.     public boolean isCredentialsNonExpired() {
  29.         return true;
  30.     }
  31.     @Override
  32.     public boolean isEnabled() {
  33.         return true;
  34.     }
  35. }
复制代码
接下来,看前端的实现,写一个登录表单,在登录成功后将双Token保存在storage中。
  1. @Service
  2. public class UserDetailService implements UserDetailsService {
  3.     @Autowired
  4.     private UserMapper userMapper;
  5.     @Override
  6.     public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
  7. //        User user = userMapper.findByUsername(username);
  8.         User user = new User("zlw", "$2a$10$m/4kcUo2LylsP4PKmFEFz.AcnV8DLtL/7krYxU7JcmqSPimnexd56");
  9.         if(user==null){
  10.             throw new UsernameNotFoundException("用户不存在");
  11.         }else{
  12.             return new UserDetail(user);
  13.         }
  14.     }
  15. }
复制代码
其中login函数的请求方式可以单独封装到一个js文件中:
  1. //UserService.java
  2. public Result<?> login(User user) {
  3.     Authentication authenticationToken = new UsernamePasswordAuthenticationToken(user.getUsername(),user.getPassword(),null);
  4.     Authentication authenticate = authenticationManager.authenticate(authenticationToken);
  5.     if (Objects.isNull(authenticate)) {
  6.         throw new RuntimeException("登陆失败");
  7.     }
  8.     UserDetails userDetail = userDetailService.loadUserByUsername(user.getUsername());
  9.     //登陆并通过账号密码认证后,生成双Token返回前端
  10.     String accessToken = JWTUtil.getJwtToken(userDetail.getUsername(), TokenConstant.ACCESS_TOKEN_EXPIRATION_TIME);
  11.     String refreshToken = JWTUtil.getJwtToken(userDetail.getUsername(), TokenConstant.REFRESH_TOKEN_EXPIRATION_TIME);
  12.     //把refreshToken的生成时间保存在Redis里,这是为了后面利用refreshToken生成accessToken时判断refreshToken有没有过期
  13.     redisTemplate.opsForValue().set(userDetail.getUsername()+TokenConstant.REFRESH_TOKEN_START_TIME, String.valueOf(System.currentTimeMillis()), TokenConstant.REFRESH_TOKEN_EXPIRATION_TIME, TimeUnit.MILLISECONDS);
  14.     Map<String,Object> map = new HashMap<>();
  15.     map.put(TokenConstant.ACCESS_TOKEN, accessToken);
  16.     map.put(TokenConstant.REFRESH_TOKEN, refreshToken);
  17.     map.put("userInfo", userDetail);
  18.     return Result.ok(map);
  19. }
复制代码
登录成功后,其他的请求都需要携带accessToken才气正常访问服务器的数据,我们需要设置Axios的请求拦截器和相应拦截器
  1. <template xmlns="http://www.w3.org/1999/html">
  2.   
  3.    
  4.       账号:<input placeholder="输入账号" type="text"  v-model="userLogin.username" />
  5.    
  6.    
  7.       密码:<input placeholder="输入密码" type="password"  v-model="userLogin.password"/>
  8.    
  9.    
  10.       <button @click="loginMethod">登录</button>
  11.    
  12.    
  13.       测试账号:zlw
  14.    
  15.    
  16.       测试密码:123123
  17.    
  18.   
  19. </template>
复制代码
在相应拦截器中,当返回状态码401,阐明accessToken已颠末期了,这时需要从store中拿到refreshToken,并用refreshToken重新请求新的双Token,后端的实现接口如下:
[code]//UserService.javapublic Result refreshToken(String refreshToken) {    Map map = new HashMap();    String username = JWTUtil.getUsername(refreshToken);    String accessToken = JWTUtil.getJwtToken(username,TokenConstant.ACCESS_TOKEN_EXPIRATION_TIME);    String refreshTokenStr = (String) redisTemplate.opsForValue().get(username+TokenConstant.REFRESH_TOKEN_START_TIME);    if(StringUtils.isBlank(refreshTokenStr)){        return Result.fail(map);    }    long refreshTokenStartTime = Long.parseLong(refreshTokenStr);        //假如refreshToken也过期了,就返回501错误码    if(refreshTokenStartTime+TokenConstant.REFRESH_TOKEN_EXPIRATION_TIME < System.currentTimeMillis()){        return Result.forbidden(map);    } else if(refreshTokenStartTime+TokenConstant.REFRESH_TOKEN_EXPIRATION_TIME-System.currentTimeMillis()




欢迎光临 ToB企服应用市场:ToB评测及商务社交产业平台 (https://dis.qidao123.com/) Powered by Discuz! X3.4