Skip to content

Commit

Permalink
feat: email confirmation + otp backup codes
Browse files Browse the repository at this point in the history
  • Loading branch information
fasenderos committed Sep 2, 2023
1 parent 1a3bfab commit 38e730b
Show file tree
Hide file tree
Showing 16 changed files with 2,165 additions and 136 deletions.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,10 @@ If you want to speed up the Bitify release, please contribute to this project by
### Authentication Server

- [x] User registration and login
- [ ] Email verification
- [x] Email verification
- [ ] Forgot and reset password
- [x] Two Factor Authentication
- [x] OTP Code Backup
- [ ] Captcha
- [ ] Role-Base or Attribute Base ACL
- [ ] API Keys with permissions and IP restriction
Expand Down
3 changes: 3 additions & 0 deletions packages/api-gateway/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,11 @@
"homepage": "https://github.com/fasenderos/bitify#readme",
"dependencies": {
"@fastify/static": "^6.10.2",
"@nestjs-modules/mailer": "^1.9.1",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/event-emitter": "^2.0.2",
"@nestjs/jwt": "^10.1.0",
"@nestjs/passport": "^10.0.0",
"@nestjs/platform-fastify": "^10.0.0",
Expand All @@ -42,6 +44,7 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"cryptr": "^6.2.0",
"handlebars": "^4.7.8",
"lodash": "^4.17.21",
"nestjs-pino": "^3.3.0",
"nestjs-real-ip": "^3.0.1",
Expand Down
12 changes: 10 additions & 2 deletions packages/api-gateway/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
} from '@nestjs/common';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { AuthService, I2FAResponse, ILoginResponse } from './auth.service';
import { AuthService } from './auth.service';
import { JwtGuard } from './guards/jwt.guard';
import { JwtRefreshGuard } from './guards/jwt-refresh.guard';
import { GetUser } from '../common/decorators/get-user.decorator';
Expand All @@ -21,6 +21,8 @@ import { Jwt2FAGuard } from './guards/jwt-2fa.guard';
import { VerifyOTPDto } from './dto/verify-otp.dto';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { TrimPipe } from '../common/pipes/trim.pipe';
import { I2FAResponse, I2FaEnabled, ILoginResponse } from './interfaces';
import { ConfirmEmailDto } from './dto/confirm-email.dto';

@ApiTags('auth')
@Controller('auth')
Expand Down Expand Up @@ -74,6 +76,12 @@ export class AuthController {
return this.auth.finalizeLogin(userId);
}

@Post('confirm-email')
confirmEmail(@Body(ValidationPipe) dto: ConfirmEmailDto) {
const { email, code } = dto;
return this.auth.confirmEmail(email, code);
}

