ADR-UI-010. Аутентификация

ADR Версия: 0.1 proposed

Простая аутентификация по логину и паролю с access-token (JWT) в HTTP-заголовке Bearer. Токен — opaque для UI (JWT не парсим). Сессия живёт в сигнальном сторе AuthStore; профиль грузится при старте; маршруты закрывает AuthGuard; HTTP-interceptor подмешивает токен и обрабатывает 401/403. Закрывает отложенный «второй режим layout» (экраны входа) и гарды маршрутов.

⟨/⟩ Исходник

1. Контекст и постановка задачи

ADR_011_AbacAuthorization вводит доступ; бэкенд-сторона — ADR_SRV_006_AuthImplementation (вход по паролю → JWT, профиль с UI-claims, отзыв токена). Фронт был спроектирован «с запасом»: основной layout не зависит от доступа, а «второй режим» (экраны входа) и гарды маршрутов явно отложены до появления ролей (ADR_UI_005_Layout, ADR_UI_006_Routing). Настало время закрыть это.

Зафиксируем: хранение и срок жизни токена; состав сессионного стора; инициализацию профиля при старте; гард аутентификации; поведение при 401/403; экраны входа/выхода. Права и их проверку выносим в ADR_UI_011_Claims.

2. Драйверы решения

  1. Сохранение сессии при перезагрузке страницы (F5).
  2. Гарантия инициализации профиля к моменту, когда первый гард/страница читает claims.
  3. Декларативная защита маршрутов — гард на корневой обёртке (ADR_UI_006_Routing).
  4. Единая точка сессии — весь код про токен/профиль в одном сторе; страницы про это не думают.
  5. Весь трафик через сгенерированный клиент (ADR_UI_012_ApiAndModels) — auth-эндпоинты не исключение.

3. Рассмотренные варианты

4. Решение

Выбран Вариант B: access-token в localStorage, сессия — в сигнальном сторе AuthStore.

4.1. Хранение токена

4.2. AuthStore (сессионный стор)

Singleton (providedIn: 'root'), сигнальный (ADR-UI-008); имя/файл — по ADR-UI-002 (auth-store.ts).

export interface UserProfile {
  userId: string;
  fullName: string;
  login: string;
  claims: readonly string[];   // UI-claims из профиля (см. ADR-UI-011)
}

@Injectable({ providedIn: 'root' })
export class AuthStore {
  private readonly _profile = signal<UserProfile | null>(null);
  public  readonly profile = this._profile.asReadonly();
  public  readonly isAuthenticated = computed(() => this._profile() !== null);

  login(login: string, password: string): Observable<void> { /* ... + loadProfile */ }
  loadProfile(): Observable<UserProfile | null> { /* GetUserProfile по токену */ }
  clearSession(): void { /* remove token + _profile.set(null) */ }
  getAccessToken(): string | null { /* localStorage */ }
}

Claims из профиля отдаются в ClaimService (ADR-UI-011). Auth-эндпоинты (login/logout/profile) вызываются через сгенерированный клиент (ADR-UI-012), а не прямым HttpClient.

4.3. Bootstrap (инициализатор)

После загрузки runtime-конфига (ADR-UI-014) ещё один provideAppInitializer: если токен есть — загрузить профиль (ошибку → clearSession); если нет — показать /login. К моменту проверки гардом профиль уже установлен (или явно null).

4.4. AuthGuard

CanActivateFn на корневой обёртке маршрутов: isAuthenticated() → пропустить, иначе → /login. Лежит в src/app/guards/auth.guard.ts.

4.5. HTTP-interceptor

Один функциональный interceptor icdAuthHttpInterceptor (withInterceptors([...]) в app.config.ts; сейчас provideHttpClient() без интерсепторов):

4.6. Вход / выход

4.7. Чего не делаем

5. Положительные следствия

6. Отрицательные следствия и компромиссы

7. Проверка

8. Открытые вопросы / отложено

Связанные артефакты

Документы