Интеграция внешнего сервера авторизации https://authorizer.dev в фулстек приложение на NestJS и Angular
В этой статье я подключу в проект внешний сервер авторизации https://authorizer.dev и напишу дополнительные бэкенд и фронтенд модули для интеграции с ним.
Код будет собран для запуска через Docker Compose
и Kubernetes
.
1. Создаем Angular-библиотеку по авторизации
Создаем пустую Angular
-библиотеку для хранения компонент с формами авторизации и регистрации, а также различные сервисы и Guards
.
Команды
# Create Angular library
./node_modules/.bin/nx g @nx/angular:library --name=auth-angular --buildable --publishable --directory=libs/core/auth-angular --simpleName=true --strict=true --prefix= --standalone=true --selector= --changeDetection=OnPush --importPath=@nestjs-mod-fullstack/auth-angular
# Change file with test options
rm -rf libs/core/auth-angular/src/test-setup.ts
cp apps/client/src/test-setup.ts libs/core/auth-angular/src/test-setup.ts
Вывод консоли
$ ./node_modules/.bin/nx g @nx/angular:library --name=auth-angular --buildable --publishable --directory=libs/core/auth-angular --simpleName=true --strict=true --prefix= --standalone=true --selector= --changeDetection=OnPush --importPath=@nestjs-mod-fullstack/auth-angular
NX Generating @nx/angular:library
CREATE libs/core/auth-angular/project.json
CREATE libs/core/auth-angular/README.md
CREATE libs/core/auth-angular/ng-package.json
CREATE libs/core/auth-angular/package.json
CREATE libs/core/auth-angular/tsconfig.json
CREATE libs/core/auth-angular/tsconfig.lib.json
CREATE libs/core/auth-angular/tsconfig.lib.prod.json
CREATE libs/core/auth-angular/src/index.ts
CREATE libs/core/auth-angular/jest.config.ts
CREATE libs/core/auth-angular/src/test-setup.ts
CREATE libs/core/auth-angular/tsconfig.spec.json
CREATE libs/core/auth-angular/src/lib/auth-angular/auth-angular.component.css
CREATE libs/core/auth-angular/src/lib/auth-angular/auth-angular.component.html
CREATE libs/core/auth-angular/src/lib/auth-angular/auth-angular.component.spec.ts
CREATE libs/core/auth-angular/src/lib/auth-angular/auth-angular.component.ts
CREATE libs/core/auth-angular/.eslintrc.json
UPDATE tsconfig.base.json
NX 👀 View Details of auth-angular
Run "nx show project auth-angular" to view details about this project.
2. Создаем NestJS-библиотеку по авторизации
Создаем пустую NestJS
-библиотеку.
Команды
./node_modules/.bin/nx g @nestjs-mod/schematics:library auth --buildable --publishable --directory=libs/core/auth --simpleName=true --projectNameAndRootFormat=as-provided --strict=true
Вывод консоли
$ ./node_modules/.bin/nx g @nestjs-mod/schematics:library auth --buildable --publishable --directory=libs/core/auth --simpleName=true --projectNameAndRootFormat=as-provided --strict=true
NX Generating @nestjs-mod/schematics:library
CREATE libs/core/auth/tsconfig.json
CREATE libs/core/auth/src/index.ts
CREATE libs/core/auth/tsconfig.lib.json
CREATE libs/core/auth/README.md
CREATE libs/core/auth/package.json
CREATE libs/core/auth/project.json
CREATE libs/core/auth/.eslintrc.json
CREATE libs/core/auth/jest.config.ts
CREATE libs/core/auth/tsconfig.spec.json
UPDATE tsconfig.base.json
CREATE libs/core/auth/src/lib/auth.configuration.ts
CREATE libs/core/auth/src/lib/auth.constants.ts
CREATE libs/core/auth/src/lib/auth.environments.ts
CREATE libs/core/auth/src/lib/auth.module.ts
3. Устанавливаем дополнительные библиотеки
Устанавливаем JS
-клиент и NestJS
-модуль для работы с сервером authorizer
с фронтенда и бэкенда.
В тестах мы часто используем случайные данные, для быстрой генерации таких данных устанавливаем пакет @faker-js/faker
.
Команды
npm install --save @nestjs-mod/authorizer @authorizerdev/authorizer-js @faker-js/faker
Вывод консоли
$ npm install --save @nestjs-mod/authorizer @authorizerdev/authorizer-js @faker-js/faker
added 3 packages, removed 371 packages, and audited 2787 packages in 18s
344 packages are looking for funding
run `npm fund` for details
34 vulnerabilities (3 low, 12 moderate, 19 high)
To address issues that do not require attention, run:
npm audit fix
To address all issues (including breaking changes), run:
npm audit fix --force
Run `npm audit` for details.
4. Подключаем новые модули в бэкенд
apps/server/src/main.ts
import {
AuthorizerModule,
AuthorizerUser,
CheckAccessOptions,
defaultAuthorizerCheckAccessValidator,AUTHORIZER_ENV_PREFIX
} from '@nestjs-mod/authorizer';
// ...
import {
DOCKER_COMPOSE_FILE,
DockerCompose,
DockerComposeAuthorizer,
DockerComposePostgreSQL,
} from '@nestjs-mod/docker-compose';
// ...
import { ExecutionContext } from '@nestjs/common';
// ...
bootstrapNestApplication({
modules: {
// ...
core: [
AuthorizerModule.forRoot({
staticConfiguration: {
extraHeaders: {
'x-authorizer-url': `http://localhost:${process.env.SERVER_AUTHORIZER_EXTERNAL_CLIENT_PORT}`,
},
checkAccessValidator: async (
authorizerUser?: AuthorizerUser,
options?: CheckAccessOptions,
ctx?: ExecutionContext
) => {
if (
typeof ctx?.getClass === 'function' &&
typeof ctx?.getHandler === 'function' &&
ctx?.getClass().name === 'TerminusHealthCheckController' &&
ctx?.getHandler().name === 'check'
) {
return true;
}
return defaultAuthorizerCheckAccessValidator(
authorizerUser,
options
);
},
},
}),
],
infrastructure: [
DockerComposePostgreSQL.forFeature({
featureModuleName: AUTHORIZER_ENV_PREFIX,
}),
DockerComposeAuthorizer.forRoot({
staticEnvironments: {
databaseUrl: '%SERVER_AUTHORIZER_INTERNAL_DATABASE_URL%',
},
staticConfiguration: {
image: 'lakhansamani/authorizer:1.4.4',
disableStrongPassword: 'true',
disableEmailVerification: 'true',
featureName: AUTHORIZER_ENV_PREFIX,
organizationName: 'NestJSModFullstack',
dependsOnServiceNames: {
'postgre-sql': 'service_healthy',
redis: 'service_healthy',
},
isEmailServiceEnabled: 'true',
isSmsServiceEnabled: 'false',
env: 'development',
},
}),
]}
);
5. Запускаем генерацию дополнительного кода по инфраструктуре
Команды
npm run docs:infrastructure
6. Добавляем весь необходимый код в модуль AuthModule (NestJS-библиотека)
При запуске приложения модуль может создать администратора по умолчанию, его емайл и пароль нужно передавать через переменные окружения, если не передали, то админ по умолчанию не будет создан.
Обновляем файл libs/core/auth/src/lib/auth.environments.ts
import { EnvModel, EnvModelProperty } from '@nestjs-mod/common';
import { IsNotEmpty } from 'class-validator';
@EnvModel()
export class AuthEnvironments {
@EnvModelProperty({
description: 'Global admin username',
default: 'admin@example.com',
})
adminEmail?: string;
@EnvModelProperty({
description: 'Global admin username',
default: 'admin',
})
@IsNotEmpty()
adminUsername?: string;
@EnvModelProperty({
description: 'Global admin password',
})
adminPassword?: string;
}
Создаем сервис для вызова администраторских методов сервера авторизации, добавляем метод создания админа, этот метод будет вызываться при старте приложения и создавать админа системы по умолчанию.
Создаем файл libs/core/auth/src/lib/services/auth-authorizer.service.ts
import { AuthorizerService } from '@nestjs-mod/authorizer';
import { Injectable, Logger } from '@nestjs/common';
import { AuthError } from '../auth.errors';
@Injectable()
export class AuthAuthorizerService {
private logger = new Logger(AuthAuthorizerService.name);
constructor(private readonly authorizerService: AuthorizerService) {}
authorizerClientID() {
return this.authorizerService.config.clientID;
}
async createAdmin(user: { username?: string; password: string; email: string }) {
const signupUserResult = await this.authorizerService.signup({
nickname: user.username,
password: user.password,
confirm_password: user.password,
email: user.email.toLowerCase(),
roles: ['admin'],
});
if (signupUserResult.errors.length > 0) {
this.logger.error(signupUserResult.errors[0].message, signupUserResult.errors[0].stack);
if (!signupUserResult.errors[0].message.includes('has already signed up')) {
throw new AuthError(signupUserResult.errors[0].message);
}
} else {
if (!signupUserResult.data?.user) {
throw new AuthError('Failed to create a user');
}
await this.verifyUser({
externalUserId: signupUserResult.data.user.id,
email: signupUserResult.data.user.email,
});
this.logger.debug(`Admin with email: ${signupUserResult.data.user.email} successfully created!`);
}
}
async verifyUser({ externalUserId, email }: { externalUserId: string; email: string }) {
await this.updateUser(externalUserId, { email_verified: true, email });
return this;
}
async updateUser(
externalUserId: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
params: Partial<Record<string, any>>
) {
if (Object.keys(params).length > 0) {
const paramsForUpdate = Object.entries(params)
.map(([key, value]) => (typeof value === 'boolean' ? `${key}: ${value}` : `${key}: "${value}"`))
.join(',');
const updateUserResult = await this.authorizerService.graphqlQuery({
query: `mutation {
_update_user(params: {
id: "${externalUserId}", ${paramsForUpdate} }) {
id
}
}`,
});
if (updateUserResult.errors.length > 0) {
this.logger.error(updateUserResult.errors[0].message, updateUserResult.errors[0].stack);
throw new AuthError(updateUserResult.errors[0].message);
}
}
}
}
Создаем сервис с OnModuleInit
-хуком в котором при старте модуля запускаем процесс создания дефолтного админа, если его не существует.
Создаем файл libs/core/auth/src/lib/services/auth-authorizer-bootstrap.service.ts
import { isInfrastructureMode } from '@nestjs-mod/common';
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { AuthAuthorizerService } from './auth-authorizer.service';
import { AuthEnvironments } from '../auth.environments';
@Injectable()
export class AuthAuthorizerBootstrapService implements OnModuleInit {
private logger = new Logger(AuthAuthorizerBootstrapService.name);
constructor(private readonly authAuthorizerService: AuthAuthorizerService, private readonly authEnvironments: AuthEnvironments) {}
async onModuleInit() {
this.logger.debug('onModuleInit');
if (!isInfrastructureMode()) {
try {
await this.createAdmin();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
this.logger.error(err, err.stack);
}
}
}
private async createAdmin() {
try {
if (this.authEnvironments.adminEmail && this.authEnvironments.adminPassword) {
await this.authAuthorizerService.createAdmin({
username: this.authEnvironments.adminUsername,
password: this.authEnvironments.adminPassword,
email: this.authEnvironments.adminEmail,
});
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
this.logger.error(err, err.stack);
}
}
}
Добавляем созданные сервисы в AuthModule
, в этом модуле подключаем глобальный Guard
для постоянной проверки наличия токена авторизации при вызове любых методов бэкенда, а также подключаем фильтр для трансформации ошибок авторизации.
Переменные окружения для этого модуля будут иметь префикс AUTH_
, для включения этого префикса нужно переопределить опцию propertyNameFormatters
.
Названия переменных окружения: SERVER_AUTH_ADMIN_EMAIL
, SERVER_AUTH_ADMIN_USERNAME
, SERVER_AUTH_ADMIN_PASSWORD
.
Обновляем файл libs/core/auth/src/lib/auth.module.ts
import { AuthorizerGuard, AuthorizerModule } from '@nestjs-mod/authorizer';
import { createNestModule, getFeatureDotEnvPropertyNameFormatter, NestModuleCategory } from '@nestjs-mod/common';
import { APP_FILTER, APP_GUARD } from '@nestjs/core';
import { AUTH_FEATURE, AUTH_MODULE } from './auth.constants';
import { AuthEnvironments } from './auth.environments';
import { AuthExceptionsFilter } from './auth.filter';
import { AuthorizerController } from './controllers/authorizer.controller';
import { AuthAuthorizerBootstrapService } from './services/auth-authorizer-bootstrap.service';
import { AuthAuthorizerService } from './services/auth-authorizer.service';
export const { AuthModule } = createNestModule({
moduleName: AUTH_MODULE,
moduleCategory: NestModuleCategory.feature,
staticEnvironmentsModel: AuthEnvironments,
imports: [
AuthorizerModule.forFeature({
featureModuleName: AUTH_FEATURE,
}),
],
controllers: [AuthorizerController],
providers: [{ provide: APP_GUARD, useClass: AuthorizerGuard }, { provide: APP_FILTER, useClass: AuthExceptionsFilter }, AuthAuthorizerService, AuthAuthorizerBootstrapService],
wrapForRootAsync: (asyncModuleOptions) => {
if (!asyncModuleOptions) {
asyncModuleOptions = {};
}
const FomatterClass = getFeatureDotEnvPropertyNameFormatter(AUTH_FEATURE);
Object.assign(asyncModuleOptions, {
environmentsOptions: {
propertyNameFormatters: [new FomatterClass()],
name: AUTH_FEATURE,
},
});
return { asyncModuleOptions };
},
});
7. Добавляем логику автоматического создания пользователей для модуля WebhookModule
Так как гард авторизации срабатывает автоматически при вызове любых методов, в том числе методов модуля WebhookModule
, то мы можем создать нового пользователя для модуля WebhookModule
в момент валидации токена авторизации.
Метод создания нового пользователя вынесем в отдельный сервис, который будет доступен при импорте модуля как фича WebhookModule.forFeature()
.
Создаем файл libs/feature/webhook/src/lib/services/webhook-users.service.ts
import { InjectPrismaClient } from '@nestjs-mod/prisma';
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/webhook-client';
import { omit } from 'lodash/fp';
import { randomUUID } from 'node:crypto';
import { CreateWebhookUserArgs, WebhookUserObject } from '../types/webhook-user-object';
import { WEBHOOK_FEATURE } from '../webhook.constants';
@Injectable()
export class WebhookUsersService {
constructor(
@InjectPrismaClient(WEBHOOK_FEATURE)
private readonly prismaClient: PrismaClient
) {}
async createUser(user: Omit<CreateWebhookUserArgs, 'id'>) {
const data = {
externalTenantId: randomUUID(),
userRole: 'User',
...omit(['id', 'createdAt', 'updatedAt', 'Webhook_Webhook_createdByToWebhookUser', 'Webhook_Webhook_updatedByToWebhookUser'], user),
} as WebhookUserObject;
const existsUser = await this.prismaClient.webhookUser.findFirst({
where: {
externalTenantId: user.externalTenantId,
externalUserId: user.externalUserId,
},
});
if (!existsUser) {
return await this.prismaClient.webhookUser.create({
data,
});
}
return existsUser;
}
}
Экспортируем новый сервис из модуля и призма модуль который он использует.
Обновляем файл libs/feature/webhook/src/lib/webhook.module.ts
import { PrismaToolsModule } from '@nestjs-mod-fullstack/prisma-tools';
import { createNestModule, getFeatureDotEnvPropertyNameFormatter, NestModuleCategory } from '@nestjs-mod/common';
import { PrismaModule } from '@nestjs-mod/prisma';
import { HttpModule } from '@nestjs/axios';
import { UseFilters, UseGuards } from '@nestjs/common';
import { ApiHeaders } from '@nestjs/swagger';
import { WebhookUsersController } from './controllers/webhook-users.controller';
import { WebhookController } from './controllers/webhook.controller';
import { WebhookServiceBootstrap } from './services/webhook-bootstrap.service';
import { WebhookToolsService } from './services/webhook-tools.service';
import { WebhookUsersService } from './services/webhook-users.service';
import { WebhookService } from './services/webhook.service';
import { WebhookConfiguration, WebhookStaticConfiguration } from './webhook.configuration';
import { WEBHOOK_FEATURE, WEBHOOK_MODULE } from './webhook.constants';
import { WebhookEnvironments } from './webhook.environments';
import { WebhookExceptionsFilter } from './webhook.filter';
import { WebhookGuard } from './webhook.guard';
export const { WebhookModule } = createNestModule({
moduleName: WEBHOOK_MODULE,
moduleCategory: NestModuleCategory.feature,
staticEnvironmentsModel: WebhookEnvironments,
staticConfigurationModel: WebhookStaticConfiguration,
configurationModel: WebhookConfiguration,
imports: [
HttpModule,
PrismaModule.forFeature({
contextName: WEBHOOK_FEATURE,
featureModuleName: WEBHOOK_FEATURE,
}),
PrismaToolsModule.forFeature({
featureModuleName: WEBHOOK_FEATURE,
}),
],
sharedImports: [
PrismaModule.forFeature({
contextName: WEBHOOK_FEATURE,
featureModuleName: WEBHOOK_FEATURE,
}),
],
providers: [WebhookToolsService, WebhookServiceBootstrap],
controllers: [WebhookUsersController, WebhookController],
sharedProviders: [WebhookService, WebhookUsersService],
wrapForRootAsync: (asyncModuleOptions) => {
if (!asyncModuleOptions) {
asyncModuleOptions = {};
}
const FomatterClass = getFeatureDotEnvPropertyNameFormatter(WEBHOOK_FEATURE);
Object.assign(asyncModuleOptions, {
environmentsOptions: {
propertyNameFormatters: [new FomatterClass()],
name: WEBHOOK_FEATURE,
},
});
return { asyncModuleOptions };
},
preWrapApplication: async ({ current }) => {
const staticEnvironments = current.staticEnvironments as WebhookEnvironments;
const staticConfiguration = current.staticConfiguration as WebhookStaticConfiguration;
for (const ctrl of [WebhookController, WebhookUsersController]) {
if (staticEnvironments.useFilters) {
UseFilters(WebhookExceptionsFilter)(ctrl);
}
if (staticEnvironments.useGuards) {
UseGuards(WebhookGuard)(ctrl);
}
if (staticConfiguration.externalUserIdHeaderName && staticConfiguration.externalTenantIdHeaderName) {
ApiHeaders([
{
name: staticConfiguration.externalUserIdHeaderName,
allowEmptyValue: true,
},
{
name: staticConfiguration.externalTenantIdHeaderName,
allowEmptyValue: true,
},
])(ctrl);
}
}
},
});
Обновляем функцию создания конфигурации модуля AuthorizerModule
, добавляем использование сервиса из модуля WebhookModule
.
Обновляем файл apps/server/src/main.ts
//...
bootstrapNestApplication({
modules: {
//...
core: [
AuthorizerModule.forRootAsync({
imports: [WebhookModule.forFeature({ featureModuleName: AUTH_FEATURE })],
inject: [WebhookUsersService],
configurationFactory: (webhookUsersService: WebhookUsersService) => {
return {
extraHeaders: {
'x-authorizer-url': `http://localhost:${process.env.SERVER_AUTHORIZER_EXTERNAL_CLIENT_PORT}`,
},
checkAccessValidator: async (authorizerUser?: AuthorizerUser, options?: CheckAccessOptions, ctx?: ExecutionContext) => {
if (typeof ctx?.getClass === 'function' && typeof ctx?.getHandler === 'function' && ctx?.getClass().name === 'TerminusHealthCheckController' && ctx?.getHandler().name === 'check') {
return true;
}
const result = await defaultAuthorizerCheckAccessValidator(authorizerUser, options);
if (ctx && authorizerUser?.id) {
const webhookUser = await webhookUsersService.createUser({
externalUserId: authorizerUser?.id,
externalTenantId: authorizerUser?.id,
userRole: authorizerUser.roles?.includes('admin') ? 'Admin' : 'User',
});
const req: WebhookRequest = getRequestFromExecutionContext(ctx);
req.externalTenantId = webhookUser.externalTenantId;
}
return result;
},
};
},
}),
//...
],
//...
},
//...
});