Skip to content

Commit

Permalink
Implement forgotten password funcitonality (#324)
Browse files Browse the repository at this point in the history
* forgot password and reset forgotten password

* included APP_URL_LOCAL in .env file

* throw Exceptions on error

* new providers into the tests

* replaced exception with logger and increase jwt time of expiration

* conditional on the value of APP_ENV

* renamed functions

* renamed functions

* template change requested

* renamed as requested

* exception message

* test errors

* test errors

* throw error instead logger
  • Loading branch information
borislavstoychev authored Aug 14, 2022
1 parent ff9f1e7 commit 7bf1f2a
Show file tree
Hide file tree
Showing 15 changed files with 301 additions and 2 deletions.
2 changes: 2 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ TARGET_ENV=development
##############
API_PORT=5010
APP_URL=https://dev.podkrepi.bg
APP_URL_LOCAL=http://localhost:3040


## Database ##
##############
Expand Down
1 change: 1 addition & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ STRIPE_SECRET_KEY=stripe-key
STRIPE_WEBHOOK_SECRET=stripe-secret
S3_ACCESS_KEY=s3-access-key
S3_SECRET_ACCESS_KEY=s3-secret-access-key
JWT_SECRET_KEY = VerySecretPrivetKey
10 changes: 10 additions & 0 deletions apps/api/src/account/account.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { HttpService } from '@nestjs/axios'
import { KEYCLOAK_INSTANCE } from 'nest-keycloak-connect'
import KeycloakConnect from 'keycloak-connect'
import { mock, mockDeep } from 'jest-mock-extended'
import { JwtService } from '@nestjs/jwt'
import { EmailService } from '../email/email.service'

describe('AccountController', () => {
let controller: AccountController
Expand Down Expand Up @@ -56,6 +58,14 @@ describe('AccountController', () => {
provide: KEYCLOAK_INSTANCE,
useValue: mock<KeycloakConnect.Keycloak>(),
},
{
provide: JwtService,
useValue: mockDeep<JwtService>(),
},
{
provide: EmailService,
useValue: mockDeep<EmailService>(),
},
],
})
.overrideProvider(ConfigService)
Expand Down
10 changes: 10 additions & 0 deletions apps/api/src/account/account.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import KeycloakAdminClient from '@keycloak/keycloak-admin-client'
import { HttpService } from '@nestjs/axios'
import { ConfigService } from '@nestjs/config'
import { JwtService } from '@nestjs/jwt'
import { Test, TestingModule } from '@nestjs/testing'
import { mock, mockDeep } from 'jest-mock-extended'
import KeycloakConnect from 'keycloak-connect'
import { KEYCLOAK_INSTANCE } from 'nest-keycloak-connect'
import { AuthService } from '../auth/auth.service'
import { EmailService } from '../email/email.service'
import { PersonService } from '../person/person.service'
import { MockPrismaService } from '../prisma/prisma-client.mock'
import { AccountService } from './account.service'
Expand Down Expand Up @@ -43,6 +45,14 @@ describe('AccountService', () => {
provide: KEYCLOAK_INSTANCE,
useValue: mock<KeycloakConnect.Keycloak>(),
},
{
provide: JwtService,
useValue: mockDeep<JwtService>(),
},
{
provide: EmailService,
useValue: mockDeep<EmailService>(),
},
],
}).compile()

Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/assets/templates/forgot-password.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"subject": "Забравена парола за Подкрепи.бг"
}
55 changes: 55 additions & 0 deletions apps/api/src/assets/templates/forgot-password.mjml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<mjml>
<mj-body background-color="#ffffff" font-size="13px">
<mj-section
background-color="#009FE3"
vertical-align="top"
padding-bottom="0px"
padding-top="0">
</mj-section>
<mj-section background-color="#009fe3" padding-bottom="20px" padding-top="20px">
<mj-column vertical-align="middle" width="100%">
<mj-text
align="left"
color="#ffffff"
font-size="22px"
font-family="open Sans Helvetica, Arial, sans-serif"
padding-left="25px"
padding-right="25px">
<span style="color: #feeb35"> Здравейте {{firstName}} {{lastName}}, </span>
<br /><br />
</mj-text>
<mj-text
align="left"
color="#ffffff"
font-size="15px"
font-family="open Sans Helvetica, Arial, sans-serif"
padding-left="25px"
padding-right="25px">
някой (може би Вие) е пуснал заявка за смяна на парола за достъп до Подкрепи.бг.<br /><br />
Ако не сте пускали подобна заявка, не е необходимо да правите нищо.<br /><br />
Ако Вие сте поискали възстановяване на паролата, натиснете бутона „Нова парола“.<br /><br />
</mj-text>
<mj-button
align="center"
font-size="18px"
background-color="#20c5a0"
border-radius="8px"
color="#fff"
font-family="open Sans Helvetica, Arial, sans-serif"
href="{{link}}"
>Нова парола
</mj-button>
<mj-text
align="left"
color="#ffffff"
font-size="15px"
font-family="open Sans Helvetica, Arial, sans-serif"
padding-left="25px"
padding-right="25px">
Поздрави, <br />
Екипът на Подкрепи.бг
</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>
6 changes: 5 additions & 1 deletion apps/api/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,17 @@ import { KeycloakConfigService } from '../config/keycloak-config.service'
import { RefreshController } from './refresh.controller'
import { HttpModule } from '@nestjs/axios'
import { ProviderLoginController } from './provider-login.controller'
import { JwtModule, JwtService } from '@nestjs/jwt'
import { EmailService } from '../email/email.service'
import { TemplateService } from '../email/template.service'

