⟨/⟩ 40_Arch/Tech/Angular/ADR_UI_010_Auth.cs

194 строк · в начало

using System;
using Ban.Sdaid.Notation.Documents;
using Ban.Sdaid.Icd;

namespace Ban.Sdaid.Icd.Arch.Tech.Angular
{
    /// <summary>
    /// ADR-UI-010: Аутентификация на фронте. Хранение access-token, состав сессионного стора,
    /// инициализация профиля при старте, гард аутентификации, HTTP-interceptor и обработка 401/403,
    /// экраны входа/выхода. UI не парсит JWT — права приходят из профиля (ADR-UI-011).
    /// </summary>
    public class ADR_UI_010_Auth : IAdrDocument, IFolder<ArchAngularFolder>
    {
        public string Name => "ADR-UI-010. Аутентификация";

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

        public string Version => "0.1";
        public string Status => "proposed";
        public string[] Comments => new[]
        {
            "2026-08-20. Фронт-сторона ADR-011 (ABAC). Адаптировано из rtfm ADR-UI-010; закрывает открытые вопросы про auth в ADR-UI-005 (layout) и ADR-UI-006 (гарды).",
        };

        public Type? Supersedes => null;

        public static string S1_Context = $"""
            ## Контекст и постановка задачи

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

            Зафиксируем: хранение и срок жизни токена; состав сессионного стора; инициализацию профиля при
            старте; гард аутентификации; поведение при 401/403; экраны входа/выхода. Права и их проверку выносим
            в {nameof(Ban.Sdaid.Icd.Arch.Tech.Angular.ADR_UI_011_Claims)}.
            """;

        public static string S2_DecisionDrivers = $"""
            ## Драйверы решения

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

        public static string S3_ConsideredOptions = """
            ## Рассмотренные варианты

            - **A. Cookie-сессия (HttpOnly).** Безопаснее к XSS, но требует серверных сессий/CSRF-защиты и хуже
              ложится на stateless-JWT бэкенда.
            - **B. Access-token в `localStorage`, opaque для UI (выбрано).** Простая модель под JWT-бэкенд; токен
              не парсим, права берём из профиля. Стандартный компромисс SPA (требования к XSS жёстче).
            - **C. Token в памяти (без хранилища).** Теряется при F5 — неудобно; отклонено.
            """;

        public static string S4_DecisionOutcome = """
            ## Решение

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

            ### Хранение токена

            - Ключ: `icd.accessToken` в `localStorage`. Значение — строка; на UI токен **opaque** (JWT не парсим).
            - Очистка при logout / 401: удалить ключ + сбросить сигналы стора.

            ### `AuthStore` (сессионный стор)

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

            ```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`.

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

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

            ### `AuthGuard`

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

            ### HTTP-interceptor

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

            - подмешивает `Authorization: Bearer {token}` **только** к запросам на свой `apiBaseUrl` (чтобы не
              утекал на сторонние домены);
            - **401** → `clearSession()` + уведомление + `/login`;
            - **403** → `/access-denied` **без** очистки сессии (запрещено ≠ не аутентифицирован; иначе петля
              редиректов, т.к. `/login` уводит залогиненного обратно).

            ### Вход / выход

            - **Вход** `/login` (второй режим layout, без оболочки — закрывает отложенное в ADR-UI-005): если
              уже залогинен → на главную; иначе форма (ADR-UI-009),
              на submit `AuthStore.login()` → успех: на главную; ошибка: сообщение из ответа.
            - **Выход** `/logout`: технический маршрут, пустой шаблон; зовёт `clearSession()` (и `Logout` на бэке)
              → `/login`. Любая точка «выйти» навигирует на `/logout`, не зовёт `clearSession` напрямую.

            ### Чего не делаем

            - **Не парсим JWT на UI** — права только из профиля (`GetUserProfile`).
            - **Не используем cookie/sessionStorage** для токена.
            - **Не реализуем refresh-token** — срок жизни задаёт бэк, UI реагирует на 401.
            - **Не дублируем navigate-логику выхода** в страницах — только через `/logout`.
            """;

        public static string S5_PositiveConsequences = """
            ## Положительные следствия

            - Сессия переживает перезагрузку страницы.
            - Все auth-инварианты — на уровне инфраструктуры (interceptor + guard + initializer); страницам об
              этом думать не нужно.
            - Единая точка сессии — замена mock на реальный API/правки контракта не расходятся по коду.
            """;

        public static string S6_NegativeConsequences = """
            ## Отрицательные следствия и компромиссы

            - `localStorage` доступен любому JS на странице → требования к XSS жёстче (стандартное ограничение
              SPA с токеном в хранилище; принимается).
            - Один interceptor — точка отказа: ошибка в нём ломает все запросы (покрывается тестами).
            - Нет refresh-token: при истечении токена пользователь возвращается на вход (осознанно).
            """;

        public static string S7_Validation = """
            ## Проверка

            - После входа и F5 пользователь остаётся в системе; шапка показывает его имя.
            - 401 от сервера → авто-возврат на `/login` с уведомлением.
            - 403 → `/access-denied`, сессия сохранена, без петли редиректов.
            - Замена тела `AuthStore.login`/`loadProfile` (mock → API) не требует правок гарда/директив/страниц.
            """;

        public static string S8_OpenQuestions = """
            ## Открытые вопросы / отложено

            - **Mock-режим** на время отсутствия бэка: `login()`/`loadProfile()` возвращают фиксированный
              профиль; при появлении API — замена тела методов, контракт стора не меняется.
            - **Refresh-token, OIDC** — отдельными ADR при появлении требований.
            - **Многотабовая синхронизация выхода** — слушатель `storage` на ключ `icd.accessToken` (при нужде).
            - **Состав сообщений формы входа** — согласуется с бэком; пока показываем то, что пришло.
            """;

        public static string S99_Related = $"""
            ## Связанные артефакты

            - {nameof(Ban.Sdaid.Icd.Arch.Adr.ADR_011_AbacAuthorization)} — системное решение (ABAC).
            - {nameof(Ban.Sdaid.Icd.Arch.Tech.CSharp.ADR_SRV_006_AuthImplementation)} — backend: JWT, профиль, отзыв токена.
            - {nameof(Ban.Sdaid.Icd.Arch.Tech.Angular.ADR_UI_011_Claims)} — claims и проверка прав на UI.
            - {nameof(Ban.Sdaid.Icd.Arch.Tech.Angular.ADR_UI_005_Layout)} — «второй режим» (экраны входа), закрываемый здесь.
            - {nameof(Ban.Sdaid.Icd.Arch.Tech.Angular.ADR_UI_006_Routing)} — гарды маршрутов, закрываемые здесь.
            - {nameof(Ban.Sdaid.Icd.Arch.Tech.Angular.ADR_UI_012_ApiAndModels)} — auth-эндпоинты через сгенерированный клиент.
            - {nameof(Ban.Sdaid.Icd.Arch.Tech.Angular.ADR_UI_014_RuntimeConfig)} — initializer и `apiBaseUrl`.
            """;
    }
}