Оригинальное фото на обложке Сэнди Миллар на Unsplash.com


Что ограничивает доступ?

Ограничение доступа в приложении существенно меняет то, как разные пользователи могут взаимодействовать с нашей платформой. У некоторых пользователей могут быть разрешения на просмотр некоторых страниц, а у некоторых нет; аутентифицированные пользователи могут посещать определенные сегменты приложения и не иметь возможности посещать другие; или это может быть более детализировано, например, отключение разделов на страницах, которые в противном случае доступны, или даже выполнение некоторых действий, но не полностью. Сегодня мы рассмотрим, как это можно сделать наиболее удобным способом, используя NgRx в приложениях Angular.


Какие инструменты у нас есть?

Прежде всего, мы должны иметь в виду, что сам Angular уже предоставляет довольно мощные инструменты, которые помогают нам добиться ограничения доступа. Давайте сначала повторим их:

  1. Охранники — специальные классы, которые ограничивают доступ к страницам через маршрутизацию на основе заданного нами условия
  2. Перехватчики — хотя они не предназначены специально для ограничения доступа, их можно использовать для изменения и даже предотвращения сетевых запросов.
  3. гифка директива — да, это наш основной инструмент при работе с гранулярным ограничением доступа
  4. НгСвитч директива — основной инструмент, когда нам нужно предоставить различные компоненты конечному пользователю на основе какой-либо роли

Цель этой статьи не в том, чтобы попытаться заменить эти инструменты на NgRx, а в том, чтобы заставить наш реактивный магазин взаимодействовать с этими инструментами для достижения наилучшего UX с помощью как можно меньшего количества конкретного кода.


Каковы наши варианты использования?

Мы собираемся показать ограничение доступа по трем основным сценариям:

  1. Аутентификация с помощью NgRx
  2. Управление разрешениями
  3. Отображение определенных функций в зависимости от ролей/разрешений, хранящихся в NgRx Store

Начнем с авторизации.


Авторизация с помощью NgRx

При работе с приложениями, использующими NgRx, важно помнить, что нельзя дублировать данные и поддерживать простой и эффективный доступ нашего интерфейса к данным Store. Для этого нам нужно понять, с каким сценарием мы имеем дело. Для целей этого примера мы реализуем авторизацию со следующими характеристиками:

  1. Используя Веб-токен JSON
  2. Хранение токена в файлах cookie
  3. Получение пользовательских данных по токену для обращения к специальному API
  4. Проверка статуса авторизации пользователя
  5. Получение информации о пользователе (для страницы профиля или для отображения где-либо)

Это несколько специфический сценарий, но его можно легко экстраполировать на другие варианты использования, например, при использовании localStorage вместо cookies (имейте в виду, что localStorage считается небезопасным) или с использованием совершенно другой стратегии авторизации.

Теперь давайте разберемся, какие данные нам нужно хранить в Магазине. На первый взгляд нам, вероятно, потребуется хранить следующее:

  • Текущие данные пользователя
  • логическое значение, указывающее, вошел ли пользователь в систему
  • сам токен авторизации

Кажется, все в порядке, не так ли? Неправильный!. Нам не нужно хранить ничего, кроме самих пользовательских данных, потому что:

  1. Существование пользовательских данных уже доказывает, что пользователь вошел в систему
  2. Сам токен содержит пользовательские данные, его нужно только расшифровать.
  3. Вызов API будет сделан сразу после запуска приложения.

Поэтому мы просто будем хранить пользовательские данные в нашем auth раздел хранилища и напишите несколько селекторов, которые обращаются к состоянию входа в систему, самому токену и декодированным данным пользователя. Итак, наш AuthState будет выглядеть очень просто, вот так:

export interface AuthState {
  token: string;
  user: User;
}

export const initialState: AuthState = {
  token: "",
  user: null,
};
Войти в полноэкранный режим

Выйти из полноэкранного режима

Теперь нам нужны действия, чтобы перевести токен в состояние, удалить его и так далее:

import { createAction, props } from "@ngrx/store";

export const setToken = createAction(
  "[Auth] Set Token",
  props<{ token: string }>()
);

export const setUser = createAction(
  "[Auth] Set user",
  props<{ user: User }>(),
);

export const removeToken = createAction("[Auth] Remove Token");
Войти в полноэкранный режим

Выйти из полноэкранного режима

А затем простой редьюсер, который будет обрабатывать взаимодействия:

import { createReducer, on } from "@ngrx/store";
import { removeToken, setToken } from "./actions";
import { AuthState, initialState } from "./state";

export const authReducer = createReducer(
  initialState,
  on(setToken, (state, { token }): AuthState => ({ ...state, token })),
  on(removeToken, (state): AuthState => ({ ...state, token: "" })),
  on(setUser, (state, { user }): AuthState => ({ ...state, user }))
);
Войти в полноэкранный режим

Выйти из полноэкранного режима

