Доброго времени суток! Веду разработку проекта по единой аутентификации Trusted.ID и сталкиваюсь с достаточно интересными проблемами и решениями с которыми я хотел бы поделиться. Но обо всем по порядку.

Backend-разработка — это отдельное направление, которое имеет очень много нюансов. Но их все можно свести к одному золотому правилу: «В основе backend-разработки должна быть четкая логика и структура». Где порядок, там меньше багов, потерь времени на запросы и уязвимостей в безопасности.

NestJS

На нашем проекте мы используем framework NestJS для построения сервера. Этот фреймворк позволяет решить массу проблем с архитектурой сервера. Но даже при таком подходе в один прекрасный момент в проекте начался хаос. Мы стали достаточно много тратить время на какие-либо изменения, любая задача или исправления бага начинали отнимать все больше времени. Приступив к анализу этой проблемы мы обнаружили что все дело было в некорректном применении guards, а точнее в том что нами была некорректно построена архитектура NestJS на уровне guards.

Что такое Guards в NestJS

NestJS делит middlewares на несколько слоёв:

  • Guards — проверка прав и условий доступа;

  • Interceptors — перехват и модификация запроса/ответа;

  • Pipes — валидация и трансформация данных.

Guards — это линия обороны. Они решают, должен ли запрос попасть в контроллер на сервере или же нет.

Вот пример одного из наших старых guards который отвечал за авторизацию через логин и пароль:

import { CanActivate, ExecutionContext, mixin, Inject } from '@nestjs/common';
import { UserService } from 'src/modules/user/user.service';
import { AuthService } from '../modules/auth/auth.service';

export const CredentialsGuard = () => {
  class CredentialsGuard implements CanActivate {
    constructor(
      @Inject(AuthService) private authService: AuthService,
      @Inject(UserService) private userService: UserService,
    ) {}

    async canActivate(context: ExecutionContext): Promise<boolean> {
      try {
        const request = context.switchToHttp().getRequest();
        const { identifier, password } = request.body;

        const result = await this.authService.checkUserCredentials({ identifier, password });

        if (result) {
          const user = await this.userService.getUserByIdentifier(identifier);
          Reflect.defineMetadata('user_id', user.id, context.getHandler());
        }

        return result;
      } catch (e) {
        console.log('CredentialsGuard error: ', e);
        return false;
      }
    }
  }

  const injectableGuard = mixin(CredentialsGuard);

  return injectableGuard as new () => { [key in keyof CredentialsGuard]: CredentialsGuard[key] };
};

На первый взгляд такой guard выглядит достаточно корректно, и применение его тоже достаточно просто:

@Get('')
@UseGuards(CredentialsGuard)
async get() { return this.service.get(); }

Но именно в его простоте и кроется проблема.

В нашем проекте наблюдалось 7 таких элементов:

  • AccessTokenGuard

  • ClientCredentialsGuard

  • CredentialsGuard

  • DisableAccessTokenGuard

  • PersonalGuard

  • PublicAccessTokenGuard

  • RoleGuard

На каждом контроллере применялись свои комбинации из этих guards.

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

Как использовать Guards

NestJS позволяет использовать guards гибко, вешая их на нужный уровень — один контроллер, группа контроллеров или глобально.

1. На один контроллер

@Get('')
@UseGuards(AuthGuard1, AuthGuard2)
async get() { return this.service.get(); }

2. На группу контроллеров

@Controller('settings')
@UseGuards(AuthGuard1, AuthGuard2)
export class SettingsController {}

3. Глобально

@Module({
  providers: [
    { provide: APP_GUARD, useClass: AuthGuard1 },
    { provide: APP_GUARD, useClass: AuthGuard2 },
  ],
})
export class AppModule {}

Особенности применения

На небольших проектах обычно всё просто: guard на контроллер и поехали. Именно так и начинался наш проект. Но как лучше поступить с большими проектами? Ведь чем крупнее проект, тем больше сущностей, ролей и проверок. Если не задать четкие правила, архитектура и безопасность поплывут.

Несмотря на то, что в нашем проекте присутствовало 7 guards, нам необходимо было добавить по такой схеме еще штук 5. Это явно начинало походить на проблему так как нам приходилось при добавлении нового guard проходить по всем контроллерам и добавлять его там где нужно.

С одной стороны, применение guards на отдельные контроллеры придает гибкости системе. Но эта гибкость начинает играть плохую шутку с вашим проектом при его увеличении.

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

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

Как организовать Guards

NestJS позволяет применять guards последовательно, что позволяет выстроить защиту «слоями» через которые должен пройти любой запрос к серверу.

Это достаточно удобный вариант, так как он позволяет развести логику проверок на разные «слои». А также исключить необходимость при добавлении нового guard проходить по всем контроллерам в отдельности.

Давайте рассмотрим это более подробно на примере.

Мы создали для начала guards "AccessGuard", отвечающий за проверку токена в запросе.
Если в запросе присутствует токен, то мы получаем информацию о пользователе и его роли, если нет, то (внимание!) пропускаем далее.

@Injectable()
export class AccessGuard implements CanActivate {
 private oidcService: OidcService;

