Skip to content
This repository has been archived by the owner on Apr 19, 2023. It is now read-only.

Commit

Permalink
✨ Add support for merging users (fixed #950)
Browse files Browse the repository at this point in the history
  • Loading branch information
AnandChowdhary committed Nov 8, 2020
1 parent 8ac6f7f commit cbc8034
Show file tree
Hide file tree
Showing 7 changed files with 142 additions and 7 deletions.
1 change: 1 addition & 0 deletions src/config/configuration.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface Configuration {
totpWindowPast: number;
totpWindowFuture: number;
mfaTokenExpiry: string;
mergeUsersTokenExpiry: string;
accessTokenExpiry: string;
passwordPwnedCheck: boolean;
unusedRefreshTokenExpiryDays: number;
Expand Down
1 change: 1 addition & 0 deletions src/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const configuration: Configuration = {
totpWindowPast: int(process.env.TOTP_WINDOW_PAST, 1),
totpWindowFuture: int(process.env.TOTP_WINDOW_PAST, 0),
mfaTokenExpiry: process.env.MFA_TOKEN_EXPIRY ?? '10m',
mergeUsersTokenExpiry: process.env.MERGE_USERS_TOKEN_EXPIRY ?? '30m',
accessTokenExpiry: process.env.ACCESS_TOKEN_EXPIRY ?? '1h',
passwordPwnedCheck: !!process.env.PASSWORD_PWNED_CHECK,
unusedRefreshTokenExpiryDays: int(process.env.DELETE_EXPIRED_SESSIONS, 30),
Expand Down
1 change: 1 addition & 0 deletions src/modules/tokens/tokens.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export const EMAIL_VERIFY_TOKEN = 'EMAIL_VERIFY_TOKEN';
export const APPROVE_SUBNET_TOKEN = 'APPROVE_SUBNET_TOKEN';
export const EMAIL_MFA_TOKEN = 'EMAIL_MFA_TOKEN';
export const LOGIN_ACCESS_TOKEN = 'LOGIN_ACCESS_TOKEN';
export const MERGE_ACCOUNTS_TOKEN = 'MERGE_ACCOUNTS_TOKEN';
27 changes: 27 additions & 0 deletions src/modules/users/users.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import {
Param,
ParseIntPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { users } from '@prisma/client';
import { RateLimit } from 'nestjs-rate-limiter';
import { Expose } from '../../modules/prisma/prisma.interface';
import { CursorPipe } from '../../pipes/cursor.pipe';
import { OptionalIntPipe } from '../../pipes/optional-int.pipe';
Expand Down Expand Up @@ -54,4 +56,29 @@ export class UserController {
async remove(@Param('id', ParseIntPipe) id: number): Promise<Expose<users>> {
return this.usersService.deleteUser(Number(id));
}

@Post(':id/merge-request')
@Scopes('user-{id}:merge')
@RateLimit({
points: 10,
duration: 60,
errorMessage: 'Wait for 60 seconds before trying to merge again',
})
async mergeRequest(
@Param('id', ParseIntPipe) id: number,
@Body('email') email: string,
): Promise<void> {
return this.usersService.requestMerge(Number(id), email);
}

@Post('merge')
@Scopes('user-{id}:merge')
@RateLimit({
points: 10,
duration: 60,
errorMessage: 'Wait for 60 seconds before trying to merge again',
})
async merge(@Body('token') token: string): Promise<void> {
return this.usersService.mergeUsers(token);
}
}
5 changes: 4 additions & 1 deletion src/modules/users/users.module.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AuthModule } from '../auth/auth.module';
import { EmailModule } from '../email/email.module';
import { PrismaModule } from '../prisma/prisma.module';
import { TokensModule } from '../tokens/tokens.module';
import { UserController } from './users.controller';
import { UsersService } from './users.service';

@Module({
imports: [PrismaModule, AuthModule],
imports: [PrismaModule, AuthModule, EmailModule, ConfigModule, TokensModule],
controllers: [UserController],
providers: [UsersService],
exports: [UsersService],
Expand Down
105 changes: 99 additions & 6 deletions src/modules/users/users.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,37 @@ import {
HttpException,
HttpStatus,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { ConfigService } from '@nestjs/config';
import {
usersUpdateInput,
emailsDelegate,
users,
usersCreateInput,
usersWhereUniqueInput,
usersWhereInput,
usersOrderByInput,
usersUpdateInput,
usersWhereInput,
usersWhereUniqueInput,
} from '@prisma/client';
import { compare } from 'bcrypt';
import { safeEmail } from '../../helpers/safe-email';
import { Expose } from '../../modules/prisma/prisma.interface';
import { AuthService } from '../auth/auth.service';
import { compare } from 'bcrypt';
import { EmailService } from '../email/email.service';
import { PrismaService } from '../prisma/prisma.service';
import { MERGE_ACCOUNTS_TOKEN } from '../tokens/tokens.constants';
import { TokensService } from '../tokens/tokens.service';
import { PasswordUpdateInput } from './users.interface';

@Injectable()
export class UsersService {
constructor(private prisma: PrismaService, private auth: AuthService) {}
constructor(
private prisma: PrismaService,
private auth: AuthService,
private email: EmailService,
private configService: ConfigService,
private tokensService: TokensService,
) {}

async getUser(id: number): Promise<Expose<users>> {
const user = await this.prisma.users.findOne({
Expand Down Expand Up @@ -93,4 +106,84 @@ export class UsersService {
});
return this.prisma.expose<users>(user);
}

async requestMerge(userId: number, email: string): Promise<void> {
const emailSafe = safeEmail(email);
const user = await this.prisma.users.findFirst({
where: { emails: { some: { emailSafe } } },
include: { prefersEmail: true },
});
if (!user) throw new NotFoundException('User not found');
const minutes = parseInt(
this.configService.get<string>('security.mergeUsersTokenExpiry') ?? '',
);
return this.email.send({
to: `"${user.name}" <${user.prefersEmail.email}>`,
template: 'auth/mfa-code',
data: {
name: user.name,
minutes,
link: `${this.configService.get<string>(
'frontendUrl',
)}/auth/merge-accounts?token=${this.tokensService.signJwt(
MERGE_ACCOUNTS_TOKEN,
{ baseUserId: userId, mergeUserId: user.id },
`${minutes}m`,
)}`,
},
});
}

async mergeUsers(token: string): Promise<void> {
let baseUserId: number | undefined = undefined;
let mergeUserId: number | undefined = undefined;
try {
const result = this.tokensService.verify<{
baseUserId: number;
mergeUserId: number;
}>(MERGE_ACCOUNTS_TOKEN, token);
baseUserId = result.baseUserId;
mergeUserId = result.mergeUserId;
} catch (error) {}
if (!baseUserId || !mergeUserId) throw new BadRequestException();
return this.merge(baseUserId, mergeUserId);
}

private async merge(baseUserId: number, mergeUserId: number): Promise<void> {
const baseUser = await this.prisma.users.findOne({
where: { id: baseUserId },
});
const mergeUser = await this.prisma.users.findOne({
where: { id: mergeUserId },
});
if (!baseUser || !mergeUser) throw new NotFoundException('User not found');

const combinedUser = { ...baseUser };
Object.keys(mergeUser).forEach((key) => {
if (mergeUser[key]) combinedUser[key] = mergeUser[key];
});
await this.prisma.users.update({
where: { id: baseUserId },
data: combinedUser,
});

for await (const dataType of [
this.prisma.memberships,
this.prisma.emails,
this.prisma.sessions,
this.prisma.approvedSubnets,
this.prisma.backupCodes,
this.prisma.identities,
this.prisma.auditLogs,
]) {
for await (const item of await (dataType as emailsDelegate).findMany({
where: { user: { id: mergeUserId } },
select: { id: true },
}))
await (dataType as emailsDelegate).update({
where: { id: item.id },
data: { user: { connect: { id: baseUserId } } },
});
}
}
}
9 changes: 9 additions & 0 deletions src/templates/users/merge-request.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Account merge request

Hi {{name}},

We received a request to merge your account with another account.

<a href="{{ link }}" class="btn btn-primary">Merge this account</a>

Note that this link is valid for {{ minutes }} minutes only. If you didn't request this email, you can just ignore it; we won't give anyone else access to your account.

0 comments on commit cbc8034

Please sign in to comment.