diff --git a/backend/src/common/data-injection.tokens.ts b/backend/src/common/data-injection.tokens.ts index a37a440ae..c8ce4fc3e 100644 --- a/backend/src/common/data-injection.tokens.ts +++ b/backend/src/common/data-injection.tokens.ts @@ -112,6 +112,7 @@ export enum UseCaseType { SAAS_COMPANY_REGISTRATION = 'SAAS_COMPANY_REGISTRATION', SAAS_GET_USER_INFO = 'SAAS_GET_USER_INFO', SAAS_USUAL_REGISTER_USER = 'SAAS_USUAL_REGISTER_USER', + SAAS_USUAL_LOGIN_USER = 'SAAS_USUAL_LOGIN_USER', SAAS_DEMO_USER_REGISTRATION = 'SAAS_DEMO_USER_REGISTRATION', SAAS_LOGIN_USER_WITH_GOOGLE = 'SAAS_LOGIN_USER_WITH_GOOGLE', SAAS_LOGIN_USER_WITH_GITHUB = 'SAAS_LOGIN_USER_WITH_GITHUB', diff --git a/backend/src/microservices/saas-microservice/saas.controller.ts b/backend/src/microservices/saas-microservice/saas.controller.ts index fdea7c1ae..56ce2eaaf 100644 --- a/backend/src/microservices/saas-microservice/saas.controller.ts +++ b/backend/src/microservices/saas-microservice/saas.controller.ts @@ -54,6 +54,7 @@ import { ISaasGetUsersInfosByEmail, ISaasRegisterUser, ISaasSAMLRegisterUser, + ISaasUsualLoginUser, ISuspendUsers, ISuspendUsersOverLimit, IUpdateHostedConnectionPassword, @@ -76,6 +77,8 @@ export class SaasController { private readonly getUsersInfosByEmailUseCase: ISaasGetUsersInfosByEmail, @Inject(UseCaseType.SAAS_USUAL_REGISTER_USER) private readonly usualRegisterUserUseCase: ISaasRegisterUser, + @Inject(UseCaseType.SAAS_USUAL_LOGIN_USER) + private readonly usualLoginUserUseCase: ISaasUsualLoginUser, @Inject(UseCaseType.SAAS_DEMO_USER_REGISTRATION) private readonly demoRegisterUserUseCase: ISaasDemoRegisterUser, @Inject(UseCaseType.SAAS_LOGIN_USER_WITH_GOOGLE) @@ -170,6 +173,27 @@ export class SaasController { return await this.usualRegisterUserUseCase.execute({ email, password, gclidValue, name, companyId, companyName }); } + @ApiOperation({ summary: 'User login webhook' }) + @ApiResponse({ + status: 200, + description: 'Credentials verified; user info returned (no token is signed here — the caller signs the cookie).', + type: FoundUserDto, + }) + @Post('user/login') + async usualUserLogin( + @Body('email') email: string, + @Body('password') password: string, + @Body('companyId') companyId: string, + @Body('request_domain') request_domain: string, + @Body('ipAddress') ipAddress: string, + @Body('userAgent') userAgent: string, + ): Promise { + return await this.usualLoginUserUseCase.execute( + { email, password, companyId, request_domain, ipAddress, userAgent, gclidValue: null }, + InTransactionEnum.OFF, + ); + } + @ApiOperation({ summary: 'Register demo user register webhook' }) @ApiBody({ type: SaasUsualUserRegisterDS }) @ApiResponse({ diff --git a/backend/src/microservices/saas-microservice/saas.module.ts b/backend/src/microservices/saas-microservice/saas.module.ts index c14a12a7c..37b57dfc4 100644 --- a/backend/src/microservices/saas-microservice/saas.module.ts +++ b/backend/src/microservices/saas-microservice/saas.module.ts @@ -21,6 +21,7 @@ import { LoginWithGoogleUseCase } from './use-cases/login-with-google.use.case.j import { RegisteredCompanyWebhookUseCase } from './use-cases/register-company-webhook.use.case.js'; 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 { SaasUsualLoginUseCase } from './use-cases/saas-usual-login.use.case.js'; import { SaasUsualRegisterUseCase } from './use-cases/saas-usual-register-user.use.case.js'; import { SuspendUsersUseCase } from './use-cases/suspend-users.use.case.js'; import { SuspendUsersOverLimitUseCase } from './use-cases/suspend-users-over-limit.use.case.js'; @@ -46,6 +47,10 @@ import { UpdateHostedConnectionPasswordUseCase } from './use-cases/update-hosted provide: UseCaseType.SAAS_USUAL_REGISTER_USER, useClass: SaasUsualRegisterUseCase, }, + { + provide: UseCaseType.SAAS_USUAL_LOGIN_USER, + useClass: SaasUsualLoginUseCase, + }, { provide: UseCaseType.SAAS_LOGIN_USER_WITH_GOOGLE, useClass: LoginWithGoogleUseCase, @@ -124,6 +129,7 @@ export class SaasModule { { path: 'saas/user/:userId', method: RequestMethod.GET }, { path: 'saas/users/email/:userEmail', method: RequestMethod.GET }, { path: 'saas/user/register', method: RequestMethod.POST }, + { path: 'saas/user/login', method: RequestMethod.POST }, { path: 'saas/user/demo/register', method: RequestMethod.POST }, { path: 'saas/user/google/login', method: RequestMethod.POST }, { path: 'saas/user/github/login', method: RequestMethod.POST }, diff --git a/backend/src/microservices/saas-microservice/use-cases/saas-use-cases.interface.ts b/backend/src/microservices/saas-microservice/use-cases/saas-use-cases.interface.ts index 907593f73..8c150f57d 100644 --- a/backend/src/microservices/saas-microservice/use-cases/saas-use-cases.interface.ts +++ b/backend/src/microservices/saas-microservice/use-cases/saas-use-cases.interface.ts @@ -1,6 +1,7 @@ import { CompanyInfoEntity } from '../../../entities/company-info/company-info.entity.js'; import { CreatedConnectionDTO } from '../../../entities/connection/application/dto/created-connection.dto.js'; import { SaaSRegisterDemoUserAccountDS } from '../../../entities/user/application/data-structures/demo-user-account-register.ds.js'; +import { UsualLoginDs } from '../../../entities/user/application/data-structures/usual-login.ds.js'; import { SaasUsualUserRegisterDS } from '../../../entities/user/application/data-structures/usual-register-user.ds.js'; import { FoundUserDto } from '../../../entities/user/dto/found-user.dto.js'; import { UserEntity } from '../../../entities/user/user.entity.js'; @@ -40,6 +41,10 @@ export interface ISaasRegisterUser { execute(userData: SaasUsualUserRegisterDS): Promise; } +export interface ISaasUsualLoginUser { + execute(userData: UsualLoginDs, inTransaction?: InTransactionEnum): Promise; +} + export interface ISaasDemoRegisterUser { execute(userData: SaaSRegisterDemoUserAccountDS): Promise; } diff --git a/backend/src/microservices/saas-microservice/use-cases/saas-usual-login.use.case.ts b/backend/src/microservices/saas-microservice/use-cases/saas-usual-login.use.case.ts new file mode 100644 index 000000000..ea7f27854 --- /dev/null +++ b/backend/src/microservices/saas-microservice/use-cases/saas-usual-login.use.case.ts @@ -0,0 +1,207 @@ +import { BadRequestException, Inject, Injectable, NotFoundException } 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 { UsualLoginDs } from '../../../entities/user/application/data-structures/usual-login.ds.js'; +import { FoundUserDto } from '../../../entities/user/dto/found-user.dto.js'; +import { UserEntity } from '../../../entities/user/user.entity.js'; +import { SignInMethodEnum } from '../../../entities/user-sign-in-audit/enums/sign-in-method.enum.js'; +import { SignInStatusEnum } from '../../../entities/user-sign-in-audit/enums/sign-in-status.enum.js'; +import { SignInAuditService } from '../../../entities/user-sign-in-audit/sign-in-audit.service.js'; +import { Messages } from '../../../exceptions/text/messages.js'; +import { isSaaS } from '../../../helpers/app/is-saas.js'; +import { isTest } from '../../../helpers/app/is-test.js'; +import { Constants } from '../../../helpers/constants/constants.js'; +import { Encryptor } from '../../../helpers/encryption/encryptor.js'; +import { ValidationHelper } from '../../../helpers/validators/validation-helper.js'; +import { SaasCompanyGatewayService } from '../../gateways/saas-gateway.ts/saas-company-gateway.service.js'; +import { ISaasUsualLoginUser } from './saas-use-cases.interface.js'; + +/** + * Verifies an email/password login for the SaaS control plane and returns the matched user. + * + * This duplicates the lookup + password-verification + sign-in-audit logic of the open-source + * `UsualLoginUseCase` (`entities/user/use-cases/usual-login-use.case.ts`) **on purpose** — the + * self-hosted `POST /user/login` path is intentionally left untouched. The difference here is that + * this use case is a microservice bridge: it never signs a JWT or sets a cookie. It returns the + * user info (including `is_2fa_enabled`) so `rocketadmin-saas` can sign the end-user cookie itself, + * exactly as it already does for registration and OAuth login. + */ +@Injectable() +export class SaasUsualLoginUseCase extends AbstractUseCase implements ISaasUsualLoginUser { + constructor( + @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, ipAddress, userAgent } = userData; + let { companyId } = userData; + const email = userData.email.toLowerCase(); + let user: UserEntity | null = null; + + 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()) { + const foundUserCompanyIdByDomain = + await this.saasCompanyGatewayService.getCompanyIdByCustomDomain(request_domain); + const foundUser = await this._dbContext.userRepository.findOneUserByEmailAndCompanyId( + email, + 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; + companyId = foundUser.company.id; + } 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; + } + + 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); + } + + await this.validateRequestDomain(request_domain, companyId); + + 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); + } + + // Mirror UsualLoginUseCase: a SUCCESS audit is recorded only once login is actually complete. + // For OTP-enabled users the password step is just the first leg — the success is recorded by the + // subsequent OTP-login step — so we don't record it here. + if (!user.isOTPEnabled) { + await this.recordSignInAudit(email, user.id, SignInStatusEnum.SUCCESS, ipAddress, userAgent); + } + + return this.buildFoundUserDto(user); + } + + private buildFoundUserDto(user: UserEntity): FoundUserDto { + return { + id: user.id, + createdAt: user.createdAt, + isActive: user.isActive, + email: user.email, + intercom_hash: undefined, + name: user.name, + role: user.role, + is_2fa_enabled: user.isOTPEnabled, + suspended: user.suspended, + externalRegistrationProvider: user.externalRegistrationProvider, + show_test_connections: user.showTestConnections, + }; + } + + 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; + } + + const allowedDomains: Array = [...Constants.PRIMARY_SAAS_DOMAINS, Constants.APP_DOMAIN_ADDRESS]; + + if (isTest()) { + allowedDomains.push(`127.0.0.1`); + if (allowedDomains.includes(requestDomain)) { + return; + } + } + + if (allowedDomains.includes(requestDomain)) { + return; + } + + if (!ValidationHelper.isValidDomain(requestDomain) && !isTest()) { + throw new BadRequestException(Messages.INVALID_REQUEST_DOMAIN_FORMAT); + } + + const companyIdByDomain: string | null = + await this.saasCompanyGatewayService.getCompanyIdByCustomDomain(requestDomain); + + if (companyIdByDomain && companyIdByDomain === companyId) { + return; + } + throw new BadRequestException(Messages.INVALID_REQUEST_DOMAIN); + } +} diff --git a/backend/test/ava-tests/saas-tests/saas-user-login-e2e.test.ts b/backend/test/ava-tests/saas-tests/saas-user-login-e2e.test.ts new file mode 100644 index 000000000..df0020001 --- /dev/null +++ b/backend/test/ava-tests/saas-tests/saas-user-login-e2e.test.ts @@ -0,0 +1,183 @@ +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 jwt from 'jsonwebtoken'; +import request from 'supertest'; +import { DataSource } from 'typeorm'; +import { ApplicationModule } from '../../../src/app.module.js'; +import { BaseType } from '../../../src/common/data-injection.tokens.js'; +import { CompanyInfoEntity } from '../../../src/entities/company-info/company-info.entity.js'; +import { WinstonLogger } from '../../../src/entities/logging/winston-logger.js'; +import { UserRoleEnum } from '../../../src/entities/user/enums/user-role.enum.js'; +import { UserEntity } from '../../../src/entities/user/user.entity.js'; +import { AllExceptionsFilter } from '../../../src/exceptions/all-exceptions.filter.js'; +import { ValidationException } from '../../../src/exceptions/custom-exceptions/validation-exception.js'; +import { Cacher } from '../../../src/helpers/cache/cacher.js'; +import { appConfig } from '../../../src/shared/config/app-config.js'; +import { DatabaseModule } from '../../../src/shared/database/database.module.js'; +import { DatabaseService } from '../../../src/shared/database/database.service.js'; +import { TestUtils } from '../../utils/test.utils.js'; + +// Tests for the SaaS login bridge POST /saas/user/login (SaaSAuthMiddleware / microservice JWT). +// The bridge verifies email + password and returns the user info (FoundUserDto) WITHOUT signing a +// token — rocketadmin-saas signs the cookie. The open-source POST /user/login path is unaffected. + +let app: INestApplication; +let currentTest: string; +let _testUtils: TestUtils; + +// A microservice JWT identical in shape to the one rocketadmin-saas signs (payload { request_id }). +function microserviceAuthHeader(): string { + const token = jwt.sign({ request_id: faker.string.uuid() }, appConfig.auth.microserviceJwtSecret); + return `Bearer ${token}`; +} + +async function createCoreUser(opts: { isOTPEnabled?: boolean } = {}): Promise<{ + email: string; + password: string; + companyId: string; + userId: string; +}> { + const dataSource = app.get(BaseType.DATA_SOURCE); + const userRepository = dataSource.getRepository(UserEntity); + const companyRepository = dataSource.getRepository(CompanyInfoEntity); + + const email = `${faker.lorem.word()}_${faker.string.alphanumeric(6)}_${faker.internet.email()}`.toLowerCase(); + const password = `#r@dY^e&7R4b5Ib@31iE4xbn`; + + const company = await companyRepository.save( + companyRepository.create({ id: faker.string.uuid(), name: faker.company.name() }), + ); + // password is hashed by UserEntity's @BeforeInsert hook (Encryptor.hashUserPassword). + const user = await userRepository.save( + userRepository.create({ + email, + password, + isActive: true, + company, + role: UserRoleEnum.ADMIN, + isOTPEnabled: !!opts.isOTPEnabled, + }), + ); + + return { email, password, companyId: company.id, userId: user.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 = 'POST /saas/user/login'; + +test.serial(`${currentTest} verifies credentials and returns the user (no token signed here)`, async (t) => { + const { email, password, companyId, userId } = await createCoreUser(); + + const result = await request(app.getHttpServer()) + .post('/saas/user/login') + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json') + .send({ email, password, companyId, request_domain: '127.0.0.1' }); + + t.is(result.status, 201); + const ro = JSON.parse(result.text); + t.is(ro.id, userId); + t.is(ro.email, email); + t.is(ro.is_2fa_enabled, false); + t.is(ro.suspended, false); + // The bridge must NOT sign a token / set a cookie — that is rocketadmin-saas's job. + t.is(Object.hasOwn(ro, 'token'), false); + t.is(Object.hasOwn(ro, 'expires'), false); + t.is(result.headers['set-cookie'], undefined); + t.pass(); +}); + +test.serial(`${currentTest} surfaces is_2fa_enabled so the caller can issue a temporary token`, async (t) => { + const { email, password, companyId } = await createCoreUser({ isOTPEnabled: true }); + + const result = await request(app.getHttpServer()) + .post('/saas/user/login') + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json') + .send({ email, password, companyId, request_domain: '127.0.0.1' }); + + t.is(result.status, 201); + const ro = JSON.parse(result.text); + t.is(ro.email, email); + t.is(ro.is_2fa_enabled, true); + t.pass(); +}); + +test.serial(`${currentTest} rejects a wrong password`, async (t) => { + const { email, companyId } = await createCoreUser(); + + const result = await request(app.getHttpServer()) + .post('/saas/user/login') + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json') + .send({ email, password: 'definitely-the-wrong-password', companyId, request_domain: '127.0.0.1' }); + + t.is(result.status, 400); + t.is(result.headers['set-cookie'], undefined); + t.pass(); +}); + +test.serial(`${currentTest} rejects an unknown user`, async (t) => { + const result = await request(app.getHttpServer()) + .post('/saas/user/login') + .set('Authorization', microserviceAuthHeader()) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json') + .send({ + email: `nobody_${faker.string.alphanumeric(8)}@example.com`, + password: 'whatever-password', + request_domain: '127.0.0.1', + }); + + t.is(result.status, 404); + t.pass(); +}); + +test.serial(`${currentTest} rejects a request without a microservice JWT`, async (t) => { + const { email, password, companyId } = await createCoreUser(); + + const result = await request(app.getHttpServer()) + .post('/saas/user/login') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json') + .send({ email, password, companyId, request_domain: '127.0.0.1' }); + + t.is(result.status, 401); + t.pass(); +});