Strona główna / Artykuły

Rozwiązywanie problemów z Angular AuthService: Gateway, Store, Facade i Ports

Podzielić przeciążony Angular AuthService na bramkę, magazyn sygnałów, port przechowywania oraz fasadę, z możliwością odnowienia tokena przy jednej transakcji, interseptorem o zasięgu ograniczonym oraz strażnikami nadającymi się do testowania.

Wydawca: REACTAPP.TOP17 min czytania3866 słów

Źródła: Clean Architecture in Angular: Refactoring the AuthService with Ports & Adapters

Prawie każda aplikacja Angular posiada AuthService, a w wielu projektach jest to plik, którego nikt nie chce edytować. Łączy on w sobie wywołania HTTP, przechowywanie tokenów, stan reaktywny, logikę odświeżania oraz nawigację, więc zmiana któregokolwiek z tych elementów oznacza konieczność przejrzenia całego pliku. Ten artykuł opisuje, jak przekształcić tę usługę w małe warstwy z jednokierunkowymi zależnościami, oraz pokazuje dwa rzeczywiste błędy, które ujawniły się w wyniku takiej reorganizacji: awarię odświeżania, która pomijała proces wylogowania, oraz interceptor, który ujawniał tokeny do API stron trzecich. Otrzymasz plan, który możesz zastosować we własnej funkcji autoryzacji, oraz jasne pojęcie o sytuacjach, gdy dodatkowe pliki nie są konieczne.

Jak powstaje „bóg” – AuthService

Nikt specjalnie nie projektuje usługi autoryzacji składającej się z 300 linii kodu. Wszystko zaczyna się od metody login(), która wywołuje jeden endpoint. Następnie token musi być gdzieś przechowywany, więc pojawia się odpowiednie pole. Aby token przetrwał ponowne załadowanie strony, dodaje się localStorage. Potem pojawiają się tokeny odświeżania, następnie BehaviorSubject, aby komponenty mogły reagować na stan logowania, a także router.navigate('/login') przy wylogowaniu – ponieważ nie wydawało się, by istniało lepsze miejsce na to. Rok i pół później klasa importuje Router, HttpClient oraz localStorage, a zmiana strategii przechowywania tokenów wymaga zrozumienia całego pliku.

Zwięzła wersja tego punktu wyjścia wygląda tak. Zwróć uwagę, ile niespowiązanych zadaní korzysta z jednego tap w funkcji login(): aktualizacja pola, zapisanie dwóch kluczy przechowywania, wysłanie informacji o stanie i nawigacja.

@Injectable({ providedIn: 'root' })
export class AuthService {
  private token: string | null = localStorage.getItem('token');
  public isLoggedIn$ = new BehaviorSubject(!!this.token);

  constructor(
    private http: HttpClient,
    private router: Router,
  ) {}

  login(username: string, password: string) {
    return this.http
      .post<any>('https://api.example.com/auth/login', { username, password })
      .pipe(
        tap((res) => {
          this.token = res.accessToken;
          localStorage.setItem('token', res.accessToken);
          localStorage.setItem('refresh', res.refreshToken);
          this.isLoggedIn$.next(true);
          this.router.navigate(['/dashboard']);
        }),
      );
  }
  // ...and 250 more lines
}

Technicznie nic z tego nie jest błędne, a to właśnie jest pułapka. Wszystko działa dopóki nie zmieni się jeden z wymagań, a wtedy każde zadanie okazuje się powiązane z każdym innym. Reszta tego artykułu opisuje restrukturyzację funkcji autoryzacji w małym projekcie Angular 22. Otrzymany kształt nie jest nowy, a każda jego część ma dobrze znane nazwy, ale zastosowanie go do konkretnego kodu z konkretnymi błędami ilustruje sytuację lepiej niż diagram.

Docelowa struktura

Jedna klasa staje się kilkoma skoncentrowanymi plikami, zgrupowanymi w folderze core obejmującym elementy wspólne dla całego projektu oraz w osobnym folderze przeznaczonym dla funkcji autoryzacji.

src/app/
  core/
    api-config.ts          ← injectable base URL
    auth-interceptor.ts    ← cross-cutting: attach token, retry on 401
  auth/
    auth-models.ts         ← types
    auth-api.ts            ← HTTP transport, nothing else
    auth-store.ts          ← state, and only state
    auth-storage.ts        ← storage port + adapters
    auth-service.ts        ← orchestration (the facade)
    auth-guard.ts          ← route-level policy
    login/
  dashboard/

