Skip to content

Commit

Permalink
email verification
Browse files Browse the repository at this point in the history
  • Loading branch information
mrkeksz committed Dec 11, 2023
1 parent 3a92f60 commit 1174419
Show file tree
Hide file tree
Showing 2 changed files with 128 additions and 43 deletions.
98 changes: 97 additions & 1 deletion src/email-verification/email-verification.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1 +1,97 @@
// TODO: tests
import {Test, TestingModule} from '@nestjs/testing'
import {getRepositoryToken} from '@nestjs/typeorm'
import {Repository} from 'typeorm'
import {EmailVerificationService} from './email-verification.service'
import {UsersService} from '../users/users.service'
import {AuthService} from '../auth/auth.service'
import {EmailVerificationConfigService} from '../config/email-verification/email-verification.config.service'
import {EmailVerificationCode} from './entities/email-verification-code.entity'
import {User} from '../users/entities/user.entity'
import {UnauthorizedException} from '@nestjs/common'

describe('EmailVerificationService', () => {
let service: EmailVerificationService
let usersService: UsersService
let authService: AuthService
let verificationCodeRepository: Repository<EmailVerificationCode>

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
EmailVerificationService,
{provide: getRepositoryToken(EmailVerificationCode), useClass: Repository},
{
provide: UsersService,
useValue: {
getOrCreateUserByEmail: jest.fn(),
getUserByEmail: jest.fn(),
},
},
{
provide: AuthService,
useValue: {
login: jest.fn(),
},
},
{
provide: EmailVerificationConfigService,
useValue: {verificationCodeLifetimeMilliseconds: 60000},
},
],
}).compile()

service = module.get<EmailVerificationService>(EmailVerificationService)
usersService = module.get<UsersService>(UsersService)
authService = module.get<AuthService>(AuthService)
verificationCodeRepository = module.get<Repository<EmailVerificationCode>>(
getRepositoryToken(EmailVerificationCode),
)
})

it('should send verification code to email', async () => {
const user = new User()
const verificationCode = new EmailVerificationCode()
verificationCode.code = 123456

jest.spyOn(usersService, 'getOrCreateUserByEmail').mockResolvedValue(user)
jest.spyOn(verificationCodeRepository, 'findOne').mockResolvedValue(verificationCode)

await service.sendVerificationCodeToEmail('[email protected]')
})

it('should throw UnauthorizedException when verifying email with invalid user', async () => {
jest.spyOn(usersService, 'getUserByEmail').mockResolvedValue(null)

await expect(service.verifyEmail('[email protected]', 123456)).rejects.toThrow(
UnauthorizedException,
)
})

it('should throw UnauthorizedException when verifying email with invalid code', async () => {
const user = new User()
const verificationCode = new EmailVerificationCode()
verificationCode.code = 123456

jest.spyOn(usersService, 'getUserByEmail').mockResolvedValue(user)
jest.spyOn(verificationCodeRepository, 'findOne').mockResolvedValue(verificationCode)

await expect(service.verifyEmail('[email protected]', 654321)).rejects.toThrow(
UnauthorizedException,
)
})

it('should verify email with valid user and code', async () => {
const user = new User()
const verificationCode = new EmailVerificationCode()
verificationCode.code = 123456

jest.spyOn(usersService, 'getUserByEmail').mockResolvedValue(user)
jest.spyOn(verificationCodeRepository, 'findOne').mockResolvedValue(verificationCode)
jest.spyOn(authService, 'login').mockResolvedValue({} as any)
jest.spyOn(verificationCodeRepository, 'save').mockResolvedValue(undefined as any)

await expect(service.verifyEmail('[email protected]', 123456)).resolves.toBeDefined()

expect(verificationCode.status).toBe('used')
})
})
73 changes: 31 additions & 42 deletions src/email-verification/email-verification.service.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,26 @@
import {Injectable, Logger, ForbiddenException} from '@nestjs/common'
import {Injectable, Logger, UnauthorizedException} from '@nestjs/common'
import {InjectRepository} from '@nestjs/typeorm'
import {MoreThan, Repository} from 'typeorm'
import {EmailVerificationCodeSendingAttempt as SendingAttempt} from './entities/email-verification-code-sending-attempt.entity'
import {Repository} from 'typeorm'
import {EmailVerificationCode as VerificationCode} from './entities/email-verification-code.entity'
import {UsersService} from '../users/users.service'
import {EmailVerificationConfigService} from '../config/email-verification/email-verification.config.service'
import {User} from '../users/entities/user.entity'
import {Auth} from '../auth/models/auth.model'
import {AuthService} from '../auth/auth.service'

