前后端实现双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的设置。- @Configuration
- @EnableWebSecurity
- public class SecurityConfig extends WebSecurityConfigurerAdapter {
- //自定义JWT拦截器
- @Autowired
- private JwtLoginFilter jwtLoginFilter;
- @Autowired
- private UserDetailService userDetailService;
- //自定义认证方案
- @Autowired
- private TokenAuthenticationEntryPoint tokenAuthenticationEntryPoint;
- @Bean
- @Override
- protected AuthenticationManager authenticationManager() throws Exception {
- return super.authenticationManager();
- }
- @Override
- protected void configure(HttpSecurity http) throws Exception {
- // 关闭csrf和frameOptions,如果不关闭会影响前端请求接口(这里不展开细讲了,感兴趣的自行了解)
- http.csrf().disable();
- http.headers().frameOptions().disable();
- http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
- // 开启跨域以便前端调用接口
- http.cors();
- // 这是配置的关键,决定哪些接口开启防护,哪些接口绕过防护
- http.authorizeRequests()
- // 注意这里,是允许前端跨域联调的一个必要配置
- .requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
- // 指定某些接口不需要通过验证即可访问。登陆、注册接口肯定是不需要认证的
- .antMatchers("/api/login", "/login","/refreshToken").permitAll()
- // 这里意思是其它所有接口需要认证才能访问
- .anyRequest().authenticated();
- //http.formLogin().loginPage("/login").defaultSuccessUrl("/").permitAll();
- // http.exceptionHandling().authenticationEntryPoint(((httpServletRequest, httpServletResponse, e) -> {
- // httpServletResponse.sendRedirect("/login");
- // }));
- http.exceptionHandling().authenticationEntryPoint(tokenAuthenticationEntryPoint);
- http.addFilterBefore(jwtLoginFilter, UsernamePasswordAuthenticationFilter.class);
- }
- @Override
- protected void configure(AuthenticationManagerBuilder auth) throws Exception {
- // 指定UserDetailService和加密器
- auth.userDetailsService(userDetailService).passwordEncoder(passwordEncoder());
- }
- @Bean
- public PasswordEncoder passwordEncoder(){
- return new BCryptPasswordEncoder();
- }
- @Bean
- CorsConfigurationSource corsConfigurationSource() {
- UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
- CorsConfiguration configuration = new CorsConfiguration();
- configuration.setAllowCredentials(true);
- configuration.setAllowedOrigins(Arrays.asList("*"));
- configuration.setAllowedMethods(Arrays.asList("*"));
- configuration.setAllowedHeaders(Arrays.asList("*"));
- configuration.setMaxAge(Duration.ofHours(1));
- source.registerCorsConfiguration("/**",configuration);
- return source;
- }
- }
复制代码 我们需要自界说一个JWT的拦截器(JwtLoginFilter.java)- @Component
- public class JwtLoginFilter extends OncePerRequestFilter {
- @Autowired
- private UserDetailService userDetailService;
- @Override
- protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
- String accessToken = httpServletRequest.getHeader("accessToken");
- if(!StringUtils.hasText(accessToken)){
- filterChain.doFilter(httpServletRequest,httpServletResponse);
- return;
- }
- boolean checkToken = JWTUtil.checkToken(accessToken);
- if(!checkToken){
- throw new RuntimeException("token无效");
- }
- String username = JWTUtil.getUsername(accessToken);
- UserDetails userDetails = userDetailService.loadUserByUsername(username);
- UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userDetails,null,null);
- SecurityContextHolder.getContext().setAuthentication(authenticationToken);
- filterChain.doFilter(httpServletRequest,httpServletResponse);
- }
- }
复制代码 为了封装JWT相关的操作,可以编写了一个工具类(JWTUtil.java)- public class JWTUtil {
- //定义两个常量,1.设置过期时间 2.密钥(随机,由公司生成)
- public static final String APP_SECRET = "ukc8BDbRigUDaY6pZFfWus2jZWLPHO";
- /**
- * 生成token
- *
- * @param username
- * @param expirationTime
- * @return
- */
- public static String getJwtToken(String username, long expirationTime) {
- return Jwts.builder()
- //设置token的头信息
- .setHeaderParam("typ", "JWT")
- .setHeaderParam("alg", "HS256")
- //设置过期时间
- .setSubject("user")
- .setIssuedAt(new Date())
- //设置刷新
- .setExpiration(new Date(System.currentTimeMillis() + expirationTime))
- //设置token的主题部分
- .claim("username", username)
- //签名哈希
- .signWith(SignatureAlgorithm.HS256, APP_SECRET)
- .compact();
- }
- /**
- * 判断token是否存在与有效
- *
- * @param jwtToken
- * @return
- */
- public static boolean checkToken(String jwtToken) {
- if (StringUtils.isEmpty(jwtToken)) {
- return false;
- }
- try {
- //验证是否有效的token
- Jwts.parser().setSigningKey(APP_SECRET).parseClaimsJws(jwtToken);
- } catch (Exception e) {
- return false;
- }
- return true;
- }
- /**
- * 根据token信息得到getUserId
- *
- * @param jwtToken
- * @return
- */
- public static String getUsername(String jwtToken) {
- //验证是否有效的token
- Jws<Claims> claimsJws = Jwts.parser().setSigningKey(APP_SECRET).parseClaimsJws(jwtToken);
- //得到字符串的主题部分
- Claims claims = claimsJws.getBody();
- return (String) claims.get("username");
- }
- /**
- * 判断token是否存在与有效
- *
- * @param request
- * @return
- */
- public static boolean checkToken(HttpServletRequest request) {
- try {
- String jwtToken = request.getHeader(TokenConstant.ACCESS_TOKEN);
- if (StringUtils.isEmpty(jwtToken)) {
- return false;
- }
- Jwts.parser().setSigningKey(APP_SECRET).parseClaimsJws(jwtToken);
- } catch (Exception e) {
- e.printStackTrace();
- return false;
- }
- return true;
- }
- }
复制代码 另外,在利用SpringSecurity时,我们需要编写一个UserDetail类和一个UserDetailService类分别实现UserDetails和UserDetailsService接口- @Data
- @AllArgsConstructor
- @NoArgsConstructor
- public class UserDetail implements UserDetails {
- @Autowired
- private User user;
- @Override
- public Collection<? extends GrantedAuthority> getAuthorities() {
- return null;
- }
- @Override
- public String getPassword() {
- return user.getPassword();
- }
- @Override
- public String getUsername() {
- return user.getUsername();
- }
- @Override
- public boolean isAccountNonExpired() {
- return true;
- }
- @Override
- public boolean isAccountNonLocked() {
- return true;
- }
- @Override
- public boolean isCredentialsNonExpired() {
- return true;
- }
- @Override
- public boolean isEnabled() {
- return true;
- }
- }
复制代码 接下来,看前端的实现,写一个登录表单,在登录成功后将双Token保存在storage中。- @Service
- public class UserDetailService implements UserDetailsService {
- @Autowired
- private UserMapper userMapper;
- @Override
- public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
- // User user = userMapper.findByUsername(username);
- User user = new User("zlw", "$2a$10$m/4kcUo2LylsP4PKmFEFz.AcnV8DLtL/7krYxU7JcmqSPimnexd56");
- if(user==null){
- throw new UsernameNotFoundException("用户不存在");
- }else{
- return new UserDetail(user);
- }
- }
- }
复制代码 其中login函数的请求方式可以单独封装到一个js文件中:- //UserService.java
- public Result<?> login(User user) {
- Authentication authenticationToken = new UsernamePasswordAuthenticationToken(user.getUsername(),user.getPassword(),null);
- Authentication authenticate = authenticationManager.authenticate(authenticationToken);
- if (Objects.isNull(authenticate)) {
- throw new RuntimeException("登陆失败");
- }
- UserDetails userDetail = userDetailService.loadUserByUsername(user.getUsername());
- //登陆并通过账号密码认证后,生成双Token返回前端
- String accessToken = JWTUtil.getJwtToken(userDetail.getUsername(), TokenConstant.ACCESS_TOKEN_EXPIRATION_TIME);
- String refreshToken = JWTUtil.getJwtToken(userDetail.getUsername(), TokenConstant.REFRESH_TOKEN_EXPIRATION_TIME);
- //把refreshToken的生成时间保存在Redis里,这是为了后面利用refreshToken生成accessToken时判断refreshToken有没有过期
- redisTemplate.opsForValue().set(userDetail.getUsername()+TokenConstant.REFRESH_TOKEN_START_TIME, String.valueOf(System.currentTimeMillis()), TokenConstant.REFRESH_TOKEN_EXPIRATION_TIME, TimeUnit.MILLISECONDS);
- Map<String,Object> map = new HashMap<>();
- map.put(TokenConstant.ACCESS_TOKEN, accessToken);
- map.put(TokenConstant.REFRESH_TOKEN, refreshToken);
- map.put("userInfo", userDetail);
- return Result.ok(map);
- }
复制代码 登录成功后,其他的请求都需要携带accessToken才气正常访问服务器的数据,我们需要设置Axios的请求拦截器和相应拦截器- <template xmlns="http://www.w3.org/1999/html">
-
-
- 账号:<input placeholder="输入账号" type="text" v-model="userLogin.username" />
-
-
- 密码:<input placeholder="输入密码" type="password" v-model="userLogin.password"/>
-
-
- <button @click="loginMethod">登录</button>
-
-
- 测试账号:zlw
-
-
- 测试密码:123123
-
-
- </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() |