Доброго времени суток! Веду разработку проекта по единой аутентификации 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, ]);
При таком подходе легко расширять проверки под свой проект и производить масштабирование:
-
Добавить в AccessGuard поддержку работы с Basic и JWT.
-
Добавить новые guards, которые будут проверять доступ к определенным сущностям. Например, к пользователям или приложениям.
-
Настроить статический список scopes на каждую роль или сделать его настраиваемым через интерфейс.
Архитектура Guards
Чтобы выстроить хорошую защиту сервера на базе guards, продумывайте не только набор проверок, но и способ их применения.
Необходимо сокращать до минимума индивидуальное применение guards на контроллерах и больше применять глобальные.
Каждый guard должен быть специализирован на своем и защищать только по своей специализации, но применяться ко всем контроллерам глобально. К примеру, если контроллер в своем адресе имеет userId, то применяется UserGuard, который перепроверяет доступ к данному пользователю, но если нет userId, то UserGuard пропускает далее к следующим проверкам.
Принцип «слоёв» предполагает применение guards глобально, где каждый guard имеет свою специализацию и знает когда он должен отработать, а когда пропустить запрос к следующей проверке.
Применяя архитектуру «слоёв», система приобретает четкую и понятную структуру с возможностью расширения и масштабирования.
Пример:
Запрос —> AccessGuard —> ScopeGuard —> ResourceGuard —> Сервис
В результате вы получаете архитектуру без хаоса, а значит и надежную безопасность в проекте.
Заключение
Архитектура на backend является очень важным моментом. Некорректно построенная архитектура сервера приводит к хаосу в проекте. Структура применения guards в NestJS должна быть грамотно выстроена:
-
отдельные guards с раздельным функционалом;
-
контроллеры без дублирования кода;
-
централизованная и многослойная защита;
-
легкость расширения и масштабирования.
Это фундамент любого проекта, чем он крепче, тем стабильнее сервер.
Надеюсь я смог передать саму идею организации работы с guards в NestJS. Заглядывайте в наш репозиторий Trusted.ID, мы за последнее время привнесли в проект большое количество интересных решений и идей: расширяемый профиль пользователя, загрузка модулей и многое другое.
ссылка на оригинал статьи https://habr.com/ru/articles/917266/
Добавить комментарий