Теперь давайте зарегистрируем это в нашем AppModule:

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { FormsModule } from "@angular/forms";

import { AppComponent } from "./app.component";
import {
  UserDashboardComponent,
} from "./user-dashboard/user-dashboard.component";
import { AppRoutingModule } from "./routing.module";
import { LoginComponent } from "./login/login.component";
import { StoreModule } from "@ngrx/store";
import { authReducer } from "./store/reducer";
import { CommonModule } from "@angular/common";

@NgModule({
  imports: [
    BrowserModule,
    FormsModule,
    AppRoutingModule,
    CommonModule,
    StoreModule.forRoot({ auth: authReducer }),
  ],
  declarations: [AppComponent, UserDashboardComponent, LoginComponent],
  bootstrap: [AppComponent],
})
export class AppModule {}
Войти в полноэкранный режим

Выйти из полноэкранного режима

Теперь нам нужно написать селекторы для получения нужных нам данных:

import { createFeatureSelector, createSelector } from "@ngrx/store";
import { AuthState } from "./state";
import { decode } from "some-jwt-library";

export const authFeature = createFeatureSelector<AuthState>("auth");

export const selectToken = createSelector(
  authFeature,
  (state) => state.token,
);
export const selectIsAuth = createSelector(
  authFeature,
  (state) => !!state.token
);
export const selectUserData = createSelector(
  authFeature,
  (state) => state.user
);
Войти в полноэкранный режим

Выйти из полноэкранного режима

И это все для начальной части, теперь мы можем хранить данные о пользователе и безопасно извлекать их. Теперь давайте обсудим, как мы можем получить эти данные в хранилище. Для этого мы будем использовать три эффекта:

  1. Войти: отправьте данные для входа в бэкэнд, получите токен, сохраните в куки, а затем в сам магазин.
  2. Выйти: удалить токен, перенаправить на страницу входа
  3. Получить токен: когда приложение; запустится, проверьте файлы cookie на наличие токена и сохраните его в Магазине.

Сначала мы добавляем соответствующие действия:

export const login = createAction(
  "[Auth] Login",
  props<{ email: string; password: string }>()
);

export const loginError = createAction(
  "[Auth] Login",
  props<{ message: string }>()
);

export const logout = createAction("[Auth] Log Out");
Войти в полноэкранный режим

Выйти из полноэкранного режима

И сами эффекты. Особое внимание уделите последнему:

@Injectable()
export class AuthEffects {
  // on login, send auth data to backend,
  // get the token and put into the store and cookies
  login$ = createEffect(() => {
    return this.actions$.pipe(
      ofType(login),
      mergeMap(({ email, password }) => {
        return this.authService.login(email, password).pipe(
          tap(({ token }) => this.cookieService.set("token", token)),
          map(({ token }) => setToken({ token })),
          catchError(() => of(loginError({ message: "Login failed" })))
        );
      })
    );
  });

  // on logout, just remove the token
  // and navigate to login page
  // no need to dispatch any actions after that
  logout$ = createEffect(
    () => {
      return this.actions$.pipe(
        ofType(logout),
        tap(() => {
          this.cookieService.remove("token");
          this.router.navigateByUrl("/login");
        })
      );
    },
    { dispatch: false }
  );

  // when app has started, get the user data
  // using the token from cookies
  // and put into the store
  init$ = createEffect(() => {
    return this.actions$.pipe(
      ofType(ROOT_EFFECTS_INIT),
      mergeMap(({ email, password }) => {
        return this.authService.getCurrentUser().pipe(
          map(({ token }) => setUser({ user })),
          catchError(() => of(setUserError({ message: "Error" })))
        );
      })
    );
  });

  constructor(
    private readonly actions$: Actions,
    private readonly authService: AuthService,
    private readonly router: Router,
    private readonly cookieService: CookieService
  ) {}
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

Первые два эффекта довольно просты, поэтому давайте сосредоточимся на последнем. Здесь мы воспользовались встроенным ROOT_EFFECTS_INIT действие, которое отправляется самим NgRx, когда оно подписывается на наши эффекты, по существу выступая в качестве уведомителя для запуска нашего приложения в этом сценарии. Затем мы помещаем пользовательские данные в наше состояние, по сути регидратируя их.

Итак, теперь у нас есть готовая авторизация в NgRx, последним дополнением будет создание специальной защиты, которая будет проверять существование нашего токена авторизации:

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(
    private readonly store: Store,
    private readonly router: Router,
  ) {}

