Angular 中的身份验证:DI 中的循环依赖问题突然冒出来


从我们离开的地方开始,我们的 Auth 服务的第一个用例是标头令牌。 添加标头令牌的最佳方法是通过 Http 拦截器。 开始吧。

由于我们无论如何都要注入 AuthService,并使用 AppModule,因此使用 HttpInterceptorFunction 而不是 goold ol' HttpClientModule 并没有太大区别。 最终会更明显地表明它确实是一个更好的选择。

您可以阅读有关 Angular 15 独立 HTTPClient 提供程序的信息。

在我们的 App Module provider 数组中,我们为拦截器添加了另一个条目:

// app.module
@NgModule({
    // ...  
    providers: [
        {
        provide: HTTP_INTERCEPTORS,
        multi: true,
        useClass: AppInterceptor,
      },
        // ...
    ]
})
export class AppModule {}

拦截器立即注入 AuthService 以使用它。 让我在第一行添加一个控制台日志。

// services/http HttpInterceptor
@Injectable()
export class AppInterceptor implements HttpInterceptor {
  constructor(private authState: AuthState) {
    console.log('interceptor injected');
  }
  intercept(req: HttpRequest, next: HttpHandler): Observable {
    // prefixing the api with proper value, mostly from config
    // remote config url are expected to be filtered out, it would not make sense
    const url = 'https://saphire.sekrab.com/api' + req.url;

    const adjustedReq = req.clone({
      url: url,
      setHeaders: this.getHeaders(),
    });

    return next.handle(adjustedReq);
  }
  private getHeaders(): any {
    // TODO authorization here
    let headers: any = {};

    return headers;
  }
}

在我们添加我们的头之前,让我们记住我们的事件顺序:假设我们使用 Http 调用来获取远程配置,它通常有正确的 API URL,很明显我们需要过滤掉配置 URL。 在这个例子中,我没有为配置调用远程 URL,但很高兴知道拦截器应该检查 req.url 并过滤掉它不想处理的那些。

// simple check to exclude local data or config url
if (req.url.indexOf('config') > -1) {
    // pass through
    return next(req);
}

DI 问题中的循环依赖

错误:NG0200:为 InjectionToken HTTP_INTERCEPTORS 检测到 DI 中的循环依赖。

你见过这个吗? 当您在另一个服务中注入一个服务时,它会发生,该服务将其注入自身。 在我们的例子中,AuthService 和 HttpClient 相互注入。

除了这两个服务之外,AuthService中还注入了使用Http的配置服务。 怎么看都是乱七八糟的。

但在你步入中年之前,这件事会杀死你。 由于我们没有在 AuthService 构造函数中使用 HttpClient,因此这个肿瘤是良性的。 然而,如果我们确实在构造函数中发起了一个 Http 调用,那就是它在我们面前爆炸的时候。

有很多修复,其中大部分都是围绕延迟 Http 调用来确保 AuthService 已构建。 就像等待远程配置准备就绪一样。 但这不是一个干净的解决方案。

那么,作为一般规则,那就解决了:避免在服务构造函数中进行 Http 请求。 特别是那些早期注射的。

如果您确实需要注入一个在其构造函数中调用 Http 的服务(下周我们将有一个用例),请将您的服务分开,并将它们分散在您的应用程序中。

AuthState 服务

为了在我们前进的过程中进行清理并变得更加系统化,让我们将所有与 Http 无关的方法移至它们自己的服务中。 AuthState 是将保存 Observable 状态的服务,并且不包含对 HttpClient 的引用。 构造函数负责读取LocalStorage信息,它有GetToken新方法返回token。

// services/auth.state

@Injectable({ providedIn: 'root' })
export class AuthState {
  // create an internal subject and an observable to keep track
  private stateItem: BehaviorSubject = new BehaviorSubject(
    null
  );
  stateItem$: Observable = this.stateItem.asObservable();

  constructor() {
    // simpler to initiate state here
    // check item validity
    console.log('authState in');
    const _localuser: IAuthInfo = JSON.parse(localStorage.getItem('user'));
    if (this.CheckAuth(_localuser)) {
      this.SetState(_localuser);
    } else {
      this.Logout();
    }
  }
// also move here: SetState  RemoveState CheckAuth  Logout
}

现在AuthService就简单多了,只有Login,用AuthState存入localStorage。 我们稍后会在使用适当的 localStorage 包装器时增强它。

