前后端实现双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的配置。
@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;
}
}
@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);
}
}
}
到这里,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){
//请求头中加入accessToken
config.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重新请求accessToken
return userStore.getNewToken(refreshToken).then(async (rftRes)=>{
console.log(rftRes);
//如果refreshToken也失效了,就重新登录
if(rftRes.data.code===501){
window.location.href = "/login";
}
const accessToken = rftRes.data.accessToken;
//保存新的双Token
storage.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快过期了,就生成一个新的refreshToken
refreshToken = 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