Подключение:
import globals, { ACCESS_SECRET } from './config/global.config';
import { JwtModule } from '@slonum/common';
@Module({
imports: [JwtModule.register({ ACCESS_SECRET: globals()[ACCESS_SECRET]})],
})
export class AppModule {}
Использование:
@Auth()
@Get('me')
getCurrentUser() {
// user-info
}
@Auth('ADMIN')
@Get('secret')
getSecretData() {
// секретные данные
}
Подключение:
// app.module.ts
// Сначала импортируем в AppModule.
// Это необходимо, чтобы сработала инъекция ConfigService в экспортируемый RmqService
// Делать для этого ничего не нужно, инъекция произойдет сама
import { RmqModule } from '@slonum/common';
@Module({
imports: [RmqModule],
})
export class AppModule {}
// main.ts
const rmqService: RmqService = app.get(RmqService);
app.connectMicroservice(rmqService.getRmqOptions('название очереди'));
await app.startAllMicroservices();
Регистрация сервиса:
import { RmqModule } from '@slonum/common';
@Module({
imports: [RmqModule.register({ service: 'NAME_SERVICE', queue: 'queue' })],
})
export class AppModule {}
В этом случае также экспортируется RmqService для подключения очереди в main.ts.
Также можно перенастроить дефолтные опции
export interface SimplifiedRmqOptions {
/**
* Название токена сервиса для DI
*/
service: string;
/**
* Название очереди
*/
queue: string;
extras?: RmqOptions;
}
env для дева
CORS_ORIGIN_CONFIG=".slonum.ru$
localhost"
env для прода
CORS_ORIGIN_CONFIG=".slonum.ru$"
Устанавливаем cookie-parser
npm i cookie-parser
Подключаем его
// main.ts
import * as cookieParser from 'cookie-parser';
// Настраиваем cors
const configService: ConfigService = app.get(ConfigService);
const origin: RegExp[] = configService
.get<string>('CORS_ORIGIN_CONFIG')
?.split('\n')
.map((item: string): RegExp => new RegExp(item));
if (!origin) logger.warn('Не удалось прочитать CORS_ORIGIN_CONFIG');
app.enableCors({ origin, credentials: true });
// Подключаем куки в свагере
const config = new DocumentBuilder()
.setTitle('Authentication')
.setDescription('Here we can find all API methods of Authentication')
.setVersion(configService.get<string>('npm_package_version'))
.addCookieAuth()
.build();
// Подключаем парсер к сервису
app.use(cookieParser())
Установка куки
import { Response } from 'express';
import { setTokenCookies } from '@slonum/common';
@Post('register-participant')
async registerParticipant(@Res() res: Response, @Body() registerParticipantDto: RegisterParticipantDto): ResponseDto {
const responseDto: ResponseDto = await this.participantService.registerParticipant(registerParticipantDto);
setTokenCookies(res, response.tokens);
return response;
}
При необходимости третьим аргументом можно передать CookieOptions
setTokenCookies(res, tokens, { ...cookieOptions })
Настройка кук для локалхоста
setTokenCookies(res, tokens, { domain: null })
- ValidationException
- splitFullName
- joinFullName
- setTokenCookies
- removeTokenCookies
- AtGuard
- RolesGuard
- ValidationPipe
Базовый класс сервисов сообщений
- AuthModule
- AuthService
- AuthMessagePatterns
- ProfileModule
- ProfileService
- ProfileMessagePatterns
- LoggerModule
- CustomLoggerService
- RpcExceptionLogger
Данные сущности User в сервисе slonum-auth
export type AuthData = {
login?: string;
email?: string;
password?: string;
vkId?: number;
googleId?: string;
metadata?: AuthMetaData;
};
export type AuthMetaData = {
ipAddress?: string;
userAgent?: string;
};
Данные access_token
import { IRole } from '../interfaces/role.interface';
export type JwtPayload = {
id: number;
email: string;
vkId: number;
emailConfirmed: boolean;
googleId: string;
roles: IRole[];
};
Данные refresh_token
export type JwtPayloadRT = {
id: number;
userId: number;
userAgent: string;
ipAddress: string;
}
export type Name = {
firstName: string;
lastName?: string;
};
export type Tokens = {
accessToken: string;
refreshToken: string;
};
Базовый интерфейс профиля. От него наследуются интерфейсы профилей родителя и ребёнка. Его поля содержаться в обоих наследуемых интерфейсах
export interface IProfile {
firstName?: string;
lastName?: string;
fullName?: string;
city?: string;
avatarLink?: string;
registrationSource: RegistrationSource;
}
export interface IChildProfile extends IProfile {
login: string;
password: string;
birthDate?: Date | string;
parentProfileId?: number;
parentProfile: IParentProfile;
}
export interface IParentProfile extends IProfile {
email: string;
children?: IChildProfile[];
}
export interface IRefreshToken {
id: number;
userId: number;
user: IUser;
userAgent: string;
ipAddress: string;
}
export interface IRequest {
err?: any;
user: JwtPayload;
info: any;
context: any;
status: any;
}
export interface IRole {
id: number;
value: RoleEnum;
description: string;
}
В таком виде могут приходить исключения из сервисов при обращении к ним через rabbit.
export interface IRpcException {
response: { statusCode: number; message: string; error: string };
name: string;
message: string;
status: number;
error?: IRpcException;
}
Интерфейс сущности User в slonum-auth
export interface IUser {
id: number;
login?: string;
email?: string;
vkId?: number;
roles?: IRole[];
emailConfirmed: boolean;
googleId?: string;
passwordHash?: string;
refreshTokens?: IRefreshToken[];
}
export class RegisterDto {
@ApiProperty({ description: 'Email родителя', example: 'parent@example.com', required: false })
@IsEmail({}, { message: 'Неверно указан email' })
@IsOptional()
parentEmail?: string;
@ApiProperty({ description: 'Пароль пользователя', example: 'password123' })
@IsString({ message: 'Должно быть строкой' })
@Length(MIN_PASSWORD_LENGTH, undefined, { message: `Минимальная длина - ${MIN_PASSWORD_LENGTH}` })
password: string;
@ApiProperty({ description: 'Фамилия, имя родителя', example: 'Иванов Иван', required: false })
@IsString()
@IsOptional()
parentFullName?: string;
@ApiProperty({ description: 'Город', example: 'Москва', required: false })
@IsString()
@IsOptional()
city?: string;
@ApiProperty({
description: 'Мероприятие, через которое происходит регистрация. По умолчанию будет главная страница',
example: RegistrationSource.OLYMPIAD,
required: false,
})
@IsEnum(RegistrationSource)
@IsOptional()
registrationSource?: RegistrationSource;
@ApiProperty({ type: ChildDto, description: 'Данные ребёнка', required: false })
@IsOptional()
childDto?: ChildDto;
metaData: AuthMetaData;
}
export class RegisterResponseDto {
@ApiProperty({ description: 'id пользователя', example: 1, type: 'number ' })
userId?: number;
@ApiProperty({ description: 'Токены пользователя', example: { accessToken: 'accessToken', refreshToken: 'refreshToken' } })
tokens: Tokens;
@ApiProperty({ description: 'Данные для входа в аккаунт ребёнка', type: LoginDto, required: false })
childLoginDto?: LoginDto;
}
export class ChildDto {
@ApiProperty({ description: 'Фамилия, имя ребенка', example: 'Иванова Анна', required: false })
@IsOptional()
@IsString()
childFullName?: string;
@ApiProperty({ description: 'Дата рождения ребенка', example: '2000-01-01T00:00:00.000Z', required: false })
@Type(() => Date)
@IsDate()
@IsOptional()
birthDate?: Date;
@ApiProperty({ description: 'Город', example: 'Москва', required: false })
@IsString()
@IsOptional()
city?: string;
}
export class LoginDto {
@ApiProperty({ description: 'Логин пользователя', example: 'parent@example.com' })
@IsString()
login: string;
@ApiProperty({ description: 'Пароль пользователя', example: 'password123' })
@IsString()
password: string;
authMetaData?: AuthMetaData;
@ApiResponseProperty({ type: Number, example: 1 })
childId?: number;
}
export class AuthDto {
@ApiProperty({ example: 'john_doe', description: 'Логин пользователя', required: false })
@IsOptional()
@IsString()
login?: string;
@ApiProperty({ example: 'old_password', description: 'Старый пароль пользователя', required: false })
@IsOptional()
@IsString()
oldPassword?: string;
@ApiProperty({ example: 'new_password', description: 'Новый пароль пользователя', required: false })
@IsOptional()
@IsString()
newPassword?: string;
@ApiProperty({ example: 'new_password', description: 'Подтверждение нового пароля', required: false })
@IsOptional()
@IsString()
passwordConfirm?: string;
@ApiProperty({ example: 'john@example.com', description: 'Адрес электронной почты пользователя', required: false })
@IsOptional()
@IsEmail()
email?: string;
}
export class ProfileDto {
@ApiProperty({ example: 'Иван Иванов', description: 'Полное имя пользователя', required: false })
@IsOptional()
@IsString()
fullName?: string;
@ApiProperty({ example: 'Нью-Йорк', description: 'Город проживания пользователя', required: false })
@IsOptional()
@IsString()
city?: string;
@ApiProperty({ example: '1990-01-01', description: 'Дата рождения пользователя', required: false })
@IsOptional()
@IsDate()
@Type(() => Date)
birthDate?: Date;
@ApiProperty({ example: 'https://example.com/avatar.jpg', description: 'URL аватара пользователя', required: false })
@IsOptional()
@IsString()
avatarUrl?: string;
}
export class UpdateProfileDto {
@ApiProperty({ type: ProfileDto, description: 'Данные профиля', required: false })
@IsOptional()
profileDto?: ProfileDto;
@ApiProperty({ type: AuthDto, description: 'Данные авторизации', required: false })
@IsOptional()
authDto?: AuthDto;
@ApiProperty({ example: 1, description: 'ID ребенка. Нужно передать, если родитель редактирует профиль ребёнка', required: false })
@IsOptional()
childId?: number;
}
export enum RegistrationSource {
MAIN = 'MAIN',
BLOG = 'BLOG',
DRAWING_COMPETITION = 'DRAWING_COMPETITION',
ENGLISH_LANG = 'ENGLISH_LANG',
FRACTION = 'FRACTION',
LK = 'LK',
OLYMPIAD = 'OLYMPIAD',
VOCABULARY_WORDS = 'VOCABULARY_WORDS',
}
export enum RoleEnum {
ADMIN = 'ADMIN',
PARENT = 'PARENT',
CHILD = 'CHILD',
}
export function splitFullName(fullName: string): Name {
const [firstName, lastName] = fullName.split(' ');
return { firstName, lastName };
}
export function joinFullName(name: Name): string {
return `${name.firstName} ${name.lastName}`;
}
export function setTokenCookies(res: Response, tokens: Tokens): void {
res.cookie('access_token', tokens.accessToken, { httpOnly: true, sameSite: 'strict', secure: true });
res.cookie('refresh_token', tokens.refreshToken);
}
export function removeTokenCookies(res: Response): void {
res.clearCookie('access_token');
res.clearCookie('refresh_token');
}
AtStrategy импортируется при регистрации JwtModule
@Injectable()
export class AtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(@Inject(JWT_OPTIONS_TOKEN) { ACCESS_SECRET }: JwtModuleOptions) {
super({
jwtFromRequest: ExtractJwt.fromExtractors([ExtractJwt.fromAuthHeaderAsBearerToken(), AtStrategy.extractJwtFromCookies]),
ignoreExpiration: false,
secretOrKey: ACCESS_SECRET || 'ACCESS_SECRET',
});
}
private static extractJwtFromCookies(req: Request): string | null {
if (req.cookies && req.cookies.access_token) {
return req.cookies.access_token;
}
return null;
}
validate(payload: JwtPayload): JwtPayload {
return payload;
}
}
Применение:
@Module({})
export class AppModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(LoggerMiddleware).forRoutes('*');
}
}
От него наследуются остальные сервисы сообщений
AuthService. Использоваться должен ТОЛЬКО в user-info. Из других сервисов запросы сюда поступать не должны
Регистрация.
async register(authData: AuthData, metaData: AuthMetaData, role: RoleEnum): Promise<RegisterResponseDto> {
return this.authMessageService.send({ authData, metaData, role }, AuthMessagePatterns.REGISTER);
}
Обновление User
async updateUser(updateUserDto: IUpdateUser): Promise<IUser> {
return this.authMessageService.send(updateUserDto, AuthMessagePatterns.UPDATE_USER);
}
Удаление User
async deleteUser(toDeleteId: number): Promise<number> {
return this.authMessageService.send({ id: toDeleteId }, AuthMessagePatterns.DELETE);
}
Подтвердить email(Неизвестно работает ли этот эндпоинт)
async sendConfirmationEmail(user: IUser): Promise<boolean> {
return this.authMessageService.send(user, AuthMessagePatterns.SEND_CONFIRM_EMAIL);
}
Выдать роль. Единственный эндпоинт, который, можно использовать с других сервисов, но, опять же, я не знаю работает ли он и зачем он у нас есть, так как роли у нас выдаются по http.
async provideUserRole(provideRoleDto: IProvideUserRole): Promise<IUser> {
return this.authMessageService.send(provideRoleDto, AuthMessagePatterns.PROVIDE_ROLE);
}
Поиск User по id
async findOneById(id: number): Promise<IUser | null> {
return this.authMessageService.send({ id }, AuthMessagePatterns.FIND_ONE_BY_ID);
}
Регистрация. Создаёт профиль и User в auth. Можно регистрировать и родителя отдельно, и родителя с ребёнком. Если передан childFullName, значит регистрация происходит не через главную страницу, а через страницу мероприятия, следовательно также необходимо передать с какого мероприятия происходит регистрация
async register(createUserInfoDto: RegisterDto): Promise<RegisterResponseDto> {
return this.profileMessageService.send(createUserInfoDto, ProfileMessagePatterns.REGISTER);
}
Получение профиля по id
async getProfileById(id: number): Promise<IProfile> {
return this.profileMessageService.send({ id }, ProfileMessagePatterns.GET_PROFILE_BY_ID);
}
Получение нескольких профилей по массиву id
async getProfilesByIds(ids: number[]): Promise<IProfile[]> {
return this.profileMessageService.send({ ids }, ProfileMessagePatterns.GET_PROFILES_BY_IDS);
}
Обновление данных профиля и User.
При обращении через rabbit схема запроса должна выглядеть следующим образом:
{
"user": { ...JwtPayload },
"updateProfileDto": { ...здесь_обычная_схема }
}
async updateProfile(user: JwtPayload, udpateProfileDto: UpdateProfileDto): Promise<IProfile> {
return this.profileMessageService.send({ user, udpateProfileDto }, ProfileMessagePatterns.UPDATE_PROFILE);
}
Проверяет принадлежит ли ребёнок родителю.
async checkParentByChild(parentId, childId): Promise<boolean> {
return this.profileMessageService.send({ parentId, childId }, ProfileMessagePatterns.CHECK_PARENT_BY_CHILD);
}
Импортирование:
import { ProfileModule } from '@slonum/common';
@Module({
imports: [ProfileModule],
})
export class ParticipantModule {}
Применение:
import { ProfileService } from '@slonum/common';
import { RegisterDto } from '@slonum/common';
@Injectable()
export class ParticipantService {
constructor(
private readonly profileService: ProfileService, // Зарегистрированный сервис
) {}
async registerParticipant(registerDto: RegisterDto, ...rest) {
const registerResponseDto: RegisterResponseDto = await this.profileService.register(registerDto);
// Ваш код
}
}
Логгер для ошибок в rabbit контроллерах
Глобальное применение
// main.ts
app.useGlobalFilters(new RpcExceptionLogger());
Применение к контроллеру
@Controller()
@UseFilters(new RpcExceptionLogger())
export class ProfileRabbitController
Просто выводит логи ошибок
@Catch(RpcException)
export class RpcExceptionLogger implements RpcExceptionFilter<RpcException> {
catch(exception: RpcException, host: ArgumentsHost) {
logger.error(exception);
return throwError(() => exception.getError());
}
}
Выводит лог в консоль и формирует ответ с сервера
// main.ts
app.useGlobalFilters(new HttpExceptionFilter());
const logger = new CustomLoggerService();
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const status = exception.getStatus();
const res: any = { ...exception };
logger.warn(res.response?.message ?? res.message, 'Exception');
response.status(status).json(res.response);
}
}
Глобальное применение
// main.ts
app.useGlobalFilters(new RpcExceptionFilter());
Применение к контроллеру
@Controller()
@UseFilters(new RpcExceptionFilter())
export class ProfileRabbitController
@Catch(RpcException)
export class RpcExceptionFilter implements ExceptionFilter {
catch(exception: RpcException, host: ArgumentsHost) {
let err = exception.getError() as IRpcException;
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
if (err.error) err = err.error;
logger.error(err);
if (!err.response) {
return response.status(500).json({ statusCode: 500, error: 'Internal server error', message: 'Internal server error' });
}
response.status(err.response?.statusCode ?? err.status).json(err.response ?? err);
}
}
Содержит в себе все необходимые декораторы для настройки доступа к эндпоинту
Параметр roles
- Роли, требуемые для доступа к эндпоинту. Опционально
export function Auth(...roles: string[]) {
return applyDecorators(SetMetadata(ROLES_KEY, roles), UseGuards(AtGuard, RolesGuard), ApiBearerAuth('jwt'));
}
Примеры использования:
@ApiOperation({ summary: 'Добавление ребёнка', description: 'Логин для ребёнка генерируется автоматически' })
@Post('add-child')
@ApiBody({ type: ChildDto })
@ApiCreatedResponse({ type: LoginDto, description: 'Логин и пароль ребёнка' })
@Auth('PARENT')
async addChild(
@GetJwtPayload('id') parentId: number,
@Body() childDto: ChildDto,
@MetaData() metaData: AuthMetaData,
): Promise<LoginDto> {
return this.profileService.addChild(parentId, childDto, metaData);
}
@ApiOperation({ summary: 'Получение данных о текущем пользователе', description: 'Данные получаются по id пользователя из токена' })
@ApiResponse({ type: Profile })
@Auth()
@Get()
async getCurrentUserById(@GetJwtPayload('id') id: number): Promise<IProfile> {
return this.profileService.getCurrentUserById(id);
}
Параметр data
— ключ JwtPayload
Возвращает декодированный токен, если не передан data
Возвращает значение data
из токена, если передан
export const GetJwtPayload = createParamDecorator(
(data: keyof JwtPayload | undefined, context: ExecutionContext): JwtPayload | JwtPayload[keyof JwtPayload] => {
const user = context.switchToHttp().getRequest().user;
if (!data) return user;
return user[data];
},
);
Параметр data
— ключ JwtPayloadRT
Возвращает декодированный токен, если не передан data
Возвращает значение data
из токена, если передан
export const GetRtJwtPayload = createParamDecorator(
(data: keyof JwtPayloadRT | undefined, context: ExecutionContext): JwtPayloadRT | JwtPayloadRT[keyof JwtPayloadRT] => {
const user = context.switchToHttp().getRequest().user;
if (!data) return user;
return user[data];
},
);
Возвращает AuthMetaData
export const MetaData = createParamDecorator((data: unknown, ctx: ExecutionContext): AuthMetaData => {
const req = ctx.switchToHttp().getRequest();
return { ipAddress: req.ip, userAgent: req.headers['user-agent'] };
});