Intercode SaaS Kit
  • Welcome to SaaS Starter Kit
  • Getting Started
    • Technology stack
    • Database Setup
    • Local Environment Setup
  • Basics
    • Dependencies
    • App architecture
    • Deployment
    • App roles
    • Endpoints List
      • Auth
      • Two Factor Auth
      • Businesses
      • Demo
      • Email
      • Export Document
      • Email Files
      • Files Demo
      • Leads
      • Orders
      • Payments
      • Subscriptions
      • Teams
      • Team Memberships
      • User Admin
  • Animation and Styles
    • Framer Motion
    • Ant Design and Styles
  • Pages
    • Auth
      • Working with PassportJS
      • Two-Factor Auth
      • OAuth Providers
    • Leads
    • Businesses
    • Team management
      • Ownership
    • Profile
    • User Settings
      • App Tour
    • App Settings
      • Lead Statuses
    • Dashboard
      • Lead volume widget
      • Doughnut chart widget
      • Recent leads table widget
      • Lead count over period widget
    • Demo
  • Features
    • Impersonation
    • Subscriptions (Stripe)
    • Search
    • Sentry
    • Captcha
    • Audit Logs
    • Internationalization
  • External integrations
    • Mailer
    • Google oAuth2
    • Facebook oAuth2
    • S3 compatible storage (AWS, MinIO)
Powered by GitBook
On this page
  1. Features

Impersonation

Here you can read how impersonation functionality works step by step

PreviousDemoNextSubscriptions (Stripe)

Last updated 5 months ago

Impersonation is the ability to log in as a specific user from the Teams List page. While impersonating, you can see the app exactly as that user does. In our app, this is particularly useful for logging in as a specific team owner.

However, you cannot log in as all owners; they can disable this option in their profile settings. We have the impersonationAllowed field in the userEntity that controls this:

    @Column({ default: true })
    public impersonationAllowed: boolean;

On UI you can switch this option on Profile Setting page:

It is also other factors, with which you cannot impersonate user:

  • Your two-factor authentication is disabled

  • You are trying to impersonate yourself

  • You are trying to impersonate user with Super Admin role

  • You are trying to impersonate another Admin while you are not Super Admin

  • You are trying to impersonate other users when you are already impersonating someone

In api/src/auth/controller/two-factor-auth.controller.ts you can find impersonate endpoint:

    @UseGuards(JwtTwoFactorGuard, AdminGuard)
    @Post('impersonate')
    async impersonate(@Body() body: TwoFactorImpersonationDto, @GetUser() impersonator: JwtUserPayload) {
        const isCodeValid = await this.twoFactorAuthService.verifyTwoFactorAuthCode(body.code, impersonator);
        if (!isCodeValid) {
            throw new HttpException(
                {
                    message: 'Code is not valid!',
                    description: 'An incorrect code or code time has expired. Please try again.',
                    code: 'INVALID_TWO_FACTOR_AUTH_CODE',
                },
                HttpStatus.BAD_REQUEST
            );
        }

        return await this.twoFactorAuthService.impesonateUser(body.userId, body.teamId, impersonator);
    }

As you can see, at first we should pass two-factor verification for security reasons. On the higher level we have JwtTwoFactorGuard which will check if user 2FA is enabled and AdminGuard which will check if user is Admin or Super Admin.

After that we move to the service which will handle security checks and if all passed, we will generate impersonation token:

    async impesonateUser(userId: number, teamId: string, impersonator: JwtUserPayload) {
        const userToImpersonate = await this.userRepository.findOne({
            where: { id: userId },
            relations: ['userRoles', 'userRoles.role'],
        });

        if (!userToImpersonate) {
            throw new HttpException(
                {
                    message: 'Error',
                    description: 'User not found',
                    code: 'USER_NOT_FOUND',
                },
                HttpStatus.BAD_REQUEST
            );
        }

        // avoiding double impersonation
        if (impersonator.impersonatedBy) {
            throw new ForbiddenException('Access denied');
        }

        if (!userToImpersonate.impersonationAllowed) {
            throw new HttpException(
                {
                    message: 'Error',
                    description: 'User disabled impersonation ability',
                    code: 'USER_IMPERSONATION_DISABLED',
                },
                HttpStatus.BAD_REQUEST
            );
        }

        const userRolesNames = userToImpersonate.userRoles.map(roleEntity => roleEntity.role.name);

        if (
            userRolesNames.includes(Role.SuperAdmin) ||
            (userRolesNames.includes(Role.Admin) && !impersonator.roles.includes(Role.SuperAdmin))
        ) {
            throw new ForbiddenException('Access denied');
        }

        return await this.generateImpersonationAccessToken(userId, teamId, impersonator);
    }

