Skip to content

Commit ad48b86

Browse files
authored
Merge pull request #1851 from rocket-admin/backend-frontend_agent
implement usual user login functionality with validation and auditing
2 parents caea992 + 583247b commit ad48b86

6 files changed

Lines changed: 426 additions & 0 deletions

File tree

backend/src/common/data-injection.tokens.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ export enum UseCaseType {
112112
SAAS_COMPANY_REGISTRATION = 'SAAS_COMPANY_REGISTRATION',
113113
SAAS_GET_USER_INFO = 'SAAS_GET_USER_INFO',
114114
SAAS_USUAL_REGISTER_USER = 'SAAS_USUAL_REGISTER_USER',
115+
SAAS_USUAL_LOGIN_USER = 'SAAS_USUAL_LOGIN_USER',
115116
SAAS_DEMO_USER_REGISTRATION = 'SAAS_DEMO_USER_REGISTRATION',
116117
SAAS_LOGIN_USER_WITH_GOOGLE = 'SAAS_LOGIN_USER_WITH_GOOGLE',
117118
SAAS_LOGIN_USER_WITH_GITHUB = 'SAAS_LOGIN_USER_WITH_GITHUB',

backend/src/microservices/saas-microservice/saas.controller.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import {
5454
ISaasGetUsersInfosByEmail,
5555
ISaasRegisterUser,
5656
ISaasSAMLRegisterUser,
57+
ISaasUsualLoginUser,
5758
ISuspendUsers,
5859
ISuspendUsersOverLimit,
5960
IUpdateHostedConnectionPassword,
@@ -76,6 +77,8 @@ export class SaasController {
7677
private readonly getUsersInfosByEmailUseCase: ISaasGetUsersInfosByEmail,
7778
@Inject(UseCaseType.SAAS_USUAL_REGISTER_USER)
7879
private readonly usualRegisterUserUseCase: ISaasRegisterUser,
80+
@Inject(UseCaseType.SAAS_USUAL_LOGIN_USER)
81+
private readonly usualLoginUserUseCase: ISaasUsualLoginUser,
7982
@Inject(UseCaseType.SAAS_DEMO_USER_REGISTRATION)
8083
private readonly demoRegisterUserUseCase: ISaasDemoRegisterUser,
8184
@Inject(UseCaseType.SAAS_LOGIN_USER_WITH_GOOGLE)
@@ -170,6 +173,27 @@ export class SaasController {
170173
return await this.usualRegisterUserUseCase.execute({ email, password, gclidValue, name, companyId, companyName });
171174
}
172175

176+
@ApiOperation({ summary: 'User login webhook' })
177+
@ApiResponse({
178+
status: 200,
179+
description: 'Credentials verified; user info returned (no token is signed here — the caller signs the cookie).',
180+
type: FoundUserDto,
181+
})
182+
@Post('user/login')
183+
async usualUserLogin(
184+
@Body('email') email: string,
185+
@Body('password') password: string,
186+
@Body('companyId') companyId: string,
187+
@Body('request_domain') request_domain: string,
188+
@Body('ipAddress') ipAddress: string,
189+
@Body('userAgent') userAgent: string,
190+
): Promise<FoundUserDto> {
191+
return await this.usualLoginUserUseCase.execute(
192+
{ email, password, companyId, request_domain, ipAddress, userAgent, gclidValue: null },
193+
InTransactionEnum.OFF,
194+
);
195+
}
196+
173197
@ApiOperation({ summary: 'Register demo user register webhook' })
174198
@ApiBody({ type: SaasUsualUserRegisterDS })
175199
@ApiResponse({

backend/src/microservices/saas-microservice/saas.module.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { LoginWithGoogleUseCase } from './use-cases/login-with-google.use.case.j
2121
import { RegisteredCompanyWebhookUseCase } from './use-cases/register-company-webhook.use.case.js';
2222
import { SaasRegisterDemoUserAccountUseCase } from './use-cases/register-demo-user-account.use.case.js';
2323
import { SaaSRegisterUserWIthSamlUseCase } from './use-cases/register-user-with-saml-use.case.js';
24+
import { SaasUsualLoginUseCase } from './use-cases/saas-usual-login.use.case.js';
2425
import { SaasUsualRegisterUseCase } from './use-cases/saas-usual-register-user.use.case.js';
2526
import { SuspendUsersUseCase } from './use-cases/suspend-users.use.case.js';
2627
import { SuspendUsersOverLimitUseCase } from './use-cases/suspend-users-over-limit.use.case.js';
@@ -46,6 +47,10 @@ import { UpdateHostedConnectionPasswordUseCase } from './use-cases/update-hosted
4647
provide: UseCaseType.SAAS_USUAL_REGISTER_USER,
4748
useClass: SaasUsualRegisterUseCase,
4849
},
50+
{
51+
provide: UseCaseType.SAAS_USUAL_LOGIN_USER,
52+
useClass: SaasUsualLoginUseCase,
53+
},
4954
{
5055
provide: UseCaseType.SAAS_LOGIN_USER_WITH_GOOGLE,
5156
useClass: LoginWithGoogleUseCase,
@@ -124,6 +129,7 @@ export class SaasModule {
124129
{ path: 'saas/user/:userId', method: RequestMethod.GET },
125130
{ path: 'saas/users/email/:userEmail', method: RequestMethod.GET },
126131
{ path: 'saas/user/register', method: RequestMethod.POST },
132+
{ path: 'saas/user/login', method: RequestMethod.POST },
127133
{ path: 'saas/user/demo/register', method: RequestMethod.POST },
128134
{ path: 'saas/user/google/login', method: RequestMethod.POST },
129135
{ path: 'saas/user/github/login', method: RequestMethod.POST },

backend/src/microservices/saas-microservice/use-cases/saas-use-cases.interface.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { CompanyInfoEntity } from '../../../entities/company-info/company-info.entity.js';
22
import { CreatedConnectionDTO } from '../../../entities/connection/application/dto/created-connection.dto.js';
33
import { SaaSRegisterDemoUserAccountDS } from '../../../entities/user/application/data-structures/demo-user-account-register.ds.js';
4+
import { UsualLoginDs } from '../../../entities/user/application/data-structures/usual-login.ds.js';
45
import { SaasUsualUserRegisterDS } from '../../../entities/user/application/data-structures/usual-register-user.ds.js';
56
import { FoundUserDto } from '../../../entities/user/dto/found-user.dto.js';
67
import { UserEntity } from '../../../entities/user/user.entity.js';
@@ -40,6 +41,10 @@ export interface ISaasRegisterUser {
4041
execute(userData: SaasUsualUserRegisterDS): Promise<FoundUserDto>;
4142
}
4243

44+
export interface ISaasUsualLoginUser {
45+
execute(userData: UsualLoginDs, inTransaction?: InTransactionEnum): Promise<FoundUserDto>;
46+
}
47+
4348
export interface ISaasDemoRegisterUser {
4449
execute(userData: SaaSRegisterDemoUserAccountDS): Promise<FoundUserDto>;
4550
}
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
2+
import AbstractUseCase from '../../../common/abstract-use.case.js';
3+
import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js';
4+
import { BaseType } from '../../../common/data-injection.tokens.js';
5+
import { UsualLoginDs } from '../../../entities/user/application/data-structures/usual-login.ds.js';
6+
import { FoundUserDto } from '../../../entities/user/dto/found-user.dto.js';
7+
import { UserEntity } from '../../../entities/user/user.entity.js';
8+
import { SignInMethodEnum } from '../../../entities/user-sign-in-audit/enums/sign-in-method.enum.js';
9+
import { SignInStatusEnum } from '../../../entities/user-sign-in-audit/enums/sign-in-status.enum.js';
10+
import { SignInAuditService } from '../../../entities/user-sign-in-audit/sign-in-audit.service.js';
11+
import { Messages } from '../../../exceptions/text/messages.js';
12+
import { isSaaS } from '../../../helpers/app/is-saas.js';
13+
import { isTest } from '../../../helpers/app/is-test.js';
14+
import { Constants } from '../../../helpers/constants/constants.js';
15+
import { Encryptor } from '../../../helpers/encryption/encryptor.js';
16+
import { ValidationHelper } from '../../../helpers/validators/validation-helper.js';
17+
import { SaasCompanyGatewayService } from '../../gateways/saas-gateway.ts/saas-company-gateway.service.js';
18+
import { ISaasUsualLoginUser } from './saas-use-cases.interface.js';
19+
20+
/**
21+
* Verifies an email/password login for the SaaS control plane and returns the matched user.
22+
*
23+
* This duplicates the lookup + password-verification + sign-in-audit logic of the open-source
24+
* `UsualLoginUseCase` (`entities/user/use-cases/usual-login-use.case.ts`) **on purpose** — the
25+
* self-hosted `POST /user/login` path is intentionally left untouched. The difference here is that
26+
* this use case is a microservice bridge: it never signs a JWT or sets a cookie. It returns the
27+
* user info (including `is_2fa_enabled`) so `rocketadmin-saas` can sign the end-user cookie itself,
28+
* exactly as it already does for registration and OAuth login.
29+
*/
30+
@Injectable()
31+
export class SaasUsualLoginUseCase extends AbstractUseCase<UsualLoginDs, FoundUserDto> implements ISaasUsualLoginUser {
32+
constructor(
33+
@Inject(BaseType.GLOBAL_DB_CONTEXT)
34+
protected _dbContext: IGlobalDatabaseContext,
35+
private readonly saasCompanyGatewayService: SaasCompanyGatewayService,
36+
private readonly signInAuditService: SignInAuditService,
37+
) {
38+
super();
39+
}
40+
41+
protected async implementation(userData: UsualLoginDs): Promise<FoundUserDto> {
42+
const { request_domain, ipAddress, userAgent } = userData;
43+
let { companyId } = userData;
44+
const email = userData.email.toLowerCase();
45+
let user: UserEntity | null = null;
46+
47+
if (companyId) {
48+
user = await this._dbContext.userRepository.findOneUserByEmailAndCompanyId(email, companyId);
49+
if (!user) {
50+
await this.recordSignInAudit(
51+
email,
52+
null,
53+
SignInStatusEnum.FAILED,
54+
ipAddress,
55+
userAgent,
56+
Messages.USER_NOT_FOUND,
57+
);
58+
throw new NotFoundException(Messages.USER_NOT_FOUND);
59+
}
60+
} else if (!Constants.APP_REQUEST_DOMAINS().includes(request_domain) && isSaaS()) {
61+
const foundUserCompanyIdByDomain =
62+
await this.saasCompanyGatewayService.getCompanyIdByCustomDomain(request_domain);
63+
const foundUser = await this._dbContext.userRepository.findOneUserByEmailAndCompanyId(
64+
email,
65+
foundUserCompanyIdByDomain,
66+
);
67+
if (!foundUser) {
68+
await this.recordSignInAudit(
69+
email,
70+
null,
71+
SignInStatusEnum.FAILED,
72+
ipAddress,
73+
userAgent,
74+
Messages.USER_NOT_FOUND_FOR_THIS_DOMAIN,
75+
);
76+
throw new BadRequestException(Messages.USER_NOT_FOUND_FOR_THIS_DOMAIN);
77+
}
78+
user = foundUser;
79+
companyId = foundUser.company.id;
80+
} else {
81+
const foundUsers = await this._dbContext.userRepository.findAllUsersWithEmail(email);
82+
if (foundUsers.length > 1) {
83+
await this.recordSignInAudit(
84+
email,
85+
null,
86+
SignInStatusEnum.FAILED,
87+
ipAddress,
88+
userAgent,
89+
Messages.LOGIN_DENIED_SHOULD_CHOOSE_COMPANY,
90+
);
91+
throw new BadRequestException(Messages.LOGIN_DENIED_SHOULD_CHOOSE_COMPANY);
92+
}
93+
user = foundUsers[0];
94+
companyId = foundUsers[0]?.company?.id;
95+
}
96+
97+
if (!user) {
98+
await this.recordSignInAudit(email, null, SignInStatusEnum.FAILED, ipAddress, userAgent, Messages.USER_NOT_FOUND);
99+
throw new NotFoundException(Messages.USER_NOT_FOUND);
100+
}
101+
if (!userData.password) {
102+
await this.recordSignInAudit(
103+
email,
104+
user.id,
105+
SignInStatusEnum.FAILED,
106+
ipAddress,
107+
userAgent,
108+
Messages.PASSWORD_MISSING,
109+
);
110+
throw new BadRequestException(Messages.PASSWORD_MISSING);
111+
}
112+
113+
await this.validateRequestDomain(request_domain, companyId);
114+
115+
const passwordValidationResult = await Encryptor.verifyUserPassword(userData.password, user.password);
116+
if (!passwordValidationResult) {
117+
await this.recordSignInAudit(
118+
email,
119+
user.id,
120+
SignInStatusEnum.FAILED,
121+
ipAddress,
122+
userAgent,
123+
Messages.LOGIN_DENIED,
124+
);
125+
throw new BadRequestException(Messages.LOGIN_DENIED);
126+
}
127+
128+
// Mirror UsualLoginUseCase: a SUCCESS audit is recorded only once login is actually complete.
129+
// For OTP-enabled users the password step is just the first leg — the success is recorded by the
130+
// subsequent OTP-login step — so we don't record it here.
131+
if (!user.isOTPEnabled) {
132+
await this.recordSignInAudit(email, user.id, SignInStatusEnum.SUCCESS, ipAddress, userAgent);
133+
}
134+
135+
return this.buildFoundUserDto(user);
136+
}
137+
138+
private buildFoundUserDto(user: UserEntity): FoundUserDto {
139+
return {
140+
id: user.id,
141+
createdAt: user.createdAt,
142+
isActive: user.isActive,
143+
email: user.email,
144+
intercom_hash: undefined,
145+
name: user.name,
146+
role: user.role,
147+
is_2fa_enabled: user.isOTPEnabled,
148+
suspended: user.suspended,
149+
externalRegistrationProvider: user.externalRegistrationProvider,
150+
show_test_connections: user.showTestConnections,
151+
};
152+
}
153+
154+
private async recordSignInAudit(
155+
email: string,
156+
userId: string | null,
157+
status: SignInStatusEnum,
158+
ipAddress?: string,
159+
userAgent?: string,
160+
failureReason?: string,
161+
): Promise<void> {
162+
try {
163+
await this.signInAuditService.createSignInAuditRecord({
164+
email,
165+
userId,
166+
status,
167+
signInMethod: SignInMethodEnum.EMAIL,
168+
ipAddress,
169+
userAgent,
170+
failureReason,
171+
});
172+
} catch (e) {
173+
console.error('Failed to record sign-in audit:', e);
174+
}
175+
}
176+
177+
private async validateRequestDomain(requestDomain: string, companyId: string): Promise<void> {
178+
if (!isSaaS()) {
179+
return;
180+
}
181+
182+
const allowedDomains: Array<string> = [...Constants.PRIMARY_SAAS_DOMAINS, Constants.APP_DOMAIN_ADDRESS];
183+
184+
if (isTest()) {
185+
allowedDomains.push(`127.0.0.1`);
186+
if (allowedDomains.includes(requestDomain)) {
187+
return;
188+
}
189+
}
190+
191+
if (allowedDomains.includes(requestDomain)) {
192+
return;
193+
}
194+
195+
if (!ValidationHelper.isValidDomain(requestDomain) && !isTest()) {
196+
throw new BadRequestException(Messages.INVALID_REQUEST_DOMAIN_FORMAT);
197+
}
198+
199+
const companyIdByDomain: string | null =
200+
await this.saasCompanyGatewayService.getCompanyIdByCustomDomain(requestDomain);
201+
202+
if (companyIdByDomain && companyIdByDomain === companyId) {
203+
return;
204+
}
205+
throw new BadRequestException(Messages.INVALID_REQUEST_DOMAIN);
206+
}
207+
}

0 commit comments

Comments
 (0)