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

cnblogs 2024-10-23 08:09:00 阅读 92

前后端实现双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的配置。

<code>@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">code>

<div >

<div >

账号:<input placeholder="输入账号" type="text" v-model="userLogin.username" />code>

</div>

<div >

密码:<input placeholder="输入密码" type="password" v-model="userLogin.password"/>code>

</div>

<div >

<button @click="loginMethod">登录</button>code>

</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



声明

本文内容仅代表作者观点,或转载于其他网站,本站不以此文作为商业用途
如有涉及侵权,请联系本站进行删除
转载本站原创文章,请注明来源及作者。