Siedem małych plików zamiast jednego dużego jest dobrym rozwiązaniem tylko wtedy, gdy każdy z tych plików jest łatwiejszy do zrozumienia osobno niż oryginalny plik jako całość. Praktycznym testem jest to, czy można opisać zadanie każdego pliku w jednym zdaniu. Tutaj to możliwe, a schemat zależności jest na tyle krótki, że można go narysować w kilku liniach:

auth-interceptor  →  auth-service  →  auth-api    (talks to the network)
   (cross-cutting)      (facade)    →  auth-store  (holds the truth)
                                    →  auth-models (describes the shapes)

Każda strzałka wskazuje w prawo. To właśnie ta zasada – że zależności przepływają w jednym kierunku bez cykli – sprawia, że cała struktura funkcjonuje spójnie. Każda z poniższych sekcji jest jej konsekwencją.

Brama wejściowa: wiedza o sieciach w jednym miejscu

Najniższa warstwa otacza punkty końcowe HTTP i nie robi nic więcej. Każda metoda mapuje swoje argumenty na zapytanie o określonym typie i zwraca wynikowy obiekt do obserwacji.

@Service()
export class AuthApi {
  private readonly httpClient = inject(HttpClient);
  private readonly baseUrl = inject(API_BASE_URL);

  public login(request: LoginInput) {
    return this.httpClient.post<AuthResponse>(`${this.baseUrl}/auth/login`, request);
  }

  public refresh(refreshToken: string, expiresInMins: number) {
    return this.httpClient.post<RefreshResponse>(`${this.baseUrl}/auth/refresh`, {
      refreshToken,
      expiresInMins,
    });
  }

  public me() {
    return this.httpClient.get<User>(`${this.baseUrl}/auth/me`);
  }
}

To jest wzorzec Gateway z książki Martina Fowlera „Patterns of Enterprise Application Architecture”. Często nazywa się go repositorem, ale repozytorium oznacza abstrakcję przypominającą zbiór, którą można wyszukiwać. Typizowany wrapper wokół odległych wywołań można bardziej dokładnie opisać jako bramę.

Zaletą jest izolacja wiedzy o protokole. Jeśli punkt wejścia do logowania przeniesie się z /auth/login na /v2/sessions, zmiana dotyczy wyłącznie tej klasy; reszta aplikacji nigdy nie obsługuje adresu URL.

To, czego brakuje, ma takie samo znaczenie jak to, co jest obecne: brak stanu, brak localStorage, brak Router oraz brak obsługi błędów poza tym, co oferuje sam HTTP. Każda metoda po prostu przekształca swoje argumenty w zwykły obiekt Observable, co ułatwia zastąpienie tej klasy fałszywym odpowiednikiem podczas testów.

Adres URL bazowy jest wprowadzany dynamicznie, a nie ustawiany bezpośrednio, za pomocą tokena iniekcji:

export const API_BASE_URL = new InjectionToken<string>('API_BASE_URL');

Token dla pojedynczej ciągu znaków może wyglądać jak coś skomplikowanego. W rzeczywistości tak nie jest, a sekcja dotycząca interceptora wyjaśnia, dlaczego staje się jednym z najważniejszych elementów tej funkcjonalności.

Kilka słów na temat dekoratora: przykłady używają @Service() zgodnie z tym, jak jest to zapisane w projekcie. Jeśli Twoja wersja Angulara tego nie oferuje, @Injectable({ providedIn: 'root' }) pełni tę samą funkcję; sprawdź dokumentację dotyczącą używanej wersji.

Magazyn: jeden właściciel stanu sesji

Magazyn przechowuje informacje o sesji i jest jedynym elementem, który może je zmieniać. Przechowuje token dostępu, token odnowienia oraz informacje o użytkowniku w formie sygnałów.

@Service()
export class AuthStore {
  private readonly storage = inject(AUTH_STORAGE);

  private readonly accessTokenState = signal<string | null>(null);
  private readonly refreshTokenState = signal<string | null>(null);
  private readonly userState = signal<User | null>(null);

