前后端分离架构下,认证这块最容易踩坑。后端用Spring OAuth2搭好了授权服务器,前端JS这边却经常搞得一团糟:要么token拿到了却带不上,要么token过期后页面直接卡死,要么刷新令牌放在localStorage里被人一扫而空。其实JS与Spring OAuth2的配合有一套相对固定的套路,理解了授权码流程中每一步前端要做什么,问题就解决了一大半。本文从前端开发者的角度出发,把整个对接过程掰开揉碎讲清楚。

一、先搞清楚前端在OAuth2授权码流程中的位置
Spring OAuth2最推荐的模式是授权码模式(Authorization Code Flow),整个流程里前端要做的事情其实只有三件:引导用户跳转授权页、用授权码换令牌、带着令牌访问资源。很多前端开发者搞不定OAuth2,本质原因是把整个流程想象成了普通的登录接口调用,以为发一个POST请求就能拿到token,结果发现一堆重定向和跨域问题。
标准的授权码流程是这样的:用户访问前端页面,前端发现本地没有令牌,就把浏览器重定向到授权服务器的/oauth2/authorize端点,URL里带上client_id、redirect_uri、response_type=code、scope等参数。用户在授权页登录并同意授权后,Spring授权服务器会带着一个code参数重定向回你指定的redirect_uri。前端页面加载时检查URL中有没有code参数,有就拿它去/oauth2/token端点换令牌。换到令牌后,把access_token存起来,后续请求统一放到Authorization头里。
注意一个细节:换令牌这一步,如果前端是公开客户端(比如纯浏览器SPA应用,没有后端中转),是不应该携带client_secret的,因为浏览器里藏不住任何密钥。这时候要用PKCE(Proof Key for Code Exchange)来代替密钥做防护。PKCE的思路是前端先生成一个随机字符串code_verifier,算出它的SHA-256哈希作为code_challenge传给授权端点,换令牌时再把原始的code_verifier传过去,授权服务器验证两者匹配才发令牌。
二、令牌存储方案的选择与实现
拿到令牌后第一件事就是决定存哪里。主流方案有两种:localStorage和httpOnly Cookie,各有明显的优缺点。
localStorage的读写最方便,JS可以直接操作,代码也很直观:
function saveTokens(tokenResponse) {
localStorage.setItem('access_token', tokenResponse.access_token);
localStorage.setItem('refresh_token', tokenResponse.refresh_token);
// access_token通常2小时过期,提前存好过期时间点
const expiresAt = Date.now() + tokenResponse.expires_in * 1000;
localStorage.setItem('expires_at', String(expiresAt));
}
function getAccessToken() {
return localStorage.getItem('access_token');
}
function isTokenExpired() {
return Date.now() > Number(localStorage.getItem('expires_at'));
}</code>但localStorage的问题是任何一段被注入的XSS脚本都能读到令牌,一旦泄露攻击者就能直接冒充用户。httpOnly Cookie方案则把令牌写到只有浏览器能读、JS读不到的Cookie里,配合Spring后端的CSRF防护一起使用,安全性明显更高。代价是每次都要处理跨域Cookie的问题,前端请求必须设置withCredentials: true,后端CORS配置里也要明确允许凭证。
如果是企业内部系统或者安全要求高的项目,建议令牌相关的操作都由一个轻量的BFF层(Backend For Frontend)代理,前端只跟同源的BFF交互,Cookie由BFF和授权服务器之间流转,浏览器端完全接触不到令牌本体。这是目前社区公认比较稳的架构。
三、用axios拦截器实现请求携带与无感知刷新
令牌有了,接下来就是让每个请求自动带上它,并且在过期时自动刷新。用axios的拦截器可以做到一次封装、全局生效。
请求拦截器负责在发请求前塞入Authorization头,响应拦截器负责捕获401并触发刷新逻辑:
import axios from 'axios';
const api = axios.create({ baseURL: 'https://api.ipipp.com' });
// 请求拦截:统一塞令牌
api.interceptors.request.use(config => {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = 'Bearer ' + token;
}
return config;
});
let refreshing = null; // 保存进行中的刷新请求,防止并发重复刷新
api.interceptors.response.use(
res => res,
async err => {
const { config, response } = err;
if (response && response.status === 401 && !config._retried) {
config._retried = true;
// 保证同一时刻只有一个刷新请求,其他请求等它完成
refreshing = refreshing || refreshToken();
const newToken = await refreshing;
refreshing = null;
config.headers.Authorization = 'Bearer ' + newToken;
return api(config); // 重发原请求
}
return Promise.reject(err);
}
);
async function refreshToken() {
const res = await axios.post('https://auth.ipipp.com/oauth2/token',
new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: localStorage.getItem('refresh_token'),
client_id: 'spa-client'
}),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
);
localStorage.setItem('access_token', res.data.access_token);
localStorage.setItem('refresh_token', res.data.refresh_token);
return res.data.access_token;
}这里有个容易被忽略的坑:页面同时发出多个请求,令牌恰好过期时,多个请求都会收到401,如果没有并发控制,会触发多次刷新,而Spring授权服务器默认refresh_token是旋转的(用一次就作废),第二次刷新就会失败。上面代码用refreshing变量把刷新请求合并成一个,所有401的请求都等同一个刷新结果,这是生产环境必须处理的细节。
另外注意Content-Type必须是application/x-www-form-urlencoded,OAuth2 token端点不接收JSON格式的请求体,很多前端在这里折腾半天收到的都是400错误,原因就是发的是JSON。
四、跨域与Spring端的配套配置
前端配置都对了还报CORS错误,说明Spring这边没配好。授权服务器和资源服务器需要分别处理跨域。对于token端点的跨域,Spring Authorization Server需要显式放行OPTIONS预检请求,否则预检被拦截后真正的POST请求根本发不出去。
资源服务器侧的典型配置如下:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(corsSource()))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 ->
oauth2.jwt(Customizer.withDefaults()));
return http.build();
}
@Bean
public CorsConfigurationSource corsSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://app.ipipp.com"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true); // 使用Cookie方案时必须为true
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
}两个容易踩的点:一是setAllowCredentials(true)和setAllowedOrigins里的通配符不能同时使用,带了凭证就必须写明确的域名,否则Spring会直接抛异常;二是前端如果用Cookie存session或令牌,axios实例要配置withCredentials: true,两边缺一个Cookie都带不过去。
整体来说,JS与Spring OAuth2的配合没有太多玄学,核心就是理清授权码流程里前端的三个动作,选对令牌存储方案,把拦截器和并发刷新处理好,再让后端把CORS配规范。按这套思路落地,一套安全且体验流畅的认证体系就能稳定跑起来了。
Spring OAuth2JS前端认证JWT令牌修改时间:2026-09-13 15:10:50