@Injectable()
export class EmailVerificationService {
private readonly logger = new Logger(EmailVerificationService.name)
private readonly maxSendingAttempts: number
private readonly lifetimeMilliseconds: number
private readonly verificationCodeLifetimeMs: number

constructor(
@InjectRepository(SendingAttempt)
private readonly sendingAttemptRepository: Repository<SendingAttempt>,
@InjectRepository(VerificationCode)
private readonly verificationCodeRepository: Repository<VerificationCode>,
private readonly usersService: UsersService,
private readonly authService: AuthService,
emailVerificationConfig: EmailVerificationConfigService,
) {
this.maxSendingAttempts = emailVerificationConfig.maxSendingVerificationCodeAttempts
this.lifetimeMilliseconds = emailVerificationConfig.verificationCodeLifetimeMilliseconds
}

// TODO: метод проверки введенного кода

// TODO: вынести email verification sending attempt в отдельный сервис и все методы, связанные с ним
public async enforceEmailVerificationSendingLimit(
senderIp: string,
email: string,
): Promise<void> {
const tenMinutesAgo = new Date(Date.now() - this.lifetimeMilliseconds)
const sendingAttemptsCount = await this.sendingAttemptRepository.count({
where: {senderIp, createdAt: MoreThan(tenMinutesAgo)},
take: this.maxSendingAttempts,
cache: false,
})
if (sendingAttemptsCount >= this.maxSendingAttempts) {
throw new ForbiddenException(
'You have exceeded the limit of email verification requests for the last 10 minutes.',
)
}
await this.createSendingAttempt({senderIp, email})
this.verificationCodeLifetimeMs = emailVerificationConfig.verificationCodeLifetimeMilliseconds
}

public async sendVerificationCodeToEmail(email: string): Promise<void> {
Expand All @@ -54,6 +31,22 @@ export class EmailVerificationService {
this.logger.debug(`Sending the verification code "${verificationCode.code}" to "${email}"`)
}

public async verifyEmail(email: string, code: number): Promise<Auth> {
const user = await this.usersService.getUserByEmail(email)
if (!user) {
throw new UnauthorizedException('Invalid verification code')
}

const verificationCode = await this.getActiveVerificationCodeByUser(user)
if (verificationCode?.code !== code) {
throw new UnauthorizedException('Invalid verification code')
}

verificationCode.status = 'used'
await this.verificationCodeRepository.save(verificationCode)
return this.authService.login(user.id)
}

private generateRandomCode(): number {
return Math.floor(Math.random() * (999999 - 100000)) + 100000
}
Expand All @@ -65,12 +58,9 @@ export class EmailVerificationService {
}

private createVerificationCodeByUser(user: User): Promise<VerificationCode> {
const verificationCode = this.verificationCodeRepository.create({
user,
code: this.generateRandomCode(),
expirationDate: new Date(Date.now() + this.lifetimeMilliseconds),
})
return this.verificationCodeRepository.save(verificationCode)
const code = this.generateRandomCode()
const expirationDate = new Date(Date.now() + this.verificationCodeLifetimeMs)
return this.createVerificationCode({user, code, expirationDate})
}

private getActiveVerificationCodeByUser(user: User): Promise<VerificationCode | null> {
Expand All @@ -79,11 +69,10 @@ export class EmailVerificationService {
})
}

private async createSendingAttempt({
senderIp,
email,
}: Pick<SendingAttempt, 'senderIp' | 'email'>): Promise<SendingAttempt> {
const sendingAttempt = this.sendingAttemptRepository.create({senderIp, email})
return this.sendingAttemptRepository.save(sendingAttempt)
private createVerificationCode(
data: Pick<VerificationCode, 'user' | 'code' | 'expirationDate'>,
): Promise<VerificationCode> {
const verificationCode = this.verificationCodeRepository.create(data)
return this.verificationCodeRepository.save(verificationCode)
}
}

0 comments on commit 1174419

Please sign in to comment.