所以现在我们需要创建一个 GetToken 方法来检索访问令牌,然后在 HttpInterceptor 中使用它

// services/auth.state
// add this new method
GetToken() {
  const _auth = this.stateItem.getValue();
  // check if auth is still valid first before you return
  return this.CheckAuth(_auth) ? _auth.accessToken : null;;
}

稍后我们将添加检查令牌的逻辑。 然后我们在拦截器中使用它

// update http file to fill out get headers
private getHeaders(): any {
  //  authorization here
  let headers: any = {};
    const _auth = this.authState.GetToken();
    if (_auth && _auth !== '') {
      headers['authorization'] = `Bearer ${_auth}`;
    }
    return headers;
}

在全模块解决方案中,一切都是我们类的私有成员。 在独立的情况下,一切都是松散变量。 你更喜欢哪种方式?

401刷新

当我们收到 401 时会发生什么? 我们可以将用户显示出来,或者使用我们的刷新令牌来获取新的访问令牌。 在 Angular 中,这可能是让我伤痕累累的任务之一。 这是事件的顺序:

赶上 401(哪个 401)

使用刷新令牌创建新的 Http 调用,并请求新的访问令牌

等待回应

更新本地存储

重新提交原始请求(重试)

回去好好生活

否则注销

捕获另一个并发 401,排队等待

所以我们先修改Http函数来捕获401,并为其调用一个函数。

// services/http

intercept(req: HttpRequest, next: HttpHandler): Observable {
    // ...

    return next.handle(adjustedReq).pipe(
      catchError(error => {
         // if this is really an http error
         if (error instanceof HttpErrorResponse
            // and of 401 status
            && error.status === 401
          ){
                    // handle 401 error, return an observable to continue the pipe
          return this.handle401Error();
         }
         // rethrow error, to be caught elsewhere
         return throwError(() => error);
      })
    );
  }

  private handle401Error(): Observable {
    // let's first try to submit a refresh access token request
        // return authService.RefreshToken()
    // switchMap when done to resubmit the req passed, using next.handler
    // catchError means it is not working, rethrow and logout
  }

我们现在要做的是填写 handle401Error 函数。 首先,看起来我们需要 AuthService(而不是 AuthState)中的 RefreshToken 方法。 这意味着我们也需要注入它。 请记住:AuthService 在构造函数中没有 Http 调用。

// services/auth.service
// add RefreshToken method
RefreshToken(): Observable {
  return this.http
    .post(this._refreshUrl, { token: this.authState.GetToken() })
    .pipe(
      map((response) => {
        // this response has the new refresh token and access token
       if (!response) {
          // something terrible happened
          throw(new Error('Oh oh'));
        }

        // update session
        const retUser: IAuthInfo = (response).data;
        // we'll be more selective later...
        localStorage.setItem('user', JSON.stringify(retUser));

        this.authState.SetState(retUser);

        return true;
      })
    );
}

回到我们的 handle401Error 函数

// services/http
// update handle401Error function, also, inject AuthService in the constructor
private handle401Error(
    // pass in orginalReq and handler
    originalReq: HttpRequest,
  next: HttpHandler
): Observable {
    return this.authService.RefreshToken().pipe(
      switchMap((result: boolean) => {
        if (result) {
          // token saved (in RefreshToken), now recall the original req after adjustment
            // so we need to pass "next" handler, and "originalReq"
          return next.handle(originalReq.clone({setHeaders: this.getHeaders()}));
        }
      }),
      catchError(error => {
        // else refresh token did not work, its bigger than both of us
        // log out and throw error
        this.authState.Logout();
        return throwError(() => error);
      })
    );
}

我们调整签名以传入 originalReq 和下一个处理程序:

// services/http
// adjust call
return next.handle(adjustedReq).pipe(
  catchError((error) => {
        // ...
      return this.handle401Error(adjustedReq, next);
    }
        // ...
  })
);

对此进行测试,第一个问题是 /login 点。 如果是 401,则无需重试,这仅表示凭据错误。 所以处理程序必须过滤掉 /login 点

