Доброго времени суток! Веду разработку проекта по единой аутентификации 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, мы за последнее время привнесли в проект большое количество интересных решений и идей: расширяемый профиль пользователя, загрузка модулей и многое другое.
Автор: Romashine
