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.
Ź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:
- Testy wykorzystują prawdziwy sklep. Iniekcja
MemoryStorageumożliwia sprawdzenie rzeczywistego zachowania przechowywania danych, takiego jak zapisywanie, ponowne ładowanie i przywracanie, bez konieczności usuwania stanu globalnego pomiędzy testami.
localStorage, więc mechanizm automatycznie przechodzi na przechowywanie w pamięci.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:
tapprzechowuje nowe tokeny. Ponieważ znajduje się przedshareReplay, 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.finalizeszyfruje 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:
- 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.
- Żą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
- Jak Angular Dependency Injection rozwiązuje problemy z usługami za pomocą inject() — Zrozum, w jaki sposób Angular DI dostarcza usługi za pomocą funkcji inject(), providerów oraz hierarchicznych injectorów, a także jak określać zakres instancji i unikać błędów związanych z brakującymi providerami.
- Projektowanie aplikacji w Angularze pod kątem nieudanych zapytań: stany, intercepcje i ponawianie prób — Dowiedz się, jak klasyfikować błędy HTTP w Angularze, oczyszczać stan ładowania, centralizować obsługę błędów w intercepcjach, bezpiecznie ponawiać próby oraz wyświetlać komunikaty, na które użytkownicy mogą zareagować.