Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/src/common/data-injection.tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
24 changes: 24 additions & 0 deletions backend/src/microservices/saas-microservice/saas.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
ISaasGetUsersInfosByEmail,
ISaasRegisterUser,
ISaasSAMLRegisterUser,
ISaasUsualLoginUser,
ISuspendUsers,
ISuspendUsersOverLimit,
IUpdateHostedConnectionPassword,
Expand All @@ -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)
Expand Down Expand Up @@ -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,
})
Comment on lines +176 to +181
@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<FoundUserDto> {
return await this.usualLoginUserUseCase.execute(
{ email, password, companyId, request_domain, ipAddress, userAgent, gclidValue: null },
InTransactionEnum.OFF,
);
Comment on lines +183 to +194

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Validate the login body before invoking the use case.

The handler passes raw body fields into the use case; a missing email reaches userData.email.toLowerCase() and can turn a bad request into a 500. Use a DTO with runtime validators or explicit checks for required email, password, and request_domain.

Proposed direction
-	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<FoundUserDto> {
+	async usualUserLogin(`@Body`() loginData: SaasUsualLoginDto): Promise<FoundUserDto> {
 		return await this.usualLoginUserUseCase.execute(
-			{ email, password, companyId, request_domain, ipAddress, userAgent, gclidValue: null },
+			{ ...loginData, gclidValue: null },
 			InTransactionEnum.OFF,
 		);
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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<FoundUserDto> {
return await this.usualLoginUserUseCase.execute(
{ email, password, companyId, request_domain, ipAddress, userAgent, gclidValue: null },
InTransactionEnum.OFF,
);
async usualUserLogin(`@Body`() loginData: SaasUsualLoginDto): Promise<FoundUserDto> {
return await this.usualLoginUserUseCase.execute(
{ ...loginData, gclidValue: null },
InTransactionEnum.OFF,
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/microservices/saas-microservice/saas.controller.ts` around lines
183 - 194, Validate the request body in usualUserLogin before calling
usualLoginUserUseCase.execute by introducing a DTO or explicit guards for
required fields like email, password, and request_domain. Update the controller
method to accept the validated object instead of raw `@Body` fields, and ensure
missing/invalid inputs are rejected early so userData.email.toLowerCase() is
never reached with undefined.

}

@ApiOperation({ summary: 'Register demo user register webhook' })
@ApiBody({ type: SaasUsualUserRegisterDS })
@ApiResponse({
Expand Down
6 changes: 6 additions & 0 deletions backend/src/microservices/saas-microservice/saas.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -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 },
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -40,6 +41,10 @@ export interface ISaasRegisterUser {
execute(userData: SaasUsualUserRegisterDS): Promise<FoundUserDto>;
}

export interface ISaasUsualLoginUser {
execute(userData: UsualLoginDs, inTransaction?: InTransactionEnum): Promise<FoundUserDto>;
}

export interface ISaasDemoRegisterUser {
execute(userData: SaaSRegisterDemoUserAccountDS): Promise<FoundUserDto>;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<UsualLoginDs, FoundUserDto> 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<FoundUserDto> {
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<void> {
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<void> {
if (!isSaaS()) {
return;
}

const allowedDomains: Array<string> = [...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);
}
}
Loading
Loading