Angular令牌认证Bug:Auth Guard与Interceptor协同导致无法刷新令牌
问题根源拆解
- AuthGuard仅做Token存在性校验:你的
isAuthenticated()应该只是检查了localStorage中是否存在accessToken,未验证Token是否过期——导致过期Token仍能通过Guard进入页面,但发起请求时触发401错误。 - Interceptor刷新逻辑有漏洞:刷新成功后未更新localStorage的accessToken,并发请求下会重复调用刷新接口,且刷新失败后直接登出,但未同步Guard的状态。
- Guard为同步判断逻辑:无法处理Token刷新这类异步操作,遇到过期Token直接跳转登录,不给刷新重试的机会。
分步解决方案
1. 重构AuthService,完善Token核心管理逻辑
先把AuthService改成支持过期校验、防抖刷新、状态同步的版本:
// auth.service.ts import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { BehaviorSubject, Observable, of } from 'rxjs'; import { tap, catchError, switchMap } from 'rxjs/operators'; @Injectable({ providedIn: 'root' }) export class AuthService { // 标记是否正在刷新Token,避免并发请求重复调用接口 private isRefreshing$ = new BehaviorSubject<boolean>(false); // 用BehaviorSubject管理Token状态,确保Guard和Interceptor能拿到最新值 private accessToken$ = new BehaviorSubject<string | null>(localStorage.getItem('accessToken')); constructor(private http: HttpClient) {} // 校验Token是否有效(存在且未过期) isAuthenticated(): boolean { const token = this.accessToken$.value; if (!token) return false; return !this.isTokenExpired(token); } // 解析Token payload,判断是否过期 private isTokenExpired(token: string): boolean { const payload = JSON.parse(atob(token.split('.')[1])); return Date.now() >= payload.exp * 1000; } // 刷新Token,自带防抖逻辑 refreshToken(): Observable<string | null> { if (this.isRefreshing$.value) { // 如果正在刷新,直接订阅Token状态,等待刷新完成拿新值 return this.accessToken$.asObservable(); } this.isRefreshing$.next(true); const refreshToken = localStorage.getItem('refreshToken'); if (!refreshToken) { this.logout(); this.isRefreshing$.next(false); return of(null); } return this.http.post<{ data: { accessToken: string } }>('/api/refresh-token', { refreshToken }).pipe( tap(res => { const newToken = res.data.accessToken; localStorage.setItem('accessToken', newToken); this.accessToken$.next(newToken); }), catchError(() => { this.logout(); return of(null); }), tap(() => this.isRefreshing$.next(false)), switchMap(res => of(res?.data?.accessToken || null)) ); } logout(): void { localStorage.removeItem('accessToken'); localStorage.removeItem('refreshToken'); this.accessToken$.next(null); } // 对外暴露Token的Observable,供Interceptor和Guard订阅 getCurrentAccessToken$(): Observable<string | null> { return this.accessToken$.asObservable(); } }
2. 改造AuthGuard,支持异步刷新Token
将Guard改为异步判断逻辑,遇到过期Token先尝试刷新,成功则放行,失败再跳转登录:
// auth.guard.ts import { CanActivateFn, Router } from '@angular/router'; import { inject } from '@angular/core'; import { AuthService } from './auth.service'; import { map, switchMap } from 'rxjs/operators'; import { of } from 'rxjs'; export const AuthGuard: CanActivateFn = (route, state) => { const authService = inject(AuthService); const router = inject(Router); // 先检查Token是否有效 if (authService.isAuthenticated()) { return of(true); } // Token无效,尝试刷新 return authService.refreshToken().pipe( switchMap(newToken => { if (newToken) { // 刷新成功,直接放行 return of(true); } else { // 刷新失败,跳转登录并记录返回地址 router.navigate(['/login'], { queryParams: { returnUrl: state.url } }); return of(false); } }), catchError(() => { router.navigate(['/login'], { queryParams: { returnUrl: state.url } }); return of(false); }) ); };
3. 优化Interceptor,解决并发刷新与Token同步问题
让Interceptor订阅AuthService的Token流,避免直接读取localStorage,同时处理401时的刷新逻辑:
// auth.interceptor.ts import { HttpInterceptorFn, HttpRequest, HttpErrorResponse } from '@angular/common/http'; import { inject } from "@angular/core"; import { AuthService } from "./auth.service"; import { catchError, switchMap, take } from "rxjs"; import { IS_PUBLIC } from './public-token'; export const AuthInterceptor: HttpInterceptorFn = (req, next) => { const authSvc = inject(AuthService); // 标记为公开的请求直接放行,无需添加Token if (req.context.get(IS_PUBLIC)) { return next(req); } // 订阅当前Token状态,确保拿到最新值 return authSvc.getCurrentAccessToken$().pipe( take(1), switchMap(accessToken => { if (!accessToken) { authSvc.logout(); return next(req); } // 携带Token发起请求 const authReq = addAuthorizationHeader(req, accessToken); return next(authReq).pipe( catchError(error => { if (error instanceof HttpErrorResponse && error.status === 401) { // 401时尝试刷新Token,用新Token重发请求 return authSvc.refreshToken().pipe( switchMap(newToken => { if (newToken) { return next(addAuthorizationHeader(req, newToken)); } else { authSvc.logout(); return next(req); } }) ); } // 其他错误直接登出(可根据业务调整,比如仅处理401) authSvc.logout(); throw error; }) ); }) ); }; // 工具函数:给请求添加Authorization头 const addAuthorizationHeader = (req: HttpRequest<any>, token: string) => { return req.clone({ headers: req.headers.set('Authorization', `Bearer ${token}`) }); };
4. 补充IS_PUBLIC常量定义
// public-token.ts import { HttpContextToken } from '@angular/common/http'; export const IS_PUBLIC = new HttpContextToken(() => false);
核心优化点说明
- 防抖刷新:用
isRefreshing$标记刷新状态,避免多个并发请求同时调用刷新接口,减少后端压力。 - 状态同步:通过BehaviorSubject管理Token,Guard和Interceptor都能实时获取最新Token值,避免“刷新后仍用旧Token”的问题。
- 异步Guard:将原本的同步判断改为异步流程,给Token刷新留足重试机会,解决“Token过期直接跳转登录”的问题。
- Interceptor依赖Token流:不再直接读取localStorage,而是从AuthService获取Token,确保数据一致性。
内容的提问来源于stack exchange,提问作者Erenb
相关产品推荐
相关产品推荐