In generateImpersonationAccessToken function we will create impersonation record, with randomly generated 24 characters code and user who done impersonation, timestamp will be generated automatically:

const impersonationRecord = this.impersonationRepository.create({
            user: impersonator,
            code: generateRandomCode(24),
        });

Next, we will generate JWT token with some extra fields:

const payload: Partial<JwtUserPayload> = {
            id: userWithRole.id,
            email: userWithRole.email,
            roles: userWithRole.userRoles.map(userRole => userRole.role.name),
            isTwoFactorEnable: false,
            impersonatedBy: impersonator.email,
            impersonatedAt: impersonationRecord.impersonatedAt.toISOString(),
            impersonationCode: impersonationRecord.code,
            impersonatorTeamId: impersonator.teamId,
            isTwoFactorAuthenticated: false,
        };

        let membership;

        if (teamId) {
            membership = userWithRole.memberships.find(membership => membership.team.id === teamId);
        } else {
            membership = userWithRole.memberships[0];
        }

        if (membership) {
            const { team, role } = membership;

            payload.teamId = teamId;
            payload.teamRole = role;
            payload.subscriptionTier = team?.subscription?.tier.priority;
        } else {
            throw ForbiddenException;
        }

        const token = await sign(payload, this.tokenSecret, {
            expiresIn: TokensExpiredTime.impersonationToken,
        });

As you can see we have 4 extra fields: impersonatedBy, impersonatedAt, impersonationCode, impersonatorTeamId. The impersonatorTeamId value is used to return impersonator to the team from where he ded impersonation. isTwoFactorEnable and isTwoFactorAuthenticated should be always setted to false, in case user has 2FA enabled, this logic will allow us to skip 2FA on this user. To regular field we pass user which will be impersonated credentials that will allow backend to identify impersonator like other user.

Token is valid for the next 30 minutes.

While impersonation we will have some functionality blocked.

On frontend currentUserin context will have impersonatedBy field by which we know when to disable specific actions and show impersonation alert.

Changing teams and creating new teams will be blocked both in the UI and on the backend. The reason for this is that changing teams requires generating new tokens, which would break the impersonation logic. To forbid that we can add simple condition:

if (user.impersonatedAt || user.impersonatedBy || user.impersonationCode) {
            throw new ForbiddenException();
        }

Additionally, during impersonation, we will be able to leave comments on businesses and leads, but they will be posted under the impersonator's name, with the prefix 'Support team.' The same will apply to audit logs. This logic will allow us to clearly identify who performed the actions.

AuditLoggerEntity, BusinessCommentEntity and LeadCommentEntity have isImpersonated field to identify which record was created while impersonation.

On backend we handle that scenario with one condition (similar for both logs and comments):

        if (user.impersonatedBy) {
            const impersonator = await this.userRepository.findOne({ where: { email: user.impersonatedBy } });

            newLog.user = impersonator;
            newLog.userEmail = impersonator.email;
            newLog.isImpersonated = true;
        }

To return back, we will use the cancelImpersonation function in the two-factor-auth.controller. We will find the impersonation record by user, code, and timestamp to ensure we find the correct record (we normalize user.impersonatedAt and truncate the timestamp to get accurate results, since JWT tokens don't support the timestamp format used in PostgreSQL). After that, we generate tokens for the admin, allowing them to return to their profile and team.

const normalizedImpersonatedAt = dayjs(user.impersonatedAt).utc().format('YYYY-MM-DDTHH:mm:ss');

        const impersonationRecord = await this.impersonationRepository
            .createQueryBuilder('impersonation')
            .leftJoinAndSelect('impersonation.user', 'user')
            .where("DATE_TRUNC('second', impersonation.impersonatedAt) = :date", { date: normalizedImpersonatedAt })
            .andWhere('impersonation.code = :code', { code: user.impersonationCode })
            .andWhere('impersonation.user = :user', { user: impersonator.id })
            .getOne();

        if (!impersonationRecord) {
            throw new ForbiddenException('Access denied');
        }

        const impersonatorAccessToken = await this.authService.generateAccessToken({
            userId: impersonationRecord.user.id,
            isTwoFactorAuthenticated: impersonator.isTwoFactorEnable,
            teamId: user.impersonatorTeamId,
        });

        return { accessToken: impersonatorAccessToken, refreshToken: impersonator.refreshToken };

You can see full process of impersonation in block schemas:

Page cover image
Display on frontend
Start of impersonation
End of impersonating