前后端实现双Token无感刷新用户认证

news/2024/10/22 21:12:24

前后端实现双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、如果刷新令牌有效,服务器生成并返回新的访问令牌;否则,要求用户重新登录。

image-20241022201734278

代码实现:

本文完整代码保存在Github仓库:https://github.com/Bombtsti/DoubleTokenDemo

忽略依赖导入和配置文件,直接从代码部分开始。

首先,编写一个SpringSecurity配置类(SecurityConfig.java)进行SpringSecurity的配置。

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {//自定义JWT拦截器@Autowiredprivate JwtLoginFilter jwtLoginFilter;@Autowiredprivate UserDetailService userDetailService;//自定义认证方案@Autowiredprivate TokenAuthenticationEntryPoint tokenAuthenticationEntryPoint;@Bean@Overrideprotected AuthenticationManager authenticationManager() throws Exception {return super.authenticationManager();}@Overrideprotected 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);}@Overrideprotected void configure(AuthenticationManagerBuilder auth) throws Exception {// 指定UserDetailService和加密器auth.userDetailsService(userDetailService).passwordEncoder(passwordEncoder());}@Beanpublic PasswordEncoder passwordEncoder(){return new BCryptPasswordEncoder();}@BeanCorsConfigurationSource 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 {@Autowiredprivate UserDetailService userDetailService;@Overrideprotected 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 {//验证是否有效的tokenJwts.parser().setSigningKey(APP_SECRET).parseClaimsJws(jwtToken);} catch (Exception e) {return false;}return true;}/*** 根据token信息得到getUserId** @param jwtToken* @return*/public static String getUsername(String jwtToken) {//验证是否有效的tokenJws<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 {@Autowiredprivate User user;@Overridepublic Collection<? extends GrantedAuthority> getAuthorities() {return null;}@Overridepublic String getPassword() {return user.getPassword();}@Overridepublic String getUsername() {return user.getUsername();}@Overridepublic boolean isAccountNonExpired() {return true;}@Overridepublic boolean isAccountNonLocked() {return true;}@Overridepublic boolean isCredentialsNonExpired() {return true;}@Overridepublic boolean isEnabled() {return true;}
}
@Service
public class UserDetailService implements UserDetailsService {@Autowiredprivate UserMapper userMapper;@Overridepublic 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);}}
}

到这里,SpringSecurity和JWT的基本的配置完成了,接下来实现登录接口

//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);
}

接下来,看前端的实现,写一个登录表单,在登录成功后将双Token保存在storage中。

<!--login.vue>-->
<template xmlns="http://www.w3.org/1999/html"><div class="loginForm"><div class="username">账号:<input placeholder="输入账号" type="text"  v-model="userLogin.username" /></div><div class="password">密码:<input placeholder="输入密码" type="password"  v-model="userLogin.password"/></div><div class="loginBtn"><button @click="loginMethod">登录</button></div><div><span>测试账号:zlw</span></div><div><span>测试密码:123123</span></div></div>
</template><script setup>import {ref} from "vue";import {login} from "@/api/user.js";import {storage} from "@/utils/storage.js";import router from "@/router/index.js";import {useUserStore} from "@/store/userStore.js";const userStore = useUserStore();const userLogin = ref({username:"",password:""})const loginMethod = ()=>{console.log("denglu");login(userLogin.value).then((res)=>{console.log(res)storage.set("accessToken",res.data.accessToken);storage.set("refreshToken",res.data.refreshToken);userStore.setUserInfo(res.data.userInfo);console.log(res.data.accessToken);router.push({path:"/"});}).catch((error)=>{console.log("error");console.log(error);});}
</script>

其中login函数的请求方式可以单独封装到一个js文件中:

//user.js
export const login = (data)=>{return request({url:"/login",method:"post",data:data});
};

登录成功后,其他的请求都需要携带accessToken才能正常访问服务器的数据,我们需要配置Axios的请求拦截器和响应拦截器

