DevGang
Авторизоваться

Создание конфигурируемых защит Angular

При создании веб-приложений время от времени возникает необходимость защитить маршруты от несанкционированного доступа. В Angular это можно сделать с помощью защит маршрутизаторов.

Это не введение в защиту Angular, поэтому если вы не знакомы с ней, то можете прочитать о ней подробнее в официальной документации.

В этой статье мы рассмотрим, как можно создавать настраиваемую защиту в Angular. Мы создадим защиту, которая будет проверять, имеет ли вошедший в систему пользователь определенную роль, и если нет, то она будет перенаправлять пользователя на неавторизованную страницу.

We will use a fake AuthService that will have a method to check if the logged in user has a specific role.

// auth.service.ts
export const ROLES = {
  ADMIN: 'ADMIN',
  MANAGER: 'MANAGER',
};

@Injectable({ providedIn: 'root' })
export class AuthService {
  userRole = ROLES.ADMIN;

  hasRole(role: string): boolean {
    return this.userRole === role;
  }
}

До Angular 14 у нас существовала защита на основе классов, и базовым решением нашей проблемы было бы создание защиты на основе класса для каждой роли и применение ее к маршрутам, которые мы хотим защитить.

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

Это не очень удобно и может привести к дублированию кода.

Это выглядело бы примерно так:

// admin.guard.ts
@Injectable({ providedIn: 'root' })
export class AdminGuard implements CanActivate {
  authService = inject(AuthService);
  router = inject(Router);

  canActivate(): boolean | UrlTree {
    const hasAccess = this.authService.hasRole(ROLES.ADMIN);
    return hasAccess ? true : this.router.createUrlTree(['/unauthorized']);
  }
}
// manager.guard.ts
@Injectable({ providedIn: 'root' })
export class ManagerGuard implements CanActivate {
  authService = inject(AuthService);
  router = inject(Router);

  canActivate(): boolean | UrlTree {
    const hasAccess = this.authService.hasRole(ROLES.MANAGER);
    return hasAccess ? true : this.router.createUrlTree(['/unauthorized']);
  }
}

А затем мы накладываем защиту на те маршруты, которые хотим защитить:

// routes.ts
export const routes: Routes = [
  { path: 'home', component: HomeComponent },
  { path: 'admin', component: AdminComponent, canActivate: [AdminGuard] },
  { path: 'manager', component: ManagerComponent, canActivate: [ManagerGuard] },
  { path: 'unauthorized', component: NotAuthorizedComponent },
];

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

Конфигурируемая защита на основе классов

Angular Router предоставляет поле данных в маршруте, которое мы можем использовать для передачи данных защите. Мы можем использовать это поле данных для передачи роли, которую хотим проверить. Таким образом, мы можем создать одну защиту, которая будет проверять роль, указанную в поле данных.

// role.guard.ts
@Injectable({ providedIn: 'root' })
export class RoleGuard implements CanActivate {
  authService = inject(AuthService);
  router = inject(Router);

  canActivate(route: ActivatedRouteSnapshot): boolean | UrlTree {
    // Get the role from the route data
    const role = route.data.role;
    const hasAccess = this.authService.hasRole(role);
    return hasAccess ? true : this.router.createUrlTree(['/unauthorized']);
  }
}

А затем мы применим защиту к тем маршрутам, которые хотим защитить:

// routes.ts
export const routes: Routes = [
  { path: 'home', component: HomeComponent },
  { 
    path: 'admin', 
    component: AdminComponent, 
    canActivate: [RoleGuard], 
    data: { role: ROLES.ADMIN },
  },
  { 
    path: 'manager', 
    component: ManagerComponent,
    canActivate: [RoleGuard],
    data: { role: ROLES.MANAGER },
  },
  { path: 'unauthorized', component: NotAuthorizedComponent },
];

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

interface RoleGuardData {
  role: 'ADMIN' | 'MANAGER'; // We can add more roles here or infer them from the AuthService
}

// And then we can use this interface to type the data field:

export const routes: Routes = [
  { 
    // ...
    data: { role: ROLES.MANAGER } as RoleGuardData,
  },
];

Теперь, если мы попытаемся передать строку, которая не является допустимой ролью, мы получим ошибку типа.

Конфигурируемая защита на основе функций

