Angular 17+NgRx中刷新令牌拦截器的并发同步问题
问题分析
核心问题在于并发请求触发重复刷新令牌:当多个请求同时检测到令牌过期时,会并发调用refresh-token接口。第一个请求成功刷新后,数据库中的account.Token已更新为新值;后续请求携带旧令牌调用刷新接口时,后端校验("Bearer " + account.Token) != token不匹配,直接抛出「Session Not Found」错误,导致所有后续请求阻塞。
现有拦截器的refreshTokenAndContinue方法每次都会发起新的刷新请求,完全没有做并发控制,这是问题的根源。
解决方案
核心思路是共享刷新令牌的请求流,确保同一时间只有一个refresh-token请求在执行,后续请求等待该请求完成后复用结果,同时优化令牌过期判断逻辑,减少不必要的刷新调用。
修改后的拦截器代码
@Injectable() export class HttpHeaderRequestInterceptor implements HttpInterceptor { // 全局变量:跟踪当前是否有刷新令牌请求在执行 private refreshTokenInProgress$: Observable<string> | null = null; constructor( private _accountAPI: AccountAPI, private _store: Store, private _authUtils: AuthUtils ) {} intercept( request: HttpRequest<any>, next: HttpHandler ): Observable<HttpEvent<any>> { const refreshEndpoint = 'refresh-token'; const excludedEndpoints = ['login', 'register', 'reset-password']; if (request.url.includes(refreshEndpoint)) { return this.setAuthorizationHeader(request, next); } else if ( excludedEndpoints.some(endpoint => request.url.includes(endpoint)) || !request.url.includes('api') ) { return next.handle(request); } else { // 先校验当前令牌是否过期,再决定是否刷新 return from(this._store.select(selectAuthToken)).pipe( take(1), switchMap(token => { if (token && !this._authUtils.isAuthTokenExpired(token)) { // 令牌未过期,直接添加请求头 return this.addRequestHeaders(request, next, token); } // 令牌过期,执行带并发控制的刷新逻辑 return this.refreshTokenIfNeeded().pipe( switchMap(newToken => this.addRequestHeaders(request, next, newToken)) ); }) ); } } private setAuthorizationHeader( request: HttpRequest<any>, next: HttpHandler ): Observable<HttpEvent<any>> { return from(this._store.select(selectAuthToken)).pipe( take(1), switchMap(token => { const updatedRequest = request.clone({ setHeaders: { Authorization: `Bearer ${token}` } }); return next.handle(updatedRequest); }) ); } // 统一封装请求头添加逻辑 private addRequestHeaders( request: HttpRequest<any>, next: HttpHandler, token: string ): Observable<HttpEvent<any>> { return this._store.select(selectLangState).pipe( take(1), switchMap(lang => { const timeZoneOffset = new Date().getTimezoneOffset().toString(); const updatedRequest = request.clone({ setHeaders: { Authorization: `Bearer ${token}`, 'Accept-Language': lang || DEFAULT_LANG, 'Timezone-Offset': timeZoneOffset } }); return next.handle(updatedRequest); }) ); } // 带并发控制的刷新令牌逻辑 private refreshTokenIfNeeded(): Observable<string> { // 如果已有刷新请求在执行,直接复用该流 if (this.refreshTokenInProgress$) { return this.refreshTokenInProgress$; } // 发起新的刷新请求,并绑定全局跟踪变量 this.refreshTokenInProgress$ = this._accountAPI.refreshToken().pipe( switchMap(tokenResponse => { const tokenExists = tokenResponse.headers.get('Authorization'); const token = tokenExists ? tokenExists.split(' ')[1].trim() : ''; const tokenExpiration = this._authUtils.getAuthTokenExpirationDate(token); // 更新Store中的令牌 this._store.dispatch( accountActions.refreshTokenSuccess({ authToken: token, tokenExpiration }) ); return of(token); }), catchError(error => { // 刷新失败时清空全局变量,避免后续请求复用错误流 this.refreshTokenInProgress$ = null; throw error; }), finalize(() => { // 请求完成(成功/失败)后清空全局变量 this.refreshTokenInProgress$ = null; }) ); return this.refreshTokenInProgress$; } }
关键修改点说明
- 并发控制:通过
refreshTokenInProgress$全局变量跟踪刷新请求状态,确保同一时间只有一个刷新请求执行,后续请求复用已有流。 - 令牌过期校验:先判断当前令牌是否真的过期,避免无意义的刷新调用。
- 代码解耦:拆分出
addRequestHeaders方法,统一处理请求头添加逻辑,减少重复代码。 - 异常处理:在刷新失败时清空全局变量,避免后续请求一直阻塞在错误流上。
后端代码补充说明
后端RefreshToken方法中,当令牌未过期时直接返回原令牌的逻辑是合理的,但前端的并发控制已经避免了重复调用。如果需要进一步优化,可以考虑在返回原令牌时更新account.LastLogin字段,但这不是必须的。
内容的提问来源于stack exchange,提问作者Aurora
相关产品推荐
相关产品推荐

