fix: Server-level impersonation doesn't bypass 2FA when enabled (#14340)
## Description - This PR solves the a sub-issue from https://github.com/twentyhq/core-team-issues/issues/1421 - impersonation tokens bypasses 2FA as intended - Added Audit trails to cover all impersonation events - Added Proper testing coverage --------- Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
+19
-4
@@ -4,6 +4,7 @@ import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import axios from 'axios';
|
||||
|
||||
import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-panel.service';
|
||||
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
|
||||
import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
@@ -77,6 +78,14 @@ describe('AdminPanelService', () => {
|
||||
TwentyConfigServiceGetVariableWithMetadataMock,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: AuditService,
|
||||
useValue: {
|
||||
createContext: jest.fn().mockReturnValue({
|
||||
insertWorkspaceEvent: jest.fn(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -108,7 +117,11 @@ describe('AdminPanelService', () => {
|
||||
expiresAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await service.impersonate('user-id', 'workspace-id');
|
||||
const result = await service.impersonate(
|
||||
'user-id',
|
||||
'workspace-id',
|
||||
'user-id',
|
||||
);
|
||||
|
||||
expect(UserFindOneMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -126,6 +139,8 @@ describe('AdminPanelService', () => {
|
||||
expect(LoginTokenServiceGenerateLoginTokenMock).toHaveBeenCalledWith(
|
||||
'user@example.com',
|
||||
'workspace-id',
|
||||
'impersonation',
|
||||
{ impersonatorUserId: 'user-id' },
|
||||
);
|
||||
|
||||
expect(result).toEqual(
|
||||
@@ -149,11 +164,11 @@ describe('AdminPanelService', () => {
|
||||
UserFindOneMock.mockReturnValueOnce(null);
|
||||
|
||||
await expect(
|
||||
service.impersonate('invalid-user-id', 'workspace-id'),
|
||||
service.impersonate('invalid-user-id', 'workspace-id', 'user-id'),
|
||||
).rejects.toThrow(
|
||||
new AuthException(
|
||||
'User not found or impersonation not enable on workspace',
|
||||
AuthExceptionCode.INVALID_INPUT,
|
||||
'User not found in workspace or impersonation not enabled',
|
||||
AuthExceptionCode.USER_WORKSPACE_NOT_FOUND,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -5,11 +5,13 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AdminPanelHealthService } from 'src/engine/core-modules/admin-panel/admin-panel-health.service';
|
||||
import { AdminPanelResolver } from 'src/engine/core-modules/admin-panel/admin-panel.resolver';
|
||||
import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-panel.service';
|
||||
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
|
||||
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
|
||||
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { HealthModule } from 'src/engine/core-modules/health/health.module';
|
||||
import { RedisClientModule } from 'src/engine/core-modules/redis-client/redis-client.module';
|
||||
import { TelemetryModule } from 'src/engine/core-modules/telemetry/telemetry.module';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
|
||||
@Module({
|
||||
@@ -21,6 +23,8 @@ import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
RedisClientModule,
|
||||
TerminusModule,
|
||||
FeatureFlagModule,
|
||||
AuditModule,
|
||||
TelemetryModule,
|
||||
],
|
||||
providers: [AdminPanelResolver, AdminPanelService, AdminPanelHealthService],
|
||||
exports: [AdminPanelService],
|
||||
|
||||
@@ -15,6 +15,10 @@ import { UserLookup } from 'src/engine/core-modules/admin-panel/dtos/user-lookup
|
||||
import { UserLookupInput } from 'src/engine/core-modules/admin-panel/dtos/user-lookup.input';
|
||||
import { VersionInfo } from 'src/engine/core-modules/admin-panel/dtos/version-info.dto';
|
||||
import { QueueMetricsTimeRange } from 'src/engine/core-modules/admin-panel/enums/queue-metrics-time-range.enum';
|
||||
import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
|
||||
import { FeatureFlagException } from 'src/engine/core-modules/feature-flag/feature-flag.exception';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
@@ -26,6 +30,8 @@ import { type MessageQueue } from 'src/engine/core-modules/message-queue/message
|
||||
import { type ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables';
|
||||
import { ConfigVariableGraphqlApiExceptionFilter } from 'src/engine/core-modules/twenty-config/filters/config-variable-graphql-api-exception.filter';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
|
||||
import { AdminPanelGuard } from 'src/engine/guards/admin-panel-guard';
|
||||
import { ImpersonateGuard } from 'src/engine/guards/impersonate-guard';
|
||||
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
|
||||
@@ -53,8 +59,20 @@ export class AdminPanelResolver {
|
||||
@Mutation(() => ImpersonateOutput)
|
||||
async impersonate(
|
||||
@Args() { workspaceId, userId }: ImpersonateInput,
|
||||
@AuthUser() adminUser: User,
|
||||
): Promise<ImpersonateOutput> {
|
||||
return await this.adminService.impersonate(userId, workspaceId);
|
||||
if (!adminUser.id) {
|
||||
throw new AuthException(
|
||||
'Admin user not found',
|
||||
AuthExceptionCode.UNAUTHENTICATED,
|
||||
);
|
||||
}
|
||||
|
||||
return await this.adminService.impersonate(
|
||||
userId,
|
||||
workspaceId,
|
||||
adminUser.id,
|
||||
);
|
||||
}
|
||||
|
||||
@UseGuards(WorkspaceAuthGuard, UserAuthGuard, ImpersonateGuard)
|
||||
|
||||
@@ -11,6 +11,8 @@ import { type ConfigVariablesGroupData } from 'src/engine/core-modules/admin-pan
|
||||
import { type ConfigVariablesOutput } from 'src/engine/core-modules/admin-panel/dtos/config-variables.output';
|
||||
import { type UserLookup } from 'src/engine/core-modules/admin-panel/dtos/user-lookup.entity';
|
||||
import { type VersionInfo } from 'src/engine/core-modules/admin-panel/dtos/version-info.dto';
|
||||
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
|
||||
import { MONITORING_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/monitoring/monitoring';
|
||||
import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
@@ -25,6 +27,7 @@ import { type ConfigVariablesGroup } from 'src/engine/core-modules/twenty-config
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { userValidator } from 'src/engine/core-modules/user/user.validate';
|
||||
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
|
||||
@Injectable()
|
||||
export class AdminPanelService {
|
||||
@@ -32,11 +35,16 @@ export class AdminPanelService {
|
||||
private readonly loginTokenService: LoginTokenService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
private readonly auditService: AuditService,
|
||||
@InjectRepository(User)
|
||||
private readonly userRepository: Repository<User>,
|
||||
) {}
|
||||
|
||||
async impersonate(userId: string, workspaceId: string) {
|
||||
async impersonate(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
impersonatorUserId: string,
|
||||
) {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: {
|
||||
id: userId,
|
||||
@@ -53,25 +61,60 @@ export class AdminPanelService {
|
||||
userValidator.assertIsDefinedOrThrow(
|
||||
user,
|
||||
new AuthException(
|
||||
'User not found or impersonation not enable on workspace',
|
||||
AuthExceptionCode.INVALID_INPUT,
|
||||
'User not found in workspace or impersonation not enabled',
|
||||
AuthExceptionCode.USER_WORKSPACE_NOT_FOUND,
|
||||
),
|
||||
);
|
||||
|
||||
const loginToken = await this.loginTokenService.generateLoginToken(
|
||||
user.email,
|
||||
user.userWorkspaces[0].workspace.id,
|
||||
);
|
||||
const auditService = this.auditService.createContext({
|
||||
workspaceId: user.userWorkspaces[0].workspace.id,
|
||||
userId: impersonatorUserId,
|
||||
});
|
||||
|
||||
return {
|
||||
workspace: {
|
||||
id: user.userWorkspaces[0].workspace.id,
|
||||
workspaceUrls: this.domainManagerService.getWorkspaceUrls(
|
||||
user.userWorkspaces[0].workspace,
|
||||
),
|
||||
},
|
||||
loginToken,
|
||||
};
|
||||
await auditService.insertWorkspaceEvent(MONITORING_EVENT, {
|
||||
eventName: 'server.impersonation.attempt',
|
||||
message: `Impersonation attempt: targetUserId=${userId}, workspaceId=${workspaceId}, impersonatorUserId=${impersonatorUserId}`,
|
||||
});
|
||||
|
||||
try {
|
||||
await auditService.insertWorkspaceEvent(MONITORING_EVENT, {
|
||||
eventName: 'server.impersonation.login_token_attempt',
|
||||
message: `Impersonation token generation attempt for user ${userId}`,
|
||||
});
|
||||
|
||||
const loginToken = await this.loginTokenService.generateLoginToken(
|
||||
user.email,
|
||||
user.userWorkspaces[0].workspace.id,
|
||||
AuthProviderEnum.Impersonation,
|
||||
{
|
||||
impersonatorUserId,
|
||||
},
|
||||
);
|
||||
|
||||
await auditService.insertWorkspaceEvent(MONITORING_EVENT, {
|
||||
eventName: 'server.impersonation.login_token_generated',
|
||||
message: `Impersonation token generated successfully for user ${userId}`,
|
||||
});
|
||||
|
||||
return {
|
||||
workspace: {
|
||||
id: user.userWorkspaces[0].workspace.id,
|
||||
workspaceUrls: this.domainManagerService.getWorkspaceUrls(
|
||||
user.userWorkspaces[0].workspace,
|
||||
),
|
||||
},
|
||||
loginToken,
|
||||
};
|
||||
} catch {
|
||||
await auditService.insertWorkspaceEvent(MONITORING_EVENT, {
|
||||
eventName: 'server.impersonation.login_token_failed',
|
||||
message: `Impersonation token generation failed for targetUserId=${userId}`,
|
||||
});
|
||||
throw new AuthException(
|
||||
'Impersonation failed',
|
||||
AuthExceptionCode.INVALID_DATA,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async userLookup(userIdentifier: string): Promise<UserLookup> {
|
||||
|
||||
@@ -23,23 +23,23 @@ export class MyService {
|
||||
|
||||
async doSomething() {
|
||||
// Create an analytics context
|
||||
const analytics = this.auditService.createContext({
|
||||
const auditService = this.auditService.createContext({
|
||||
workspaceId: 'workspace-id',
|
||||
userId: 'user-id',
|
||||
});
|
||||
|
||||
// Track a workspace event
|
||||
analytics.insertWorkspaceEvent(CUSTOM_DOMAIN_ACTIVATED_EVENT, {});
|
||||
auditService.insertWorkspaceEvent(CUSTOM_DOMAIN_ACTIVATED_EVENT, {});
|
||||
|
||||
// Track an object event
|
||||
analytics.createObjectEvent(OBJECT_RECORD_CREATED_EVENT, {
|
||||
auditService.createObjectEvent(OBJECT_RECORD_CREATED_EVENT, {
|
||||
recordId: 'record-id',
|
||||
objectMetadataId: 'object-metadata-id',
|
||||
// other properties
|
||||
});
|
||||
|
||||
// Track a pageview
|
||||
analytics.createPageviewEvent('page-name', {
|
||||
auditService.createPageviewEvent('page-name', {
|
||||
href: '/path',
|
||||
locale: 'en-US',
|
||||
// other properties
|
||||
|
||||
+4
-4
@@ -26,26 +26,26 @@ export class CreateAuditLogFromInternalEvent {
|
||||
}
|
||||
: eventData.properties;
|
||||
|
||||
const analytics = this.auditService.createContext({
|
||||
const auditService = this.auditService.createContext({
|
||||
workspaceId: workspaceEventBatch.workspaceId,
|
||||
userId: eventData.userId,
|
||||
});
|
||||
|
||||
// Since these are object record events, we use createObjectEvent
|
||||
if (workspaceEventBatch.name.endsWith('.updated')) {
|
||||
analytics.createObjectEvent(OBJECT_RECORD_UPDATED_EVENT, {
|
||||
auditService.createObjectEvent(OBJECT_RECORD_UPDATED_EVENT, {
|
||||
...eventProperties,
|
||||
recordId: eventData.recordId,
|
||||
objectMetadataId: eventData.objectMetadata.id,
|
||||
});
|
||||
} else if (workspaceEventBatch.name.endsWith('.created')) {
|
||||
analytics.createObjectEvent(OBJECT_RECORD_CREATED_EVENT, {
|
||||
auditService.createObjectEvent(OBJECT_RECORD_CREATED_EVENT, {
|
||||
...eventProperties,
|
||||
recordId: eventData.recordId,
|
||||
objectMetadataId: eventData.objectMetadata.id,
|
||||
});
|
||||
} else if (workspaceEventBatch.name.endsWith('.deleted')) {
|
||||
analytics.createObjectEvent(OBJECT_RECORD_DELETED_EVENT, {
|
||||
auditService.createObjectEvent(OBJECT_RECORD_DELETED_EVENT, {
|
||||
...eventProperties,
|
||||
recordId: eventData.recordId,
|
||||
objectMetadataId: eventData.objectMetadata.id,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
|
||||
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { AppTokenService } from 'src/engine/core-modules/app-token/services/app-token.service';
|
||||
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
|
||||
import { GoogleAPIsAuthController } from 'src/engine/core-modules/auth/controllers/google-apis-auth.controller';
|
||||
import { GoogleAuthController } from 'src/engine/core-modules/auth/controllers/google-auth.controller';
|
||||
import { MicrosoftAPIsAuthController } from 'src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller';
|
||||
@@ -107,6 +108,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
|
||||
UserRoleModule,
|
||||
TwoFactorAuthenticationModule,
|
||||
ApiKeyModule,
|
||||
AuditModule,
|
||||
],
|
||||
controllers: [
|
||||
GoogleAuthController,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { ApiKeyService } from 'src/engine/core-modules/api-key/api-key.service';
|
||||
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
|
||||
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
|
||||
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
|
||||
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
|
||||
@@ -125,6 +126,14 @@ describe('AuthResolver', () => {
|
||||
provide: TwentyConfigService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: AuditService,
|
||||
useValue: {
|
||||
createContext: jest.fn().mockReturnValue({
|
||||
insertWorkspaceEvent: jest.fn(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
// {
|
||||
// provide: OAuthService,
|
||||
// useValue: {},
|
||||
|
||||
@@ -22,6 +22,8 @@ import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filt
|
||||
// import { OAuthService } from 'src/engine/core-modules/auth/services/oauth.service';
|
||||
import { ApiKeyService } from 'src/engine/core-modules/api-key/api-key.service';
|
||||
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
|
||||
import { MONITORING_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/monitoring/monitoring';
|
||||
import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
@@ -40,7 +42,10 @@ import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services
|
||||
import { RenewTokenService } from 'src/engine/core-modules/auth/token/services/renew-token.service';
|
||||
import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service';
|
||||
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
|
||||
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import {
|
||||
JwtTokenTypeEnum,
|
||||
LoginTokenJwtPayload,
|
||||
} from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { CaptchaGuard } from 'src/engine/core-modules/captcha/captcha.guard';
|
||||
import { CaptchaGraphqlApiExceptionFilter } from 'src/engine/core-modules/captcha/filters/captcha-graphql-api-exception.filter';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
@@ -53,6 +58,7 @@ import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
|
||||
import { TwoFactorAuthenticationVerificationInput } from 'src/engine/core-modules/two-factor-authentication/dto/two-factor-authentication-verification.input';
|
||||
import { TwoFactorAuthenticationExceptionFilter } from 'src/engine/core-modules/two-factor-authentication/two-factor-authentication-exception.filter';
|
||||
import { TwoFactorAuthenticationService } from 'src/engine/core-modules/two-factor-authentication/two-factor-authentication.service';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
|
||||
import { UserService } from 'src/engine/core-modules/user/services/user.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
@@ -113,6 +119,7 @@ export class AuthResolver {
|
||||
private userWorkspaceService: UserWorkspaceService,
|
||||
private emailVerificationTokenService: EmailVerificationTokenService,
|
||||
private sSOService: SSOService,
|
||||
private readonly auditService: AuditService,
|
||||
) {}
|
||||
|
||||
@UseGuards(CaptchaGuard, PublicEndpointGuard)
|
||||
@@ -536,44 +543,174 @@ export class AuthResolver {
|
||||
@Args() getAuthTokensFromLoginTokenInput: GetAuthTokensFromLoginTokenInput,
|
||||
@Args('origin') origin: string,
|
||||
): Promise<AuthTokens> {
|
||||
const {
|
||||
sub: email,
|
||||
workspaceId,
|
||||
authProvider,
|
||||
} = await this.loginTokenService.verifyLoginToken(
|
||||
const tokenPayload = await this.validateAndDecodeLoginToken(
|
||||
getAuthTokensFromLoginTokenInput.loginToken,
|
||||
);
|
||||
|
||||
const workspace = await this.validateWorkspaceAccess(
|
||||
origin,
|
||||
tokenPayload.workspaceId,
|
||||
);
|
||||
|
||||
const { user, userWorkspace } = await this.validateUserAccess(
|
||||
tokenPayload.sub,
|
||||
tokenPayload.workspaceId,
|
||||
);
|
||||
|
||||
if (tokenPayload.authProvider === AuthProviderEnum.Impersonation) {
|
||||
await this.validateAndLogImpersonation(
|
||||
tokenPayload,
|
||||
workspace,
|
||||
user.email,
|
||||
);
|
||||
} else {
|
||||
await this.validateRegularAuthentication(workspace, userWorkspace);
|
||||
}
|
||||
|
||||
return await this.authService.verify(
|
||||
user.email,
|
||||
workspace.id,
|
||||
tokenPayload.authProvider,
|
||||
);
|
||||
}
|
||||
|
||||
private async validateAndDecodeLoginToken(
|
||||
loginToken: string,
|
||||
): Promise<LoginTokenJwtPayload> {
|
||||
return await this.loginTokenService.verifyLoginToken(loginToken);
|
||||
}
|
||||
|
||||
private async validateWorkspaceAccess(
|
||||
origin: string,
|
||||
tokenWorkspaceId: string,
|
||||
): Promise<Workspace> {
|
||||
const workspace =
|
||||
await this.domainManagerService.getWorkspaceByOriginOrDefaultWorkspace(
|
||||
origin,
|
||||
);
|
||||
|
||||
workspaceValidator.assertIsDefinedOrThrow(workspace);
|
||||
workspaceValidator.assertIsDefinedOrThrow(
|
||||
workspace,
|
||||
new AuthException(
|
||||
'Workspace not found',
|
||||
AuthExceptionCode.WORKSPACE_NOT_FOUND,
|
||||
),
|
||||
);
|
||||
|
||||
if (workspaceId !== workspace.id) {
|
||||
if (tokenWorkspaceId !== workspace.id) {
|
||||
throw new AuthException(
|
||||
'Token is not valid for this workspace',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
return workspace;
|
||||
}
|
||||
|
||||
private async validateUserAccess(
|
||||
email: string,
|
||||
workspaceId: string,
|
||||
): Promise<{ user: User; userWorkspace: UserWorkspace }> {
|
||||
const user = await this.userService.getUserByEmail(email);
|
||||
|
||||
await this.authService.checkIsEmailVerified(user.isEmailVerified);
|
||||
|
||||
const currentUserWorkspace =
|
||||
const userWorkspace =
|
||||
await this.userWorkspaceService.getUserWorkspaceForUserOrThrow({
|
||||
userId: user.id,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return { user, userWorkspace };
|
||||
}
|
||||
|
||||
private async validateRegularAuthentication(
|
||||
workspace: Workspace,
|
||||
userWorkspace: UserWorkspace,
|
||||
): Promise<void> {
|
||||
await this.twoFactorAuthenticationService.validateTwoFactorAuthenticationRequirement(
|
||||
workspace,
|
||||
currentUserWorkspace.twoFactorAuthenticationMethods,
|
||||
userWorkspace.twoFactorAuthenticationMethods,
|
||||
);
|
||||
}
|
||||
|
||||
return await this.authService.verify(email, workspace.id, authProvider);
|
||||
private async validateAndLogImpersonation(
|
||||
tokenPayload: LoginTokenJwtPayload,
|
||||
workspace: Workspace,
|
||||
targetUserEmail: string,
|
||||
): Promise<void> {
|
||||
const { impersonatorUserId } = tokenPayload;
|
||||
|
||||
const auditService = this.auditService.createContext({
|
||||
workspaceId: workspace.id,
|
||||
userId: impersonatorUserId,
|
||||
});
|
||||
|
||||
await auditService.insertWorkspaceEvent(MONITORING_EVENT, {
|
||||
eventName: 'server.impersonation.token_exchange_attempt',
|
||||
message: `Impersonation token exchange attempt for ${targetUserEmail} by ${impersonatorUserId}`,
|
||||
});
|
||||
|
||||
if (workspace.allowImpersonation !== true) {
|
||||
throw new AuthException(
|
||||
'Impersonation not allowed on this workspace',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
if (!impersonatorUserId) {
|
||||
await auditService.insertWorkspaceEvent(MONITORING_EVENT, {
|
||||
eventName: 'server.impersonation.token_exchange_failed',
|
||||
message: `Invalid impersonation token (missing impersonator user ID) for ${targetUserEmail}`,
|
||||
});
|
||||
throw new AuthException(
|
||||
'Invalid impersonation token (missing impersonator user ID)',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
const impersonatorUser = await this.userRepository.findOne({
|
||||
where: { id: impersonatorUserId },
|
||||
});
|
||||
|
||||
if (!impersonatorUser) {
|
||||
await auditService.insertWorkspaceEvent(MONITORING_EVENT, {
|
||||
eventName: 'server.impersonation.token_exchange_failed',
|
||||
message: `Impersonator user not found: ${impersonatorUserId} for ${targetUserEmail}`,
|
||||
});
|
||||
throw new AuthException(
|
||||
'Impersonator user not found',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
if (impersonatorUser.canImpersonate !== true) {
|
||||
await auditService.insertWorkspaceEvent(MONITORING_EVENT, {
|
||||
eventName: 'server.impersonation.token_exchange_failed',
|
||||
message: `User not authorized to impersonate: ${impersonatorUserId} for ${targetUserEmail}`,
|
||||
});
|
||||
throw new AuthException(
|
||||
'User not authorized to impersonate',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
await this.logImpersonationEvent(workspace.id, impersonatorUserId);
|
||||
}
|
||||
|
||||
private async logImpersonationEvent(
|
||||
workspaceId: string,
|
||||
impersonatorUserId: string,
|
||||
): Promise<void> {
|
||||
const auditService = this.auditService.createContext({
|
||||
workspaceId,
|
||||
userId: impersonatorUserId,
|
||||
});
|
||||
|
||||
await auditService.insertWorkspaceEvent(MONITORING_EVENT, {
|
||||
eventName: 'server.impersonation.login_token_exchanged',
|
||||
message: 'Impersonation token exchanged',
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => AuthorizeApp)
|
||||
|
||||
@@ -268,7 +268,7 @@ export class AuthService {
|
||||
async verify(
|
||||
email: string,
|
||||
workspaceId: string,
|
||||
authProvider?: AuthProviderEnum,
|
||||
authProvider: AuthProviderEnum,
|
||||
): Promise<AuthTokens> {
|
||||
if (!email) {
|
||||
throw new AuthException(
|
||||
|
||||
+7
-1
@@ -13,6 +13,7 @@ import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrap
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
|
||||
@@ -131,7 +132,11 @@ describe('AccessTokenService', () => {
|
||||
} as any);
|
||||
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
|
||||
|
||||
const result = await service.generateAccessToken({ userId, workspaceId });
|
||||
const result = await service.generateAccessToken({
|
||||
userId,
|
||||
workspaceId,
|
||||
authProvider: AuthProviderEnum.Password,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
token: mockToken,
|
||||
@@ -155,6 +160,7 @@ describe('AccessTokenService', () => {
|
||||
service.generateAccessToken({
|
||||
userId: 'non-existent-user',
|
||||
workspaceId: 'workspace-id',
|
||||
authProvider: AuthProviderEnum.Password,
|
||||
}),
|
||||
).rejects.toThrow(AuthException);
|
||||
});
|
||||
|
||||
+55
-2
@@ -2,6 +2,7 @@ import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
|
||||
import { LoginTokenService } from './login-token.service';
|
||||
|
||||
@@ -55,7 +56,11 @@ describe('LoginTokenService', () => {
|
||||
jest.spyOn(twentyConfigService, 'get').mockReturnValue(mockExpiresIn);
|
||||
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
|
||||
|
||||
const result = await service.generateLoginToken(email, workspaceId);
|
||||
const result = await service.generateLoginToken(
|
||||
email,
|
||||
workspaceId,
|
||||
AuthProviderEnum.Password,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
token: mockToken,
|
||||
@@ -69,12 +74,60 @@ describe('LoginTokenService', () => {
|
||||
'LOGIN_TOKEN_EXPIRES_IN',
|
||||
);
|
||||
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
|
||||
{ sub: email, workspaceId, type: 'LOGIN' },
|
||||
{
|
||||
sub: email,
|
||||
workspaceId,
|
||||
type: 'LOGIN',
|
||||
authProvider: AuthProviderEnum.Password,
|
||||
impersonatorUserId: undefined,
|
||||
},
|
||||
{ secret: mockSecret, expiresIn: mockExpiresIn },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateLoginToken with impersonation', () => {
|
||||
it('should include impersonatorUserId in JWT payload when using Impersonation auth provider', async () => {
|
||||
const email = 'test@example.com';
|
||||
const mockSecret = 'mock-secret';
|
||||
const mockToken = 'mock-token';
|
||||
const workspaceId = 'workspace-id';
|
||||
const impersonatorUserId = 'impersonator-id';
|
||||
|
||||
jest
|
||||
.spyOn(jwtWrapperService, 'generateAppSecret')
|
||||
.mockReturnValue(mockSecret);
|
||||
jest.spyOn(twentyConfigService, 'get').mockReturnValue('1h');
|
||||
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
|
||||
|
||||
const result = await service.generateLoginToken(
|
||||
email,
|
||||
workspaceId,
|
||||
AuthProviderEnum.Impersonation,
|
||||
{ impersonatorUserId },
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
token: mockToken,
|
||||
expiresAt: expect.any(Date),
|
||||
});
|
||||
expect(jwtWrapperService.generateAppSecret).toHaveBeenCalledWith(
|
||||
'LOGIN',
|
||||
workspaceId,
|
||||
);
|
||||
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
|
||||
{
|
||||
sub: email,
|
||||
workspaceId,
|
||||
type: 'LOGIN',
|
||||
authProvider: AuthProviderEnum.Impersonation,
|
||||
impersonatorUserId,
|
||||
},
|
||||
{ secret: mockSecret, expiresIn: expect.any(String) },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyLoginToken', () => {
|
||||
it('should verify a login token successfully', async () => {
|
||||
const mockToken = 'valid-token';
|
||||
|
||||
+5
-3
@@ -4,12 +4,12 @@ import { addMilliseconds } from 'date-fns';
|
||||
import ms from 'ms';
|
||||
|
||||
import { type AuthToken } from 'src/engine/core-modules/auth/dto/token.entity';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import {
|
||||
type LoginTokenJwtPayload,
|
||||
JwtTokenTypeEnum,
|
||||
} from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { type AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
|
||||
@Injectable()
|
||||
@@ -22,13 +22,15 @@ export class LoginTokenService {
|
||||
async generateLoginToken(
|
||||
email: string,
|
||||
workspaceId: string,
|
||||
authProvider?: AuthProviderEnum,
|
||||
authProvider: AuthProviderEnum,
|
||||
options?: { impersonatorUserId?: string },
|
||||
): Promise<AuthToken> {
|
||||
const jwtPayload: LoginTokenJwtPayload = {
|
||||
type: JwtTokenTypeEnum.LOGIN,
|
||||
sub: email,
|
||||
workspaceId,
|
||||
authProvider,
|
||||
impersonatorUserId: options?.impersonatorUserId,
|
||||
};
|
||||
|
||||
const secret = this.jwtWrapperService.generateAppSecret(
|
||||
|
||||
+4
-2
@@ -10,6 +10,7 @@ import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services
|
||||
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
|
||||
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { type User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
|
||||
import { RenewTokenService } from './renew-token.service';
|
||||
|
||||
@@ -86,7 +87,7 @@ describe('RenewTokenService', () => {
|
||||
jest.spyOn(refreshTokenService, 'verifyRefreshToken').mockResolvedValue({
|
||||
user: mockUser,
|
||||
token: mockAppToken as AppToken,
|
||||
authProvider: undefined,
|
||||
authProvider: AuthProviderEnum.Password,
|
||||
targetedTokenType: JwtTokenTypeEnum.ACCESS,
|
||||
});
|
||||
jest.spyOn(appTokenRepository, 'update').mockResolvedValue({} as any);
|
||||
@@ -114,9 +115,10 @@ describe('RenewTokenService', () => {
|
||||
expect(accessTokenService.generateAccessToken).toHaveBeenCalledWith({
|
||||
userId: mockUser.id,
|
||||
workspaceId: mockWorkspaceId,
|
||||
authProvider: AuthProviderEnum.Password,
|
||||
});
|
||||
expect(refreshTokenService.generateRefreshToken).toHaveBeenCalledWith({
|
||||
authProvider: undefined,
|
||||
authProvider: AuthProviderEnum.Password,
|
||||
targetedTokenType: JwtTokenTypeEnum.ACCESS,
|
||||
userId: mockUser.id,
|
||||
workspaceId: mockWorkspaceId,
|
||||
|
||||
+7
-2
@@ -14,6 +14,7 @@ import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/
|
||||
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
|
||||
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
|
||||
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
|
||||
@Injectable()
|
||||
export class RenewTokenService {
|
||||
@@ -57,6 +58,10 @@ export class RenewTokenService {
|
||||
const targetedTokenType =
|
||||
targetedTokenTypeFromPayload ?? JwtTokenTypeEnum.ACCESS;
|
||||
|
||||
// Support legacy tokens where authProvider might be undefined
|
||||
// TODO: remove in November 2025
|
||||
const resolvedAuthProvider = authProvider ?? AuthProviderEnum.Password;
|
||||
|
||||
const accessToken =
|
||||
isDefined(authProvider) &&
|
||||
targetedTokenType === JwtTokenTypeEnum.WORKSPACE_AGNOSTIC
|
||||
@@ -69,13 +74,13 @@ export class RenewTokenService {
|
||||
: await this.accessTokenService.generateAccessToken({
|
||||
userId: user.id,
|
||||
workspaceId,
|
||||
authProvider,
|
||||
authProvider: resolvedAuthProvider,
|
||||
});
|
||||
|
||||
const refreshToken = await this.refreshTokenService.generateRefreshToken({
|
||||
userId: user.id,
|
||||
workspaceId,
|
||||
authProvider,
|
||||
authProvider: resolvedAuthProvider,
|
||||
targetedTokenType,
|
||||
});
|
||||
|
||||
|
||||
@@ -43,7 +43,8 @@ export type FileTokenJwtPayload = CommonPropertiesJwtPayload & {
|
||||
export type LoginTokenJwtPayload = CommonPropertiesJwtPayload & {
|
||||
type: JwtTokenTypeEnum.LOGIN;
|
||||
workspaceId: string;
|
||||
authProvider?: AuthProviderEnum;
|
||||
authProvider: AuthProviderEnum;
|
||||
impersonatorUserId?: string;
|
||||
};
|
||||
|
||||
export type TransientTokenJwtPayload = CommonPropertiesJwtPayload & {
|
||||
@@ -81,7 +82,7 @@ export type AccessTokenJwtPayload = CommonPropertiesJwtPayload & {
|
||||
userId: string;
|
||||
workspaceMemberId?: string;
|
||||
userWorkspaceId: string;
|
||||
authProvider?: AuthProviderEnum;
|
||||
authProvider: AuthProviderEnum;
|
||||
};
|
||||
|
||||
export type PostgresProxyTokenJwtPayload = CommonPropertiesJwtPayload & {
|
||||
|
||||
+5
-2
@@ -71,7 +71,7 @@ export class CloudflareController {
|
||||
|
||||
if (!workspace) return;
|
||||
|
||||
const analytics = this.auditService.createContext({
|
||||
const auditService = this.auditService.createContext({
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
@@ -100,7 +100,10 @@ export class CloudflareController {
|
||||
...workspaceUpdated,
|
||||
});
|
||||
|
||||
await analytics.insertWorkspaceEvent(CUSTOM_DOMAIN_ACTIVATED_EVENT, {});
|
||||
await auditService.insertWorkspaceEvent(
|
||||
CUSTOM_DOMAIN_ACTIVATED_EVENT,
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
return res.status(200).send();
|
||||
|
||||
+2
-2
@@ -199,11 +199,11 @@ export class CustomDomainService {
|
||||
|
||||
await this.workspaceRepository.save(workspace);
|
||||
|
||||
const analytics = this.auditService.createContext({
|
||||
const auditService = this.auditService.createContext({
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
analytics.insertWorkspaceEvent(
|
||||
auditService.insertWorkspaceEvent(
|
||||
workspace.isCustomDomainEnabled
|
||||
? CUSTOM_DOMAIN_ACTIVATED_EVENT
|
||||
: CUSTOM_DOMAIN_DEACTIVATED_EVENT,
|
||||
|
||||
@@ -48,7 +48,7 @@ export class CallWebhookJob {
|
||||
webhookId: data.webhookId,
|
||||
eventName: data.eventName,
|
||||
};
|
||||
const analytics = this.auditService.createContext({
|
||||
const auditService = this.auditService.createContext({
|
||||
workspaceId: data.workspaceId,
|
||||
});
|
||||
|
||||
@@ -79,13 +79,13 @@ export class CallWebhookJob {
|
||||
|
||||
const success = response.status >= 200 && response.status < 300;
|
||||
|
||||
analytics.insertWorkspaceEvent(WEBHOOK_RESPONSE_EVENT, {
|
||||
auditService.insertWorkspaceEvent(WEBHOOK_RESPONSE_EVENT, {
|
||||
status: response.status,
|
||||
success,
|
||||
...commonPayload,
|
||||
});
|
||||
} catch (err) {
|
||||
analytics.insertWorkspaceEvent(WEBHOOK_RESPONSE_EVENT, {
|
||||
auditService.insertWorkspaceEvent(WEBHOOK_RESPONSE_EVENT, {
|
||||
success: false,
|
||||
...commonPayload,
|
||||
...(err.response && { status: err.response.status }),
|
||||
|
||||
@@ -3,4 +3,5 @@ export enum AuthProviderEnum {
|
||||
Microsoft = 'microsoft',
|
||||
Password = 'password',
|
||||
SSO = 'sso',
|
||||
Impersonation = 'impersonation',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user