В Angular 14 появилась защита на основе функций. Это означает, что мы можем создать функцию, которая будет возвращать защиту. Это очень удобно, поскольку мы можем создать функцию, которая будет возвращать защиту для определенной роли, а затем применить эту функцию к маршрутам, которые мы хотим защитить.

// role.guard.ts
export const roleGuard = (role: 'MANAGER' | 'ADMIN'): CanActivateFn => {
  const guard: CanActivateFn = () => {
    const authService = inject(AuthService);
    const router = inject(Router);

    const hasAccess = authService.hasRole(role);
    return hasAccess ? true : router.createUrlTree(['/unauthorized']);
  };

  return guard;
};

А затем мы применим эту функцию к тем маршрутам, которые хотим защитить:

// routes.ts
export const routes: Routes = [
  { 
    path: 'admin', 
    component: AdminComponent, 
    canActivate: [roleGuard(ROLES.ADMIN)],
  },
  { 
    path: 'manager', 
    component: ManagerComponent,
    canActivate: [roleGuard(ROLES.MANAGER)],
  },
];

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

Как проверить работу защитных механизмов

Мы можем протестировать защиту, создав макет AuthService, но в нашем случае мы можем использовать настоящий AuthService, поскольку он уже является поддельным сервисом.

// role.guard.spec.ts
import { TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { ActivatedRouteSnapshot, Router } from '@angular/router';
import { ROLES, AuthService } from './auth.service';
import { RoleGuard } from './role.guard';

describe('RoleGuard', () => {
  let router: Router;
  let guard: RoleGuard;
  let authService: AuthService;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [RouterTestingModule],
      providers: [RoleGuard, AuthService],
    });

    router = TestBed.inject(Router);
    guard = TestBed.inject(RoleGuard);
    authService = TestBed.inject(AuthService);
  });

  it('should be created', () => {
    expect(guard).toBeTruthy();
  });

  it('should return true if the user has the role', () => {
    authService.userRole = ROLES.ADMIN; // Set the user role
    const route = { data: { role: ROLES.ADMIN } } as unknown as ActivatedRouteSnapshot;
    expect(guard.canActivate(route)).toBeTrue();
  });

  it('should return /unauthorized if the user does not have the role', () => {
    authService.userRole = ROLES.ADMIN; // Set the user role
    const route = { data: { role: ROLES.MANAGER } } as unknown as ActivatedRouteSnapshot;
    const unauthorizedUrlTree = router.createUrlTree(['/unauthorized']);
    expect(guard.canActivate(route)).toEqual(unauthorizedUrlTree);
  });
});

А вот тест для защиты на основе функций:

// role.guard.spec.ts
describe('RoleGuard', () => {
  it('allows user to navigate to route if he has access', async () => {
    TestBed.configureTestingModule({
      providers: [
        AuthService,
        provideRouter([
          {path: 'admin', component: AdminComponent, canActivate: [roleGuard(ROLES.ADMIN)]},
        ]),
      ],
    });
    const authService = TestBed.inject(AuthService);
    const harness = await RouterTestingHarness.create();

    authService.userRole = ROLES.ADMIN;
    let instance = await harness.navigateByUrl('/admin');
    expect(instance).toBeInstanceOf(AdminComponent);
  });

  it('redirects to unauthorized if the user doesnt have access', async () => {
    TestBed.configureTestingModule({
      providers: [
        AuthService,
        provideRouter([
          {path: 'manager', component: ManagerComponent, canActivate: [roleGuard(ROLES.MANAGER)]},
          {path: 'unauthorized', component: NotAuthorizedComponent},
        ]),
      ],
    });

    const authService = TestBed.inject(AuthService);
    const harness = await RouterTestingHarness.create();

    authService.userRole = ROLES.ADMIN;
    let instance = await harness.navigateByUrl('/manager');
    expect(instance).toBeInstanceOf(NotAuthorizedComponent);
  });
});

Я изменил способ тестирования функциональной защиты на использование RouterTestingHarness, потому что так проще думать о тесте (по крайней мере, для меня).

Вот и всё!

Спасибо, что читаете!

Источник:

#JavaScript #Angular
Комментарии
Чтобы оставить комментарий, необходимо авторизоваться

Присоединяйся в тусовку

В этом месте могла бы быть ваша реклама

Разместить рекламу