// Controller that init the request to enable 2FA
@ApiBearerAuth()
@UseGuards(JwtGuard)
Expand All @@ -94,7 +102,7 @@ export class AuthController {
verify2FA(
@GetUser('id') userId: string,
@Body(ValidationPipe) dto: Verify2FADto,
): Promise<void> {
): Promise<I2FaEnabled> {
return this.auth.verify2FA(userId, dto);
}

Expand Down
86 changes: 62 additions & 24 deletions packages/api-gateway/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,32 +8,37 @@ import { toDataURL } from 'qrcode';
import { ConfigService } from '@nestjs/config';
import { Verify2FADto } from './dto/verify-2fa.dto';
import { CipherService } from '../common/modules/cipher/cipher.service';

export interface I2FAResponse {
twoFactorToken: string;
}

export interface ILoginResponse {
accessToken: string;
refreshToken: string;
}
import { randomInt } from 'crypto';
import { I2FAResponse, I2FaEnabled, ILoginResponse } from './interfaces';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { EmailConfirmation } from '../events';
import timestring from 'timestring';
import { UserStatus } from '../common/constants';

@Injectable()
export class AuthService {
APP_NAME: string;
appName: string;
expVerifyMail: number;

constructor(
private readonly session: SessionService,
private readonly token: TokenService,
private readonly user: UserService,
private readonly cipher: CipherService,
private readonly config: ConfigService,
private readonly event: EventEmitter2,
) {
this.APP_NAME = this.config.get<string>('app.name') as string;
this.appName = this.config.get<string>('app.name') as string;
this.expVerifyMail = timestring(
this.config.get<string>('auth.expVerifyMail') as string,
'ms',
);
}

async register(email: string, password: string): Promise<void> {
await this.user.createUser({ email, password });
const code = this.createPinCode();
await this.user.createUser({ email, password, otpCodes: [code] });
this.event.emit(EmailConfirmation, { email, code });
}

async login(
Expand All @@ -48,6 +53,17 @@ export class AuthService {
return this.finalizeLogin(user.id);
}

async finalizeLogin(userId: string) {
const now = Date.now();
const session = await this.session.createSession(userId, now);
const { accessToken, refreshToken } = await this.createTokens(
userId,
session.id,
now,
);
return { accessToken, refreshToken };
}

async logout(auth: string): Promise<void> {
const jwt = this.token.decode(auth);
await this.session.deleteById(jwt.jti);
Expand All @@ -67,9 +83,13 @@ export class AuthService {

async verifyOTP(userId: string, otp: string) {
const user = await this.user.getUserWithUnselected({ id: userId });
if (!user || !user.otpSecret || !user.otp)
if (!user || !user.otpSecret || !user.otp || !user.otpCodes)
throw new UnauthorizedException('Wrong OTP configuration');

// Check if one of the OTP backUp code has been used
// TODO if so we have to remove the used one and/or recreate one/ten new OTP Code??
if (user.otpCodes.includes(parseInt(otp))) return;

const userSecret = this.cipher.decrypt(user.otpSecret);
const isValid = this.isOTPValid(otp, userSecret);

Expand All @@ -83,7 +103,7 @@ export class AuthService {
// Generate secret for this user
const secret = authenticator.generateSecret();
// Generate otpauth uri
const otpauth = authenticator.keyuri(user.email, this.APP_NAME, secret);
const otpauth = authenticator.keyuri(user.email, this.appName, secret);
// Create QR Code from otpauth
const qrcode = await toDataURL(otpauth);
return {
Expand All @@ -99,29 +119,43 @@ export class AuthService {
});
}

async verify2FA(userId: string, dto: Verify2FADto): Promise<void> {
async verify2FA(userId: string, dto: Verify2FADto): Promise<I2FaEnabled> {
const isValid = this.isOTPValid(dto.otp, dto.secret);
if (!isValid)
throw new UnauthorizedException(
'The provided one time password is not valid',
);

// Fixed OTP Code that can be used to bypass the OTP
const otpCodes = new Array(10).map(this.createPinCode);

// Save secret (encrypted) to db and enable 2FA
await this.user.updateById(userId, {
otp: true,
otpSecret: this.cipher.encrypt(dto.secret),
otpCodes,
});
return { otpCodes };
}

async finalizeLogin(userId: string) {
const now = Date.now();
const session = await this.session.createSession(userId, now);
const { accessToken, refreshToken } = await this.createTokens(
userId,
session.id,
now,
);
return { accessToken, refreshToken };
async confirmEmail(email: string, code: number) {
const user = await this.user.getUserWithUnselected({ email, level: 0 });

if (!user || user.otpCodes?.includes(code))
throw new UnauthorizedException(
'You have entered an invalid verification code',
);

const codeExpired =
Date.now() - user.updatedAt.getTime() > this.expVerifyMail;
if (codeExpired)
throw new UnauthorizedException('Your verification code is expired');

await this.user.updateById(user.id, {
level: 1,
state: UserStatus.ACTIVE,
otpCodes: null,
});
}

private isOTPValid(otp: string, secret: string): boolean {
Expand All @@ -142,4 +176,8 @@ export class AuthService {
]);
return { accessToken: tokens[0], refreshToken: tokens[1] };
}

private createPinCode(): number {
return randomInt(100000, 999999);
}
}
14 changes: 14 additions & 0 deletions packages/api-gateway/src/auth/dto/confirm-email.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail } from 'class-validator';
import { IsNotEmptyString } from '../../common/decorators/is-not-empty-string.decorator';

export class ConfirmEmailDto {
@ApiProperty({ description: 'User email', example: 'email@mysite.com' })
@IsEmail()
@IsNotEmptyString()
readonly email!: string;

@ApiProperty({ description: 'User code', example: '123456' })
@IsNotEmptyString()
readonly code!: number;
}
12 changes: 12 additions & 0 deletions packages/api-gateway/src/auth/interfaces/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export interface I2FAResponse {
twoFactorToken: string;
}

export interface ILoginResponse {
accessToken: string;
refreshToken: string;
}

export interface I2FaEnabled {
otpCodes: number[];
}
12 changes: 1 addition & 11 deletions packages/api-gateway/src/base/base.entity.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import {
Column,
CreateDateColumn,
DeleteDateColumn,
PrimaryGeneratedColumn,
Expand All @@ -13,18 +12,9 @@ export abstract class BaseEntity {
@CreateDateColumn()
createdAt!: Date;

@Column({ type: 'uuid' })
createdBy!: string;

@UpdateDateColumn()
updatedAt!: Date;

@Column({ type: 'uuid' })
updatedBy!: string;

@DeleteDateColumn({ nullable: true })
deletedAt?: Date;

@Column({ type: 'uuid', nullable: true })
deletedBy?: string;
deletedAt!: Date | null;
}
5 changes: 5 additions & 0 deletions packages/api-gateway/src/common/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export enum UserStatus {
ACTIVE = 'active',
PENDING = 'pending',
BANNED = 'banned',
}
34 changes: 34 additions & 0 deletions packages/api-gateway/src/common/modules/mailer/mailer.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { MailerModule } from '@nestjs-modules/mailer';
import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter';
import { Module } from '@nestjs/common';
import { MailService } from './mailer.service';
import { join } from 'path';
import { ConfigModule, ConfigService } from '@nestjs/config';

@Module({
imports: [
ConfigModule,
MailerModule.forRootAsync({
imports: [ConfigModule],
useFactory: async (config: ConfigService) => {
const transport = config.get<string>('email.transport');
return {
transport: transport ?? { jsonTransport: true },
defaults: {
from: config.get<string>('email.from'),
},
template: {
dir: join(__dirname, 'templates'),
adapter: new HandlebarsAdapter(),
options: {
strict: true,
},
},
};
},
inject: [ConfigService],
}),
],
providers: [MailService],
})
export class MailModule {}
25 changes: 25 additions & 0 deletions packages/api-gateway/src/common/modules/mailer/mailer.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { EmailConfirmation, EmailConfirmationDto } from '../../../events';
import { MailerService } from '@nestjs-modules/mailer';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class MailService {
appName: string;
constructor(
private readonly mail: MailerService,
private readonly config: ConfigService,
) {
this.appName = this.config.get<string>('app.name') as string;
}
@OnEvent(EmailConfirmation)
async emailConfirmation({ email, code }: EmailConfirmationDto) {
await this.mail.sendMail({
to: email,
subject: `Welcome to ${this.appName}! Confirm your Email`,
template: 'email-confirmation',
context: { code, appName: this.appName },
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<h3>Welcome to {{appName}}!</h3>
<p>Your email authentication code is {{code}} - Valid for 8 minutes.</p>
<p>For account security purposes, please do not share this authentication code
with anyone.</p>
<p>Regards,</p>
<p>{{appName}} Team</p>
6 changes: 5 additions & 1 deletion packages/api-gateway/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { AppConfig } from '../typings/common';

function ensureValues(
key: string,
defaultValue: string,
defaultValue?: string,
throwOnMissing = true,
): string {
const value = process.env[key];
Expand Down Expand Up @@ -54,6 +54,10 @@ export default (): AppConfig => {
password: ensureValues('POSTGRES_PASSWORD', 'postgres'),
database: ensureValues('POSTGRES_DATABASE', 'postgres'),
},
email: {
transport: ensureValues('EMAIL_TRANSPORT', undefined, false),
from: ensureValues('EMAIL_FROM', undefined),
},
encryption: {
secret: ensureValues('ENCRYPTION_KEY', 'CHANGE-ENCRYPTION-KEY'),
},
Expand Down
5 changes: 5 additions & 0 deletions packages/api-gateway/src/events/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const EmailConfirmation = 'email.confirmation.token';
export interface EmailConfirmationDto {
email: string;
code: string;
}
Loading

0 comments on commit 38e730b

Please sign in to comment.