-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[TM-1311] Implement password reset #46
base: staging
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
import { ApiProperty } from "@nestjs/swagger"; | ||
import { JsonApiDto } from "@terramatch-microservices/common/decorators"; | ||
import { JsonApiAttributes } from "@terramatch-microservices/common/dto/json-api-attributes"; | ||
|
||
|
||
@JsonApiDto({ type: 'logins', id: 'number' }) | ||
export class RequestResetPasswordDto extends JsonApiAttributes<RequestResetPasswordDto> { | ||
@ApiProperty({ | ||
description: | ||
'User email', | ||
example: '[email protected]', | ||
}) | ||
emailAddress: string; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
import { JsonApiDto } from "@terramatch-microservices/common/decorators"; | ||
import { JsonApiAttributes } from "@terramatch-microservices/common/dto/json-api-attributes"; | ||
import { ApiProperty } from "@nestjs/swagger"; | ||
|
||
@JsonApiDto({ type: 'logins', id: 'number' }) | ||
export class ResetPasswordResponseOperationDto extends JsonApiAttributes<ResetPasswordResponseOperationDto> { | ||
@ApiProperty({ | ||
description: | ||
'User email', | ||
example: '[email protected]', | ||
}) | ||
message: string; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
import { ApiProperty } from "@nestjs/swagger"; | ||
import { JsonApiDto } from "@terramatch-microservices/common/decorators"; | ||
import { JsonApiAttributes } from "@terramatch-microservices/common/dto/json-api-attributes"; | ||
|
||
@JsonApiDto({ type: 'logins', id: 'number' }) | ||
export class ResetPasswordResponseDto extends JsonApiAttributes<ResetPasswordResponseDto> { | ||
@ApiProperty({ | ||
description: 'User id', | ||
example: 'ac905c37-025c-4548-9851-f749ed15b5e1' | ||
}) | ||
uuid: string; | ||
|
||
@ApiProperty({ | ||
description: | ||
'User email', | ||
example: '[email protected]', | ||
}) | ||
emailAddress: string; | ||
|
||
@ApiProperty({ | ||
description: 'User Id', | ||
example: '12345', | ||
}) | ||
userId: number; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
export class ResetPasswordDto { | ||
newPassword: string; | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should have some validation on it. See |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,55 @@ | ||
import { | ||
Controller, | ||
Body, | ||
Post, | ||
HttpStatus, | ||
BadRequestException, | ||
Param, | ||
} from '@nestjs/common'; | ||
import { ResetPasswordService } from './reset-password.service'; | ||
import { ApiOperation } from '@nestjs/swagger'; | ||
import { JsonApiResponse } from '@terramatch-microservices/common/decorators'; | ||
import { buildJsonApi, JsonApiDocument } from '@terramatch-microservices/common/util'; | ||
import { ApiException } from '@nanogiants/nestjs-swagger-api-exception-decorator'; | ||
import { RequestResetPasswordDto } from './dto/reset-password-request.dto'; | ||
import { ResetPasswordDto } from './dto/reset-password.dto'; | ||
import { NoBearerAuth } from '@terramatch-microservices/common/guards'; | ||
import { ResetPasswordResponseOperationDto } from "./dto/reset-password-response-operation.dto"; | ||
|
||
@Controller('auth/v3/reset-password') | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For resource named endpoints in this codebase, they should be camel case and plural. I would probably call this |
||
export class ResetPasswordController { | ||
constructor(private readonly resetPasswordService: ResetPasswordService) {} | ||
|
||
@Post('request') | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When creating a resource (in the case of this controller, a password reset), it's more typical in REST to post directly to the root resource path. So, this would be simply |
||
@NoBearerAuth | ||
@ApiOperation({ | ||
operationId: 'requestPasswordReset', | ||
description: 'Send password reset email with a token', | ||
}) | ||
@JsonApiResponse({ status: HttpStatus.CREATED, data: { type: RequestResetPasswordDto } }) | ||
@ApiException(() => BadRequestException, { description: 'Invalid request or email.' }) | ||
async requestReset(@Body() { emailAddress }: RequestResetPasswordDto): Promise<JsonApiDocument> { | ||
const response = await this.resetPasswordService.sendResetPasswordEmail(emailAddress); | ||
return buildJsonApi() | ||
.addData(`${response.userId}`, response) | ||
.document.serialize(); | ||
} | ||
|
||
@Post('reset/:token') | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it would follow REST conventions better if this were |
||
@NoBearerAuth | ||
@ApiOperation({ | ||
operationId: 'resetPassword', | ||
description: 'Reset password using the provided token', | ||
}) | ||
@JsonApiResponse({ status: HttpStatus.OK, data: { type: ResetPasswordResponseOperationDto } }) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These endpoints are returning different resources, which is not in keeping with a resource-oriented API. A given resource controller should return the same payload (DTO) for all create and update endpoints so that the FE can expect a consistent shape for the resource. |
||
@ApiException(() => BadRequestException, { description: 'Invalid or expired token.' }) | ||
async resetPassword( | ||
@Param('token') token: string, | ||
@Body() { newPassword }: ResetPasswordDto | ||
): Promise<JsonApiDocument> { | ||
const response = await this.resetPasswordService.resetPassword(token, newPassword); | ||
return buildJsonApi() | ||
.addData('sads',response) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
.document.serialize(); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,72 @@ | ||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; | ||
import bcrypt from 'bcryptjs'; | ||
import { JwtService } from '@nestjs/jwt'; | ||
import { User, LocalizationKeys } from '@terramatch-microservices/database/entities'; | ||
import { ResetPasswordDto } from './dto/reset-password.dto'; | ||
import { RequestResetPasswordDto } from './dto/reset-password-request.dto'; | ||
import { ResetPasswordResponseDto } from './dto/reset-password-response.dto'; | ||
import { EmailService } from '../email/email.service'; | ||
import {ConfigService} from "@nestjs/config"; | ||
import { ResetPasswordResponseOperationDto } from "./dto/reset-password-response-operation.dto"; | ||
|
||
@Injectable() | ||
export class ResetPasswordService { | ||
constructor( | ||
private readonly jwtService: JwtService, | ||
private readonly emailService: EmailService, | ||
private readonly configService: ConfigService, | ||
//private readonly userService: UserService // Assuming you have a User service to interact with the database | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please don't leave in commented out code. |
||
) {} | ||
|
||
async sendResetPasswordEmail(emailAddress: string) { | ||
const user = await User.findOne({ where: { emailAddress } }); | ||
if (!user) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎨 I haven't set up a linter rule for this yet so you can leave it for now, but I greatly prefer explicit null checks (I'm a little surprised this passed the linter actually, I'll have to look into it)
|
||
throw new NotFoundException('User not found'); | ||
} | ||
|
||
const resetToken = await this.jwtService.signAsync( | ||
{ sub: user.uuid }, // user id as the subject | ||
{ expiresIn: '1h', secret: 'reset_password_secret' } // token expires in 1 hour | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What is this secret accomplishing? Since our JWTs are signed with a secret key, I don't think there's any real value here. If there is some value to including it, it should be from the environment (and be a random string), not included in our publicly accessible codebase. |
||
); | ||
|
||
const localizationKeys = await LocalizationKeys.findOne({where: { key: 'reset-password.body'}}); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm surprised this is passing the linter too. I clearly have some work to do there. I recommend setting up your editor to use our prettier config on save so that the styling of your code matches the codebase (the thing that stands out here is the lack of spaces inside the curly brace ( |
||
|
||
if (!localizationKeys) { | ||
throw new NotFoundException('Localization body not found'); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I like to have anything that can be checked and might throw an error to happen at the top of the method so additional work isn't done when something fails. In this case that would mean moving the |
||
} | ||
const url = this.configService.get('TERRAMATCH_WEBSITE_URL'); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please make sure new ENV variables are included in |
||
const resetLink = `${url}/auth/reset-password/${resetToken}`; | ||
const bodyEmail = localizationKeys.value.replace('link', `<a href="${resetLink}">link</a>`); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should probably include |
||
await this.emailService.sendEmail( | ||
user.emailAddress, | ||
'Reset Password', | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The title needs to be translated as well as the body. |
||
bodyEmail, | ||
); | ||
|
||
return new ResetPasswordResponseDto({emailAddress: user.emailAddress, uuid: user.uuid, userId: user.id}); | ||
} | ||
|
||
async resetPassword(resetToken: string, newPassword: string) { | ||
console.log(" token: ", resetToken) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please don't leave |
||
let userId; | ||
try { | ||
const payload = await this.jwtService.verifyAsync(resetToken, { | ||
secret: 'reset_password_secret', | ||
}); | ||
userId = payload.sub; | ||
} catch (error) { | ||
console.log(error) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should probably be kept as a log, but use the log service instead. |
||
throw new BadRequestException('Invalid or provide token is expired'); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Small typo here - should be |
||
} | ||
|
||
const user = await User.findOne({ where: { id: userId } }); | ||
if (!user) { | ||
throw new NotFoundException('User not found'); | ||
} | ||
|
||
const hashedPassword = await bcrypt.hash(newPassword, 10); | ||
await User.update({ password: hashedPassword }, { where: { id: userId } }); | ||
|
||
return new ResetPasswordResponseOperationDto({ message: 'Password successfully reset' }); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
import { Module } from '@nestjs/common'; | ||
import { ConfigModule } from '@nestjs/config'; | ||
import { EmailService } from './email.service'; | ||
|
||
@Module({ | ||
imports: [ConfigModule], | ||
providers: [EmailService], | ||
exports: [EmailService], | ||
}) | ||
export class EmailModule {} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please move this module to the |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
import { Injectable } from '@nestjs/common'; | ||
import * as nodemailer from 'nodemailer'; | ||
import { ConfigService } from '@nestjs/config'; | ||
|
||
@Injectable() | ||
export class EmailService { | ||
private transporter: nodemailer.Transporter; | ||
|
||
constructor(private readonly configService: ConfigService) { | ||
this.transporter = nodemailer.createTransport({ | ||
host: this.configService.get<string>('MAIL_HOST'), | ||
port: this.configService.get<number>('MAIL_PORT'), | ||
secure: false, // true for 465, false for other ports | ||
auth: { | ||
user: this.configService.get<string>('MAIL_USERNAME'), | ||
pass: this.configService.get<string>('MAIL_PASSWORD'), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Make sure these new config values are documented in |
||
}, | ||
}); | ||
} | ||
|
||
async sendEmail(to: string, subject: string, body: string): Promise<void> { | ||
const mailOptions = { | ||
from: this.configService.get<string>('MAIL_FROM_ADDRESS'), | ||
to, | ||
subject, | ||
html: body, | ||
}; | ||
|
||
await this.transporter.sendMail(mailOptions); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
import { | ||
AllowNull, | ||
AutoIncrement, | ||
Column, | ||
Model, | ||
PrimaryKey, | ||
Table, | ||
Unique | ||
} from "sequelize-typescript"; | ||
import { BIGINT, NUMBER, STRING } from "sequelize"; | ||
|
||
|
||
|
||
@Table({ tableName: "localization_keys", underscored: true, paranoid: false }) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
export class LocalizationKeys extends Model<LocalizationKeys> { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The convention in laravel is to have the database table name be pluralized, but in this codebase all entities are singular: |
||
|
||
@PrimaryKey | ||
@AutoIncrement | ||
@Column(BIGINT.UNSIGNED) | ||
override id: number; | ||
|
||
@AllowNull | ||
@Column(STRING) | ||
key: string | null; | ||
|
||
@AllowNull | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. According to the current DB schema, |
||
@Column(STRING) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This column appears to be a TEXT type, not STRING in the DB ( |
||
value: string | null; | ||
|
||
@AllowNull | ||
@Unique | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure if these value ids are required to be unique or not, but I do see that the current DB schema doesn't set a uniqueness constraint on this column, so we shouldn't here either. |
||
@Column(NUMBER) | ||
valueId: number; | ||
|
||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I haven't seen how this is used yet, but this description / example doesn't seem right for a property named
message
.