前後端實現雙Token無感重新整理使用者認證

宁夏路东發表於2024-10-22

前後端實現雙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攔截器
    @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

相關文章