  canActivate(
    next: ActivatedRouteSnapshot,
    state: RouterStateSnapshot,
  ) {
    return this.store.select(selectIsAuth).pipe(
      map((isAuth) => {
        return isAuth ? true : this.router.parseUrl("/login");
      })
    );
  }
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

По сути, вся логика авторизации была делегирована в магазин NgRx, и охранник просто спрашивает магазин, следует ли продолжать или нет.

Это в основном так. Теперь давайте обсудим ограничения доступа на основе разрешений.


Обработка разрешений с помощью NgRx

Различные приложения имеют разные подходы к разрешениям, разные способы их хранения и проверки их существования. Самое классное в NgRx то, что нам все равно, какой формы разрешения — мы всегда можем написать селекторы, которые предоставляют аналогичный интерфейс для проверки разрешений и предоставления доступа. несмотря на как выглядят разрешения.

Для простоты в этом примере мы представим, что разрешения в нашем приложении хранятся в токене JWT вместе с пользовательскими данными в массиве строк — если имя разрешения присутствует, значит, у пользователя есть это разрешение, иначе нет. Итак, наш пользовательский объект может выглядеть примерно так:

interface UserData {
  firstName: string;
  lastName: string;
  permissions: string[];
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

Теперь давайте добавим соответствующие селекторы:

export const selectPermissions = createSelector(
  selectUserData,
  (userData) => userData?.permissions ?? []
);
Войти в полноэкранный режим

Выйти из полноэкранного режима

Теперь это вернет массив, который у нас есть, но в некоторых случаях нам нужно проверить наличие определенного разрешения. Для этого мы можем написать селектор с аргументом:

export const selectHasPermission = (permission: string) =>
  createSelector(selectPermissions, (permissions) =>
    permissions.includes(permission)
  );
Войти в полноэкранный режим

Выйти из полноэкранного режима

Затем в наших компонентах мы можем использовать этот селектор, чтобы проверить, есть ли у пользователя разрешение на выполнение определенного действия:

export class UserDashboardComponent implements OnInit {
  canCreateOrder$ = this.store.select(
    selectHasPermission("CreateOrder"),
  );
  constructor(private readonly store: Store) {}
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

А потом в шаблоне:

<button *ngIf="canCreateOrder$ | async" (click)="createOrder()">
  Create Order
</button>
Войти в полноэкранный режим

Выйти из полноэкранного режима

Начиная с Angular 14, используя inject мы можем создать настраиваемую функциональную защиту для проверки разрешений:

export function hasPermissionGuard(permission: string) {
  return function () {
    const store = inject(Store);
    return store.select(selectHasPermission(permission));
  };
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

И тогда мы можем использовать его в наших маршрутах:

const routes: Routes = [
  {
    path: "orders",
    component: OrdersComponent,
    canActivate: [hasPermissionGuard("ViewOrders")],
  },
  {
    path: "orders/create",
    component: CreateOrderComponent,
    canActivate: [hasPermissionGuard("CreateOrder")],
  },
];
Войти в полноэкранный режим

Выйти из полноэкранного режима

Примечание: мы можем настроить защиту так, чтобы она принимала несколько разрешений и проверяла, есть ли у пользователя все из них, а также, при необходимости, добавляла еще один аргумент для обработки перенаправлений.

Бонусный балл: наконец, мы посещаем раздел, где мы предоставляем детальный доступ к определенным частям пользовательского интерфейса:


Детальный доступ к элементам пользовательского интерфейса

Для этого можно создать директиву, которая будет скрывать элемент, если у пользователя нет разрешения. Эта директива будет:

  1. Быть структурной директивой — *appHasPermission="permission"
  2. На вход взять строку — имя разрешения
  3. Использовать Store для доступа к разрешениям
  4. Использовать selectHasPermission селектор, чтобы проверить, есть ли у пользователя разрешение
  5. Скрыть элемент, если у пользователя нет разрешения
  6. Показать элемент, если у пользователя есть разрешение
  7. Отписаться от магазина при уничтожении элемента

Вот так:

@Directive({
  selector: "[appHasPermission]",
})
export class HasPermissionDirective implements OnInit, OnDestroy {
  @Input("appHasPermission") permission: string;
  destroy$ = new Subject<void>();

  constructor(
    private readonly templateRef: TemplateRef<any>,
    private readonly viewContainer: ViewContainerRef,
    private readonly store: Store
  ) {}

  ngOnInit() {
    this.store
      .select(selectHasPermission(this.permission))
      .pipe(takeUntil(this.destroy$))
      .subscribe((hasPermission) => {
        if (hasPermission) {
          this.viewContainer.createEmbeddedView(this.templateRef);
        } else {
          this.viewContainer.clear();
        }
      });
  }

  ngOnDestroy() {
    this.destroy$.next();
  }
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

А потом в шаблоне:

<a *appHasPermission="'CreateOrder'" routerLink="/orders/create">
  Create Order
</a>
Войти в полноэкранный режим

Выйти из полноэкранного режима


Вывод

Ограничение доступа играет очень важную роль во многих приложениях, особенно в корпоративных. NgRx — отличный инструмент для обработки авторизации и разрешений, и его можно использовать различными способами. В этой статье мы рассмотрели наиболее распространенные варианты использования, но их гораздо больше, поэтому никогда не стесняйтесь исследовать новые способы работы с NgRx.