Предыдущая статья: Создание пользовательского интерфейса для модуля Webhook с помощью 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; }, }; }, }), //... ], //... }, //... });
8. Добавляем весь необходимый код в Angular-библиотеку по авторизации
Экземпляр клиента сервера авторизации создаем с помощью DI
от Angular
.
Создаем файл libs/core/auth-angular/src/lib/services/authorizer.service.ts
import { Inject, Injectable, InjectionToken } from '@angular/core'; import { Authorizer, ConfigType } from '@authorizerdev/authorizer-js'; export const AUTHORIZER_URL = new InjectionToken<string>('AuthorizerURL'); @Injectable({ providedIn: 'root' }) export class AuthorizerService extends Authorizer { constructor( @Inject(AUTHORIZER_URL) private readonly authorizerURL: string ) { super({ authorizerURL: // need for override from e2e-tests localStorage.getItem('authorizerURL') || // use from environments authorizerURL, clientID: '', redirectURL: window.location.origin, } as ConfigType); } }
Все дополнительные методы для работе с сервером авторизации, добавляем в AuthService
.
Создаем файл libs/core/auth-angular/src/lib/services/auth.service.ts
import { Injectable } from '@angular/core'; import { AuthToken, LoginInput, SignupInput, User } from '@authorizerdev/authorizer-js'; import { mapGraphqlErrors } from '@nestjs-mod-fullstack/common-angular'; import { BehaviorSubject, catchError, from, map, of, tap } from 'rxjs'; import { AuthorizerService } from './authorizer.service'; @Injectable({ providedIn: 'root' }) export class AuthService { profile$ = new BehaviorSubject<User | undefined>(undefined); tokens$ = new BehaviorSubject<AuthToken | undefined>(undefined); constructor(private readonly authorizerService: AuthorizerService) {} getAuthorizerClientID() { return this.authorizerService.config.clientID; } setAuthorizerClientID(clientID: string) { this.authorizerService.config.clientID = clientID; } signUp(data: SignupInput) { return from( this.authorizerService.signup({ ...data, email: data.email?.toLowerCase(), }) ).pipe( mapGraphqlErrors(), map((result) => { this.setProfileAndTokens(result); return { profile: result?.user, tokens: this.tokens$.value, }; }) ); } signIn(data: LoginInput) { return from( this.authorizerService.login({ ...data, email: data.email?.toLowerCase(), }) ).pipe( mapGraphqlErrors(), map((result) => { this.setProfileAndTokens(result); return { profile: result?.user, tokens: this.tokens$.value, }; }) ); } signOut() { return from(this.authorizerService.logout(this.getAuthorizationHeaders())).pipe( mapGraphqlErrors(), tap(() => { this.clearProfileAndTokens(); }) ); } refreshToken() { return from(this.authorizerService.browserLogin()).pipe( mapGraphqlErrors(), tap((result) => { this.setProfileAndTokens(result); }), catchError((err) => { console.error(err); this.clearProfileAndTokens(); return of(null); }) ); } clearProfileAndTokens() { this.setProfileAndTokens({} as AuthToken); } setProfileAndTokens(result: AuthToken | undefined) { this.tokens$.next(result as AuthToken); this.profile$.next(result?.user); } getAuthorizationHeaders() { if (!this.tokens$.value?.access_token) { return undefined; } return { Authorization: `Bearer ${this.tokens$.value.access_token}`, }; } }
Часть страниц имеют ограничения по ролям, для активации такой возможности нам нужно создать Guard
.
Создаем файл libs/core/auth-angular/src/lib/services/auth-guard.service.ts
import { Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, CanActivate } from '@angular/router'; import { of } from 'rxjs'; import { AuthService } from './auth.service'; export const AUTH_GUARD_DATA_ROUTE_KEY = 'authGuardData'; export class AuthGuardData { roles?: string[]; constructor(options?: AuthGuardData) { Object.assign(this, options); } } @Injectable({ providedIn: 'root' }) export class AuthGuardService implements CanActivate { constructor(private readonly authAuthService: AuthService) {} canActivate(route: ActivatedRouteSnapshot) { if (route.data[AUTH_GUARD_DATA_ROUTE_KEY] instanceof AuthGuardData) { const authGuardData = route.data[AUTH_GUARD_DATA_ROUTE_KEY]; const authUser = this.authAuthService.profile$.value; const authGuardDataRoles = (authGuardData.roles || []).map((role) => role.toLowerCase()); return of(Boolean((authUser && authGuardDataRoles.length > 0 && authGuardDataRoles.some((r) => authUser.roles?.includes(r))) || (authGuardDataRoles.length === 0 && !authUser?.roles))); } return of(true); } }
Добавляем компоненту формы регистрации libs/core/auth-angular/src/lib/forms/auth-sign-up-form/auth-sign-up-form.component.ts
import { AsyncPipe, NgIf } from '@angular/common'; import { ChangeDetectionStrategy, Component, EventEmitter, Inject, Input, OnInit, Optional, Output } from '@angular/core'; import { FormsModule, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms'; import { RouterModule } from '@angular/router'; import { AuthToken, SignupInput } from '@authorizerdev/authorizer-js'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { FormlyFieldConfig, FormlyModule } from '@ngx-formly/core'; import { NzButtonModule } from 'ng-zorro-antd/button'; import { NzFormModule } from 'ng-zorro-antd/form'; import { NzInputModule } from 'ng-zorro-antd/input'; import { NzMessageService } from 'ng-zorro-antd/message'; import { NZ_MODAL_DATA } from 'ng-zorro-antd/modal'; import { BehaviorSubject, catchError, of, tap } from 'rxjs'; import { AuthService } from '../../services/auth.service'; @UntilDestroy() @Component({ standalone: true, imports: [FormlyModule, NzFormModule, NzInputModule, NzButtonModule, FormsModule, ReactiveFormsModule, AsyncPipe, NgIf, RouterModule], selector: 'auth-sign-up-form', templateUrl: './auth-sign-up-form.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export class AuthSignUpFormComponent implements OnInit { @Input() hideButtons?: boolean; @Output() afterSignUp = new EventEmitter<AuthToken>(); form = new UntypedFormGroup({}); formlyModel$ = new BehaviorSubject<object | null>(null); formlyFields$ = new BehaviorSubject<FormlyFieldConfig[] | null>(null); constructor( @Optional() @Inject(NZ_MODAL_DATA) private readonly nzModalData: AuthSignUpFormComponent, private readonly authService: AuthService, private readonly nzMessageService: NzMessageService ) {} ngOnInit(): void { Object.assign(this, this.nzModalData); this.setFieldsAndModel({ password: '', confirm_password: '' }); } setFieldsAndModel(data: SignupInput = { password: '', confirm_password: '' }) { this.formlyFields$.next([ { key: 'email', type: 'input', validation: { show: true, }, props: { label: `auth.form.email`, placeholder: 'email', required: true, }, }, { key: 'password', type: 'input', validation: { show: true, }, props: { label: `auth.form.password`, placeholder: 'password', required: true, type: 'password', }, }, { key: 'confirm_password', type: 'input', validation: { show: true, }, props: { label: `auth.form.confirm_password`, placeholder: 'confirm_password', required: true, type: 'password', }, }, ]); this.formlyModel$.next(this.toModel(data)); } submitForm(): void { if (this.form.valid) { const value = this.toJson(this.form.value); this.authService .signUp({ ...value }) .pipe( tap((result) => { if (result.tokens) { this.afterSignUp.next(result.tokens); this.nzMessageService.success('Success'); } }), // eslint-disable-next-line @typescript-eslint/no-explicit-any catchError((err: any) => { console.error(err); this.nzMessageService.error(err.message); return of(null); }), untilDestroyed(this) ) .subscribe(); } else { console.log(this.form.controls); this.nzMessageService.warning('Validation errors'); } } private toModel(data: SignupInput): object | null { return { email: data['email'], password: data['password'], confirm_password: data['confirm_password'], }; } private toJson(data: SignupInput) { return { email: data['email'], password: data['password'], confirm_password: data['confirm_password'], }; } }
Добавляем шаблон формы регистрации libs/core/auth-angular/src/lib/forms/auth-sign-up-form/auth-sign-up-form.component.html
@if (formlyFields$ | async; as formlyFields) { <form nz-form [formGroup]="form" (ngSubmit)="submitForm()"> <formly-form [model]="formlyModel$ | async" [fields]="formlyFields" [form]="form"> </formly-form> @if (!hideButtons) { <nz-form-control> <div class="flex justify-between"> <div> <button nz-button nzType="default" type="button" [routerLink]="'/sign-in'">Sign-in</button> </div> <button nz-button nzType="primary" type="submit" [disabled]="!form.valid">Sign-up</button> </div> </nz-form-control> } </form> }
Добавляем компоненту формы авторизации libs/core/auth-angular/src/lib/forms/auth-sign-in-form/auth-sign-in-form.component.ts
import { AsyncPipe, NgIf } from '@angular/common'; import { ChangeDetectionStrategy, Component, EventEmitter, Inject, Input, OnInit, Optional, Output } from '@angular/core'; import { FormsModule, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms'; import { RouterModule } from '@angular/router'; import { AuthToken, LoginInput } from '@authorizerdev/authorizer-js'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { FormlyFieldConfig, FormlyModule } from '@ngx-formly/core'; import { NzButtonModule } from 'ng-zorro-antd/button'; import { NzFormModule } from 'ng-zorro-antd/form'; import { NzInputModule } from 'ng-zorro-antd/input'; import { NzMessageService } from 'ng-zorro-antd/message'; import { NZ_MODAL_DATA } from 'ng-zorro-antd/modal'; import { BehaviorSubject, catchError, of, tap } from 'rxjs'; import { AuthService } from '../../services/auth.service'; @UntilDestroy() @Component({ standalone: true, imports: [FormlyModule, NzFormModule, NzInputModule, NzButtonModule, FormsModule, ReactiveFormsModule, AsyncPipe, NgIf, RouterModule], selector: 'auth-sign-in-form', templateUrl: './auth-sign-in-form.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export class AuthSignInFormComponent implements OnInit { @Input() hideButtons?: boolean; @Output() afterSignIn = new EventEmitter<AuthToken>(); form = new UntypedFormGroup({}); formlyModel$ = new BehaviorSubject<object | null>(null); formlyFields$ = new BehaviorSubject<FormlyFieldConfig[] | null>(null); constructor( @Optional() @Inject(NZ_MODAL_DATA) private readonly nzModalData: AuthSignInFormComponent, private readonly authService: AuthService, private readonly nzMessageService: NzMessageService ) {} ngOnInit(): void { Object.assign(this, this.nzModalData); this.setFieldsAndModel({ password: '' }); } setFieldsAndModel(data: LoginInput = { password: '' }) { this.formlyFields$.next([ { key: 'email', type: 'input', validation: { show: true, }, props: { label: `auth.form.email`, placeholder: 'email', required: true, }, }, { key: 'password', type: 'input', validation: { show: true, }, props: { label: `auth.form.password`, placeholder: 'password', required: true, type: 'password', }, }, ]); this.formlyModel$.next(this.toModel(data)); } submitForm(): void { if (this.form.valid) { const value = this.toJson(this.form.value); this.authService .signIn(value) .pipe( tap((result) => { if (result.tokens) { this.afterSignIn.next(result.tokens); this.nzMessageService.success('Success'); } }), // eslint-disable-next-line @typescript-eslint/no-explicit-any catchError((err: any) => { console.error(err); this.nzMessageService.error(err.message); return of(null); }), untilDestroyed(this) ) .subscribe(); } else { console.log(this.form.controls); this.nzMessageService.warning('Validation errors'); } } private toModel(data: LoginInput): object | null { return { email: data['email'], password: data['password'], }; } private toJson(data: LoginInput) { return { email: data['email'], password: data['password'], }; } }
Добавляем шаблон формы авторизации libs/core/auth-angular/src/lib/forms/auth-sign-in-form/auth-sign-in-form.component.html
@if (formlyFields$ | async; as formlyFields) { <form nz-form [formGroup]="form" (ngSubmit)="submitForm()"> <formly-form [model]="formlyModel$ | async" [fields]="formlyFields" [form]="form"> </formly-form> @if (!hideButtons) { <nz-form-control> <div class="flex justify-between"> <div> <button nz-button nzType="default" type="button" [routerLink]="'/sign-up'">Sign-up</button> </div> <button nz-button nzType="primary" type="submit" [disabled]="!form.valid">Sign-in</button> </div> </nz-form-control> } </form> }
9. Добавляем сервис инициализации в Angular-приложение
В данном сервисе мы пытаемся рефрешить токен авторизации, а также подписываемся на получение токена при регистрации, авторизации и рефреше, полученный токен устанавливаем в sdk
для работы с бэкендом.
Создаем файл apps/client/src/app/app-initializer.ts
import { HttpHeaders } from '@angular/common/http'; import { DefaultRestService, WebhookRestService } from '@nestjs-mod-fullstack/app-angular-rest-sdk'; import { AuthService } from '@nestjs-mod-fullstack/auth-angular'; import { catchError, map, mergeMap, of, Subscription, tap, throwError } from 'rxjs'; export class AppInitializer { private subscribeToTokenUpdatesSubscription?: Subscription; constructor(private readonly defaultRestService: DefaultRestService, private readonly webhookRestService: WebhookRestService, private readonly authService: AuthService) {} resolve() { this.subscribeToTokenUpdates(); return ( this.authService.getAuthorizerClientID() ? of(null) : this.defaultRestService.authorizerControllerGetAuthorizerClientID().pipe( map(({ clientID }) => { this.authService.setAuthorizerClientID(clientID); return null; }) ) ).pipe( mergeMap(() => this.authService.refreshToken()), catchError((err) => { console.error(err); return throwError(() => err); }) ); } private subscribeToTokenUpdates() { if (this.subscribeToTokenUpdatesSubscription) { this.subscribeToTokenUpdatesSubscription.unsubscribe(); this.subscribeToTokenUpdatesSubscription = undefined; } this.subscribeToTokenUpdatesSubscription = this.authService.tokens$ .pipe( tap(() => { const authorizationHeaders = this.authService.getAuthorizationHeaders(); if (authorizationHeaders) { this.defaultRestService.defaultHeaders = new HttpHeaders(authorizationHeaders); this.webhookRestService.defaultHeaders = new HttpHeaders(authorizationHeaders); } }) ) .subscribe(); } }
10. Обновляем конфигурацию Angular-приложения
Обновляем файл apps/client/src/app/app.config.ts
import { provideHttpClient } from '@angular/common/http'; import { APP_INITIALIZER, ApplicationConfig, ErrorHandler, importProvidersFrom, provideZoneChangeDetection } from '@angular/core'; import { provideClientHydration } from '@angular/platform-browser'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { provideRouter } from '@angular/router'; import { DefaultRestService, RestClientApiModule, RestClientConfiguration, WebhookRestService } from '@nestjs-mod-fullstack/app-angular-rest-sdk'; import { AUTHORIZER_URL, AuthService } from '@nestjs-mod-fullstack/auth-angular'; import { WEBHOOK_CONFIGURATION_TOKEN, WebhookConfiguration } from '@nestjs-mod-fullstack/webhook-angular'; import { FormlyModule } from '@ngx-formly/core'; import { FormlyNgZorroAntdModule } from '@ngx-formly/ng-zorro-antd'; import { en_US, provideNzI18n } from 'ng-zorro-antd/i18n'; import { serverUrl, webhookSuperAdminExternalUserId } from '../environments/environment'; import { AppInitializer } from './app-initializer'; import { AppErrorHandler } from './app.error-handler'; import { appRoutes } from './app.routes'; export const appConfig = ({ authorizerURL }: { authorizerURL?: string }): ApplicationConfig => { return { providers: [ provideClientHydration(), provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(appRoutes), provideHttpClient(), provideNzI18n(en_US), { provide: WEBHOOK_CONFIGURATION_TOKEN, useValue: new WebhookConfiguration({ webhookSuperAdminExternalUserId }), }, importProvidersFrom( BrowserAnimationsModule, RestClientApiModule.forRoot( () => new RestClientConfiguration({ basePath: serverUrl, }) ), FormlyModule.forRoot(), FormlyNgZorroAntdModule ), { provide: ErrorHandler, useClass: AppErrorHandler }, { provide: AUTHORIZER_URL, useValue: authorizerURL, }, { provide: APP_INITIALIZER, useFactory: (defaultRestService: DefaultRestService, webhookRestService: WebhookRestService, authService: AuthService) => () => new AppInitializer(defaultRestService, webhookRestService, authService).resolve(), multi: true, deps: [DefaultRestService, WebhookRestService, AuthService], }, ], }; };
11. Обновляем файлы и добавляем новые для запуска docker-compose и kubernetes
Полностью описывать изменения во всех файлах я не буду, их можно посмотреть по коммиту с изменениями для текущего поста, ниже просто добавлю обновленный docker-compose-full.yml
и его файл с переменными окружения.
Обновляем файл .docker/docker-compose-full.yml
version: '3' networks: nestjs-mod-fullstack-network: driver: 'bridge' services: nestjs-mod-fullstack-postgre-sql: image: 'bitnami/postgresql:15.5.0' container_name: 'nestjs-mod-fullstack-postgre-sql' networks: - 'nestjs-mod-fullstack-network' healthcheck: test: - 'CMD-SHELL' - 'pg_isready -U postgres' interval: '5s' timeout: '5s' retries: 5 tty: true restart: 'always' environment: POSTGRESQL_USERNAME: '${SERVER_POSTGRE_SQL_POSTGRESQL_USERNAME}' POSTGRESQL_PASSWORD: '${SERVER_POSTGRE_SQL_POSTGRESQL_PASSWORD}' POSTGRESQL_DATABASE: '${SERVER_POSTGRE_SQL_POSTGRESQL_DATABASE}' volumes: - 'nestjs-mod-fullstack-postgre-sql-volume:/bitnami/postgresql' nestjs-mod-fullstack-authorizer: image: 'lakhansamani/authorizer:1.4.4' container_name: 'nestjs-mod-fullstack-authorizer' ports: - '8000:8080' networks: - 'nestjs-mod-fullstack-network' environment: DATABASE_URL: '${SERVER_AUTHORIZER_DATABASE_URL}' DATABASE_TYPE: '${SERVER_AUTHORIZER_DATABASE_TYPE}' DATABASE_NAME: '${SERVER_AUTHORIZER_DATABASE_NAME}' ADMIN_SECRET: '${SERVER_AUTHORIZER_ADMIN_SECRET}' PORT: '${SERVER_AUTHORIZER_PORT}' AUTHORIZER_URL: '${SERVER_AUTHORIZER_URL}' COOKIE_NAME: '${SERVER_AUTHORIZER_COOKIE_NAME}' SMTP_HOST: '${SERVER_AUTHORIZER_SMTP_HOST}' SMTP_PORT: '${SERVER_AUTHORIZER_SMTP_PORT}' SMTP_USERNAME: '${SERVER_AUTHORIZER_SMTP_USERNAME}' SMTP_PASSWORD: '${SERVER_AUTHORIZER_SMTP_PASSWORD}' SENDER_EMAIL: '${SERVER_AUTHORIZER_SENDER_EMAIL}' SENDER_NAME: '${SERVER_AUTHORIZER_SENDER_NAME}' DISABLE_PLAYGROUND: '${SERVER_AUTHORIZER_DISABLE_PLAYGROUND}' ACCESS_TOKEN_EXPIRY_TIME: '${SERVER_AUTHORIZER_ACCESS_TOKEN_EXPIRY_TIME}' DISABLE_STRONG_PASSWORD: '${SERVER_AUTHORIZER_DISABLE_STRONG_PASSWORD}' DISABLE_EMAIL_VERIFICATION: '${SERVER_AUTHORIZER_DISABLE_EMAIL_VERIFICATION}' ORGANIZATION_NAME: '${SERVER_AUTHORIZER_ORGANIZATION_NAME}' IS_SMS_SERVICE_ENABLED: '${SERVER_AUTHORIZER_IS_SMS_SERVICE_ENABLED}' IS_EMAIL_SERVICE_ENABLED: '${SERVER_AUTHORIZER_IS_SMS_SERVICE_ENABLED}' ENV: '${SERVER_AUTHORIZER_ENV}' RESET_PASSWORD_URL: '${SERVER_AUTHORIZER_RESET_PASSWORD_URL}' ROLES: '${SERVER_AUTHORIZER_ROLES}' DEFAULT_ROLES: '${SERVER_AUTHORIZER_DEFAULT_ROLES}' JWT_ROLE_CLAIM: '${SERVER_AUTHORIZER_JWT_ROLE_CLAIM}' ORGANIZATION_LOGO: '${SERVER_AUTHORIZER_ORGANIZATION_LOGO}' tty: true restart: 'always' depends_on: nestjs-mod-fullstack-postgre-sql: condition: service_healthy nestjs-mod-fullstack-postgre-sql-migrations: condition: service_completed_successfully nestjs-mod-fullstack-postgre-sql-migrations: image: 'ghcr.io/nestjs-mod/nestjs-mod-fullstack-migrations:${ROOT_VERSION}' container_name: 'nestjs-mod-fullstack-postgre-sql-migrations' networks: - 'nestjs-mod-fullstack-network' tty: true environment: NX_SKIP_NX_CACHE: 'true' SERVER_ROOT_DATABASE_URL: '${SERVER_ROOT_DATABASE_URL}' SERVER_APP_DATABASE_URL: '${SERVER_APP_DATABASE_URL}' SERVER_WEBHOOK_DATABASE_URL: '${SERVER_WEBHOOK_DATABASE_URL}' SERVER_AUTHORIZER_DATABASE_URL: '${SERVER_AUTHORIZER_DATABASE_URL}' depends_on: nestjs-mod-fullstack-postgre-sql: condition: 'service_healthy' working_dir: '/usr/src/app' volumes: - './../apps:/usr/src/app/apps' - './../libs:/usr/src/app/libs' nestjs-mod-fullstack-server: image: 'ghcr.io/nestjs-mod/nestjs-mod-fullstack-server:${SERVER_VERSION}' container_name: 'nestjs-mod-fullstack-server' networks: - 'nestjs-mod-fullstack-network' extra_hosts: - 'host.docker.internal:host-gateway' healthcheck: test: ['CMD-SHELL', 'npx -y wait-on --timeout= --interval=1000 --window --verbose --log http://localhost:${SERVER_PORT}/api/health'] interval: 30s timeout: 10s retries: 10 tty: true environment: SERVER_APP_DATABASE_URL: '${SERVER_APP_DATABASE_URL}' SERVER_PORT: '${SERVER_PORT}' SERVER_WEBHOOK_DATABASE_URL: '${SERVER_WEBHOOK_DATABASE_URL}' SERVER_WEBHOOK_SUPER_ADMIN_EXTERNAL_USER_ID: '${SERVER_WEBHOOK_SUPER_ADMIN_EXTERNAL_USER_ID}' SERVER_AUTH_ADMIN_EMAIL: '${SERVER_AUTH_ADMIN_EMAIL}' SERVER_AUTH_ADMIN_USERNAME: '${SERVER_AUTH_ADMIN_USERNAME}' SERVER_AUTH_ADMIN_PASSWORD: '${SERVER_AUTH_ADMIN_PASSWORD}' NODE_TLS_REJECT_UNAUTHORIZED: '0' SERVER_AUTHORIZER_URL: '${SERVER_AUTHORIZER_URL}' SERVER_AUTHORIZER_REDIRECT_URL: '${SERVER_AUTHORIZER_REDIRECT_URL}' SERVER_AUTHORIZER_AUTHORIZER_URL: '${SERVER_AUTHORIZER_AUTHORIZER_URL}' SERVER_AUTHORIZER_ADMIN_SECRET: '${SERVER_AUTHORIZER_ADMIN_SECRET}' restart: 'always' depends_on: nestjs-mod-fullstack-postgre-sql: condition: service_healthy nestjs-mod-fullstack-postgre-sql-migrations: condition: service_completed_successfully nestjs-mod-fullstack-nginx: image: 'ghcr.io/nestjs-mod/nestjs-mod-fullstack-nginx:${CLIENT_VERSION}' container_name: 'nestjs-mod-fullstack-nginx' networks: - 'nestjs-mod-fullstack-network' healthcheck: test: ['CMD-SHELL', 'curl -so /dev/null http://localhost:${NGINX_PORT} || exit 1'] interval: 30s timeout: 10s retries: 10 environment: SERVER_PORT: '${SERVER_PORT}' NGINX_PORT: '${NGINX_PORT}' CLIENT_AUTHORIZER_URL: '${CLIENT_AUTHORIZER_URL}' CLIENT_WEBHOOK_SUPER_ADMIN_EXTERNAL_USER_ID: '${CLIENT_WEBHOOK_SUPER_ADMIN_EXTERNAL_USER_ID}' restart: 'always' depends_on: nestjs-mod-fullstack-server: condition: service_healthy ports: - '${NGINX_PORT}:${NGINX_PORT}' nestjs-mod-fullstack-e2e-tests: image: 'ghcr.io/nestjs-mod/nestjs-mod-fullstack-e2e-tests:${ROOT_VERSION}' container_name: 'nestjs-mod-fullstack-e2e-tests' networks: - 'nestjs-mod-fullstack-network' environment: IS_DOCKER_COMPOSE: 'true' BASE_URL: 'http://nestjs-mod-fullstack-nginx:${NGINX_PORT}' SERVER_AUTHORIZER_URL: 'http://nestjs-mod-fullstack-authorizer:8080' SERVER_URL: 'http://nestjs-mod-fullstack-server:8080' SERVER_AUTH_ADMIN_EMAIL: '${SERVER_AUTH_ADMIN_EMAIL}' SERVER_AUTH_ADMIN_USERNAME: '${SERVER_AUTH_ADMIN_USERNAME}' SERVER_AUTH_ADMIN_PASSWORD: '${SERVER_AUTH_ADMIN_PASSWORD}' SERVER_AUTHORIZER_ADMIN_SECRET: '${SERVER_AUTHORIZER_ADMIN_SECRET}' depends_on: nestjs-mod-fullstack-nginx: condition: service_healthy working_dir: '/usr/src/app' volumes: - './../apps:/usr/src/app/apps' - './../libs:/usr/src/app/libs' nestjs-mod-fullstack-https-portal: image: steveltn/https-portal:1 container_name: 'nestjs-mod-fullstack-https-portal' networks: - 'nestjs-mod-fullstack-network' ports: - '80:80' - '443:443' links: - nestjs-mod-fullstack-nginx restart: always environment: STAGE: '${HTTPS_PORTAL_STAGE}' DOMAINS: '${SERVER_DOMAIN} -> http://nestjs-mod-fullstack-nginx:${NGINX_PORT}' depends_on: nestjs-mod-fullstack-nginx: condition: service_healthy volumes: - nestjs-mod-fullstack-https-portal-volume:/var/lib/https-portal volumes: nestjs-mod-fullstack-postgre-sql-volume: name: 'nestjs-mod-fullstack-postgre-sql-volume' nestjs-mod-fullstack-https-portal-volume: name: 'nestjs-mod-fullstack-https-portal-volume'
Обновляем файл .docker/docker-compose-full.env
SERVER_PORT=9090 NGINX_PORT=8080 SERVER_ROOT_DATABASE_URL=postgres://postgres:postgres_password@nestjs-mod-fullstack-postgre-sql:5432/postgres?schema=public SERVER_APP_DATABASE_URL=postgres://app:app_password@nestjs-mod-fullstack-postgre-sql:5432/app?schema=public SERVER_WEBHOOK_DATABASE_URL=postgres://webhook:webhook_password@nestjs-mod-fullstack-postgre-sql:5432/webhook?schema=public SERVER_POSTGRE_SQL_POSTGRESQL_USERNAME=postgres SERVER_POSTGRE_SQL_POSTGRESQL_PASSWORD=postgres_password SERVER_POSTGRE_SQL_POSTGRESQL_DATABASE=postgres SERVER_DOMAIN=example.com HTTPS_PORTAL_STAGE=local # local|stage|production CLIENT_AUTHORIZER_URL=http://localhost:8000 SERVER_AUTHORIZER_REDIRECT_URL=http://localhost:8080 SERVER_AUTH_ADMIN_EMAIL=nestjs-mod-fullstack@site15.ru SERVER_AUTH_ADMIN_USERNAME=admin SERVER_AUTH_ADMIN_PASSWORD=SbxcbII7RUvCOe9TDXnKhfRrLJW5cGDA SERVER_URL=http://localhost:9090/api SERVER_AUTHORIZER_URL=http://localhost:8000 SERVER_AUTHORIZER_ADMIN_SECRET=VfKSfPPljhHBXCEohnitursmgDxfAyiD SERVER_AUTHORIZER_DATABASE_TYPE=postgres SERVER_AUTHORIZER_DATABASE_URL=postgres://Yk42KA4sOb:B7Ep2MwlRR6fAx0frXGWVTGP850qAxM6@nestjs-mod-fullstack-postgre-sql:5432/authorizer SERVER_AUTHORIZER_DATABASE_NAME=authorizer SERVER_AUTHORIZER_PORT=8080 SERVER_AUTHORIZER_AUTHORIZER_URL=http://nestjs-mod-fullstack-authorizer:8080 SERVER_AUTHORIZER_COOKIE_NAME=authorizer SERVER_AUTHORIZER_DISABLE_PLAYGROUND=true SERVER_AUTHORIZER_ACCESS_TOKEN_EXPIRY_TIME=30m SERVER_AUTHORIZER_DISABLE_STRONG_PASSWORD=true SERVER_AUTHORIZER_DISABLE_EMAIL_VERIFICATION=true SERVER_AUTHORIZER_ORGANIZATION_NAME=NestJSModFullstack SERVER_AUTHORIZER_IS_EMAIL_SERVICE_ENABLED=true SERVER_AUTHORIZER_IS_SMS_SERVICE_ENABLED=false SERVER_AUTHORIZER_RESET_PASSWORD_URL=/reset-password SERVER_AUTHORIZER_ROLES=user,admin SERVER_AUTHORIZER_DEFAULT_ROLES=user SERVER_AUTHORIZER_JWT_ROLE_CLAIM=role
12. Обновляем E2E-тесты
При написании и запуске E2E-тестов много кода дублируется, для того чтобы убрать дублирование создаем тестовую утилиту.
Создаем файл libs/testing/src/lib/utils/rest-client-helper.ts
import { AuthToken, Authorizer } from '@authorizerdev/authorizer-js'; import { Configuration, DefaultApi, WebhookApi } from '@nestjs-mod-fullstack/app-rest-sdk'; import axios, { AxiosInstance } from 'axios'; import { get } from 'env-var'; import { GenerateRandomUserResult, generateRandomUser } from './generate-random-user'; import { getUrls } from './get-urls'; export class RestClientHelper { private authorizerClientID!: string; authorizationTokens?: AuthToken; private webhookApi?: WebhookApi; private defaultApi?: DefaultApi; private authorizer?: Authorizer; private defaultApiAxios?: AxiosInstance; private webhookApiAxios?: AxiosInstance; randomUser?: GenerateRandomUserResult; constructor( private readonly options?: { isAdmin?: boolean; serverUrl?: string; authorizerURL?: string; randomUser?: GenerateRandomUserResult; } ) { this.randomUser = options?.randomUser; this.createApiClients(); } getGeneratedRandomUser(): Required<GenerateRandomUserResult> { if (!this.randomUser) { throw new Error('this.randomUser not set'); } return this.randomUser as Required<GenerateRandomUserResult>; } getWebhookApi() { if (!this.webhookApi) { throw new Error('webhookApi not set'); } return this.webhookApi; } getDefaultApi() { if (!this.defaultApi) { throw new Error('defaultApi not set'); } return this.defaultApi; } async getAuthorizerClient() { if (!this.authorizerClientID && this.defaultApi) { this.authorizerClientID = (await this.defaultApi.authorizerControllerGetAuthorizerClientID()).data.clientID; if (!this.options?.isAdmin) { this.authorizer = new Authorizer({ authorizerURL: this.getAuthorizerUrl(), clientID: this.authorizerClientID, redirectURL: this.getServerUrl(), }); } else { this.authorizer = new Authorizer({ authorizerURL: this.getAuthorizerUrl(), clientID: this.authorizerClientID, redirectURL: this.getServerUrl(), extraHeaders: { 'x-authorizer-admin-secret': get('SERVER_AUTHORIZER_ADMIN_SECRET').required().asString(), }, }); } } return this.authorizer as Authorizer; } async setRoles(roles: string[]) { const _updateUserResult = await ( await this.getAuthorizerClient() ).graphqlQuery({ query: `mutation { _update_user( params: { id: "${this.authorizationTokens?.user?.id}", roles: ${JSON.stringify(roles)} } ) { id roles } }`, }); if (_updateUserResult.errors.length > 0) { console.error(_updateUserResult.errors); throw new Error(_updateUserResult.errors[0].message); } await this.login(); return this; } async createAndLoginAsUser(options?: Pick<GenerateRandomUserResult, 'email' | 'password'>) { await this.generateRandomUser(options); await this.reg(); await this.login(options); if (this.options?.isAdmin) { await this.setRoles(['admin', 'user']); } return this; } async generateRandomUser(options?: Pick<GenerateRandomUserResult, 'email' | 'password'> | undefined) { if (!this.randomUser || options) { this.randomUser = await generateRandomUser(undefined, options); } return this; } async reg() { if (!this.randomUser) { this.randomUser = await generateRandomUser(); } await ( await this.getAuthorizerClient() ).signup({ email: this.randomUser.email, confirm_password: this.randomUser.password, password: this.randomUser.password, }); return this; } async login(options?: Partial<Pick<GenerateRandomUserResult, 'email' | 'password'>>) { if (!this.randomUser) { this.randomUser = await generateRandomUser(); } const loginOptions = { email: options?.email || this.randomUser.email, password: options?.password || this.randomUser.password, }; const loginResult = await (await this.getAuthorizerClient()).login(loginOptions); if (loginResult.errors.length) { throw new Error(loginResult.errors[0].message); } this.authorizationTokens = loginResult.data; if (this.webhookApiAxios) { Object.assign(this.webhookApiAxios.defaults.headers.common, this.getAuthorizationHeaders()); } if (this.defaultApiAxios) { Object.assign(this.defaultApiAxios.defaults.headers.common, this.getAuthorizationHeaders()); } return this; } async logout() { await (await this.getAuthorizerClient()).logout(this.getAuthorizationHeaders()); return this; } getAuthorizationHeaders() { return { Authorization: `Bearer ${this.authorizationTokens?.access_token}`, }; } private createApiClients() { this.webhookApiAxios = axios.create(); this.defaultApiAxios = axios.create(); this.webhookApi = new WebhookApi( new Configuration({ basePath: this.getServerUrl(), }), undefined, this.webhookApiAxios ); this.defaultApi = new DefaultApi( new Configuration({ basePath: this.getServerUrl(), }), undefined, this.defaultApiAxios ); } private getAuthorizerUrl(): string { return this.options?.authorizerURL || getUrls().authorizerUrl; } private getServerUrl(): string { return this.options?.serverUrl || getUrls().serverUrl; } }
Описывать изменения во всех файлах с тестами я не буду, добавлю только один где используются пользователи с разными ролями.
Обновляем файл apps/server-e2e/src/server/webhook-crud-as-admin.spec.ts
import { RestClientHelper } from '@nestjs-mod-fullstack/testing'; import { get } from 'env-var'; describe('CRUD operations with Webhook as "Admin" role', () => { const user1 = new RestClientHelper(); const admin = new RestClientHelper({ isAdmin: true, }); let createEventName: string; beforeAll(async () => { await user1.createAndLoginAsUser(); await admin.login({ email: get('SERVER_AUTH_ADMIN_EMAIL').required().asString(), password: get('SERVER_AUTH_ADMIN_PASSWORD').required().asString(), }); const { data: events } = await user1.getWebhookApi().webhookControllerEvents(); createEventName = events.find((e) => e.eventName.includes('create'))?.eventName || 'create'; expect(events.map((e) => e.eventName)).toEqual(['app-demo.create', 'app-demo.update', 'app-demo.delete']); }); afterAll(async () => { const { data: manyWebhooks } = await user1.getWebhookApi().webhookControllerFindMany(); for (const webhook of manyWebhooks.webhooks) { if (webhook.endpoint.startsWith(user1.getGeneratedRandomUser().site)) { await user1.getWebhookApi().webhookControllerUpdateOne(webhook.id, { enabled: false, }); } } // const { data: manyWebhooks2 } = await admin.getWebhookApi().webhookControllerFindMany(); for (const webhook of manyWebhooks2.webhooks) { if (webhook.endpoint.startsWith(admin.getGeneratedRandomUser().site)) { await admin.getWebhookApi().webhookControllerUpdateOne(webhook.id, { enabled: false, }); } } }); it('should create new webhook as user1', async () => { const { data: newWebhook } = await user1.getWebhookApi().webhookControllerCreateOne({ enabled: false, endpoint: user1.getGeneratedRandomUser().site, eventName: createEventName, }); expect(newWebhook).toMatchObject({ enabled: false, endpoint: user1.getGeneratedRandomUser().site, eventName: createEventName, }); }); it('should create new webhook as admin', async () => { const { data: newWebhook } = await admin.getWebhookApi().webhookControllerCreateOne({ enabled: false, endpoint: admin.getGeneratedRandomUser().site, eventName: createEventName, }); expect(newWebhook).toMatchObject({ enabled: false, endpoint: admin.getGeneratedRandomUser().site, eventName: createEventName, }); }); it('should read one webhooks as user', async () => { const { data: manyWebhooks } = await user1.getWebhookApi().webhookControllerFindMany(); expect(manyWebhooks).toMatchObject({ meta: { curPage: 1, perPage: 5, totalResults: 1 }, webhooks: [ { enabled: false, endpoint: user1.getGeneratedRandomUser().site, eventName: createEventName, }, ], }); }); it('should read all webhooks as admin', async () => { const { data: manyWebhooks } = await admin.getWebhookApi().webhookControllerFindMany(); expect(manyWebhooks.meta.totalResults).toBeGreaterThan(1); expect(manyWebhooks).toMatchObject({ meta: { curPage: 1, perPage: 5 }, }); }); });
Заключение
В данном посте в качестве сервера авторизации был выбран — https://authorizer.dev, но принцип работы с другими серверами авторизации примерно такой же и дополнительный код который был написан на фронтенде и бэкенде не сильно будет отличаться.
Выбранный для этого проекта сервер авторизации очень простой в плане внедрения в проект, но он не поддерживает мутитенантность, так что если вам нужна такая опция, то лучше выбрать другой сервер авторизации или написать свой.
Авто рефрешь токена при ошибке 401 в данной версии проекта не предусмотрен, он будет внедрен в будущих постах.
Планы
У пользователя сервера авторизации есть поле picture
, но на сервере авторизации нет метода для загрузки фотографии. В следующем посте я подключу https://min.io/ в проект и настрою интеграцию с NestJS и Angular…
Ссылки
https://nestjs.com — официальный сайт фреймворка
https://nestjs-mod.com — официальный сайт дополнительных утилит
https://fullstack.nestjs-mod.com — сайт из поста
https://github.com/nestjs-mod/nestjs-mod-fullstack — проект из поста
https://github.com/nestjs-mod/nestjs-mod-fullstack/compare/414980df21e585cb798e1ff756300c4547e68a42..2e4639867c55e350f0c52dee4cb581fc624b5f9d — изменения
https://github.com/nestjs-mod/nestjs-mod-fullstack/actions/runs/11729520686/artifacts/2159651164 — видео тестов
ссылка на оригинал статьи https://habr.com/ru/articles/856896/
Добавить комментарий