// services/http filter out login from handler401Error
return next.handle(adjustedReq).pipe(
  catchError((error) => {
    // if this is really an http error
    if (
      error instanceof HttpErrorResponse &&
      // and of 401 status
      error.status === 401 &&
      // filter out login calls
      req.url.indexOf('login') < 0
    ) {
      return this.handle401Error(adjustedReq, next);
    }
    // rethrow error
    return throwError(() => error);
  })

通过在某个页面中进行调用并在我的测试服务器上硬编码一些东西来测试它,这就是我注销序列的结果:


因此,您可以看到使用正确的访问令牌撤回了原始请求。

你想生产类似的彩色原木吗? 阅读驯服控制台

锁定和解锁

我们还没有完成。 让我们创建一个示例用法来查看由此产生的问题。 我们将同时发出两个请求。 这意味着当第一个请求试图刷新令牌时,第二个请求进来了,它也可能请求一个新令牌,从而搞砸了原始令牌。 这是虚拟日志,它不会破坏系统,因为它很笨:


请注意以下事项:

抛出两个 401 错误,这是预期的

两次调用刷新令牌,使用相同的访问令牌,一个应该工作,另一个不应该

响应带有新令牌,在我的示例中它是相同的,因为它很笨。 在现实生活中会有两种不同的访问令牌,一种必须失败(如果它还没有失败的话)

要解决这个问题,我们需要锁定、排队,然后解锁。

使用私有布尔成员可以直接锁定和解锁:

// services/http
// add lock boolean
@Injectable()
export class AppInterceptor implements HttpInterceptor {
  // if refreshing token, it is busy, lock
  isBusy: boolean;
  private handle401Error(
    originalReq: HttpRequest,
    next: HttpHandler
  ): Observable {

    if (!this.isBusy) {
            // lock
      this.isBusy = true;

      return this.authService.RefreshToken().pipe(
      // ...
                finalize(() => {
                    // unlock
          this.isBusy = false;
       })
      );
    } else {
      // return unadjusted, for now
      return next.handle(originalReq);
    }
  }
}

现在有了这个,一个调用会重试,而所有其他调用都会失败。 在我们调整和召回所有其他令牌之前,我们需要稍等片刻,直到令牌准备就绪。 为此,我们可以有一个私有成员来跟踪成功的令牌。 准备好后,冲洗干净。


对此最广泛接受的解决方案是使用 Boolean 的 Subject 并在其上使用管道。 它在锁定时和令牌准备就绪时更新。

// services/http update to allow subject queuing
@Injectable()
export class AppInterceptor implements HttpInterceptor {

    // create a subject to queue outstanding refresh calls
  recall: Subject = new Subject();
 // ...
 private handle401Error(...): Observable {
   if (!this.isBusy) {
    // ...
    // progress subject to false
    this.recall.next(false);
    return this.authService.RefreshToken().pipe(
      switchMap((result: boolean) => {
        if (result) {
          // progress subject to true
          this.recall.next(true);
                // ... return next.handle
        }
      }),
      // ...
    );
  } else {
    // return the subject, watch when it's ready, switch to recall original request
    return this.recall.pipe(
      filter(ready => ready === true),
      switchMap(ready => {
         // try again with adjusted header
         return next.handle(originalReq.clone({ setHeaders: this.getHeaders() }));
      })
     );
    }
}

我试图打破它,但我做不到。 如果您遇到它出现问题的情况,请告诉我。

侧点

如果访问令牌无效(已过期),您可能会想要停止传出请求。 不。 这是一个 API 决定。 有些点不需要访问令牌(如 /login),如果令牌无效,有些点可以灵活地返回较少的数据。

提高

我们可以添加的一项增强功能是,如果刷新令牌失败,则将用户重定向到登录页面。

另一个增强功能是登录解析。 我们现在可以将导致重定向的 URL 保存在 auth 状态,并在登录后尝试重定向到它。 下周将出现关于用户帐户详细信息的那件事和另一件事。

感谢您阅读到这里,您是否破坏了 401 处理程序?

展开阅读全文

页面更新:2024-06-09

标签:令牌   控制台   解锁   函数   原始   确实   解决方案   成员   错误   程序

1 2 3 4 5

上滑加载更多 ↓
推荐阅读:
友情链接:
更多:

本站资料均由网友自行发布提供,仅用于学习交流。如有版权问题,请与我联系,QQ:4156828  

© CopyRight 2020-2024 All Rights Reserved. Powered By 71396.com 闽ICP备11008920号-4
闽公网安备35020302034903号

Top