  readonly accessToken = this.accessTokenState.asReadonly();
  readonly refreshToken = this.refreshTokenState.asReadonly();
  readonly user = this.userState.asReadonly();

  readonly isAuthenticated = computed(() => this.accessTokenState() !== null);

  public setTokens(access: string, refresh: string): void {
    this.accessTokenState.set(access);
    this.refreshTokenState.set(refresh);
    this.persist();
  }

  public clear(): void {
    this.accessTokenState.set(null);
    this.refreshTokenState.set(null);
    this.userState.set(null);
    this.storage.removeItem(STORAGE_KEY);
  }
  // ...
}

Najważniejszy szczegół łatwo umknąć: każdy sygnał do zapisu jest prywatny, a każdy publiczny sygnał jest dostępny poprzez asReadonly().

To jest rozdzielenie komend od zapytań wprowadzone przez system typów. Każdy może czytać; zapis odbywa się wyłącznie za pośrednictwem metod nazwanych według określonego zamiaru, takich jak setTokens i clear. Komponent nie może bezpośrednio ustawić tokena. Nie jest to konwencja, której należy przestrzegać – jest to niemożliwe, ponieważ typ read-only Signal<T> nie posiada metody set().

Korzyści są widoczne podczas debugowania. Pytanie „Kto zmienił ten stan?” przestaje być problemem do zbadania, ponieważ tylko kilka nazwanych metod w jednym pliku może modyfikować sesję. Porównaj to z publicznym BehaviorSubject, gdzie dowolny z dziesiątek komponentów mógł wywołać next().

Sklep jest również jedyną warstwą, która ma kontakt z pamięcią trwałą, i traktuje wszystko, co odczytuje, jako niepewny wprowadzony danych.

private restore(): void {
  const raw = this.storage.getItem(STORAGE_KEY);
  if (!raw) return;

  // Storage is user-writable, so treat anything in it as untrusted input.
  try {
    const session = JSON.parse(raw) as Partial<PersistedSession>;
    if (typeof session.accessToken !== 'string' || typeof session.refreshToken !== 'string') {
      this.storage.removeItem(STORAGE_KEY);
      return;
    }
    this.accessTokenState.set(session.accessToken);
    this.refreshTokenState.set(session.refreshToken);
    this.userState.set(session.user ?? null);
  } catch {
    this.storage.removeItem(STORAGE_KEY);
  }
}

localStorage jest w rzeczywistości polem tekstowym, które użytkownik może edytować, a rozszerzenia przeglądarki lub starsze wersje własnej aplikacji mogą pozostawić w nim nieoczekiwane dane. Użycie JSON.parse bez bloku try/catch niesie ryzyko awarii podczas uruchamiania aplikacji, a ścisły tryb TypeScript nie wyda żadnego ostrzeżenia, ponieważ JSON.parse zwraca typ any. Sprawdzenie struktury danych i usunięcie wpisu, jeśli nie odpowiada on standardom, zapobiega temu, by uszkodzona sesja wyprowadziła użytkownika z aplikacji, zamiast pozostawić go przed pustym ekranem.

Port przechowywania: umożliwienie wymiany mechanizmów trwałego przechowywania

Zależność AUTH_STORAGE w sklepie nie jest samym localStorage. Jest to interfejs z tokenem iniekcji, którego domyślna fabryka wybiera odpowiednią implementację.

export interface AuthStorage {
  getItem(key: string): string | null;
  setItem(key: string, value: string): void;
  removeItem(key: string): void;
}

export const AUTH_STORAGE = new InjectionToken<AuthStorage>('AUTH_STORAGE', {
  providedIn: 'root',
  factory: () => (typeof localStorage === 'undefined' ? new MemoryStorage() : localStorage),
});

To jest istota koncepcji Portów i Adapterów (architektura sześciokątna) w swojej najprostszej formie. Interfejs AuthStorage definiuje port. Prawdziwe przechowywanie w przeglądarce łączy się z nim jako jeden adapter, a kilkulinijowa klasa MemoryStorage działająca w pamięci łączy się jako inny adapter.