@Module({
controllers: [LoginController, RegisterController, RefreshController, ProviderLoginController],
providers: [AuthService, PrismaService],
providers: [AuthService, PrismaService, EmailService, JwtService, TemplateService],
imports: [
AppConfigModule,
HttpModule,
JwtModule,
KeycloakConnectModule.registerAsync({
useExisting: KeycloakConfigService,
imports: [AppConfigModule],
Expand Down
21 changes: 21 additions & 0 deletions apps/api/src/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import { MockPrismaService, prismaMock } from '../prisma/prisma-client.mock'
import { RefreshDto } from './dto/refresh.dto'
import { firstValueFrom, Observable } from 'rxjs'
import { ProviderDto } from './dto/provider.dto'
import { EmailService } from '../email/email.service'
import { JwtService } from '@nestjs/jwt'
import { TemplateService } from '../email/template.service'

jest.mock('@keycloak/keycloak-admin-client')

Expand All @@ -25,6 +28,9 @@ describe('AuthService', () => {
let admin: KeycloakAdminClient
let httpService: HttpService
let keycloak: KeycloakConnect.Keycloak
let sendEmail: EmailService
let jwtService: JwtService
let templateService: TemplateService

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
Expand Down Expand Up @@ -53,6 +59,18 @@ describe('AuthService', () => {
provide: KEYCLOAK_INSTANCE,
useValue: mockDeep<KeycloakConnect.Keycloak>(),
},
{
provide: JwtService,
useValue: mockDeep<JwtService>(),
},
{
provide: EmailService,
useValue: mockDeep<EmailService>(),
},
{
provide: TemplateService,
useValue: mockDeep<TemplateService>(),
},
],
}).compile()

Expand All @@ -61,6 +79,9 @@ describe('AuthService', () => {
admin = module.get<KeycloakAdminClient>(KeycloakAdminClient)
keycloak = module.get<KeycloakConnect.Keycloak>(KEYCLOAK_INSTANCE)
httpService = module.get<HttpService>(HttpService)
sendEmail = module.get<EmailService>(EmailService)
jwtService = module.get<JwtService>(JwtService)
templateService = module.get<TemplateService>(TemplateService)
})

it('should be defined', () => {
Expand Down
55 changes: 55 additions & 0 deletions apps/api/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import {
BadRequestException,
Inject,
Injectable,
InternalServerErrorException,
Logger,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common'
import { HttpService } from '@nestjs/axios'
Expand All @@ -23,6 +25,11 @@ import { RefreshDto } from './dto/refresh.dto'
import { KeycloakTokenParsed } from './keycloak'
import { ProviderDto } from './dto/provider.dto'
import { UpdatePersonDto } from '../person/dto/update-person.dto'
import { ForgottenPasswordEmailDto } from './dto/forgot-password.dto'
import { JwtService } from '@nestjs/jwt'
import { EmailService } from '../email/email.service'
import { ForgottenPasswordMailDto } from '../email/template.interface'
import { NewPasswordDto } from './dto/recovery-password.dto'

type ErrorResponse = { error: string; data: unknown }
type KeycloakErrorResponse = { error: string; error_description: string }
Expand Down Expand Up @@ -50,6 +57,8 @@ export class AuthService {
private readonly admin: KeycloakAdminClient,
private readonly prismaService: PrismaService,
private readonly httpService: HttpService,
private jwtService: JwtService,
private sendEmail: EmailService,
@Inject(KEYCLOAK_INSTANCE) private keycloak: KeycloakConnect.Keycloak,
) {}

Expand Down Expand Up @@ -273,4 +282,50 @@ export class AuthService {
},
)
}

async sendMailForPasswordChange(forgotPasswordDto: ForgottenPasswordEmailDto) {
const stage = this.config.get<string>('APP_ENV') === 'development' ? 'APP_URL_LOCAL' : 'APP_URL'
const user = await this.prismaService.person.findFirst({
where: { email: forgotPasswordDto.email },
})
if (!user) {
throw new NotFoundException('Invalid email')
}
const payload = { username: user.email, sub: user.keycloakId }
const jtwSecret = process.env.JWT_SECRET_KEY
const access_token = this.jwtService.sign(payload, {
secret: jtwSecret,
expiresIn: '60m',
})
const appUrl = this.config.get<string>(stage)
const link = `${appUrl}/change-password?token=${access_token}`
const profile = {
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
link: link,
}
const userEmail = { to: [user.email] }
const mail = new ForgottenPasswordMailDto(profile)
await this.sendEmail.sendFromTemplate(mail, userEmail)
}

async updateForgottenPassword(recoveryPasswordDto: NewPasswordDto) {
try {
const { sub: keycloakId } = this.jwtService.verify(recoveryPasswordDto.token, {
secret: process.env.JWT_SECRET_KEY,
})
return await this.updateUserPassword(keycloakId, recoveryPasswordDto)
} catch (error) {
const response = {
error: error.message,
data: error?.response?.data,
}
throw response.data
? new NotFoundException(response.data)
: new BadRequestException(
'The forgotten password link has expired, request a new link and try again!',
)
}
}
}
11 changes: 11 additions & 0 deletions apps/api/src/auth/dto/forgot-password.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { ApiProperty } from '@nestjs/swagger'
import { Expose } from 'class-transformer'
import { IsEmail, IsNotEmpty } from 'class-validator'

export class ForgottenPasswordEmailDto {
@ApiProperty()
@Expose()
@IsNotEmpty()
@IsEmail()
public readonly email: string
}
17 changes: 17 additions & 0 deletions apps/api/src/auth/dto/recovery-password.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { ApiProperty } from '@nestjs/swagger'
import { Expose } from 'class-transformer'
import { IsJWT, IsNotEmpty, IsString } from 'class-validator'

export class NewPasswordDto {
@ApiProperty()
@Expose()
@IsNotEmpty()
@IsJWT()
public readonly token: string

@ApiProperty()
@Expose()
@IsNotEmpty()
@IsString()
public readonly password: string
}
13 changes: 13 additions & 0 deletions apps/api/src/auth/login.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { Body, Controller, Post } from '@nestjs/common'
import { Public, Resource, Scopes } from 'nest-keycloak-connect'

import { AuthService } from './auth.service'
import { ForgottenPasswordEmailDto } from './dto/forgot-password.dto'
import { LoginDto } from './dto/login.dto'
import { NewPasswordDto } from './dto/recovery-password.dto'

@Controller('login')
@Resource('login')
Expand All @@ -15,4 +17,15 @@ export class LoginController {
async login(@Body() loginDto: LoginDto) {
return await this.authService.login(loginDto)
}

@Post('/forgot-password')
@Public()
async forgotPassword(@Body() forgotPasswordDto: ForgottenPasswordEmailDto) {
return await this.authService.sendMailForPasswordChange(forgotPasswordDto)
}
@Post('/reset-password')
@Public()
async recoveryPassword(@Body() RecoveryPasswordDto: NewPasswordDto) {
return await this.authService.updateForgottenPassword(RecoveryPasswordDto)
}
}
6 changes: 6 additions & 0 deletions apps/api/src/email/template.interface.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { CreatePersonDto } from '../person/dto/create-person.dto'
import { CreateInquiryDto } from '../support/dto/create-inquiry.dto'
import { CreateRequestDto } from '../support/dto/create-request.dto'

Expand All @@ -6,6 +7,7 @@ export enum TemplateType {
welcomeInternal = 'welcome-internal',
inquiryReceived = 'inquiry-received',
inquiryReceivedInternal = 'inquiry-received-internal',
forgotPass = 'forgot-password',
}
export type TemplateTypeKeys = keyof typeof TemplateType
export type TemplateTypeValues = typeof TemplateType[TemplateTypeKeys]
Expand All @@ -28,6 +30,10 @@ export abstract class EmailTemplate<C> {
}
}

export class ForgottenPasswordMailDto extends EmailTemplate<CreatePersonDto> {
name = TemplateType.forgotPass
}

export class WelcomeEmailDto extends EmailTemplate<CreateRequestDto> {
name = TemplateType.welcome
}
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"@nestjs/common": "8.4.5",
"@nestjs/config": "2.0.0",
"@nestjs/core": "8.4.5",
"@nestjs/jwt": "^9.0.0",
"@nestjs/platform-express": "8.4.5",
"@nestjs/swagger": "5.2.1",
"@nestjs/terminus": "8.0.6",
Expand Down
Loading

2 comments on commit 7bf1f2a

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coverage report

St.
Category Percentage Covered / Total
🟡 Statements 72.42% 1804/2491
🔴 Branches 44.81% 216/482
🔴 Functions 45.02% 217/482
🟡 Lines 70.49% 1593/2260

Test suite run success

172 tests passing in 62 suites.

Report generated by 🧪jest coverage report action from 7bf1f2a

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coverage report

St.
Category Percentage Covered / Total
🟡 Statements 72.42% 1804/2491
🔴 Branches 44.81% 216/482
🔴 Functions 45.02% 217/482
🟡 Lines 70.49% 1593/2260

Test suite run success

172 tests passing in 62 suites.

Report generated by 🧪jest coverage report action from 7bf1f2a

Please sign in to comment.