На данный момент об инструменте guards защиты данных не так  много публикаций как он того заслуживает. В основном это техническая документация от разработчика https://docs.nestjs.com/guards . Для восполнения указанного пробела рассмотрим  наш  кейс по внедрению guards  для защиты данных от пользователя, который не имеет достаточно прав на их получение и/или изменение. Описание процедуры внедрения guards сопровождается примерами кода.

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

Итак, аутентификация - это процесс проверки личности пользователя или устройства, позволяющий получить авторизованный доступ к конфиденциальной информации или системам. То есть проверка, что вы действительно тот человек, за которого себя выдаёте.

Авторизация - это процесс определения наличия у пользователя или устройства необходимых прав для доступа к определённому ресурсу, в нашем случае к запросам (Mutation, Query...).

Функции guards

Guards определяют, будет ли запрос обрабатываться маршрутизатором или нет, в зависимости от определённых условий (в нашем случае ролей). Это часто называют авторизацией.  Авторизация обычно обрабатывается промежуточным программным обеспечением в традиционных приложениях Express. Но промежуточное ПО по своей природе ограничено. Оно не знает, какой обработчик будет выполнен после вызова next() функции. С другой стороны, guards имеют доступ к ExecutionContext экземпляру и, таким образом, точно знают, что будет выполняться дальше. Они спроектированы так,  чтобы вставить логику обработки точно в нужную точку цикла запроса/ответа и делать это декларативно.

Что такое токен?

Это JSON объект, где в защищённом виде хранится информация о пользователе. Токен присваивается пользователю, который прошел аутентификацию.

Внедрение guards

1. Создание сервиса аутентификация на основе токена

Так как предварительным условием работы guards является аутентификация пользователя, то рассмотрим  процесс аутентификации на основе токена. Если пользователь впервые прошел аутентификацию посредством ввода логина и пароля, то сервис создает токен и присваивает его пользователю.

Создание токена

Для работы с токеном используется библиотека jsonwebtoken. Так же будет необходим объект secret key, который создаётся генератором случайных значений. Так как данный объект отвечает за безопасность системы, то его необходимо защитить от попадания к третьим лицам. Рекомендуется перенести его в переменные окружения.

Создаваемый объект токен содержит id пользователя и массив ролей const data = {id: user.id, roles: roles}.

Создание токена происходит с помощью метода sign из библиотеки jsonwebtoken.

const token = jwt.sign({ data }, secretKey);

После присвоения токена пользователю, необходимо аутентифицировать пользователя, используя сервис для получения объекта data из токина. Для этого используется метод verify из библиотеки jsonwebtoken, а так же secretKey.

jwt.verify(token, secretKey);

Следующим шагом добавляем в контекст объект data, который в свою очередь содержит id: user.id, roles: roles.

Желательно добавить в контекст сразу сущность User, т.е. дополнительно написать сервис, который из базы данных будет получать сущность User по user.id ('select * from table_user where id = ?').

Процесс создания контекста здесь не рассматривается, так как это отдельный вопрос. Подразумевается, что контекст у приложения уже есть.

2. Настройка Guards

Процесс присвоения права доступа для конкретного запроса прост. При создании Mutation, Query или Subscription добавляем аннотацию @SetMetadata, а так же классу необходимо присвоить аннотацию @UseGuards(RolesGuard).

Например:

import { UseGuards, SetMetadata } from @nestjs/common';

@UseGuards(RolesGuard)

export class ClassName {

  constructor() {}

 @Mutation()

 @SetMetadata('roles', ['USER', 'ADMIN'])

....

  }

}

Из данного кода следует, что к Mutation имеют права доступа пользователи с правами 'USER', 'ADMIN'.

Итак, как же работает класс RolesGuard.

Это  очень просто -  класс фактически сравнивает два массива. Первый массив - это взятый из контекста список ролей пользователя. Второй массив - это массив который передается с помощью аннотации SetMetadata. И если в массивах совпадает хотя бы одна роль, то пользователь получает доступ к данному запросу. Пример кода:

import { Injectable, CanActivate, ExecutionContext } from @nestjs/common';

import { Reflector } from @nestjs/core';

import { GqlExecutionContext } from @nestjs/graphql';

@Injectable()

export class RolesGuard implements CanActivate {

  constructor(private reflector: Reflector) {}

  async canActivate(context: ExecutionContext) {

    const userRoles = GqlExecutionContext.create(context).getContext<any>().roles;  // список ролей из контекста

    const roles = this.reflector.get<string[]>('roles', context.getHandler());  // список ролей переданный с помощью аннотации SetMetadata

    const matchRoles = (roles: string[], userRoles: string[]) => {

      let check = false;

      roles.forEach((element) => {

        if (userRoles.includes(element)) {

          check = true;

        }

      });

      return check;

    };

    return matchRoles(roles, userRoles );

  }

}

На этом описание внедрения guards завершено, но для полноты демонстрации функционала следует привести еще пример маршрутизации внутри запроса:

@Query(() => [Product], { name: 'findAllProduct' })

 @SetMetadata('roles', ['USER', 'ADMIN'])

  findAllProduct@CurrentUser() user?: User) {

    if(user.roles.includes('ADMIN')){

      return this.productService.findAll();

    }

    return this.productService.findAllByUserId(user.id);

  }

Данный запрос доступен пользователям с ролями 'USER', 'ADMIN'. Пользователи получают список доступных продуктов в базе. В качестве аргумента в данном запросе используется сущность user, которая сохранена в контексте. Данная сущность содержит в себе следующие поля id, roles... . Зная роль каждого пользователя мы можем произвести дополнительную маршрутизацию. Например, пользователю с правами 'ADMIN' предоставим весь список продуктов, а пользователю с правами 'USER' только те продукты, что доступны в его регионе. Тем самым пользователь произведя запрос, получит именно те данные которые предназначаются ему.

Так же guards  можно использовать в ResolveField и тем самым скрывать поля от пользователя у которого не достаточно прав на их получение, но тут необходимо понимать, что время выполнения запроса будет увеличиваться, так как проверка будет вызываться каждого ResolveField.

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

Комментарии (2)


  1. B_bird
    12.08.2023 06:37

    1. Ну тогда уж синтаксического сахара на СетМетадата не хватает:

    import {SetMetadata} from '@nestjs/common';
    
    export const ACCESS_ROLES_KEY = 'accessRoles';
    export const Roles = (...args: string[]) => SetMetadata(ACCESS_ROLES_KEY, args);
    

    Тогда красиво использовать:

        @Roles(Role.Admin, Role.Pm)
        @Get()
        findAll() {
            return this.dictionaryService.findAll();
        }
    
    1. RoleGuard сделать глобальным

    providers: [
            {
                provide: APP_GUARD,
                useClass: AuthGuard,
            },
            {
                provide: APP_GUARD,
                useClass: RolesGuard,
            },
        ],
    
    1. Ну и reflector лучше брать context.getHandler(), context.getClass() тогда можно на весь класс вешать.


  1. Gotlieb Автор
    12.08.2023 06:37

    Согласен, можно использовать и ваш подход. Более того, в технической документации от разработчика есть рекомендация использовать собственные декораторы (например, @Roles()). Спасибо за комментарий.