//request.js
import axios from "axios";
import {useUserStore} from "@/store/userStore.js";
import {storage} from "@/utils/storage.js";const baseURL = "http://localhost:8080/";
let isRefreshing = false;
let requestsQueue = [];const service = axios.create({baseURL:baseURL,timeout:50000,headers:{"Content-Type":"application/json;charset=utf-8"}
});//请求拦截器
service.interceptors.request.use((config)=>{const userStore = useUserStore();if(userStore.getToken){//请求头中加入accessTokenconfig.headers.accessToken = userStore.getToken();}return config;
},(error)=>{return Promise.reject(error);
});//响应拦截器
service.interceptors.response.use((res)=> {console.log(res);if (res.data.code === 200) {return res.data;}const config = res.config;//如果返回401,说明accessToken失效if(res.data.code===401){const userStore = useUserStore();if(!isRefreshing){isRefreshing = true;storage.set("accessToken","");const refreshToken = storage.get("refreshToken");//通过refreshToken重新请求accessTokenreturn userStore.getNewToken(refreshToken).then(async (rftRes)=>{console.log(rftRes);//如果refreshToken也失效了,就重新登录if(rftRes.data.code===501){window.location.href = "/login";}const accessToken = rftRes.data.accessToken;//保存新的双Tokenstorage.set("accessToken",rftRes.data.accessToken);storage.set("refreshToken",rftRes.data.refreshToken);//重新发送请求const firstReqRes = await service.request(config);//执行请求队列中的请求requestsQueue.forEach((fuc)=>fuc(accessToken));requestsQueue = [];return firstReqRes;}).finally(()=>{isRefreshing = false;});}else{//并发情况下如果正在请求新token,把请求先放到一个请求队列中return new Promise((resolve)=>{requestsQueue.push((token)=>{config.headers.accessToken = token;resolve(service.request(config));});});}}return Promise.reject(res);},(error)=>{console.log("登陆失败");window.localStorage.clear();window.location.href = "/login";
});
export default service;

在响应拦截器中,当返回状态码401,说明accessToken已经过期了,这时需要从store中拿到refreshToken,并用refreshToken重新请求新的双Token,后端的实现接口如下:

//UserService.java
public Result<?> refreshToken(String refreshToken) {Map<String,Object> 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()<=TokenConstant.ACCESS_TOKEN_EXPIRATION_TIME){//如果refreshToken快过期了,就生成一个新的refreshTokenrefreshToken = JWTUtil.getJwtToken(username,TokenConstant.REFRESH_TOKEN_EXPIRATION_TIME);redisTemplate.opsForValue().set(username+TokenConstant.REFRESH_TOKEN_START_TIME , String.valueOf(System.currentTimeMillis()), TokenConstant.REFRESH_TOKEN_EXPIRATION_TIME, TimeUnit.MILLISECONDS);}map.put(TokenConstant.ACCESS_TOKEN,accessToken);map.put(TokenConstant.REFRESH_TOKEN,refreshToken);return Result.ok(map);
}

更具体的代码保存在Github仓库中:https://github.com/Bombtsti/DoubleTokenDemo

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.ryyt.cn/news/74809.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈,一经查实,立即删除!

相关文章

门罗币隐私保护之环签名

主页微信公众号:密码应用技术实战 博客园首页:https://www.cnblogs.com/informatics/ GIT地址:https://github.com/warm3snow简介 在《门罗币隐私保护之隐形地址》文章中,我们重点介绍了门罗币Monero的隐形地址技术,门罗币通过隐形地址保证了交易的不可链接性,并实现了用…

Maven的学习

Maven 安装与配置 今天我们来学习一下Maven,Maven就相当于一个管理的工具,原理就是使用一个插件,这个插件由多个jar包构成。 在一个公司的项目开发过程中,一个大的项目通常被分为好几个小的模块,由不同的人去完成,但是不同的人在开发的过程中,使用的组件,jar包难免会有…

jdk8中文文档及安卓阅读器

例:下载链接: 文档(密码:76nh) 软件(密码:5wrj) 原文链接: http://466dd.com

7-1计算阶乘和【PTA嵌套循环程序设计】

嵌套循环程序设计 7-1计算阶乘和#include<stdio.h>int f(int a){int sum = 1;for(int i=1;i<=a;i++){sum *= i;}return sum;}//构造N!函数int main(){int N = 0,sum = 0;//初始化scanf("%d",&N);if(N>1){for(int i=1;i<=N;i++){sum += f(i);//实…

从认识 Kubernetes 开始

你也说,我也说,那什么是 K8s 呢?Author: ACatSmiling Since: 2024-10-21认识 Kubernetes 什么是 Kubernetes 官方网站:https://kubernetes.io Kubernetes,是 Google 严格保密十几年的秘密武器 Borg 系统的一个开源版本,于 2014 年 9 月发布第一个版本,2015 年 7 月发布第…

java的三大程序结构

JAVA的三大程序结构 一:顺序结构 程序走上执行到下。 二:选择结构 if单选择结构 if(布尔表达式){ //如果布尔表达式的值为ture则执行{}里的语句块 } public class IfDemo01 {public static void main(String[] args) {//接收键盘输入Scanner scanner = new Scanner(System.…

CSP模拟赛 #42

#40 懒得写了,#41 题目质量过低。A 有 \(n\) 张长度为 \(m\) 的纸条,每张纸条有 \(k_i\) 个位置有小写字母,其他位置透明。你需要合理从上到下排列这些纸条,使得最终在上方看到的字符串为 \(s\),保证对于每个位置,至少一张纸条在该位置有一个字母。给出方案或无解。 \(1\…

markdown转pdf,方法总结

总结使用1. VScode插件Markdown Preview Enhanced。格式是正确的。但是无法批处理和指令处理2. pandoc --pdf-engine=typst。无法导出粗体和斜体需求 markdown格式转为pdf我遇到的: 1. 我现在想把多个八股文文档(GitHub项目里的 scutan90/DeepLearning-500-questions: 深度学…