You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Angular令牌认证Bug:Auth Guard与Interceptor协同导致无法刷新令牌

问题根源拆解

  1. AuthGuard仅做Token存在性校验:你的isAuthenticated()应该只是检查了localStorage中是否存在accessToken,未验证Token是否过期——导致过期Token仍能通过Guard进入页面,但发起请求时触发401错误。
  2. Interceptor刷新逻辑有漏洞:刷新成功后未更新localStorage的accessToken,并发请求下会重复调用刷新接口,且刷新失败后直接登出,但未同步Guard的状态。
  3. 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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.16 04:29:49