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

Commit

Permalink
✨ Add SMS MFA OTP
Browse files Browse the repository at this point in the history
  • Loading branch information
AnandChowdhary committed Nov 1, 2020
1 parent ec9ec37 commit ce71040
Show file tree
Hide file tree
Showing 8 changed files with 112 additions and 26 deletions.
2 changes: 2 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ enum UserRole {

enum MfaMethod {
NONE
SMS
TOTP
EMAIL
}
Expand Down Expand Up @@ -72,6 +73,7 @@ model users {
role UserRole @default(USER)
timezone String @default("America/Los_Angeles")
twoFactorMethod MfaMethod @default(NONE)
twoFactorPhone String?
twoFactorSecret String?
attributes Json?
updatedAt DateTime @updatedAt
Expand Down
6 changes: 6 additions & 0 deletions src/config/configuration.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ export interface Configuration {
};
};

sms: {
smsServiceName: string;
twilioAccountSid: string;
twilioAuthToken: string;
};

payments: {
stripeApiKey: string;
stripeProductId: string;
Expand Down
5 changes: 5 additions & 0 deletions src/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ const configuration: Configuration = {
pass: process.env.EMAIL_PASSWORD ?? '',
},
},
sms: {
smsServiceName: process.env.SMS_SERVICE_NAME ?? '',
twilioAccountSid: process.env.TWILIO_ACCOUNT_SID ?? '',
twilioAuthToken: process.env.TWILIO_AUTH_TOKEN ?? '',
},
payments: {
stripeApiKey: process.env.STRIPE_API_KEY ?? '',
stripeProductId: process.env.STRIPE_PRODUCT_ID ?? '',
Expand Down
14 changes: 11 additions & 3 deletions src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ export class AuthService {
* @returns Data URI string with QR code image
*/
async getTotpQrCode(userId: number): Promise<string> {
const secret = randomStringGenerator();
const secret = randomStringGenerator() as string;
await this.prisma.users.update({
where: { id: userId },
data: { twoFactorSecret: secret },
Expand All @@ -277,7 +277,11 @@ export class AuthService {
}

/** Enable two-factor authentication */
async enableTotp(userId: number, code: string): Promise<Expose<users>> {
async enableMfaMethod(
method: MfaMethod,
userId: number,
code: string,
): Promise<Expose<users>> {
const user = await this.prisma.users.findOne({
where: { id: userId },
select: { twoFactorSecret: true, twoFactorMethod: true },
Expand All @@ -295,7 +299,7 @@ export class AuthService {
);
const result = await this.prisma.users.update({
where: { id: userId },
data: { twoFactorMethod: 'TOTP', twoFactorSecret: user.twoFactorSecret },
data: { twoFactorMethod: method, twoFactorSecret: user.twoFactorSecret },
});
return this.prisma.expose<users>(result);
}
Expand Down Expand Up @@ -387,6 +391,10 @@ export class AuthService {
return this.prisma.expose<emails>(result);
}

getOneTimePassword(secret: string): string {
return this.authenticator.generate(secret);
}

private async loginUserWithTotpCode(
ipAddress: string,
userAgent: string,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@ import {
Post,
} from '@nestjs/common';
import { users } from '@prisma/client';
import { BadRequestError } from 'passport-headerapikey';
import { Expose } from '../../modules/prisma/prisma.interface';
import { Scopes } from '../auth/scope.decorator';
import { EnableTotpMfaDto } from './multi-factor-authentication.dto';
import {
EnableSmsMfaDto,
EnableTotpMfaDto,
} from './multi-factor-authentication.dto';
import { MultiFactorAuthenticationService } from './multi-factor-authentication.service';

@Controller('users/:userId/multi-factor-authentication')
Expand All @@ -18,33 +22,54 @@ export class MultiFactorAuthenticationController {
private multiFactorAuthenticationService: MultiFactorAuthenticationService,
) {}

@Post('regenerate')
@Scopes('user-{userId}:write-mfa')
async regenerateBackupCodes(
@Param('userId', ParseIntPipe) userId: number,
): Promise<string[]> {
return this.multiFactorAuthenticationService.regenerateBackupCodes(userId);
}

@Delete()
@Scopes('user-{userId}:delete-mfa')
async disable2FA(
@Param('userId', ParseIntPipe) userId: number,
): Promise<Expose<users>> {
return this.multiFactorAuthenticationService.disableMfa(userId);
}

@Post('totp')
@Scopes('user-{userId}:write-totp')
async enable2FA(
@Scopes('user-{userId}:write-mfa')
async enableTotp(
@Param('userId', ParseIntPipe) userId: number,
@Body() body: EnableTotpMfaDto,
): Promise<string[] | string> {
if (body.token)
return this.multiFactorAuthenticationService.enableTotpMfa(
return this.multiFactorAuthenticationService.enableMfa(
'TOTP',
userId,
body.token,
);
return this.multiFactorAuthenticationService.requestTotpMfa(userId);
}

@Post('totp/regenerate')
@Scopes('user-{userId}:write-totp')
async regenerateBackupCodes(
@Param('userId', ParseIntPipe) userId: number,
): Promise<string[]> {
return this.multiFactorAuthenticationService.regenerateBackupCodes(userId);
}

@Delete('totp')
@Scopes('user-{userId}:delete-totp')
async disable2FA(
@Post('sms')
@Scopes('user-{userId}:write-mfa')
async enableSms(
@Param('userId', ParseIntPipe) userId: number,
): Promise<Expose<users>> {
return this.multiFactorAuthenticationService.disableTotpMfa(userId);
@Body() body: EnableSmsMfaDto,
): Promise<string[] | void> {
if (body.token)
return this.multiFactorAuthenticationService.enableMfa(
'SMS',
userId,
body.token,
);
if (body.phone)
return this.multiFactorAuthenticationService.requestSmsMfa(
userId,
body.phone,
);
throw new BadRequestError('Phone number or token is required');
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import { IsOptional, IsString } from 'class-validator';
import { IsOptional, IsPhoneNumber, IsString } from 'class-validator';

export class EnableTotpMfaDto {
@IsString()
@IsOptional()
token?: string;
}

export class EnableSmsMfaDto {
@IsString()
@IsOptional()
token?: string;

@IsPhoneNumber('ZZ')
@IsOptional()
phone?: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AuthModule } from '../auth/auth.module';
import { PrismaModule } from '../prisma/prisma.module';
import { TwilioModule } from '../twilio/twilio.module';
import { MultiFactorAuthenticationController } from './multi-factor-authentication.controller';
import { MultiFactorAuthenticationService } from './multi-factor-authentication.service';

@Module({
imports: [PrismaModule, AuthModule, ConfigModule],
imports: [PrismaModule, AuthModule, TwilioModule, ConfigModule],
controllers: [MultiFactorAuthenticationController],
providers: [MultiFactorAuthenticationService],
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,20 @@ import {
} from '@nestjs/common';
import { randomStringGenerator } from '@nestjs/common/utils/random-string-generator.util';
import { ConfigService } from '@nestjs/config';
import { users } from '@prisma/client';
import { MfaMethod, users } from '@prisma/client';
import { hash } from 'bcrypt';
import { AuthService } from '../auth/auth.service';
import { Expose } from '../prisma/prisma.interface';
import { PrismaService } from '../prisma/prisma.service';
import { TwilioService } from '../twilio/twilio.service';

@Injectable()
export class MultiFactorAuthenticationService {
constructor(
private prisma: PrismaService,
private auth: AuthService,
private configService: ConfigService,
private twilioService: TwilioService,
) {}

async requestTotpMfa(userId: number): Promise<string> {
Expand All @@ -32,12 +34,39 @@ export class MultiFactorAuthenticationService {
return this.auth.getTotpQrCode(userId);
}

async enableTotpMfa(userId: number, token: string): Promise<string[]> {
await this.auth.enableTotp(userId, token);
async requestSmsMfa(userId: number, phone: string): Promise<void> {
const enabled = await this.prisma.users.findOne({
where: { id: userId },
select: { twoFactorMethod: true },
});
if (!enabled) throw new NotFoundException('User not found');
if (enabled.twoFactorMethod !== 'NONE')
throw new BadRequestException(
'Two-factor authentication is already enabled',
);
const secret = randomStringGenerator() as string;
await this.prisma.users.update({
where: { id: userId },
data: { twoFactorSecret: secret, twoFactorPhone: phone },
});
this.twilioService.send({
to: phone,
body: `${this.auth.getOneTimePassword(secret)} is your ${
this.configService.get<string>('sms.smsServiceName') ?? ''
} verification code.`,
});
}

async enableMfa(
method: MfaMethod,
userId: number,
token: string,
): Promise<string[]> {
await this.auth.enableMfaMethod(method, userId, token);
return this.regenerateBackupCodes(userId);
}

async disableTotpMfa(userId: number): Promise<Expose<users>> {
async disableMfa(userId: number): Promise<Expose<users>> {
const enabled = await this.prisma.users.findOne({
where: { id: userId },
select: { twoFactorMethod: true },
Expand Down

0 comments on commit ce71040

Please sign in to comment.