From ec7976a69dbd4c1f5098c4f9ec90a72187144b16 Mon Sep 17 00:00:00 2001 From: Artem Niehrieiev Date: Thu, 27 Nov 2025 09:28:31 +0000 Subject: [PATCH 1/5] feat: implement sign-in audit logging functionality - Added new entity `SignInAuditEntity` to track user sign-in attempts. - Created DTOs for creating and finding sign-in audit records. - Implemented repository interface and custom repository for sign-in audit operations. - Developed use cases for finding sign-in audit logs and creating sign-in audit records. - Integrated sign-in audit logging into existing user login use cases (usual and OTP login). - Added controller for handling sign-in audit log retrieval requests. - Updated user entity to establish a relationship with sign-in audit records. - Created migration to set up the sign-in audit table and its associated types. - Enhanced error handling and validation for sign-in methods and statuses. --- backend/src/app.module.ts | 2 + .../global-database-context.interface.ts | 3 + .../application/global-database-context.ts | 11 ++ backend/src/common/data-injection.tokens.ts | 2 + .../dto/create-sign-in-audit-record.ds.ts | 12 ++ .../dto/find-sign-in-audit-logs.ds.ts | 17 +++ .../dto/found-sign-in-audit-logs.ds.ts | 11 ++ .../dto/found-sign-in-audit-record.ds.ts | 32 +++++ .../entities/user-sign-in-audit/dto/index.ts | 4 + .../user-sign-in-audit/enums/index.ts | 2 + .../enums/sign-in-method.enum.ts | 7 + .../enums/sign-in-status.enum.ts | 5 + ...gn-in-audit-custom-repository-extension.ts | 124 ++++++++++++++++++ .../sign-in-audit-repository.interface.ts | 38 ++++++ .../sign-in-audit.controller.ts | 74 +++++++++++ .../sign-in-audit.entity.ts | 46 +++++++ .../sign-in-audit.module.ts | 34 +++++ .../sign-in-audit.service.ts | 40 ++++++ .../find-sign-in-audit-logs.use.case.ts | 121 +++++++++++++++++ .../use-cases/use-cases.interface.ts | 7 + .../build-found-sign-in-audit-record-ds.ts | 16 +++ .../data-structures/usual-login.ds.ts | 4 + .../data-structures/verify-otp.ds.ts | 4 + .../user/use-cases/otp-login-use.case.ts | 40 +++++- .../user/use-cases/usual-login-use.case.ts | 75 ++++++++++- backend/src/entities/user/user.controller.ts | 12 +- backend/src/entities/user/user.entity.ts | 4 + backend/src/entities/user/user.module.ts | 4 + backend/src/exceptions/text/messages.ts | 2 + .../1764166925419-AddSignInAuditEntity.ts | 25 ++++ 30 files changed, 774 insertions(+), 4 deletions(-) create mode 100644 backend/src/entities/user-sign-in-audit/dto/create-sign-in-audit-record.ds.ts create mode 100644 backend/src/entities/user-sign-in-audit/dto/find-sign-in-audit-logs.ds.ts create mode 100644 backend/src/entities/user-sign-in-audit/dto/found-sign-in-audit-logs.ds.ts create mode 100644 backend/src/entities/user-sign-in-audit/dto/found-sign-in-audit-record.ds.ts create mode 100644 backend/src/entities/user-sign-in-audit/dto/index.ts create mode 100644 backend/src/entities/user-sign-in-audit/enums/index.ts create mode 100644 backend/src/entities/user-sign-in-audit/enums/sign-in-method.enum.ts create mode 100644 backend/src/entities/user-sign-in-audit/enums/sign-in-status.enum.ts create mode 100644 backend/src/entities/user-sign-in-audit/repository/sign-in-audit-custom-repository-extension.ts create mode 100644 backend/src/entities/user-sign-in-audit/repository/sign-in-audit-repository.interface.ts create mode 100644 backend/src/entities/user-sign-in-audit/sign-in-audit.controller.ts create mode 100644 backend/src/entities/user-sign-in-audit/sign-in-audit.entity.ts create mode 100644 backend/src/entities/user-sign-in-audit/sign-in-audit.module.ts create mode 100644 backend/src/entities/user-sign-in-audit/sign-in-audit.service.ts create mode 100644 backend/src/entities/user-sign-in-audit/use-cases/find-sign-in-audit-logs.use.case.ts create mode 100644 backend/src/entities/user-sign-in-audit/use-cases/use-cases.interface.ts create mode 100644 backend/src/entities/user-sign-in-audit/utils/build-found-sign-in-audit-record-ds.ts create mode 100644 backend/src/migrations/1764166925419-AddSignInAuditEntity.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 6660a4b40..473f9d8e5 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -39,6 +39,7 @@ import { GetHelloUseCase } from './use-cases-app/get-hello.use.case.js'; import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; import { SharedJobsModule } from './entities/shared-jobs/shared-jobs.module.js'; import { TableCategoriesModule } from './entities/table-categories/table-categories.module.js'; +import { SignInAuditModule } from './entities/user-sign-in-audit/sign-in-audit.module.js'; @Module({ imports: [ @@ -81,6 +82,7 @@ import { TableCategoriesModule } from './entities/table-categories/table-categor LoggingModule, SharedJobsModule, TableCategoriesModule, + SignInAuditModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/common/application/global-database-context.interface.ts b/backend/src/common/application/global-database-context.interface.ts index 08245f31a..26ab97e78 100644 --- a/backend/src/common/application/global-database-context.interface.ts +++ b/backend/src/common/application/global-database-context.interface.ts @@ -48,6 +48,8 @@ import { IDatabaseContext } from '../database-context.interface.js'; import { TableCategoriesEntity } from '../../entities/table-categories/table-categories.entity.js'; import { ITableCategoriesCustomRepository } from '../../entities/table-categories/repository/table-categories-repository.interface.js'; import { ConnectionPropertiesEntity } from '../../entities/connection-properties/connection-properties.entity.js'; +import { SignInAuditEntity } from '../../entities/user-sign-in-audit/sign-in-audit.entity.js'; +import { ISignInAuditRepository } from '../../entities/user-sign-in-audit/repository/sign-in-audit-repository.interface.js'; export interface IGlobalDatabaseContext extends IDatabaseContext { userRepository: Repository & IUserRepository; @@ -83,4 +85,5 @@ export interface IGlobalDatabaseContext extends IDatabaseContext { tableFiltersRepository: Repository & ITableFiltersCustomRepository; aiResponsesToUserRepository: Repository & IAiResponsesToUserRepository; tableCategoriesRepository: Repository & ITableCategoriesCustomRepository; + signInAuditRepository: Repository & ISignInAuditRepository; } diff --git a/backend/src/common/application/global-database-context.ts b/backend/src/common/application/global-database-context.ts index d8e229214..077d7cc12 100644 --- a/backend/src/common/application/global-database-context.ts +++ b/backend/src/common/application/global-database-context.ts @@ -90,6 +90,9 @@ import { aiResponsesToUserRepositoryExtension } from '../../entities/ai/ai-data- import { TableCategoriesEntity } from '../../entities/table-categories/table-categories.entity.js'; import { ITableCategoriesCustomRepository } from '../../entities/table-categories/repository/table-categories-repository.interface.js'; import { tableCategoriesCustomRepositoryExtension } from '../../entities/table-categories/repository/table-categories-repository.extension.js'; +import { SignInAuditEntity } from '../../entities/user-sign-in-audit/sign-in-audit.entity.js'; +import { ISignInAuditRepository } from '../../entities/user-sign-in-audit/repository/sign-in-audit-repository.interface.js'; +import { signInAuditCustomRepositoryExtension } from '../../entities/user-sign-in-audit/repository/sign-in-audit-custom-repository-extension.js'; @Injectable({ scope: Scope.REQUEST }) export class GlobalDatabaseContext implements IGlobalDatabaseContext { @@ -128,6 +131,7 @@ export class GlobalDatabaseContext implements IGlobalDatabaseContext { private _tableFiltersRepository: Repository & ITableFiltersCustomRepository; private _aiResponsesToUserRepository: Repository & IAiResponsesToUserRepository; private _tableCategoriesRepository: Repository & ITableCategoriesCustomRepository; + private _signInAuditRepository: Repository & ISignInAuditRepository; public constructor( @Inject(BaseType.DATA_SOURCE) @@ -216,6 +220,9 @@ export class GlobalDatabaseContext implements IGlobalDatabaseContext { this._tableCategoriesRepository = this.appDataSource .getRepository(TableCategoriesEntity) .extend(tableCategoriesCustomRepositoryExtension); + this._signInAuditRepository = this.appDataSource + .getRepository(SignInAuditEntity) + .extend(signInAuditCustomRepositoryExtension); } public get userRepository(): Repository & IUserRepository { @@ -350,6 +357,10 @@ export class GlobalDatabaseContext implements IGlobalDatabaseContext { return this._tableCategoriesRepository; } + public get signInAuditRepository(): Repository & ISignInAuditRepository { + return this._signInAuditRepository; + } + public startTransaction(): Promise { this._queryRunner = this.appDataSource.createQueryRunner(); this._queryRunner.startTransaction(); diff --git a/backend/src/common/data-injection.tokens.ts b/backend/src/common/data-injection.tokens.ts index 241b4d9f1..848df8d33 100644 --- a/backend/src/common/data-injection.tokens.ts +++ b/backend/src/common/data-injection.tokens.ts @@ -171,4 +171,6 @@ export enum UseCaseType { CREATE_UPDATE_TABLE_CATEGORIES = 'CREATE_UPDATE_TABLE_CATEGORIES', FIND_TABLE_CATEGORIES = 'FIND_TABLE_CATEGORIES', + + FIND_SIGN_IN_AUDIT_LOGS = 'FIND_SIGN_IN_AUDIT_LOGS', } diff --git a/backend/src/entities/user-sign-in-audit/dto/create-sign-in-audit-record.ds.ts b/backend/src/entities/user-sign-in-audit/dto/create-sign-in-audit-record.ds.ts new file mode 100644 index 000000000..f0cd956e5 --- /dev/null +++ b/backend/src/entities/user-sign-in-audit/dto/create-sign-in-audit-record.ds.ts @@ -0,0 +1,12 @@ +import { SignInMethodEnum } from '../enums/sign-in-method.enum.js'; +import { SignInStatusEnum } from '../enums/sign-in-status.enum.js'; + +export class CreateSignInAuditRecordDs { + email: string; + userId?: string; + status: SignInStatusEnum; + signInMethod: SignInMethodEnum; + ipAddress?: string; + userAgent?: string; + failureReason?: string; +} diff --git a/backend/src/entities/user-sign-in-audit/dto/find-sign-in-audit-logs.ds.ts b/backend/src/entities/user-sign-in-audit/dto/find-sign-in-audit-logs.ds.ts new file mode 100644 index 000000000..78724f006 --- /dev/null +++ b/backend/src/entities/user-sign-in-audit/dto/find-sign-in-audit-logs.ds.ts @@ -0,0 +1,17 @@ +import { SignInMethodEnum } from '../enums/sign-in-method.enum.js'; +import { SignInStatusEnum } from '../enums/sign-in-status.enum.js'; + +export class FindSignInAuditLogsDs { + userId: string; + companyId: string; + query: { + order?: string; + page?: number; + perPage?: number; + dateFrom?: string; + dateTo?: string; + email?: string; + status?: SignInStatusEnum; + signInMethod?: SignInMethodEnum; + }; +} diff --git a/backend/src/entities/user-sign-in-audit/dto/found-sign-in-audit-logs.ds.ts b/backend/src/entities/user-sign-in-audit/dto/found-sign-in-audit-logs.ds.ts new file mode 100644 index 000000000..0e5e284aa --- /dev/null +++ b/backend/src/entities/user-sign-in-audit/dto/found-sign-in-audit-logs.ds.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { PaginationDs } from '../../table/application/data-structures/pagination.ds.js'; +import { FoundSignInAuditRecordDs } from './found-sign-in-audit-record.ds.js'; + +export class FoundSignInAuditLogsDs { + @ApiProperty({ isArray: true, type: FoundSignInAuditRecordDs }) + logs: Array; + + @ApiProperty({ type: PaginationDs }) + pagination: PaginationDs; +} diff --git a/backend/src/entities/user-sign-in-audit/dto/found-sign-in-audit-record.ds.ts b/backend/src/entities/user-sign-in-audit/dto/found-sign-in-audit-record.ds.ts new file mode 100644 index 000000000..ed23b64c4 --- /dev/null +++ b/backend/src/entities/user-sign-in-audit/dto/found-sign-in-audit-record.ds.ts @@ -0,0 +1,32 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { SignInMethodEnum } from '../enums/sign-in-method.enum.js'; +import { SignInStatusEnum } from '../enums/sign-in-status.enum.js'; + +export class FoundSignInAuditRecordDs { + @ApiProperty() + id: string; + + @ApiProperty() + email: string; + + @ApiProperty({ enum: SignInStatusEnum }) + status: SignInStatusEnum; + + @ApiProperty({ enum: SignInMethodEnum }) + signInMethod: SignInMethodEnum; + + @ApiProperty() + ipAddress: string; + + @ApiProperty() + userAgent: string; + + @ApiProperty() + failureReason: string; + + @ApiProperty() + createdAt: Date; + + @ApiProperty() + userId: string; +} diff --git a/backend/src/entities/user-sign-in-audit/dto/index.ts b/backend/src/entities/user-sign-in-audit/dto/index.ts new file mode 100644 index 000000000..c9dc7181f --- /dev/null +++ b/backend/src/entities/user-sign-in-audit/dto/index.ts @@ -0,0 +1,4 @@ +export * from './create-sign-in-audit-record.ds.js'; +export * from './found-sign-in-audit-record.ds.js'; +export * from './found-sign-in-audit-logs.ds.js'; +export * from './find-sign-in-audit-logs.ds.js'; diff --git a/backend/src/entities/user-sign-in-audit/enums/index.ts b/backend/src/entities/user-sign-in-audit/enums/index.ts new file mode 100644 index 000000000..0c7d1e140 --- /dev/null +++ b/backend/src/entities/user-sign-in-audit/enums/index.ts @@ -0,0 +1,2 @@ +export * from './sign-in-status.enum.js'; +export * from './sign-in-method.enum.js'; diff --git a/backend/src/entities/user-sign-in-audit/enums/sign-in-method.enum.ts b/backend/src/entities/user-sign-in-audit/enums/sign-in-method.enum.ts new file mode 100644 index 000000000..ee5c69f72 --- /dev/null +++ b/backend/src/entities/user-sign-in-audit/enums/sign-in-method.enum.ts @@ -0,0 +1,7 @@ +export enum SignInMethodEnum { + EMAIL = 'email', + GOOGLE = 'google', + GITHUB = 'github', + SAML = 'saml', + OTP = 'otp', +} diff --git a/backend/src/entities/user-sign-in-audit/enums/sign-in-status.enum.ts b/backend/src/entities/user-sign-in-audit/enums/sign-in-status.enum.ts new file mode 100644 index 000000000..d3ccabbf3 --- /dev/null +++ b/backend/src/entities/user-sign-in-audit/enums/sign-in-status.enum.ts @@ -0,0 +1,5 @@ +export enum SignInStatusEnum { + SUCCESS = 'success', + FAILED = 'failed', + BLOCKED = 'blocked', +} diff --git a/backend/src/entities/user-sign-in-audit/repository/sign-in-audit-custom-repository-extension.ts b/backend/src/entities/user-sign-in-audit/repository/sign-in-audit-custom-repository-extension.ts new file mode 100644 index 000000000..02a4946fd --- /dev/null +++ b/backend/src/entities/user-sign-in-audit/repository/sign-in-audit-custom-repository-extension.ts @@ -0,0 +1,124 @@ +import { UserEntity } from '../../user/user.entity.js'; +import { CreateSignInAuditRecordDs } from '../dto/create-sign-in-audit-record.ds.js'; +import { SignInAuditEntity } from '../sign-in-audit.entity.js'; +import { + IFindSignInAuditLogsOptions, + IFoundSignInAuditLogsResult, + ISignInAuditRepository, +} from './sign-in-audit-repository.interface.js'; + +export const signInAuditCustomRepositoryExtension: ISignInAuditRepository = { + async createSignInAuditRecord(data: CreateSignInAuditRecordDs): Promise { + const { email, userId, status, signInMethod, ipAddress, userAgent, failureReason } = data; + + const newRecord = new SignInAuditEntity(); + newRecord.email = email?.toLowerCase(); + newRecord.status = status; + newRecord.signInMethod = signInMethod; + newRecord.ipAddress = ipAddress || null; + newRecord.userAgent = userAgent || null; + newRecord.failureReason = failureReason || null; + + if (userId) { + const userRepository = this.manager.getRepository(UserEntity); + const user = await userRepository.findOne({ where: { id: userId } }); + if (user) { + newRecord.user = user; + } + } + + return await this.save(newRecord); + }, + + async findSignInAuditLogs(options: IFindSignInAuditLogsOptions): Promise { + const { companyId, order, page, perPage, dateFrom, dateTo, searchedEmail, status, signInMethod } = options; + + const qb = this.createQueryBuilder('signInAudit') + .leftJoinAndSelect('signInAudit.user', 'user') + .leftJoin('user.company', 'company') + .where('company.id = :companyId', { companyId }); + + if (searchedEmail) { + qb.andWhere('signInAudit.email = :email', { email: searchedEmail.toLowerCase() }); + } + + if (status) { + qb.andWhere('signInAudit.status = :status', { status }); + } + + if (signInMethod) { + qb.andWhere('signInAudit.signInMethod = :signInMethod', { signInMethod }); + } + + if (dateFrom && dateTo) { + qb.andWhere('signInAudit.createdAt >= :dateFrom', { dateFrom }); + qb.andWhere('signInAudit.createdAt <= :dateTo', { dateTo }); + } + + qb.orderBy('signInAudit.createdAt', order); + + const rowsCount = await qb.getCount(); + const lastPage = Math.ceil(rowsCount / perPage); + const offset = (page - 1) * perPage; + + qb.limit(perPage); + qb.offset(offset); + + const logs = await qb.getMany(); + + return { + logs, + pagination: { + currentPage: page, + lastPage, + perPage, + total: rowsCount, + }, + }; + }, + + async findSignInAuditLogsByUserId( + userId: string, + options: IFindSignInAuditLogsOptions, + ): Promise { + const { order, page, perPage, dateFrom, dateTo, status, signInMethod } = options; + + const qb = this.createQueryBuilder('signInAudit') + .leftJoinAndSelect('signInAudit.user', 'user') + .where('user.id = :userId', { userId }); + + if (status) { + qb.andWhere('signInAudit.status = :status', { status }); + } + + if (signInMethod) { + qb.andWhere('signInAudit.signInMethod = :signInMethod', { signInMethod }); + } + + if (dateFrom && dateTo) { + qb.andWhere('signInAudit.createdAt >= :dateFrom', { dateFrom }); + qb.andWhere('signInAudit.createdAt <= :dateTo', { dateTo }); + } + + qb.orderBy('signInAudit.createdAt', order); + + const rowsCount = await qb.getCount(); + const lastPage = Math.ceil(rowsCount / perPage); + const offset = (page - 1) * perPage; + + qb.limit(perPage); + qb.offset(offset); + + const logs = await qb.getMany(); + + return { + logs, + pagination: { + currentPage: page, + lastPage, + perPage, + total: rowsCount, + }, + }; + }, +}; diff --git a/backend/src/entities/user-sign-in-audit/repository/sign-in-audit-repository.interface.ts b/backend/src/entities/user-sign-in-audit/repository/sign-in-audit-repository.interface.ts new file mode 100644 index 000000000..adc3de16f --- /dev/null +++ b/backend/src/entities/user-sign-in-audit/repository/sign-in-audit-repository.interface.ts @@ -0,0 +1,38 @@ +import { QueryOrderingEnum } from '../../../enums/query-ordering.enum.js'; +import { SignInMethodEnum } from '../enums/sign-in-method.enum.js'; +import { SignInStatusEnum } from '../enums/sign-in-status.enum.js'; +import { CreateSignInAuditRecordDs } from '../dto/create-sign-in-audit-record.ds.js'; +import { SignInAuditEntity } from '../sign-in-audit.entity.js'; + +export interface ISignInAuditRepository { + createSignInAuditRecord(data: CreateSignInAuditRecordDs): Promise; + + findSignInAuditLogs(options: IFindSignInAuditLogsOptions): Promise; + + findSignInAuditLogsByUserId( + userId: string, + options: IFindSignInAuditLogsOptions, + ): Promise; +} + +export interface IFindSignInAuditLogsOptions { + companyId: string; + order: QueryOrderingEnum; + page: number; + perPage: number; + dateFrom?: Date; + dateTo?: Date; + searchedEmail?: string; + status?: SignInStatusEnum; + signInMethod?: SignInMethodEnum; +} + +export interface IFoundSignInAuditLogsResult { + logs: Array; + pagination: { + currentPage: number; + lastPage: number; + perPage: number; + total: number; + }; +} diff --git a/backend/src/entities/user-sign-in-audit/sign-in-audit.controller.ts b/backend/src/entities/user-sign-in-audit/sign-in-audit.controller.ts new file mode 100644 index 000000000..2d707ac4c --- /dev/null +++ b/backend/src/entities/user-sign-in-audit/sign-in-audit.controller.ts @@ -0,0 +1,74 @@ +import { Controller, Get, Inject, Injectable, Query, UseInterceptors } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { UseCaseType } from '../../common/data-injection.tokens.js'; +import { UserId } from '../../decorators/index.js'; +import { InTransactionEnum } from '../../enums/index.js'; +import { SentryInterceptor } from '../../interceptors/index.js'; +import { FindSignInAuditLogsDs } from './dto/find-sign-in-audit-logs.ds.js'; +import { FoundSignInAuditLogsDs } from './dto/found-sign-in-audit-logs.ds.js'; +import { SignInMethodEnum } from './enums/sign-in-method.enum.js'; +import { SignInStatusEnum } from './enums/sign-in-status.enum.js'; +import { IFindSignInAuditLogs } from './use-cases/use-cases.interface.js'; + +@UseInterceptors(SentryInterceptor) +@Controller() +@ApiBearerAuth() +@ApiTags('Sign In Audit') +@Injectable() +export class SignInAuditController { + constructor( + @Inject(UseCaseType.FIND_SIGN_IN_AUDIT_LOGS) + private readonly findSignInAuditLogsUseCase: IFindSignInAuditLogs, + ) {} + + @ApiOperation({ + summary: 'Find sign-in audit logs for the company.', + description: ` + You can provide the following query parameters: + - \`order=ASC|DESC\`: Sorting order by creation time. + - \`page=value\`: Page number for pagination. + - \`perPage=value\`: Number of records per page. + - \`dateFrom=value\`: Start date to filter logs. + - \`dateTo=value\`: End date to filter logs. + - \`email=value\`: Filter logs by email. + - \`status=value\`: Filter by sign-in status (success, failed, blocked). + - \`signInMethod=value\`: Filter by sign-in method (email, google, github, saml, otp). + `, + }) + @ApiResponse({ + status: 200, + description: 'Sign-in audit logs found.', + type: FoundSignInAuditLogsDs, + }) + @ApiQuery({ name: 'companyId', required: true }) + @ApiQuery({ name: 'order', required: false }) + @ApiQuery({ name: 'page', required: false }) + @ApiQuery({ name: 'perPage', required: false }) + @ApiQuery({ name: 'dateFrom', required: false }) + @ApiQuery({ name: 'dateTo', required: false }) + @ApiQuery({ name: 'email', required: false }) + @ApiQuery({ name: 'status', required: false, enum: SignInStatusEnum }) + @ApiQuery({ name: 'signInMethod', required: false, enum: SignInMethodEnum }) + @Get('/sign-in-audit/logs') + async findSignInAuditLogs( + @Query() query: any, + @Query('companyId') companyId: string, + @UserId() userId: string, + ): Promise { + const inputData: FindSignInAuditLogsDs = { + userId, + companyId, + query: { + order: query.order, + page: parseInt(query.page) || 1, + perPage: parseInt(query.perPage) || 50, + dateFrom: query.dateFrom, + dateTo: query.dateTo, + email: query.email, + status: query.status, + signInMethod: query.signInMethod, + }, + }; + return await this.findSignInAuditLogsUseCase.execute(inputData, InTransactionEnum.OFF); + } +} diff --git a/backend/src/entities/user-sign-in-audit/sign-in-audit.entity.ts b/backend/src/entities/user-sign-in-audit/sign-in-audit.entity.ts new file mode 100644 index 000000000..ef6859786 --- /dev/null +++ b/backend/src/entities/user-sign-in-audit/sign-in-audit.entity.ts @@ -0,0 +1,46 @@ +import { Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn, Relation } from 'typeorm'; +import { UserEntity } from '../user/user.entity.js'; +import { SignInStatusEnum } from './enums/sign-in-status.enum.js'; +import { SignInMethodEnum } from './enums/sign-in-method.enum.js'; + +@Entity('signInAudit') +export class SignInAuditEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ default: null }) + email: string; + + @Column('enum', { + nullable: false, + enum: SignInStatusEnum, + default: SignInStatusEnum.SUCCESS, + }) + status: SignInStatusEnum; + + @Column('enum', { + nullable: false, + enum: SignInMethodEnum, + default: SignInMethodEnum.EMAIL, + }) + signInMethod: SignInMethodEnum; + + @Column({ default: null }) + ipAddress: string; + + @Column({ default: null }) + userAgent: string; + + @Column({ default: null }) + failureReason: string; + + @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) + createdAt: Date; + + @ManyToOne((_) => UserEntity, { onDelete: 'CASCADE', nullable: true }) + @JoinColumn({ name: 'userId' }) + user: Relation; + + @Column() + userId: string; +} diff --git a/backend/src/entities/user-sign-in-audit/sign-in-audit.module.ts b/backend/src/entities/user-sign-in-audit/sign-in-audit.module.ts new file mode 100644 index 000000000..1f9ba5801 --- /dev/null +++ b/backend/src/entities/user-sign-in-audit/sign-in-audit.module.ts @@ -0,0 +1,34 @@ +import { Global, MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthMiddleware } from '../../authorization/index.js'; +import { GlobalDatabaseContext } from '../../common/application/global-database-context.js'; +import { BaseType, UseCaseType } from '../../common/data-injection.tokens.js'; +import { LogOutEntity } from '../log-out/log-out.entity.js'; +import { UserEntity } from '../user/user.entity.js'; +import { SignInAuditController } from './sign-in-audit.controller.js'; +import { SignInAuditEntity } from './sign-in-audit.entity.js'; +import { SignInAuditService } from './sign-in-audit.service.js'; +import { FindSignInAuditLogsUseCase } from './use-cases/find-sign-in-audit-logs.use.case.js'; + +@Global() +@Module({ + imports: [TypeOrmModule.forFeature([SignInAuditEntity, UserEntity, LogOutEntity])], + providers: [ + { + provide: BaseType.GLOBAL_DB_CONTEXT, + useClass: GlobalDatabaseContext, + }, + { + provide: UseCaseType.FIND_SIGN_IN_AUDIT_LOGS, + useClass: FindSignInAuditLogsUseCase, + }, + SignInAuditService, + ], + controllers: [SignInAuditController], + exports: [SignInAuditService], +}) +export class SignInAuditModule { + public configure(consumer: MiddlewareConsumer): any { + consumer.apply(AuthMiddleware).forRoutes({ path: '/sign-in-audit/logs', method: RequestMethod.GET }); + } +} diff --git a/backend/src/entities/user-sign-in-audit/sign-in-audit.service.ts b/backend/src/entities/user-sign-in-audit/sign-in-audit.service.ts new file mode 100644 index 000000000..bd98443c8 --- /dev/null +++ b/backend/src/entities/user-sign-in-audit/sign-in-audit.service.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { SignInAuditEntity } from './sign-in-audit.entity.js'; +import { CreateSignInAuditRecordDs } from './dto/create-sign-in-audit-record.ds.js'; +import { UserEntity } from '../user/user.entity.js'; +import { FoundSignInAuditRecordDs } from './dto/found-sign-in-audit-record.ds.js'; +import { buildFoundSignInAuditRecordDs } from './utils/build-found-sign-in-audit-record-ds.js'; + +@Injectable() +export class SignInAuditService { + constructor( + @InjectRepository(SignInAuditEntity) + private readonly signInAuditRepository: Repository, + @InjectRepository(UserEntity) + private readonly userRepository: Repository, + ) {} + + public async createSignInAuditRecord(data: CreateSignInAuditRecordDs): Promise { + const { email, userId, status, signInMethod, ipAddress, userAgent, failureReason } = data; + + const newRecord = new SignInAuditEntity(); + newRecord.email = email?.toLowerCase(); + newRecord.status = status; + newRecord.signInMethod = signInMethod; + newRecord.ipAddress = ipAddress || null; + newRecord.userAgent = userAgent || null; + newRecord.failureReason = failureReason || null; + + if (userId) { + const user = await this.userRepository.findOne({ where: { id: userId } }); + if (user) { + newRecord.user = user; + } + } + + const savedRecord = await this.signInAuditRepository.save(newRecord); + return buildFoundSignInAuditRecordDs(savedRecord); + } +} diff --git a/backend/src/entities/user-sign-in-audit/use-cases/find-sign-in-audit-logs.use.case.ts b/backend/src/entities/user-sign-in-audit/use-cases/find-sign-in-audit-logs.use.case.ts new file mode 100644 index 000000000..a5f02de11 --- /dev/null +++ b/backend/src/entities/user-sign-in-audit/use-cases/find-sign-in-audit-logs.use.case.ts @@ -0,0 +1,121 @@ +import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common'; +import AbstractUseCase from '../../../common/abstract-use.case.js'; +import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js'; +import { BaseType } from '../../../common/data-injection.tokens.js'; +import { QueryOrderingEnum } from '../../../enums/index.js'; +import { Messages } from '../../../exceptions/text/messages.js'; +import { Constants } from '../../../helpers/constants/constants.js'; +import { validateStringWithEnum } from '../../../helpers/validators/validate-string-with-enum.js'; +import { FindSignInAuditLogsDs } from '../dto/find-sign-in-audit-logs.ds.js'; +import { FoundSignInAuditLogsDs } from '../dto/found-sign-in-audit-logs.ds.js'; +import { SignInMethodEnum } from '../enums/sign-in-method.enum.js'; +import { SignInStatusEnum } from '../enums/sign-in-status.enum.js'; +import { buildFoundSignInAuditRecordDs } from '../utils/build-found-sign-in-audit-record-ds.js'; +import { IFindSignInAuditLogs } from './use-cases.interface.js'; + +@Injectable() +export class FindSignInAuditLogsUseCase + extends AbstractUseCase + implements IFindSignInAuditLogs +{ + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + ) { + super(); + } + + protected async implementation(inputData: FindSignInAuditLogsDs): Promise { + const { userId, companyId, query } = inputData; + + const user = await this._dbContext.userRepository.findOne({ + where: { id: userId }, + relations: ['company'], + }); + + if (!user || user.company?.id !== companyId) { + throw new HttpException( + { + message: Messages.COMPANY_NOT_FOUND, + }, + HttpStatus.FORBIDDEN, + ); + } + + let order = query.order as QueryOrderingEnum; + let page = query.page; + let perPage = query.perPage; + const dateFrom = query.dateFrom; + const dateTo = query.dateTo; + const searchedEmail = query.email?.toLowerCase(); + const status = query.status; + const signInMethod = query.signInMethod; + + if (status) { + const statusValidationResult = validateStringWithEnum(status, SignInStatusEnum); + if (!statusValidationResult) { + throw new HttpException( + { + message: Messages.INVALID_SIGN_IN_STATUS, + }, + HttpStatus.BAD_REQUEST, + ); + } + } + + if (signInMethod) { + const methodValidationResult = validateStringWithEnum(signInMethod, SignInMethodEnum); + if (!methodValidationResult) { + throw new HttpException( + { + message: Messages.INVALID_SIGN_IN_METHOD, + }, + HttpStatus.BAD_REQUEST, + ); + } + } + + if (!order || order !== QueryOrderingEnum.ASC) { + order = QueryOrderingEnum.DESC; + } + if (!page || page <= 0) { + page = 1; + } + if (!perPage || perPage <= 0) { + perPage = Constants.DEFAULT_LOG_ROWS_LIMIT; + } + + let searchedDateFrom: Date = null; + let searchedDateTo: Date = null; + if (dateFrom && dateTo) { + let dateFromParsed = Date.parse(dateFrom); + let dateToParsed = Date.parse(dateTo); + const dateFromMs = new Date(dateFromParsed).getTime(); + const dateToMs = new Date(dateToParsed).getTime(); + if (dateFromMs > dateToMs) { + const tmpDate = dateFromParsed; + dateFromParsed = dateToParsed; + dateToParsed = tmpDate; + } + searchedDateFrom = new Date(dateFromParsed); + searchedDateTo = new Date(dateToParsed); + } + + const { logs, pagination } = await this._dbContext.signInAuditRepository.findSignInAuditLogs({ + companyId, + order, + page, + perPage, + dateFrom: searchedDateFrom, + dateTo: searchedDateTo, + searchedEmail, + status, + signInMethod, + }); + + return { + logs: logs.map((log) => buildFoundSignInAuditRecordDs(log)), + pagination, + }; + } +} diff --git a/backend/src/entities/user-sign-in-audit/use-cases/use-cases.interface.ts b/backend/src/entities/user-sign-in-audit/use-cases/use-cases.interface.ts new file mode 100644 index 000000000..d887058b8 --- /dev/null +++ b/backend/src/entities/user-sign-in-audit/use-cases/use-cases.interface.ts @@ -0,0 +1,7 @@ +import { InTransactionEnum } from '../../../enums/index.js'; +import { FindSignInAuditLogsDs } from '../dto/find-sign-in-audit-logs.ds.js'; +import { FoundSignInAuditLogsDs } from '../dto/found-sign-in-audit-logs.ds.js'; + +export interface IFindSignInAuditLogs { + execute(inputData: FindSignInAuditLogsDs, inTransaction: InTransactionEnum): Promise; +} diff --git a/backend/src/entities/user-sign-in-audit/utils/build-found-sign-in-audit-record-ds.ts b/backend/src/entities/user-sign-in-audit/utils/build-found-sign-in-audit-record-ds.ts new file mode 100644 index 000000000..6358b7c15 --- /dev/null +++ b/backend/src/entities/user-sign-in-audit/utils/build-found-sign-in-audit-record-ds.ts @@ -0,0 +1,16 @@ +import { SignInAuditEntity } from '../sign-in-audit.entity.js'; +import { FoundSignInAuditRecordDs } from '../dto/found-sign-in-audit-record.ds.js'; + +export function buildFoundSignInAuditRecordDs(entity: SignInAuditEntity): FoundSignInAuditRecordDs { + return { + id: entity.id, + email: entity.email, + status: entity.status, + signInMethod: entity.signInMethod, + ipAddress: entity.ipAddress, + userAgent: entity.userAgent, + failureReason: entity.failureReason, + createdAt: entity.createdAt, + userId: entity.user?.id || null, + }; +} diff --git a/backend/src/entities/user/application/data-structures/usual-login.ds.ts b/backend/src/entities/user/application/data-structures/usual-login.ds.ts index 0e3ec5e02..d53cba8bd 100644 --- a/backend/src/entities/user/application/data-structures/usual-login.ds.ts +++ b/backend/src/entities/user/application/data-structures/usual-login.ds.ts @@ -13,4 +13,8 @@ export class UsualLoginDs { gclidValue: string; request_domain: string; + + ipAddress?: string; + + userAgent?: string; } diff --git a/backend/src/entities/user/application/data-structures/verify-otp.ds.ts b/backend/src/entities/user/application/data-structures/verify-otp.ds.ts index e3a95f4e5..8b9f7ef83 100644 --- a/backend/src/entities/user/application/data-structures/verify-otp.ds.ts +++ b/backend/src/entities/user/application/data-structures/verify-otp.ds.ts @@ -5,4 +5,8 @@ export class VerifyOtpDS { @ApiProperty() otpToken: string; + + ipAddress?: string; + + userAgent?: string; } diff --git a/backend/src/entities/user/use-cases/otp-login-use.case.ts b/backend/src/entities/user/use-cases/otp-login-use.case.ts index 3a1b94281..15f7e29c9 100644 --- a/backend/src/entities/user/use-cases/otp-login-use.case.ts +++ b/backend/src/entities/user/use-cases/otp-login-use.case.ts @@ -8,27 +8,65 @@ import { Messages } from '../../../exceptions/text/messages.js'; import { VerifyOtpDS } from '../application/data-structures/verify-otp.ds.js'; import { authenticator } from 'otplib'; import { get2FaScope } from '../utils/is-jwt-scope-need.util.js'; +import { SignInAuditService } from '../../user-sign-in-audit/sign-in-audit.service.js'; +import { SignInStatusEnum } from '../../user-sign-in-audit/enums/sign-in-status.enum.js'; +import { SignInMethodEnum } from '../../user-sign-in-audit/enums/sign-in-method.enum.js'; @Injectable() export class OtpLoginUseCase extends AbstractUseCase implements IOtpLogin { constructor( @Inject(BaseType.GLOBAL_DB_CONTEXT) protected _dbContext: IGlobalDatabaseContext, + private readonly signInAuditService: SignInAuditService, ) { super(); } protected async implementation(inputData: VerifyOtpDS): Promise { - const { userId, otpToken } = inputData; + const { userId, otpToken, ipAddress, userAgent } = inputData; const foundUser = await this._dbContext.userRepository.findOneUserById(userId); if (!foundUser) { throw new NotFoundException(Messages.USER_NOT_FOUND); } const isValid = authenticator.check(otpToken, foundUser.otpSecretKey); if (!isValid) { + await this.recordSignInAudit( + foundUser.email, + userId, + SignInStatusEnum.FAILED, + ipAddress, + userAgent, + Messages.LOGIN_DENIED_INVALID_OTP, + ); throw new BadRequestException(Messages.LOGIN_DENIED_INVALID_OTP); } + + await this.recordSignInAudit(foundUser.email, userId, SignInStatusEnum.SUCCESS, ipAddress, userAgent); + const foundUserCompany = await this._dbContext.companyInfoRepository.finOneCompanyInfoByUserId(foundUser.id); return generateGwtToken(foundUser, get2FaScope(foundUser, foundUserCompany)); } + + private async recordSignInAudit( + email: string, + userId: string | null, + status: SignInStatusEnum, + ipAddress: string, + userAgent: string, + failureReason?: string, + ): Promise { + try { + await this.signInAuditService.createSignInAuditRecord({ + email, + userId, + status, + signInMethod: SignInMethodEnum.OTP, + ipAddress, + userAgent, + failureReason, + }); + } catch (e) { + console.error('Failed to record sign-in audit:', e); + } + } } diff --git a/backend/src/entities/user/use-cases/usual-login-use.case.ts b/backend/src/entities/user/use-cases/usual-login-use.case.ts index 2605f6778..35bfbf879 100644 --- a/backend/src/entities/user/use-cases/usual-login-use.case.ts +++ b/backend/src/entities/user/use-cases/usual-login-use.case.ts @@ -14,6 +14,9 @@ import { isTest } from '../../../helpers/app/is-test.js'; import { isSaaS } from '../../../helpers/app/is-saas.js'; import { ValidationHelper } from '../../../helpers/validators/validation-helper.js'; import { Constants } from '../../../helpers/constants/constants.js'; +import { SignInAuditService } from '../../user-sign-in-audit/sign-in-audit.service.js'; +import { SignInStatusEnum } from '../../user-sign-in-audit/enums/sign-in-status.enum.js'; +import { SignInMethodEnum } from '../../user-sign-in-audit/enums/sign-in-method.enum.js'; @Injectable() export class UsualLoginUseCase extends AbstractUseCase implements IUsualLogin { @@ -21,12 +24,13 @@ export class UsualLoginUseCase extends AbstractUseCase imp @Inject(BaseType.GLOBAL_DB_CONTEXT) protected _dbContext: IGlobalDatabaseContext, private readonly saasCompanyGatewayService: SaasCompanyGatewayService, + private readonly signInAuditService: SignInAuditService, ) { super(); } protected async implementation(userData: UsualLoginDs): Promise { - const { request_domain } = userData; + const { request_domain, ipAddress, userAgent } = userData; let { companyId } = userData; const email = userData.email.toLowerCase(); let user: UserEntity = null; @@ -34,6 +38,14 @@ export class UsualLoginUseCase extends AbstractUseCase imp if (companyId) { user = await this._dbContext.userRepository.findOneUserByEmailAndCompanyId(email, companyId); if (!user) { + await this.recordSignInAudit( + email, + null, + SignInStatusEnum.FAILED, + ipAddress, + userAgent, + Messages.USER_NOT_FOUND, + ); throw new NotFoundException(Messages.USER_NOT_FOUND); } } else if (!Constants.APP_REQUEST_DOMAINS().includes(request_domain) && isSaaS()) { @@ -44,6 +56,14 @@ export class UsualLoginUseCase extends AbstractUseCase imp foundUserCompanyIdByDomain, ); if (!foundUser) { + await this.recordSignInAudit( + email, + null, + SignInStatusEnum.FAILED, + ipAddress, + userAgent, + Messages.USER_NOT_FOUND_FOR_THIS_DOMAIN, + ); throw new BadRequestException(Messages.USER_NOT_FOUND_FOR_THIS_DOMAIN); } user = foundUser; @@ -51,16 +71,33 @@ export class UsualLoginUseCase extends AbstractUseCase imp } else { const foundUsers = await this._dbContext.userRepository.findAllUsersWithEmail(email); if (foundUsers.length > 1) { + await this.recordSignInAudit( + email, + null, + SignInStatusEnum.FAILED, + ipAddress, + userAgent, + Messages.LOGIN_DENIED_SHOULD_CHOOSE_COMPANY, + ); throw new BadRequestException(Messages.LOGIN_DENIED_SHOULD_CHOOSE_COMPANY); } user = foundUsers[0]; - companyId = foundUsers[0].company.id; + companyId = foundUsers[0]?.company?.id; } if (!user) { + await this.recordSignInAudit(email, null, SignInStatusEnum.FAILED, ipAddress, userAgent, Messages.USER_NOT_FOUND); throw new NotFoundException(Messages.USER_NOT_FOUND); } if (!userData.password) { + await this.recordSignInAudit( + email, + user.id, + SignInStatusEnum.FAILED, + ipAddress, + userAgent, + Messages.PASSWORD_MISSING, + ); throw new BadRequestException(Messages.PASSWORD_MISSING); } @@ -68,15 +105,49 @@ export class UsualLoginUseCase extends AbstractUseCase imp const passwordValidationResult = await Encryptor.verifyUserPassword(userData.password, user.password); if (!passwordValidationResult) { + await this.recordSignInAudit( + email, + user.id, + SignInStatusEnum.FAILED, + ipAddress, + userAgent, + Messages.LOGIN_DENIED, + ); throw new BadRequestException(Messages.LOGIN_DENIED); } if (user.isOTPEnabled) { return generateTemporaryJwtToken(user); } + + await this.recordSignInAudit(email, user.id, SignInStatusEnum.SUCCESS, ipAddress, userAgent); + const foundUserCompany = await this._dbContext.companyInfoRepository.finOneCompanyInfoByUserId(user.id); return generateGwtToken(user, get2FaScope(user, foundUserCompany)); } + private async recordSignInAudit( + email: string, + userId: string | null, + status: SignInStatusEnum, + ipAddress: string, + userAgent: string, + failureReason?: string, + ): Promise { + try { + await this.signInAuditService.createSignInAuditRecord({ + email, + userId, + status, + signInMethod: SignInMethodEnum.EMAIL, + ipAddress, + userAgent, + failureReason, + }); + } catch (e) { + console.error('Failed to record sign-in audit:', e); + } + } + private async validateRequestDomain(requestDomain: string, companyId: string): Promise { if (!isSaaS()) { return; diff --git a/backend/src/entities/user/user.controller.ts b/backend/src/entities/user/user.controller.ts index e76595c2d..eb8eaa80c 100644 --- a/backend/src/entities/user/user.controller.ts +++ b/backend/src/entities/user/user.controller.ts @@ -148,12 +148,17 @@ export class UserController { @Body() loginUserData: LoginUserDto, ): Promise { const { email, password, companyId: userCompanyId } = loginUserData; + const ipAddress = (request.headers['x-forwarded-for'] as string) || request.ip; + const userAgent = request.headers['user-agent'] as string; + const userData: UsualLoginDs = { email: email, password: password, gclidValue: null, companyId: userCompanyId, request_domain: request.hostname, + ipAddress, + userAgent, }; const tokenInfo = await this.usualLoginUseCase.execute(userData, InTransactionEnum.OFF); @@ -430,7 +435,12 @@ export class UserController { @Body() otpTokenData: OtpTokenDto, ): Promise { const { otpToken } = otpTokenData; - const tokenInfo = await this.otpLoginUseCase.execute({ userId, otpToken }, InTransactionEnum.OFF); + const ipAddress = (request.headers['x-forwarded-for'] as string) || request.ip; + const userAgent = request.headers['user-agent'] as string; + const tokenInfo = await this.otpLoginUseCase.execute( + { userId, otpToken, ipAddress, userAgent }, + InTransactionEnum.OFF, + ); response.cookie(Constants.JWT_COOKIE_KEY_NAME, tokenInfo.token, { httpOnly: true, secure: true, diff --git a/backend/src/entities/user/user.entity.ts b/backend/src/entities/user/user.entity.ts index 2f89f3beb..91e5d2906 100644 --- a/backend/src/entities/user/user.entity.ts +++ b/backend/src/entities/user/user.entity.ts @@ -26,6 +26,7 @@ import { UserRoleEnum } from './enums/user-role.enum.js'; import { ExternalRegistrationProviderEnum } from './enums/external-registration-provider.enum.js'; import { UserApiKeyEntity } from '../api-key/api-key.entity.js'; import { AiResponsesToUserEntity } from '../ai/ai-data-entities/ai-reponses-to-user/ai-responses-to-user.entity.js'; +import { SignInAuditEntity } from '../user-sign-in-audit/sign-in-audit.entity.js'; @Entity('user') export class UserEntity { @@ -119,6 +120,9 @@ export class UserEntity { @OneToMany((_) => AiResponsesToUserEntity, (response) => response.user) ai_responses: Relation[]; + @OneToMany((_) => SignInAuditEntity, (signInAudit) => signInAudit.user) + signInAudits: Relation[]; + @Column({ default: false, type: 'boolean' }) isActive: boolean; diff --git a/backend/src/entities/user/user.module.ts b/backend/src/entities/user/user.module.ts index 371520fe5..7b4fea672 100644 --- a/backend/src/entities/user/user.module.ts +++ b/backend/src/entities/user/user.module.ts @@ -38,6 +38,8 @@ import { SaveUserSettingsUseCase } from './use-cases/save-user-session-settings. import { CompanyInfoEntity } from '../company-info/company-info.entity.js'; import { NonScopedAuthMiddleware } from '../../authorization/non-scoped-auth.middleware.js'; import { ToggleTestConnectionsDisplayModeUseCase } from './use-cases/toggle-test-connections-display-mode.use.case.js'; +import { SignInAuditEntity } from '../user-sign-in-audit/sign-in-audit.entity.js'; +import { SignInAuditService } from '../user-sign-in-audit/sign-in-audit.service.js'; @Module({ imports: [ @@ -53,6 +55,7 @@ import { ToggleTestConnectionsDisplayModeUseCase } from './use-cases/toggle-test ConnectionPropertiesEntity, LogOutEntity, CompanyInfoEntity, + SignInAuditEntity, ]), AgentModule, ], @@ -138,6 +141,7 @@ import { ToggleTestConnectionsDisplayModeUseCase } from './use-cases/toggle-test useClass: ToggleTestConnectionsDisplayModeUseCase, }, UserHelperService, + SignInAuditService, ], controllers: [UserController], exports: [UserHelperService], diff --git a/backend/src/exceptions/text/messages.ts b/backend/src/exceptions/text/messages.ts index d6cb4b471..6dcab9205 100644 --- a/backend/src/exceptions/text/messages.ts +++ b/backend/src/exceptions/text/messages.ts @@ -171,6 +171,8 @@ export const Messages = { INCORRECT_TABLE_LOG_ACTION_TYPE: `Incorrect log operation type, supported types are ${enumToString( LogOperationTypeEnum, )}`, + INVALID_SIGN_IN_STATUS: `Invalid sign-in status. Supported values are: success, failed, blocked`, + INVALID_SIGN_IN_METHOD: `Invalid sign-in method. Supported values are: email, google, github, saml, otp`, INVALID_DISPLAY_MODE: `Invalid display mode. Supported values are "on" and "off"`, INVALID_USERNAME_OR_PASSWORD: `Username or password is invalid`, INVALID_USER_COMPANY_ROLE: `Invalid user role in company. Only supported is ${enumToString(UserRoleEnum)}`, diff --git a/backend/src/migrations/1764166925419-AddSignInAuditEntity.ts b/backend/src/migrations/1764166925419-AddSignInAuditEntity.ts new file mode 100644 index 000000000..a3260f8bd --- /dev/null +++ b/backend/src/migrations/1764166925419-AddSignInAuditEntity.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddSignInAuditEntity1764166925419 implements MigrationInterface { + name = 'AddSignInAuditEntity1764166925419'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TYPE "public"."signInAudit_status_enum" AS ENUM('success', 'failed', 'blocked')`); + await queryRunner.query( + `CREATE TYPE "public"."signInAudit_signinmethod_enum" AS ENUM('email', 'google', 'github', 'saml', 'otp')`, + ); + await queryRunner.query( + `CREATE TABLE "signInAudit" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "email" character varying, "status" "public"."signInAudit_status_enum" NOT NULL DEFAULT 'success', "signInMethod" "public"."signInAudit_signinmethod_enum" NOT NULL DEFAULT 'email', "ipAddress" character varying, "userAgent" character varying, "failureReason" character varying, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "userId" uuid NOT NULL, CONSTRAINT "PK_7da6fabf99add8472fb25327a13" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `ALTER TABLE "signInAudit" ADD CONSTRAINT "FK_f0574f10d690de5779e74563f8f" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "signInAudit" DROP CONSTRAINT "FK_f0574f10d690de5779e74563f8f"`); + await queryRunner.query(`DROP TABLE "signInAudit"`); + await queryRunner.query(`DROP TYPE "public"."signInAudit_signinmethod_enum"`); + await queryRunner.query(`DROP TYPE "public"."signInAudit_status_enum"`); + } +} From 492635c4b05780c998eb48dd186a04ccde7e8412 Mon Sep 17 00:00:00 2001 From: Artem Niehrieiev Date: Thu, 27 Nov 2025 09:33:28 +0000 Subject: [PATCH 2/5] feat: add userId filter to findSignInAuditLogs options and use case --- .../repository/sign-in-audit-custom-repository-extension.ts | 6 +++++- .../repository/sign-in-audit-repository.interface.ts | 1 + .../use-cases/find-sign-in-audit-logs.use.case.ts | 1 + 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/backend/src/entities/user-sign-in-audit/repository/sign-in-audit-custom-repository-extension.ts b/backend/src/entities/user-sign-in-audit/repository/sign-in-audit-custom-repository-extension.ts index 02a4946fd..1c3638e19 100644 --- a/backend/src/entities/user-sign-in-audit/repository/sign-in-audit-custom-repository-extension.ts +++ b/backend/src/entities/user-sign-in-audit/repository/sign-in-audit-custom-repository-extension.ts @@ -31,13 +31,17 @@ export const signInAuditCustomRepositoryExtension: ISignInAuditRepository = { }, async findSignInAuditLogs(options: IFindSignInAuditLogsOptions): Promise { - const { companyId, order, page, perPage, dateFrom, dateTo, searchedEmail, status, signInMethod } = options; + const { companyId, order, page, perPage, dateFrom, dateTo, searchedEmail, status, signInMethod, userId } = options; const qb = this.createQueryBuilder('signInAudit') .leftJoinAndSelect('signInAudit.user', 'user') .leftJoin('user.company', 'company') .where('company.id = :companyId', { companyId }); + if (userId) { + qb.andWhere('user.id = :userId', { userId }); + } + if (searchedEmail) { qb.andWhere('signInAudit.email = :email', { email: searchedEmail.toLowerCase() }); } diff --git a/backend/src/entities/user-sign-in-audit/repository/sign-in-audit-repository.interface.ts b/backend/src/entities/user-sign-in-audit/repository/sign-in-audit-repository.interface.ts index adc3de16f..146046d44 100644 --- a/backend/src/entities/user-sign-in-audit/repository/sign-in-audit-repository.interface.ts +++ b/backend/src/entities/user-sign-in-audit/repository/sign-in-audit-repository.interface.ts @@ -25,6 +25,7 @@ export interface IFindSignInAuditLogsOptions { searchedEmail?: string; status?: SignInStatusEnum; signInMethod?: SignInMethodEnum; + userId?: string; } export interface IFoundSignInAuditLogsResult { diff --git a/backend/src/entities/user-sign-in-audit/use-cases/find-sign-in-audit-logs.use.case.ts b/backend/src/entities/user-sign-in-audit/use-cases/find-sign-in-audit-logs.use.case.ts index a5f02de11..726390e6e 100644 --- a/backend/src/entities/user-sign-in-audit/use-cases/find-sign-in-audit-logs.use.case.ts +++ b/backend/src/entities/user-sign-in-audit/use-cases/find-sign-in-audit-logs.use.case.ts @@ -111,6 +111,7 @@ export class FindSignInAuditLogsUseCase searchedEmail, status, signInMethod, + userId, }); return { From abfb92bd5e531354471e3a54acd48ed267c4d9f1 Mon Sep 17 00:00:00 2001 From: Artem Niehrieiev Date: Thu, 27 Nov 2025 09:45:01 +0000 Subject: [PATCH 3/5] feat: enhance user registration with optional ipAddress and userAgent fields, and implement sign-in audit logging for Google and GitHub logins --- .../saas-register-user-with-github.ts | 6 +++ .../sass-register-user-with-google.ts | 6 +++ .../saas-microservice/saas.controller.ts | 18 ++++++++- .../saas-microservice/saas.module.ts | 7 +++- .../use-cases/login-with-github.use.case.ts | 40 ++++++++++++++++++- .../use-cases/login-with-google.use.case.ts | 31 +++++++++++++- 6 files changed, 103 insertions(+), 5 deletions(-) diff --git a/backend/src/microservices/saas-microservice/data-structures/saas-register-user-with-github.ts b/backend/src/microservices/saas-microservice/data-structures/saas-register-user-with-github.ts index b87cc82e6..968d793b6 100644 --- a/backend/src/microservices/saas-microservice/data-structures/saas-register-user-with-github.ts +++ b/backend/src/microservices/saas-microservice/data-structures/saas-register-user-with-github.ts @@ -12,4 +12,10 @@ export class SaasRegisterUserWithGithub { @ApiProperty() glidCookieValue: string; + + @ApiProperty({ required: false }) + ipAddress?: string; + + @ApiProperty({ required: false }) + userAgent?: string; } diff --git a/backend/src/microservices/saas-microservice/data-structures/sass-register-user-with-google.ts b/backend/src/microservices/saas-microservice/data-structures/sass-register-user-with-google.ts index 7c1aa58fb..ee005451d 100644 --- a/backend/src/microservices/saas-microservice/data-structures/sass-register-user-with-google.ts +++ b/backend/src/microservices/saas-microservice/data-structures/sass-register-user-with-google.ts @@ -9,4 +9,10 @@ export class SaasRegisterUserWithGoogleDS { @ApiProperty() glidCookieValue: string; + + @ApiProperty({ required: false }) + ipAddress?: string; + + @ApiProperty({ required: false }) + userAgent?: string; } diff --git a/backend/src/microservices/saas-microservice/saas.controller.ts b/backend/src/microservices/saas-microservice/saas.controller.ts index dbcc5c42a..37fd45a51 100644 --- a/backend/src/microservices/saas-microservice/saas.controller.ts +++ b/backend/src/microservices/saas-microservice/saas.controller.ts @@ -171,8 +171,13 @@ export class SaasController { @Body('email') email: string, @Body('name') name: string, @Body('glidCookieValue') glidCookieValue: string, + @Body('ipAddress') ipAddress: string, + @Body('userAgent') userAgent: string, ): Promise { - return await this.loginUserWithGoogleUseCase.execute({ email, name, glidCookieValue }, InTransactionEnum.OFF); + return await this.loginUserWithGoogleUseCase.execute( + { email, name, glidCookieValue, ipAddress, userAgent }, + InTransactionEnum.OFF, + ); } @ApiOperation({ summary: 'Login or create user with github webhook' }) @@ -186,8 +191,17 @@ export class SaasController { @Body('name') name: string, @Body('githubId') githubId: number, @Body('glidCookieValue') glidCookieValue: string, + @Body('ipAddress') ipAddress: string, + @Body('userAgent') userAgent: string, ): Promise { - return await this.loginUserWithGithubUseCase.execute({ email, name, githubId, glidCookieValue }); + return await this.loginUserWithGithubUseCase.execute({ + email, + name, + githubId, + glidCookieValue, + ipAddress, + userAgent, + }); } @ApiOperation({ summary: 'Suspending users' }) diff --git a/backend/src/microservices/saas-microservice/saas.module.ts b/backend/src/microservices/saas-microservice/saas.module.ts index 249ea6607..117c75bae 100644 --- a/backend/src/microservices/saas-microservice/saas.module.ts +++ b/backend/src/microservices/saas-microservice/saas.module.ts @@ -1,4 +1,5 @@ import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { SaaSAuthMiddleware } from '../../authorization/saas-auth.middleware.js'; import { GlobalDatabaseContext } from '../../common/application/global-database-context.js'; import { BaseType, UseCaseType } from '../../common/data-injection.tokens.js'; @@ -17,9 +18,12 @@ import { UnFreezeConnectionsInCompanyUseCase } from './use-cases/unfreeze-connec import { SaasRegisterDemoUserAccountUseCase } from './use-cases/register-demo-user-account.use.case.js'; import { SaaSRegisterUserWIthSamlUseCase } from './use-cases/register-user-with-saml-use.case.js'; import { SuspendUsersOverLimitUseCase } from './use-cases/suspend-users-over-limit.use.case.js'; +import { SignInAuditEntity } from '../../entities/user-sign-in-audit/sign-in-audit.entity.js'; +import { SignInAuditService } from '../../entities/user-sign-in-audit/sign-in-audit.service.js'; +import { UserEntity } from '../../entities/user/user.entity.js'; @Module({ - imports: [], + imports: [TypeOrmModule.forFeature([SignInAuditEntity, UserEntity])], providers: [ { provide: BaseType.GLOBAL_DB_CONTEXT, @@ -81,6 +85,7 @@ import { SuspendUsersOverLimitUseCase } from './use-cases/suspend-users-over-lim provide: UseCaseType.SAAS_SUSPEND_USERS_OVER_LIMIT, useClass: SuspendUsersOverLimitUseCase, }, + SignInAuditService, ], controllers: [SaasController], exports: [], diff --git a/backend/src/microservices/saas-microservice/use-cases/login-with-github.use.case.ts b/backend/src/microservices/saas-microservice/use-cases/login-with-github.use.case.ts index 26c718f68..8271026cf 100644 --- a/backend/src/microservices/saas-microservice/use-cases/login-with-github.use.case.ts +++ b/backend/src/microservices/saas-microservice/use-cases/login-with-github.use.case.ts @@ -11,6 +11,9 @@ import { buildUserGitHubIdentifierEntity } from '../../../entities/user/utils/bu import { Messages } from '../../../exceptions/text/messages.js'; import { SaasRegisterUserWithGithub } from '../data-structures/saas-register-user-with-github.js'; import { ILoginUserWithGitHub } from './saas-use-cases.interface.js'; +import { SignInAuditService } from '../../../entities/user-sign-in-audit/sign-in-audit.service.js'; +import { SignInStatusEnum } from '../../../entities/user-sign-in-audit/enums/sign-in-status.enum.js'; +import { SignInMethodEnum } from '../../../entities/user-sign-in-audit/enums/sign-in-method.enum.js'; @Injectable() export class LoginUserWithGithubUseCase @@ -21,12 +24,13 @@ export class LoginUserWithGithubUseCase @Inject(BaseType.GLOBAL_DB_CONTEXT) protected _dbContext: IGlobalDatabaseContext, private readonly demoDataService: DemoDataService, + private readonly signInAuditService: SignInAuditService, ) { super(); } protected async implementation(inputData: SaasRegisterUserWithGithub): Promise { - const { email, githubId, glidCookieValue, name } = inputData; + const { email, githubId, glidCookieValue, name, ipAddress, userAgent } = inputData; const foundUser: UserEntity = await this._dbContext.userRepository.findOneUserByGitHubId(githubId); if (foundUser) { if (foundUser.name !== name && name) { @@ -36,6 +40,7 @@ export class LoginUserWithGithubUseCase foundUser.email = email; } await this._dbContext.userRepository.saveUserEntity(foundUser); + await this.recordSignInAudit(email, foundUser.id, SignInStatusEnum.SUCCESS, ipAddress, userAgent); return foundUser; } const userData: RegisterUserDs = { @@ -58,9 +63,42 @@ export class LoginUserWithGithubUseCase await this.demoDataService.createDemoDataForUser(savedUser.id); + await this.recordSignInAudit(email, savedUser.id, SignInStatusEnum.SUCCESS, ipAddress, userAgent); + return savedUser; } catch (_e) { + await this.recordSignInAudit( + email, + null, + SignInStatusEnum.FAILED, + ipAddress, + userAgent, + Messages.GITHUB_REGISTRATION_FAILED, + ); throw new InternalServerErrorException(Messages.GITHUB_REGISTRATION_FAILED); } } + + private async recordSignInAudit( + email: string, + userId: string | null, + status: SignInStatusEnum, + ipAddress: string, + userAgent: string, + failureReason?: string, + ): Promise { + try { + await this.signInAuditService.createSignInAuditRecord({ + email, + userId, + status, + signInMethod: SignInMethodEnum.GITHUB, + ipAddress, + userAgent, + failureReason, + }); + } catch (e) { + console.error('Failed to record sign-in audit:', e); + } + } } diff --git a/backend/src/microservices/saas-microservice/use-cases/login-with-google.use.case.ts b/backend/src/microservices/saas-microservice/use-cases/login-with-google.use.case.ts index c0afdd6de..7249be68c 100644 --- a/backend/src/microservices/saas-microservice/use-cases/login-with-google.use.case.ts +++ b/backend/src/microservices/saas-microservice/use-cases/login-with-google.use.case.ts @@ -8,6 +8,9 @@ import { ExternalRegistrationProviderEnum } from '../../../entities/user/enums/e import { UserEntity } from '../../../entities/user/user.entity.js'; import { SaasRegisterUserWithGoogleDS } from '../data-structures/sass-register-user-with-google.js'; import { ILoginUserWithGoogle } from './saas-use-cases.interface.js'; +import { SignInAuditService } from '../../../entities/user-sign-in-audit/sign-in-audit.service.js'; +import { SignInStatusEnum } from '../../../entities/user-sign-in-audit/enums/sign-in-status.enum.js'; +import { SignInMethodEnum } from '../../../entities/user-sign-in-audit/enums/sign-in-method.enum.js'; @Injectable() export class LoginWithGoogleUseCase @@ -18,12 +21,13 @@ export class LoginWithGoogleUseCase @Inject(BaseType.GLOBAL_DB_CONTEXT) protected _dbContext: IGlobalDatabaseContext, private readonly demoDataService: DemoDataService, + private readonly signInAuditService: SignInAuditService, ) { super(); } protected async implementation(inputData: SaasRegisterUserWithGoogleDS): Promise { - const { email, name, glidCookieValue } = inputData; + const { email, name, glidCookieValue, ipAddress, userAgent } = inputData; const foundUser: UserEntity = await this._dbContext.userRepository.findOneUserByEmail( email, @@ -34,6 +38,7 @@ export class LoginWithGoogleUseCase foundUser.name = name; await this._dbContext.userRepository.saveUserEntity(foundUser); } + await this.recordSignInAudit(email, foundUser.id, SignInStatusEnum.SUCCESS, ipAddress, userAgent); return foundUser; } const userData: RegisterUserDs = { @@ -48,6 +53,30 @@ export class LoginWithGoogleUseCase ExternalRegistrationProviderEnum.GOOGLE, ); await this.demoDataService.createDemoDataForUser(savedUser.id); + await this.recordSignInAudit(email, savedUser.id, SignInStatusEnum.SUCCESS, ipAddress, userAgent); return savedUser; } + + private async recordSignInAudit( + email: string, + userId: string | null, + status: SignInStatusEnum, + ipAddress: string, + userAgent: string, + failureReason?: string, + ): Promise { + try { + await this.signInAuditService.createSignInAuditRecord({ + email, + userId, + status, + signInMethod: SignInMethodEnum.GOOGLE, + ipAddress, + userAgent, + failureReason, + }); + } catch (e) { + console.error('Failed to record sign-in audit:', e); + } + } } From c48ae813f6a5ba0ca315d25ee4637c7cd10492e8 Mon Sep 17 00:00:00 2001 From: Artem Niehrieiev Date: Thu, 27 Nov 2025 10:53:20 +0000 Subject: [PATCH 4/5] feat: update sign-in audit entity and service to support nullable userId, add migration for sign-in audit table, and implement comprehensive end-to-end tests for sign-in audit logs --- .../sign-in-audit.entity.ts | 2 +- .../sign-in-audit.service.ts | 1 + .../validators/validate-string-with-enum.ts | 2 +- ... => 1764239409303-AddSignInAuditEntity.ts} | 6 +- .../saas-tests/user-sign-in-audit.e2e.test.ts | 524 ++++++++++++++++++ 5 files changed, 530 insertions(+), 5 deletions(-) rename backend/src/migrations/{1764166925419-AddSignInAuditEntity.ts => 1764239409303-AddSignInAuditEntity.ts} (86%) create mode 100644 backend/test/ava-tests/saas-tests/user-sign-in-audit.e2e.test.ts diff --git a/backend/src/entities/user-sign-in-audit/sign-in-audit.entity.ts b/backend/src/entities/user-sign-in-audit/sign-in-audit.entity.ts index ef6859786..70241efab 100644 --- a/backend/src/entities/user-sign-in-audit/sign-in-audit.entity.ts +++ b/backend/src/entities/user-sign-in-audit/sign-in-audit.entity.ts @@ -41,6 +41,6 @@ export class SignInAuditEntity { @JoinColumn({ name: 'userId' }) user: Relation; - @Column() + @Column({ nullable: true }) userId: string; } diff --git a/backend/src/entities/user-sign-in-audit/sign-in-audit.service.ts b/backend/src/entities/user-sign-in-audit/sign-in-audit.service.ts index bd98443c8..e30a6d6a0 100644 --- a/backend/src/entities/user-sign-in-audit/sign-in-audit.service.ts +++ b/backend/src/entities/user-sign-in-audit/sign-in-audit.service.ts @@ -31,6 +31,7 @@ export class SignInAuditService { const user = await this.userRepository.findOne({ where: { id: userId } }); if (user) { newRecord.user = user; + newRecord.userId = user.id; } } diff --git a/backend/src/helpers/validators/validate-string-with-enum.ts b/backend/src/helpers/validators/validate-string-with-enum.ts index c5b83f0ec..edeebea73 100644 --- a/backend/src/helpers/validators/validate-string-with-enum.ts +++ b/backend/src/helpers/validators/validate-string-with-enum.ts @@ -1,3 +1,3 @@ export function validateStringWithEnum(str: string, en: any): boolean { - return !!Object.keys(en).find((key) => key === str); + return !!Object.values(en).find((value) => value === str); } diff --git a/backend/src/migrations/1764166925419-AddSignInAuditEntity.ts b/backend/src/migrations/1764239409303-AddSignInAuditEntity.ts similarity index 86% rename from backend/src/migrations/1764166925419-AddSignInAuditEntity.ts rename to backend/src/migrations/1764239409303-AddSignInAuditEntity.ts index a3260f8bd..0c285a949 100644 --- a/backend/src/migrations/1764166925419-AddSignInAuditEntity.ts +++ b/backend/src/migrations/1764239409303-AddSignInAuditEntity.ts @@ -1,7 +1,7 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; -export class AddSignInAuditEntity1764166925419 implements MigrationInterface { - name = 'AddSignInAuditEntity1764166925419'; +export class AddSignInAuditEntity1764239409303 implements MigrationInterface { + name = 'AddSignInAuditEntity1764239409303'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(`CREATE TYPE "public"."signInAudit_status_enum" AS ENUM('success', 'failed', 'blocked')`); @@ -9,7 +9,7 @@ export class AddSignInAuditEntity1764166925419 implements MigrationInterface { `CREATE TYPE "public"."signInAudit_signinmethod_enum" AS ENUM('email', 'google', 'github', 'saml', 'otp')`, ); await queryRunner.query( - `CREATE TABLE "signInAudit" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "email" character varying, "status" "public"."signInAudit_status_enum" NOT NULL DEFAULT 'success', "signInMethod" "public"."signInAudit_signinmethod_enum" NOT NULL DEFAULT 'email', "ipAddress" character varying, "userAgent" character varying, "failureReason" character varying, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "userId" uuid NOT NULL, CONSTRAINT "PK_7da6fabf99add8472fb25327a13" PRIMARY KEY ("id"))`, + `CREATE TABLE "signInAudit" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "email" character varying, "status" "public"."signInAudit_status_enum" NOT NULL DEFAULT 'success', "signInMethod" "public"."signInAudit_signinmethod_enum" NOT NULL DEFAULT 'email', "ipAddress" character varying, "userAgent" character varying, "failureReason" character varying, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "userId" uuid, CONSTRAINT "PK_7da6fabf99add8472fb25327a13" PRIMARY KEY ("id"))`, ); await queryRunner.query( `ALTER TABLE "signInAudit" ADD CONSTRAINT "FK_f0574f10d690de5779e74563f8f" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, diff --git a/backend/test/ava-tests/saas-tests/user-sign-in-audit.e2e.test.ts b/backend/test/ava-tests/saas-tests/user-sign-in-audit.e2e.test.ts new file mode 100644 index 000000000..842aede19 --- /dev/null +++ b/backend/test/ava-tests/saas-tests/user-sign-in-audit.e2e.test.ts @@ -0,0 +1,524 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { faker } from '@faker-js/faker'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import test from 'ava'; +import { ValidationError } from 'class-validator'; +import cookieParser from 'cookie-parser'; +import request from 'supertest'; +import { ApplicationModule } from '../../../src/app.module.js'; +import { AllExceptionsFilter } from '../../../src/exceptions/all-exceptions.filter.js'; +import { ValidationException } from '../../../src/exceptions/custom-exceptions/validation-exception.js'; +import { Messages } from '../../../src/exceptions/text/messages.js'; +import { DatabaseModule } from '../../../src/shared/database/database.module.js'; +import { DatabaseService } from '../../../src/shared/database/database.service.js'; +import { registerUserAndReturnUserInfo } from '../../utils/register-user-and-return-user-info.js'; +import { TestUtils } from '../../utils/test.utils.js'; +import { Cacher } from '../../../src/helpers/cache/cacher.js'; +import { MockFactory } from '../../mock.factory.js'; +import { WinstonLogger } from '../../../src/entities/logging/winston-logger.js'; +import { SignInStatusEnum } from '../../../src/entities/user-sign-in-audit/enums/sign-in-status.enum.js'; +import { SignInMethodEnum } from '../../../src/entities/user-sign-in-audit/enums/sign-in-method.enum.js'; + +let app: INestApplication; +let currentTest: string; +let testUtils: TestUtils; + +const mockFactory = new MockFactory(); + +async function getCompanyIdByToken(app: INestApplication, token: string): Promise { + const getCompanyResult = await request(app.getHttpServer()) + .get('/company/my/full') + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const companyInfo = JSON.parse(getCompanyResult.text); + return companyInfo.id; +} + +test.before(async () => { + const moduleFixture = await Test.createTestingModule({ + imports: [ApplicationModule, DatabaseModule], + providers: [DatabaseService, TestUtils], + }).compile(); + app = moduleFixture.createNestApplication(); + testUtils = moduleFixture.get(TestUtils); + + app.use(cookieParser()); + app.useGlobalFilters(new AllExceptionsFilter(app.get(WinstonLogger))); + app.useGlobalPipes( + new ValidationPipe({ + exceptionFactory(validationErrors: ValidationError[] = []) { + return new ValidationException(validationErrors); + }, + }), + ); + await app.init(); + app.getHttpServer().listen(0); +}); + +test.after(async () => { + try { + await Cacher.clearAllCache(); + await app.close(); + } catch (e) { + console.error('After tests error ' + e); + } +}); + +currentTest = 'GET /sign-in-audit/logs'; + +test.serial(`${currentTest} should return sign-in audit logs after successful login`, async (t) => { + try { + const adminUserRegisterInfo = await registerUserAndReturnUserInfo(app); + const { token, email, password } = adminUserRegisterInfo; + + // Get company ID by token + const companyId = await getCompanyIdByToken(app, token); + + // Login again to create a sign-in audit record + const loginUserResult = await request(app.getHttpServer()) + .post('/user/login/') + .send({ email, password }) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(loginUserResult.status, 201); + + // Get sign-in audit logs + const getAuditLogsResult = await request(app.getHttpServer()) + .get(`/sign-in-audit/logs?companyId=${companyId}`) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getAuditLogsResult.status, 200); + const auditLogsRO = JSON.parse(getAuditLogsResult.text); + + t.is(auditLogsRO.hasOwnProperty('logs'), true); + t.is(auditLogsRO.hasOwnProperty('pagination'), true); + t.true(auditLogsRO.logs.length >= 1); + + // Check that the log entry has expected structure + const logEntry = auditLogsRO.logs.find((log: any) => log.email === email.toLowerCase()); + t.truthy(logEntry); + t.is(logEntry.status, SignInStatusEnum.SUCCESS); + t.is(logEntry.signInMethod, SignInMethodEnum.EMAIL); + t.is(logEntry.hasOwnProperty('createdAt'), true); + t.is(logEntry.hasOwnProperty('ipAddress'), true); + } catch (err) { + console.error(err); + throw err; + } +}); + +test.serial(`${currentTest} should return sign-in audit logs with failed login attempt`, async (t) => { + try { + const adminUserRegisterInfo = await registerUserAndReturnUserInfo(app); + const { token, email, password } = adminUserRegisterInfo; + + // Get company ID by token + const companyId = await getCompanyIdByToken(app, token); + + // Attempt login with wrong password to create a failed sign-in audit record + const wrongPassword = 'wrong_password_12345'; + const failedLoginResult = await request(app.getHttpServer()) + .post('/user/login/') + .send({ email, password: wrongPassword }) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(failedLoginResult.status, 400); + + // Get sign-in audit logs + const getAuditLogsResult = await request(app.getHttpServer()) + .get(`/sign-in-audit/logs?companyId=${companyId}`) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getAuditLogsResult.status, 200); + const auditLogsRO = JSON.parse(getAuditLogsResult.text); + + t.is(auditLogsRO.hasOwnProperty('logs'), true); + t.true(auditLogsRO.logs.length >= 1); + + // Find the failed login entry + const failedLogEntry = auditLogsRO.logs.find( + (log: any) => log.email === email.toLowerCase() && log.status === SignInStatusEnum.FAILED, + ); + t.truthy(failedLogEntry); + t.is(failedLogEntry.signInMethod, SignInMethodEnum.EMAIL); + t.truthy(failedLogEntry.failureReason); + } catch (err) { + console.error(err); + throw err; + } +}); + +test.serial(`${currentTest} should filter sign-in audit logs by status`, async (t) => { + try { + const adminUserRegisterInfo = await registerUserAndReturnUserInfo(app); + const { token, email, password } = adminUserRegisterInfo; + + // Get company ID by token + const companyId = await getCompanyIdByToken(app, token); + + // Create a failed login attempt + const wrongPassword = 'wrong_password_12345'; + await request(app.getHttpServer()) + .post('/user/login/') + .send({ email, password: wrongPassword }) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + // Create a successful login + await request(app.getHttpServer()) + .post('/user/login/') + .send({ email, password }) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + // Get only failed sign-in audit logs + const getFailedLogsResult = await request(app.getHttpServer()) + .get(`/sign-in-audit/logs?companyId=${companyId}&status=${SignInStatusEnum.FAILED}`) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getFailedLogsResult.status, 200); + const failedLogsRO = JSON.parse(getFailedLogsResult.text); + + t.is(failedLogsRO.hasOwnProperty('logs'), true); + // All returned logs should have failed status + failedLogsRO.logs.forEach((log: any) => { + t.is(log.status, SignInStatusEnum.FAILED); + }); + + // Get only successful sign-in audit logs + const getSuccessLogsResult = await request(app.getHttpServer()) + .get(`/sign-in-audit/logs?companyId=${companyId}&status=${SignInStatusEnum.SUCCESS}`) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getSuccessLogsResult.status, 200); + const successLogsRO = JSON.parse(getSuccessLogsResult.text); + + t.is(successLogsRO.hasOwnProperty('logs'), true); + // All returned logs should have success status + successLogsRO.logs.forEach((log: any) => { + t.is(log.status, SignInStatusEnum.SUCCESS); + }); + } catch (err) { + console.error(err); + throw err; + } +}); + +test.serial(`${currentTest} should filter sign-in audit logs by email`, async (t) => { + try { + const adminUserRegisterInfo = await registerUserAndReturnUserInfo(app); + const { token, email, password } = adminUserRegisterInfo; + + // Get company ID by token + const companyId = await getCompanyIdByToken(app, token); + + // Create a successful login + await request(app.getHttpServer()) + .post('/user/login/') + .send({ email, password }) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + // Get sign-in audit logs filtered by email + const getAuditLogsResult = await request(app.getHttpServer()) + .get(`/sign-in-audit/logs?companyId=${companyId}&email=${email.toLowerCase()}`) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getAuditLogsResult.status, 200); + const auditLogsRO = JSON.parse(getAuditLogsResult.text); + + t.is(auditLogsRO.hasOwnProperty('logs'), true); + // All returned logs should have the searched email + auditLogsRO.logs.forEach((log: any) => { + t.is(log.email, email.toLowerCase()); + }); + } catch (err) { + console.error(err); + throw err; + } +}); + +test.serial(`${currentTest} should filter sign-in audit logs by sign-in method`, async (t) => { + try { + const adminUserRegisterInfo = await registerUserAndReturnUserInfo(app); + const { token, email, password } = adminUserRegisterInfo; + + // Get company ID by token + const companyId = await getCompanyIdByToken(app, token); + + // Create a successful login + await request(app.getHttpServer()) + .post('/user/login/') + .send({ email, password }) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + // Get sign-in audit logs filtered by email sign-in method + const getAuditLogsResult = await request(app.getHttpServer()) + .get(`/sign-in-audit/logs?companyId=${companyId}&signInMethod=${SignInMethodEnum.EMAIL}`) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getAuditLogsResult.status, 200); + const auditLogsRO = JSON.parse(getAuditLogsResult.text); + + t.is(auditLogsRO.hasOwnProperty('logs'), true); + // All returned logs should have email sign-in method + auditLogsRO.logs.forEach((log: any) => { + t.is(log.signInMethod, SignInMethodEnum.EMAIL); + }); + } catch (err) { + console.error(err); + throw err; + } +}); + +test.serial(`${currentTest} should support pagination for sign-in audit logs`, async (t) => { + try { + const adminUserRegisterInfo = await registerUserAndReturnUserInfo(app); + const { token, email, password } = adminUserRegisterInfo; + + // Get company ID by token + const companyId = await getCompanyIdByToken(app, token); + + // Create multiple login attempts + for (let i = 0; i < 5; i++) { + await request(app.getHttpServer()) + .post('/user/login/') + .send({ email, password }) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + } + + // Get sign-in audit logs with pagination + const getAuditLogsResult = await request(app.getHttpServer()) + .get(`/sign-in-audit/logs?companyId=${companyId}&page=1&perPage=2`) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getAuditLogsResult.status, 200); + const auditLogsRO = JSON.parse(getAuditLogsResult.text); + + t.is(auditLogsRO.hasOwnProperty('logs'), true); + t.is(auditLogsRO.hasOwnProperty('pagination'), true); + t.true(auditLogsRO.logs.length <= 2); + t.is(auditLogsRO.pagination.hasOwnProperty('total'), true); + t.is(auditLogsRO.pagination.hasOwnProperty('lastPage'), true); + t.is(auditLogsRO.pagination.hasOwnProperty('perPage'), true); + t.is(auditLogsRO.pagination.hasOwnProperty('currentPage'), true); + } catch (err) { + console.error(err); + throw err; + } +}); + +test.serial(`${currentTest} should return 400 for invalid status filter`, async (t) => { + try { + const adminUserRegisterInfo = await registerUserAndReturnUserInfo(app); + const { token, email } = adminUserRegisterInfo; + + // Get company ID by token + const companyId = await getCompanyIdByToken(app, token); + + // Get sign-in audit logs with invalid status + const getAuditLogsResult = await request(app.getHttpServer()) + .get(`/sign-in-audit/logs?companyId=${companyId}&status=invalid_status`) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getAuditLogsResult.status, 400); + const errorRO = JSON.parse(getAuditLogsResult.text); + t.is(errorRO.message, Messages.INVALID_SIGN_IN_STATUS); + } catch (err) { + console.error(err); + throw err; + } +}); + +test.serial(`${currentTest} should return 400 for invalid sign-in method filter`, async (t) => { + try { + const adminUserRegisterInfo = await registerUserAndReturnUserInfo(app); + const { token, email } = adminUserRegisterInfo; + + // Get company ID by token + const companyId = await getCompanyIdByToken(app, token); + + // Get sign-in audit logs with invalid sign-in method + const getAuditLogsResult = await request(app.getHttpServer()) + .get(`/sign-in-audit/logs?companyId=${companyId}&signInMethod=invalid_method`) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getAuditLogsResult.status, 400); + const errorRO = JSON.parse(getAuditLogsResult.text); + t.is(errorRO.message, Messages.INVALID_SIGN_IN_METHOD); + } catch (err) { + console.error(err); + throw err; + } +}); + +test.serial(`${currentTest} should filter sign-in audit logs by date range`, async (t) => { + try { + const adminUserRegisterInfo = await registerUserAndReturnUserInfo(app); + const { token, email, password } = adminUserRegisterInfo; + + // Get company ID by token + const companyId = await getCompanyIdByToken(app, token); + + // Create a successful login + await request(app.getHttpServer()) + .post('/user/login/') + .send({ email, password }) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const today = new Date(); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + const dateFrom = yesterday.toISOString(); + const dateTo = tomorrow.toISOString(); + + // Get sign-in audit logs filtered by date range + const getAuditLogsResult = await request(app.getHttpServer()) + .get(`/sign-in-audit/logs?companyId=${companyId}&dateFrom=${dateFrom}&dateTo=${dateTo}`) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getAuditLogsResult.status, 200); + const auditLogsRO = JSON.parse(getAuditLogsResult.text); + + t.is(auditLogsRO.hasOwnProperty('logs'), true); + t.true(auditLogsRO.logs.length >= 1); + } catch (err) { + console.error(err); + throw err; + } +}); + +test.serial(`${currentTest} should not return logs from another company`, async (t) => { + try { + // Register first user + const firstUserInfo = await registerUserAndReturnUserInfo(app); + const { token: firstToken, email: firstEmail, password: firstPassword } = firstUserInfo; + + // Get first user's company ID + const firstCompanyId = await getCompanyIdByToken(app, firstToken); + + // Register second user (different company) + const secondUserInfo = await registerUserAndReturnUserInfo(app); + const { token: secondToken, email: secondEmail, password: secondPassword } = secondUserInfo; + + // Get second user's company ID + const secondCompanyId = await getCompanyIdByToken(app, secondToken); + + // Create login for first user + await request(app.getHttpServer()) + .post('/user/login/') + .send({ email: firstEmail, password: firstPassword }) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + // Create login for second user + await request(app.getHttpServer()) + .post('/user/login/') + .send({ email: secondEmail, password: secondPassword }) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + // Get sign-in audit logs for first company + const getFirstCompanyLogsResult = await request(app.getHttpServer()) + .get(`/sign-in-audit/logs?companyId=${firstCompanyId}`) + .set('Cookie', firstToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getFirstCompanyLogsResult.status, 200); + const firstCompanyLogsRO = JSON.parse(getFirstCompanyLogsResult.text); + + // First company logs should not contain second user's email + const secondUserLogInFirstCompany = firstCompanyLogsRO.logs.find( + (log: any) => log.email === secondEmail.toLowerCase(), + ); + t.falsy(secondUserLogInFirstCompany); + + // Get sign-in audit logs for second company + const getSecondCompanyLogsResult = await request(app.getHttpServer()) + .get(`/sign-in-audit/logs?companyId=${secondCompanyId}`) + .set('Cookie', secondToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getSecondCompanyLogsResult.status, 200); + const secondCompanyLogsRO = JSON.parse(getSecondCompanyLogsResult.text); + + // Second company logs should not contain first user's email + const firstUserLogInSecondCompany = secondCompanyLogsRO.logs.find( + (log: any) => log.email === firstEmail.toLowerCase(), + ); + t.falsy(firstUserLogInSecondCompany); + } catch (err) { + console.error(err); + throw err; + } +}); + +test.serial( + `${currentTest} should not show audit for login attempt with non-existent user in company logs`, + async (t) => { + try { + const adminUserRegisterInfo = await registerUserAndReturnUserInfo(app); + const { token, email } = adminUserRegisterInfo; + + // Get company ID by token + const companyId = await getCompanyIdByToken(app, token); + + // Attempt login with non-existent email + const nonExistentEmail = `nonexistent_${faker.string.uuid()}@example.com`; + const failedLoginResult = await request(app.getHttpServer()) + .post('/user/login/') + .send({ email: nonExistentEmail, password: 'any_password', companyId }) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(failedLoginResult.status, 404); + + // Get sign-in audit logs + const getAuditLogsResult = await request(app.getHttpServer()) + .get(`/sign-in-audit/logs?companyId=${companyId}`) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getAuditLogsResult.status, 200); + const auditLogsRO = JSON.parse(getAuditLogsResult.text); + + // Non-existent user login attempts should NOT be visible in company logs + // because the user doesn't belong to the company + const failedLogEntry = auditLogsRO.logs.find((log: any) => log.email === nonExistentEmail.toLowerCase()); + t.falsy(failedLogEntry); + } catch (err) { + console.error(err); + throw err; + } + }, +); From 514c9dc48d6252b77e09fed3e772c9d21c6b2361 Mon Sep 17 00:00:00 2001 From: Artem Niehrieiev Date: Thu, 27 Nov 2025 14:05:50 +0000 Subject: [PATCH 5/5] feat: add comprehensive end-to-end tests for sign-in audit logs, including filtering by status, email, sign-in method, and date range --- .../non-saas-user-sign-in-audit.e2e.test.ts | 459 ++++++++++++++++++ 1 file changed, 459 insertions(+) create mode 100644 backend/test/ava-tests/non-saas-tests/non-saas-user-sign-in-audit.e2e.test.ts diff --git a/backend/test/ava-tests/non-saas-tests/non-saas-user-sign-in-audit.e2e.test.ts b/backend/test/ava-tests/non-saas-tests/non-saas-user-sign-in-audit.e2e.test.ts new file mode 100644 index 000000000..41da4e5fb --- /dev/null +++ b/backend/test/ava-tests/non-saas-tests/non-saas-user-sign-in-audit.e2e.test.ts @@ -0,0 +1,459 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { faker } from '@faker-js/faker'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import test from 'ava'; +import { ValidationError } from 'class-validator'; +import cookieParser from 'cookie-parser'; +import request from 'supertest'; +import { ApplicationModule } from '../../../src/app.module.js'; +import { AllExceptionsFilter } from '../../../src/exceptions/all-exceptions.filter.js'; +import { ValidationException } from '../../../src/exceptions/custom-exceptions/validation-exception.js'; +import { Messages } from '../../../src/exceptions/text/messages.js'; +import { DatabaseModule } from '../../../src/shared/database/database.module.js'; +import { DatabaseService } from '../../../src/shared/database/database.service.js'; +import { registerUserAndReturnUserInfo } from '../../utils/register-user-and-return-user-info.js'; +import { TestUtils } from '../../utils/test.utils.js'; +import { Cacher } from '../../../src/helpers/cache/cacher.js'; +import { MockFactory } from '../../mock.factory.js'; +import { WinstonLogger } from '../../../src/entities/logging/winston-logger.js'; +import { SignInStatusEnum } from '../../../src/entities/user-sign-in-audit/enums/sign-in-status.enum.js'; +import { SignInMethodEnum } from '../../../src/entities/user-sign-in-audit/enums/sign-in-method.enum.js'; +import { setSaasEnvVariable } from '../../utils/set-saas-env-variable.js'; + +let app: INestApplication; +let currentTest: string; +let testUtils: TestUtils; + +const mockFactory = new MockFactory(); + +async function getCompanyIdByToken(app: INestApplication, token: string): Promise { + const getCompanyResult = await request(app.getHttpServer()) + .get('/company/my/full') + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const companyInfo = JSON.parse(getCompanyResult.text); + return companyInfo.id; +} + +test.before(async () => { + setSaasEnvVariable(); + const moduleFixture = await Test.createTestingModule({ + imports: [ApplicationModule, DatabaseModule], + providers: [DatabaseService, TestUtils], + }).compile(); + app = moduleFixture.createNestApplication(); + testUtils = moduleFixture.get(TestUtils); + + app.use(cookieParser()); + app.useGlobalFilters(new AllExceptionsFilter(app.get(WinstonLogger))); + app.useGlobalPipes( + new ValidationPipe({ + exceptionFactory(validationErrors: ValidationError[] = []) { + return new ValidationException(validationErrors); + }, + }), + ); + await app.init(); + app.getHttpServer().listen(0); +}); + +test.after(async () => { + try { + await Cacher.clearAllCache(); + await app.close(); + } catch (e) { + console.error('After tests error ' + e); + } +}); + +currentTest = 'GET /sign-in-audit/logs'; + +test.serial(`${currentTest} should return sign-in audit logs after successful login`, async (t) => { + try { + const adminUserRegisterInfo = await registerUserAndReturnUserInfo(app); + const { token, email, password } = adminUserRegisterInfo; + + // Get company ID by token + const companyId = await getCompanyIdByToken(app, token); + + // Login again to create a sign-in audit record + const loginUserResult = await request(app.getHttpServer()) + .post('/user/login/') + .send({ email, password }) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(loginUserResult.status, 201); + + // Get sign-in audit logs + const getAuditLogsResult = await request(app.getHttpServer()) + .get(`/sign-in-audit/logs?companyId=${companyId}`) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getAuditLogsResult.status, 200); + const auditLogsRO = JSON.parse(getAuditLogsResult.text); + + t.is(auditLogsRO.hasOwnProperty('logs'), true); + t.is(auditLogsRO.hasOwnProperty('pagination'), true); + t.true(auditLogsRO.logs.length >= 1); + + // Check that the log entry has expected structure + const logEntry = auditLogsRO.logs.find((log: any) => log.email === email.toLowerCase()); + t.truthy(logEntry); + t.is(logEntry.status, SignInStatusEnum.SUCCESS); + t.is(logEntry.signInMethod, SignInMethodEnum.EMAIL); + t.is(logEntry.hasOwnProperty('createdAt'), true); + t.is(logEntry.hasOwnProperty('ipAddress'), true); + } catch (err) { + console.error(err); + throw err; + } +}); + +test.serial(`${currentTest} should return sign-in audit logs with failed login attempt`, async (t) => { + try { + const adminUserRegisterInfo = await registerUserAndReturnUserInfo(app); + const { token, email, password } = adminUserRegisterInfo; + + // Get company ID by token + const companyId = await getCompanyIdByToken(app, token); + + // Attempt login with wrong password to create a failed sign-in audit record + const wrongPassword = 'wrong_password_12345'; + const failedLoginResult = await request(app.getHttpServer()) + .post('/user/login/') + .send({ email, password: wrongPassword }) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(failedLoginResult.status, 400); + + // Get sign-in audit logs + const getAuditLogsResult = await request(app.getHttpServer()) + .get(`/sign-in-audit/logs?companyId=${companyId}`) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getAuditLogsResult.status, 200); + const auditLogsRO = JSON.parse(getAuditLogsResult.text); + + t.is(auditLogsRO.hasOwnProperty('logs'), true); + t.true(auditLogsRO.logs.length >= 1); + + // Find the failed login entry + const failedLogEntry = auditLogsRO.logs.find( + (log: any) => log.email === email.toLowerCase() && log.status === SignInStatusEnum.FAILED, + ); + t.truthy(failedLogEntry); + t.is(failedLogEntry.signInMethod, SignInMethodEnum.EMAIL); + t.truthy(failedLogEntry.failureReason); + } catch (err) { + console.error(err); + throw err; + } +}); + +test.serial(`${currentTest} should filter sign-in audit logs by status`, async (t) => { + try { + const adminUserRegisterInfo = await registerUserAndReturnUserInfo(app); + const { token, email, password } = adminUserRegisterInfo; + + // Get company ID by token + const companyId = await getCompanyIdByToken(app, token); + + // Create a failed login attempt + const wrongPassword = 'wrong_password_12345'; + await request(app.getHttpServer()) + .post('/user/login/') + .send({ email, password: wrongPassword }) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + // Create a successful login + await request(app.getHttpServer()) + .post('/user/login/') + .send({ email, password }) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + // Get only failed sign-in audit logs + const getFailedLogsResult = await request(app.getHttpServer()) + .get(`/sign-in-audit/logs?companyId=${companyId}&status=${SignInStatusEnum.FAILED}`) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getFailedLogsResult.status, 200); + const failedLogsRO = JSON.parse(getFailedLogsResult.text); + + t.is(failedLogsRO.hasOwnProperty('logs'), true); + // All returned logs should have failed status + failedLogsRO.logs.forEach((log: any) => { + t.is(log.status, SignInStatusEnum.FAILED); + }); + + // Get only successful sign-in audit logs + const getSuccessLogsResult = await request(app.getHttpServer()) + .get(`/sign-in-audit/logs?companyId=${companyId}&status=${SignInStatusEnum.SUCCESS}`) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getSuccessLogsResult.status, 200); + const successLogsRO = JSON.parse(getSuccessLogsResult.text); + + t.is(successLogsRO.hasOwnProperty('logs'), true); + // All returned logs should have success status + successLogsRO.logs.forEach((log: any) => { + t.is(log.status, SignInStatusEnum.SUCCESS); + }); + } catch (err) { + console.error(err); + throw err; + } +}); + +test.serial(`${currentTest} should filter sign-in audit logs by email`, async (t) => { + try { + const adminUserRegisterInfo = await registerUserAndReturnUserInfo(app); + const { token, email, password } = adminUserRegisterInfo; + + // Get company ID by token + const companyId = await getCompanyIdByToken(app, token); + + // Create a successful login + await request(app.getHttpServer()) + .post('/user/login/') + .send({ email, password }) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + // Get sign-in audit logs filtered by email + const getAuditLogsResult = await request(app.getHttpServer()) + .get(`/sign-in-audit/logs?companyId=${companyId}&email=${email.toLowerCase()}`) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getAuditLogsResult.status, 200); + const auditLogsRO = JSON.parse(getAuditLogsResult.text); + + t.is(auditLogsRO.hasOwnProperty('logs'), true); + // All returned logs should have the searched email + auditLogsRO.logs.forEach((log: any) => { + t.is(log.email, email.toLowerCase()); + }); + } catch (err) { + console.error(err); + throw err; + } +}); + +test.serial(`${currentTest} should filter sign-in audit logs by sign-in method`, async (t) => { + try { + const adminUserRegisterInfo = await registerUserAndReturnUserInfo(app); + const { token, email, password } = adminUserRegisterInfo; + + // Get company ID by token + const companyId = await getCompanyIdByToken(app, token); + + // Create a successful login + await request(app.getHttpServer()) + .post('/user/login/') + .send({ email, password }) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + // Get sign-in audit logs filtered by email sign-in method + const getAuditLogsResult = await request(app.getHttpServer()) + .get(`/sign-in-audit/logs?companyId=${companyId}&signInMethod=${SignInMethodEnum.EMAIL}`) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getAuditLogsResult.status, 200); + const auditLogsRO = JSON.parse(getAuditLogsResult.text); + + t.is(auditLogsRO.hasOwnProperty('logs'), true); + // All returned logs should have email sign-in method + auditLogsRO.logs.forEach((log: any) => { + t.is(log.signInMethod, SignInMethodEnum.EMAIL); + }); + } catch (err) { + console.error(err); + throw err; + } +}); + +test.serial(`${currentTest} should support pagination for sign-in audit logs`, async (t) => { + try { + const adminUserRegisterInfo = await registerUserAndReturnUserInfo(app); + const { token, email, password } = adminUserRegisterInfo; + + // Get company ID by token + const companyId = await getCompanyIdByToken(app, token); + + // Create multiple login attempts + for (let i = 0; i < 5; i++) { + await request(app.getHttpServer()) + .post('/user/login/') + .send({ email, password }) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + } + + // Get sign-in audit logs with pagination + const getAuditLogsResult = await request(app.getHttpServer()) + .get(`/sign-in-audit/logs?companyId=${companyId}&page=1&perPage=2`) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getAuditLogsResult.status, 200); + const auditLogsRO = JSON.parse(getAuditLogsResult.text); + + t.is(auditLogsRO.hasOwnProperty('logs'), true); + t.is(auditLogsRO.hasOwnProperty('pagination'), true); + t.true(auditLogsRO.logs.length <= 2); + t.is(auditLogsRO.pagination.hasOwnProperty('total'), true); + t.is(auditLogsRO.pagination.hasOwnProperty('lastPage'), true); + t.is(auditLogsRO.pagination.hasOwnProperty('perPage'), true); + t.is(auditLogsRO.pagination.hasOwnProperty('currentPage'), true); + } catch (err) { + console.error(err); + throw err; + } +}); + +test.serial(`${currentTest} should return 400 for invalid status filter`, async (t) => { + try { + const adminUserRegisterInfo = await registerUserAndReturnUserInfo(app); + const { token, email } = adminUserRegisterInfo; + + // Get company ID by token + const companyId = await getCompanyIdByToken(app, token); + + // Get sign-in audit logs with invalid status + const getAuditLogsResult = await request(app.getHttpServer()) + .get(`/sign-in-audit/logs?companyId=${companyId}&status=invalid_status`) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getAuditLogsResult.status, 400); + const errorRO = JSON.parse(getAuditLogsResult.text); + t.is(errorRO.message, Messages.INVALID_SIGN_IN_STATUS); + } catch (err) { + console.error(err); + throw err; + } +}); + +test.serial(`${currentTest} should return 400 for invalid sign-in method filter`, async (t) => { + try { + const adminUserRegisterInfo = await registerUserAndReturnUserInfo(app); + const { token, email } = adminUserRegisterInfo; + + // Get company ID by token + const companyId = await getCompanyIdByToken(app, token); + + // Get sign-in audit logs with invalid sign-in method + const getAuditLogsResult = await request(app.getHttpServer()) + .get(`/sign-in-audit/logs?companyId=${companyId}&signInMethod=invalid_method`) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getAuditLogsResult.status, 400); + const errorRO = JSON.parse(getAuditLogsResult.text); + t.is(errorRO.message, Messages.INVALID_SIGN_IN_METHOD); + } catch (err) { + console.error(err); + throw err; + } +}); + +test.serial(`${currentTest} should filter sign-in audit logs by date range`, async (t) => { + try { + const adminUserRegisterInfo = await registerUserAndReturnUserInfo(app); + const { token, email, password } = adminUserRegisterInfo; + + // Get company ID by token + const companyId = await getCompanyIdByToken(app, token); + + // Create a successful login + await request(app.getHttpServer()) + .post('/user/login/') + .send({ email, password }) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const today = new Date(); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + const dateFrom = yesterday.toISOString(); + const dateTo = tomorrow.toISOString(); + + // Get sign-in audit logs filtered by date range + const getAuditLogsResult = await request(app.getHttpServer()) + .get(`/sign-in-audit/logs?companyId=${companyId}&dateFrom=${dateFrom}&dateTo=${dateTo}`) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getAuditLogsResult.status, 200); + const auditLogsRO = JSON.parse(getAuditLogsResult.text); + + t.is(auditLogsRO.hasOwnProperty('logs'), true); + t.true(auditLogsRO.logs.length >= 1); + } catch (err) { + console.error(err); + throw err; + } +}); + +test.serial( + `${currentTest} should not show audit for login attempt with non-existent user in company logs`, + async (t) => { + try { + const adminUserRegisterInfo = await registerUserAndReturnUserInfo(app); + const { token, email } = adminUserRegisterInfo; + + // Get company ID by token + const companyId = await getCompanyIdByToken(app, token); + + // Attempt login with non-existent email + const nonExistentEmail = `nonexistent_${faker.string.uuid()}@example.com`; + const failedLoginResult = await request(app.getHttpServer()) + .post('/user/login/') + .send({ email: nonExistentEmail, password: 'any_password', companyId }) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(failedLoginResult.status, 404); + + // Get sign-in audit logs + const getAuditLogsResult = await request(app.getHttpServer()) + .get(`/sign-in-audit/logs?companyId=${companyId}`) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getAuditLogsResult.status, 200); + const auditLogsRO = JSON.parse(getAuditLogsResult.text); + + // Non-existent user login attempts should NOT be visible in company logs + // because the user doesn't belong to the company + const failedLogEntry = auditLogsRO.logs.find((log: any) => log.email === nonExistentEmail.toLowerCase()); + t.falsy(failedLogEntry); + } catch (err) { + console.error(err); + throw err; + } + }, +);