 async canActivate(context: ExecutionContext): Promise<boolean> {
    const request: Request = context.switchToHttp().getRequest();
    let role: UserRoles = UserRoles.NONE;
    Reflect.defineMetadata(ROLEKEY, role, context.getHandler());
    Reflect.defineMetadata(USER_ID_KEY, null, context.getHandler());

    // При отсутствии токена в запросе, определяем роль как NONE
    if (!request.headers.authorization) {
      return true;
    }

    // Получаем информацию о токене
    if (request.headers.authorization.includes('Bearer')) {
      const token = request.headers.authorization.replace('Bearer ', '');

      if (!token || token.includes('undefined')) {
        return true;
      }

      // Проверка на валидность токена и получение информации о нем
      const tokenInfo = await this.oidcService.tokenIntrospection(token);

      user_id = tokenInfo.user_id;

      // Если токен уже не активен, то выбрасываем исключение
      if (!tokenInfo.active) throw new ForbiddenException('Токен не активен');
    } else {
      throw new BadRequestException('Некорректный формат Authorization');
    }

    // Сохраняем в контексте запроса user_id, для дальнейшего использования
    Reflect.defineMetadata(USER_ID_KEY, user_id, context.getHandler());

    // Получаем роль пользователя
    const roleItem = await prisma.role.findUnique({
      where: { user_id }
    });
    if (!roleItem)
      throw new BadRequestException('Роль пользователя не найдена');
  return true;
  }
}

Тут мне мои коллеги всегда задают вопрос «зачем пропускать далее?». Именно в этом и кроется особенность построения защиты «слоями». 

Если бы мы при отсутствии токена выдавали бы ошибку и не давали пройти, то нам бы пришлось бы строить отдельную цепочку всех проверок для контроллеров которые работают без токена и применять их выборочно. Здесь же мы только собираем необходимую информацию о пользователе если он использует токен. Остальные  «слои» будут использовать эту информацию для своих проверок.

Затем мы создали второй слой проверки "ScopeGuard". Он будет проверять, имеет ли данная роль пользователя доступ к данному контроллеру.

@Injectable()
export class ScopeGuard implements CanActivate {
  async canActivate(context: ExecutionContext): Promise<boolean> {
    const reflector = new Reflector();

    // Получаем скоупы, которые требуются для доступа к контроллеру
    const requiredScope = reflector.get<string>(SCOPE_KEY, context.getHandler());

    // Если скоупы на контроллере не указаны, то доступ открыт
    if (!requiredScope) return true;

    // Получаем роль пользователя
    const role = reflector.get<UserRoles>(ROLE_KEY, context.getHandler());

    // Если роль не указана, то доступ закрыт
    if (!role) return false;

    // Проверяем, есть ли у пользователя требуемый скоуп
    return ROLES.get(role).some((r) => r === requiredScope);
  }
}

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

@common.Get('catalog')
  @swagger.ApiOperation({
    summary: 'Получение списка приложений',
  })
  @Scope(ClientActions.list)
  async getCatalog(
    @common.Query() params: ListInputDto,
    @UserId() userId: string,
    @common.Res() res: Response,
  ) {
    const { clients, totalCount } = await this.clientService.catalog(params, userId);
    return prepareListResponse(res, clients, totalCount, params);
  }

Остается только реализовать механизм перечня scopes для ролей.

export enum ClientActions {
  read = 'client:read',
  list = 'client:list',
  write = 'client:write',
  delete = 'client:delete',
}

// Задаем права для роли USER
ROLES.set(UserRoles.USER, [
  ClientActions.list,
]);

При таком подходе легко расширять проверки под свой проект и производить масштабирование:

  1. Добавить в AccessGuard поддержку работы с Basic и JWT.

  2. Добавить новые guards, которые будут проверять доступ к определенным сущностям. Например, к пользователям или приложениям.

  3. Настроить статический список scopes на каждую роль или сделать его настраиваемым через интерфейс.

Архитектура Guards

Чтобы выстроить хорошую защиту сервера на базе guards, продумывайте не только набор проверок, но и способ их применения.

Необходимо сокращать до минимума индивидуальное применение guards на контроллерах и больше применять глобальные.

Каждый guard должен быть специализирован на своем и защищать только по своей специализации, но применяться ко всем контроллерам глобально. К примеру, если контроллер в своем адресе имеет userId, то применяется  UserGuard, который перепроверяет доступ к данному пользователю, но если нет userId, то UserGuard пропускает далее к следующим проверкам.

Принцип «слоёв» предполагает применение guards глобально, где каждый guard имеет свою специализацию и знает когда он должен отработать, а когда пропустить запрос к следующей проверке. 

Применяя архитектуру «слоёв», система приобретает четкую и понятную структуру с возможностью расширения и масштабирования.

Пример:

 Запрос --> AccessGuard --> ScopeGuard --> ResourceGuard --> Сервис

В результате вы получаете архитектуру без хаоса, а значит и надежную безопасность в проекте.

Заключение

Архитектура на backend является очень важным моментом. Некорректно построенная архитектура сервера приводит  к хаосу в проекте. Структура применения guards в NestJS должна быть грамотно выстроена:

  • отдельные guards с раздельным функционалом;

  • контроллеры без дублирования кода;

  • централизованная и многослойная защита;

  • легкость расширения и масштабирования.

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

Надеюсь я смог передать саму идею организации работы с guards в NestJS. Заглядывайте в наш репозиторий Trusted.ID, мы за последнее время привнесли в проект большое количество интересных решений и идей: расширяемый профиль пользователя, загрузка модулей и многое другое.

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