Port rozwiązuje trzy konkretne problemy:

  1. Testy wykorzystują prawdziwy sklep. Iniekcja MemoryStorage umożliwia sprawdzenie rzeczywistego zachowania przechowywania danych, takiego jak zapisywanie, ponowne ładowanie i przywracanie, bez konieczności usuwania stanu globalnego pomiędzy testami.
  • Rendering po stronie serwera przestaje powodować awarie. Na serwerze nie istnieje localStorage, więc mechanizm automatycznie przechodzi na przechowywanie w pamięci.
  • Decyzje dotyczące bezpieczeństwa stają się poddawalne ocenie. Przechowywanie tokena odświeżania w localStorage oznacza, że każdy skrypt działający na stronie może go odczytać, co stanowi prawdziwe zagrożenie typu XSS. Skutecznym rozwiązaniem jest użycie ciasteczka httpOnly utworzonego przez serwer. Dzięki przechowywaniu danych za pomocą portu ta migracja dotyczy tylko jednego adaptera, zamiast wymagać przeszukiwania i zastępowania w całym kodzie, a do czasu jej przeprowadzenia ryzyko znajduje się w jednym pliku z komentarzem wyjaśniającym tę sytuację.
  • Trzeci punkt jest bardziej ogólny. Dobra architektura nie zamienia złej decyzji w dobrą – sprawia, że decyzja jest lokalna, widoczna i łatwa do odwołania.

    Fasada: orkiestracja bez odpowiedzialności

    AuthService przetrwa, ale ma znacznie mniejsze zadania. Koordynuje bramkę i magazyn, a sam nie posiada żadnych zasobów.

    @Service()
    export class AuthService {
      private readonly authApi = inject(AuthApi);
      private readonly store = inject(AuthStore);
    
      readonly accessToken = this.store.accessToken;
      readonly isAuthenticated = this.store.isAuthenticated;
      readonly user = this.store.user;
    
      public login(username: string, password: string): Observable<AuthResponse> {
        return this.authApi.login({ username, password, expiresInMins: TOKEN_LIFETIME_MINUTES }).pipe(
          tap((response) => {
            this.store.setTokens(response.accessToken, response.refreshToken);
            this.store.setUser({ /* ...map response to User... */ });
          }),
        );
      }
    
      public logout(): void {
        this.store.clear();
      }
    }
    

    To jest fasada Gang of Four, która pełni również rolę pośrednika – jest to jedyna warstwa, która wie o istnieniu zarówno bramki, jak i magazynu.

    Ponowne eksportowanie sygnałów z magazynu przez fasadę to krótka operacja o dużym efekcie: komponenty polegają wyłącznie na AuthService i nie mają pojęcia o istnieniu oddzielnego AuthStore. Dzięki temu magazyn staje się szczegółem implementacyjnym danej funkcjonalności, więc później można go zastąpić NgRx bez konieczności zmiany żadnego komponentu.

    Zauważ, że Router nadal nie istnieje. Facada nie zajmuje się nawigacją, ponieważ jest to sprawa związana z prezentacją. Strona logowania chce przejść do returnUrl, przycisk wylogowania na panelu sterowania wymaga adresu /login, a dialog o wygaśnięciu sesji może w ogóle nie przeprowadzać żadnej nawigacji. Wywołanie router.navigate() wewnątrz logout() zmusiłoby do stosowania jednej polityki we wszystkich przypadkach wywołania. Zamiast tego to komponent, który wywołuje tę funkcję, podejmuje decyzję:

    protected logout(): void {
      this.auth.logout();
      void this.router.navigateByUrl('/login');
    }
    

    Kosztem jest dwie linie kodu w komponencie. W zamian AuthService może być wywoływany z interceptorów, mechanizmów ochrony i testów bez wywoływania przekierowania, którego nikt nie prosił.

    Łączenie jednoczesnych odświeżeń w jeden żądanie

    Najmocniejszym argumentem za użyciem fasady jest problem, którego trudno rozwiązać w inny sposób. Panel sterowania wysyła osiem żądań równocześnie. Token dostępu wygasł pół minuty temu, więc wszystkie osiem żądań wraca z błędem 401 niemal jednocześnie. Prosty interceptor reaguje na to rozpoczynaniem ośmiu prób odnowienia tokena. Wiele systemów backendowych rotuje tokeny odnowienia za każdym razem, gdy są używane, więc zanim dotrą późniejsze żądania, tokeny, które one zawierają, są już nieważne, w wyniku czego użytkownik zostaje wylogowany z strony, którą otworzył kilka sekund temu.

    Rozwiązaniem jest metoda single-flight, opisana również jako łączenie żądań lub zapobieganie zjawisku „thundering herd”: pierwszy żądający rozpoczyna odnowienie tokena, a pozostali łączą się do już trwającego żądania. Ta sama koncepcja w ustawieniach Axios jest omówiona w artykule dlaczego jednoczesne błędy 401 powodują wylogowanie użytkowników.

    public refresh(): Observable<RefreshResponse> {
      const refreshToken = this.store.refreshToken();
    
      if (!refreshToken) {
        return throwError(() => new Error('No refresh token available'));
      }
    
      this.refreshRequest$ ??= this.authApi.refresh(refreshToken, TOKEN_LIFETIME_MINUTES).pipe(
        tap((response) => this.store.setTokens(response.accessToken, response.refreshToken)),
        finalize(() => (this.refreshRequest$ = undefined)),
        shareReplay({ bufferSize: 1, refCount: true }),
      );
    
      return this.refreshRequest$;
    }
    

    Trzy operatory muszą występować w tej kolejności z trzech różnych powodów:

    • tap przechowuje nowe tokeny. Ponieważ znajduje się przed shareReplay, jest wykonywany raz na każdą wspólną eksploatację, a nie raz na abonenta. Gdyby znajdował się później, przepisałby dane przechowywane dla każdego z ośmiu wywołujących.
    • finalize szyfruje pole cache po zakończeniu wspólnej prośby lub wystąpieniu błędu. Bez niego przy wygaśnięciu tokena później zostałaby odtworzona stara, już użyta odpowiedź zamiast wywołać nowe wezwanie.
    • shareReplay({ bufferSize: 1, refCount: true }) wysyła jedno wezwanie HTTP do wszystkich abonentów i je kończy po odejściu ostatniego z nich.

    Przypisanie typu ??= sprawia, że mechanizm działa w trybie pojedynczego lotu: tylko wywołujący, który stwierdzi puste pole, tworzy żądanie, a wszyscy, którzy przychodzą w czasie jego oczekiwania, otrzymują to samo obserwowalne wartości. W rezultacie dochodzi do jednego wywołania sieciowego i jednej rotacji tokena.

    Synchroniczne rzucenie błędu bez wylogowania

    Użycie metody throwError w funkcji odświeżania nie jest wyborem stylistycznym. Wcześniejsza wersja klauzuli ochronnej wyglądała tak:

    if (!refreshToken) throw new Error('No refresh token available');
    

    Brzmi to naturalnie, ale jest błędne. To granica warstw sprawiła, że błąd stał się widoczny. Interceptor konsumuje funkcję refresh() w ten sposób:

    return auth.refresh().pipe(
      switchMap(/* retry the original request */),
      catchError((refreshError) => {
        auth.logout();
        return throwError(() => refreshError);
      }),
    );
    

    Synchroniczne wywołanie throw wewnątrz metody refresh() następuje, gdy wyrażenie auth.refresh() jest jeszcze w trakcie obliczania, zanim zostanie wywołana metoda .pipe(). Operator catchError nie ma szansy zobaczyć błędu, dlatego auth.logout() nigdy nie jest wykonywany. Jedyną sytuacją, dla której została napisana klauzula ochronna – brak tokena odświeżenia – była właśnie sytuacja, w której użytkownik pozostawał w stanie częściowo zalogowanym bez żadnych komunikatów.

    Zwrócenie wartości throwError(() => ...) przekształca ten błąd w powiadomienie o błędzie w strumieniu, dzięki czemu gałąź logowania z interfektora działa zgodnie z zamysłem. Jako ogólne zasada, metoda, której sygnatura zwraca obiekt Observable, powinna zgłaszać błędy za pośrednictwem tego obiektu, a nie poprzez rzucanie wyjątków.

    Lekcja z architektury polega na tym, że ten błąd łatwo się ukrywa w klasie składającej się z 300 linii kodu, gdzie są ze sobą pomieszane elementy obsługi HTTP, stanu i błędów, ponieważ nic nie zmusza nikogo do określenia, co dana metoda obiecuje swoim wywołującym. Gdy refresh() staje się publiczną metodą fasady pełniącą jedną konkretne funkcję, pytanie „czy zwraca ona nieudany strumień danych, czy rzuca błąd?” staje się oczywiste, podobnie jak test:

    it('emits an error stream instead of throwing when there is no refresh token', () => {
      const onError = vi.fn();
    
      // A synchronous throw here would bypass the interceptor's catchError/logout branch.
      expect(() => service.refresh().subscribe({ error: onError })).not.toThrow();
      expect(onError).toHaveBeenCalledOnce();
    });
    

    K warstwy nie zapobiegają błędom. One dają im miejsce, w którym stają się widoczne, i to stanowi większość ich wartości.

    Interceptor: reguły przekrojowe i token z zakresem

    Interceptor stosuje reguły autoryzacji do każdego wysyłanego żądania: dołącza token nośny, a w przypadku błędu 401 odświeża żądanie i próbuje je ponownie raz.

    const UNAUTHENTICATED_PATHS = ['/auth/login', '/auth/refresh'];
    
    export const authInterceptor: HttpInterceptorFn = (req, next) => {
      const baseUrl = inject(API_BASE_URL);
    
      if (!isOurApi(req, baseUrl) || isUnauthenticatedRequest(req)) {
        return next(req);
      }
    
      const auth = inject(AuthService);
      const accessToken = auth.accessToken();
      const authReq = accessToken ? withBearer(req, accessToken) : req;
    
      return next(authReq).pipe(
        catchError((error: HttpErrorResponse) => {
          if (error.status !== 401) {
            return throwError(() => error);
          }
    
          return auth.refresh().pipe(
            switchMap(() => {
              const newToken = auth.accessToken();
              if (!newToken) {
                return throwError(() => new Error('Refresh succeeded without token'));
              }
              return next(withBearer(req, newToken));
            }),
            catchError((refreshError) => {
              auth.logout();
              return throwError(() => refreshError);
            }),
          );
        }),
      );
    };
    

    Łańcuch interceptorów w Angular to bezpośrednia implementacja zasady łańcucha odpowiedzialności – każda funkcja interceptora modyfikuje żądanie w momencie jego przechodzenia przez łańcuch. Co ważniejsze, zasady, które egzekwuje (dołączanie danych uwierzytelniających oraz traktowanie błędu 401 jako sygnału do odświeżenia żądania i ponownej próby), mają zastosowanie do całej powierzchni API, co czyni je typowym przykładem kwestii przecinających się w projektowaniu oprogramowania. Nie należą one do żadnego pojedynczego komponentu. Bez interceptorów każda metoda usługi, która próbuje uzyskać dostęp do zaszyfrowanego endpointu, musiałaby ręcznie powtarzać operacje związane z tokenem i próbami ponownych żądań, a w momencie, gdy logika tych prób będzie choćby nieznacznie błędna w jednej z trzydziestu implementacji, pojawi się błąd, który występuje raz w tygodniu w środowisku produkcyjnym, ale nigdy w środowisku rozwojowym. Aby dowiedzieć się więcej na temat obsługi nieudanych żądań w Angular, zapoznaj się z artykułem projektowanie aplikacji Angular z myślą o nieudanych żądaniach.

    Teraz przyjrzyj się uważnie klauzuli ochronnej na górze funkcji:

    if (!isOurApi(req, baseUrl) || isUnauthenticatedRequest(req)) {
      return next(req);
    }
    

    Tutaj przydaje się wprowadzony API_BASE_URL. Rozwiązuje on dwa poważne problemy:

    1. Ucieczka tokenów do innych źródeł. Interceptor bez sprawdzania URL przypisuje token dostępu użytkownika do każdego wyświetlanego żądania, w tym do przesyłanych plików do systemów przechowywania obiektów, dostawców płytek mapowych oraz punktów końcowych analitycznych. To sprawia, że dane uwierzytelniające trafiają do niepowiązanych stron trzecich tylko dlatego, że interceptor działał zbyt chętnie.
    2. Żądania odnowienia niosące wygasły token. Bez listy wykluczeń samo żądanie odnowienia jest wysyłane z przestarzałym tokenem dostępu. Łagodny backend je ignoruje; surowy zwraca kolejny błąd 401, co może doprowadzić do pętli odnowień.

    Oba są rozwiązaniami jednoliniowymi, gdy tylko adres URL bazowy może być podstawiony. Bez niego interceptor nie ma pewnego sposobu, by określić, które żądania należą do użytkownika.

    Warto zwrócić uwagę na jeszcze jeden detal: próba ponowna jest wysyłana za pomocą next(...), a nie poprzez ponowne wejście do łańcucha interceptorów od początku. Jeśli ponownie wysłane żądanie również zwraca błąd 401, błąd propaguje się do wywołującego tylko raz i ustaje, więc interceptor nie może tworzyć pętli.

    Guardy: dostęp do ścieżek jako zasada

    Ostatnia warstwa odpowiada na jedno pytanie routera: czy ta nawigacja może się odbyć?

    export const authGuard: CanActivateFn = (_route, state) => {
      const store = inject(AuthStore);
      const router = inject(Router);
    
      if (store.isAuthenticated()) {
        return true;
      }
    
      return router.createUrlTree(['/login'], { queryParams: { returnUrl: state.url } });
    };
    
    export const guestGuard: CanActivateFn = () => {
      const store = inject(AuthStore);
      const router = inject(Router);
    
      return store.isAuthenticated() ? router.createUrlTree(['/dashboard']) : true;
    };
    

    Z punktu widzenia bezpieczeństwa „guard” to punkt egzekwowania zasad: decyzja o dostępie jest podejmowana dokładnie w jednym miejscu. Bez niego sprawdzania autoryzacji mają tendencję do pojawiania się w konstruktoraх komponentów, bywają zapominane na trasach dodawanych pod presją terminów oraz powodują wyświetlanie ekranów z interfejsem przypominającym prywatny przed przekierowaniem.

    Zwracanie obiektu UrlTree zamiast nawigacji imperatywnej jest celowe. Router porzuca próbę nawigacji i przeprowadza przekierowanie w jednym kroku, dzięki czemu chroniona trasa nigdy nie pojawia się na ekranie. Drugi „guard”, guestGuard, pełni odwrotną funkcję: zapobiega dostępowi zalogowanych użytkowników do strony logowania.

    Następnie „guards” są przypisywane do tras:

    export const routes: Routes = [
      {
        path: 'login',
        canActivate: [guestGuard],
        loadComponent: () => import('./login/login').then((m) => m.Login),
      },
      {
        path: 'dashboard',
        canActivate: [authGuard],
        loadComponent: () => import('./dashboard/dashboard').then((m) => m.Dashboard),
      },
      { path: '', pathMatch: 'full', redirectTo: 'dashboard' },
      { path: '**', redirectTo: 'dashboard' },
    ];
    

    Parametr zapytania returnUrl ustawiony przez authGuard trafia do komponentu logowania bez żadnego ręcznego kodu ActivatedRoute. Dzięki użyciu provideRouter(routes, withComponentInputBinding()) parametry zapytania są przypisywane odpowiadającym im wejściom sygnałów:

    readonly returnUrl = input('/dashboard');
    // ...
    this.auth.login(username, password).subscribe({
      next: () => this.router.navigateByUrl(this.returnUrl()),
      error: () => { /* ... */ },
    });
    

    Jeden istotny szczegół projektu: zanim pojawił się ten mechanizm ochrony, store już udostępniał isAuthenticated, sygnał obliczany, który nic nie odczytywał. Taki nieużywany stan jest oznaką niedokończonego projektu. Nie jest bezpieczny – to obietnica, której aplikacja jeszcze nie dotrzymała.

    Co dają warstwy w testach

    Największą zaletą jest to, jak mało konfiguracji wymagają te testy. Nie ma żadnych przygotowanych elementów składowych, nie trzeba czyścić localStorage, ani pisać ręcznie mocków dla HttpClient. Poniższa konfiguracja dostarcza backend do testowania HTTP w Angular i zastępuje dokładnie dwa tokeny: adres bazowy oraz port przechowywania.

    beforeEach(() => {
      TestBed.configureTestingModule({
        providers: [
          provideHttpClient(),
          provideHttpClientTesting(),
          { provide: API_BASE_URL, useValue: BASE_URL },
          { provide: AUTH_STORAGE, useValue: new MemoryStorage() },
        ],
      });
      service = TestBed.inject(AuthService);
      store = TestBed.inject(AuthStore);
      http = TestBed.inject(HttpTestingController);
    });
    
    it('shares one in-flight request across concurrent refresh calls', () => {
      store.setTokens('access-1', 'refresh-1');
    
      service.refresh().subscribe();
      service.refresh().subscribe();
    
      const request = http.expectOne(`${BASE_URL}/auth/refresh`);
      request.flush({ accessToken: 'access-2', refreshToken: 'refresh-2' });
    
      expect(store.accessToken()).toBe('access-2');
    });
    

    Test typu single-flight wywołuje refresh() dwa razy, a następnie używa http.expectOne, który zawodzi, jeśli wysłano więcej niż jeden odpowiadający żądanie. W ten sposób w kilku liniach sprawdza się zasada łączenia żądań, a sprawdzanie to pozostaje aktualne nawet po refaktoryzacji funkcji refresh() rok później. Cały zestaw narzędzi testowych składa się z dwóch podanych tokenów, co dokładnie odpowiada celowi istnienia tych portów.

    Czym ten projekt nie jest

    Dokładne określenie granic wzorca jest równie przydatne jak jego nazwanie.

    To nie jest Redux ani NgRx. Nie ma tu działań, reduktorów, efektów ani cyklu dispatch. AuthStore to zagnieżdżony stan z metodami nazwanymi według intencji. Jeśli potrzebujesz debugowania w trybie podróży w czasie, koordynacji efektów pomiędzy różnymi funkcjonalnościami lub zserwowanego logu działań do odtworzenia, przenieś się do NgRx; taka fasada sprawia, że komponenty nie zauważą zmiany.

    To nie jest pełna architektura Clean Architecture. Nie znajdziesz tu klas przypadków użycia, a model domeny nie jest oddzielony od formatu komunikacji. Typ User w pliku auth-models.ts to po prostu struktura odpowiedzi API. Ścisłe zastosowanie architektury Clean Architecture wymagałoby definiowania oddzielnych typów i ich wzajemnego przekształcania. Przy zaledwie kilku polach ta warstwa mapowania kosztuje więcej, niż przynosi korzyści, ale gdy struktura API i model domeny zaczną się różnić, to właśnie w tym miejscu należy wprowadzić takie rozwiązanie.

    To nie jest darmowe. Siedem plików, kilka pośrednich elementów oraz token iniekcji dla jednej ciągu znaków to rzeczywiste koszty. W prototypie z dwoma ekranami jest to nadmierna skomplikowaność, natomiast celowe stworzenie jednej dużej usługi jest właściwym rozwiązaniem. Punkt równowagi zazwyczaj następuje wtedy, gdy dwie funkcjonalności wymagają tego samego stanu sesji lub gdy po raz pierwszy trzeba wyjaśnić logikę odświeżania koleżeńskiemu pracownikowi.

    Podsumowanie

    Cały projekt sprowadza się do jednej zasady: zależności przepływają w jednym kierunku, a każda funkcja znajduje się dokładnie w jednym pliku.

    interceptor → service → { api, store } → models
    

    W praktyce oznacza to, że brama nie ma dostępu do stanu, magazyn nie zajmuje się sieciowaniem, fasada pozostawia routowanie swoim wywołującym, a komponenty komunikują się tylko z fasadą. Żaden warstwa nie ingeruje w strukturę pozostałych warstw.

    To właśnie to ograniczenie przekształciło synchroniczne wywołanie throw w wyniku ukrytego częściowego wylogowania w krótki, nieudany test. Sprawiło to, że pytanie „Które żądania zawierają token nośny?” ma odpowiedź jednej linii. Ponadto przekształca przyszłą zmianę z użycia localStorage na pliki cookie typu httpOnly w zmianę tylko jednego adaptera, zamiast konieczności przeszukiwania całego kodu.

    Nazwy tych wzorców brzmią imponująco: Gateway, Facade, Ports and Adapters, Chain of Responsibility, Policy Enforcement Point. W rzeczywistości wszystkie one wyrażają tę samą ideę – przypisać każdej decyzji jedno konkretne miejsce i uczynić oczywistym kierunek zależności. Wszystko inne wynika z tego.

    Literatura pokrewna

  • Zamiana flag ładowania w Angularu za pomocą Discriminated AsyncState Union — Modeluj operacje asynchroniczne w Angularu jako jedną zunion typową, wyznaczaj wartości logiczne za pomocą sygnałów obliczanych i steruj każdą zmianą stanu za pomocą pojedynczego operatora RxJS.