Skip to content

Commit bcd58a7

Browse files
committed
Add endpoints for password resrt
1 parent bb6f552 commit bcd58a7

File tree

9 files changed

+142
-23
lines changed

9 files changed

+142
-23
lines changed

apps/api/src/auth/auth.controller.ts

+17-1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { AuthService } from './auth.service';
1919
import { UserRegisterDto } from './dto/user-register.dto';
2020
import { AuthenticatedGuard } from './guards/authenticated.guard';
2121
import { LocalAuthGuard } from './guards/local-auth.guard';
22+
import { ResetPasswordDto } from './dto/reset-password.dto';
2223

2324
@Controller('auth')
2425
@ApiTags('auth')
@@ -68,7 +69,7 @@ export class AuthController {
6869
@UseGuards(AuthenticatedGuard)
6970
async startEmailConfirmation(@Req() request: Request): Promise<any> {
7071
const userId = new Types.ObjectId(request.user.id);
71-
await this.authService.retryConfirmationEmail(userId);
72+
await this.authService.sendEmailConfirmation(userId);
7273
return { statusCode: 200 };
7374
}
7475

@@ -80,4 +81,19 @@ export class AuthController {
8081
const user = await this.authService.confirmUserEmail(userId, token);
8182
return User.toPrivateDto(user);
8283
}
84+
85+
@Post('reset-password/start')
86+
@UseGuards(AuthenticatedGuard)
87+
async startPasswordReset(@Query('email') email: string): Promise<any> {
88+
await this.authService.sendPasswordResetEmail(email);
89+
return { statusCode: 200 };
90+
}
91+
92+
@Post('reset-password/reset')
93+
@UseGuards(AuthenticatedGuard)
94+
async resetPassword(@Body() dto: ResetPasswordDto): Promise<any> {
95+
const userId = new Types.ObjectId(dto.user);
96+
await this.authService.resetUserPassword(userId, dto.token, dto.password);
97+
return { statusCode: 200 };
98+
}
8399
}

apps/api/src/auth/auth.service.ts

+69-19
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
22
import * as bcrypt from 'bcrypt';
3+
import { differenceInMinutes } from 'date-fns';
34
import { Types } from 'mongoose';
45
import { EmailsService } from '../emails/emails.service';
56
import { UserDocument } from '../models/users/schemas/user.schema';
7+
import { UsersTokenService } from '../models/users/users-token.service';
68
import { UsersService } from '../models/users/users.service';
79
import { UserRegisterDto } from './dto/user-register.dto';
810
import { EmailConfirmTemplate } from './templates/email-confirm.template';
9-
import { UsersTokenService } from '../models/users/users-token.service';
10-
import { EMAIL_CONFIRM_TOKEN } from './types/emailTokenTypes';
11-
import { log } from 'console';
12-
import { differenceInDays, differenceInMinutes } from 'date-fns';
11+
import { PasswordRestTemplate } from './templates/password-reset.template';
12+
import { EMAIL_CONFIRM_TOKEN, PASSWORD_RESET_TOKEN } from './types/emailTokenTypes';
1313

1414
@Injectable()
1515
export class AuthService {
@@ -22,15 +22,15 @@ export class AuthService {
2222
private readonly logger = new Logger(AuthService.name);
2323

2424
async registerUser(data: UserRegisterDto): Promise<UserDocument> {
25-
const hash = await bcrypt.hash(data.password, 12);
25+
const hash = await this.hashPassword(data.password);
2626

2727
const user = await this.usersService.create({
2828
username: data.username,
2929
email: data.email,
3030
passwordHash: hash,
3131
});
3232

33-
await this.sendEmailConfirmation(user).catch((error) => {
33+
await this.sendEmailConfirmation(user._id).catch((error) => {
3434
this.logger.error(`Failed to send initial confirmation E-mail ${error}`);
3535
});
3636

@@ -53,7 +53,7 @@ export class AuthService {
5353
}
5454
}
5555

56-
async confirmUserEmail(userId: Types.ObjectId, token: string): Promise<any> {
56+
async confirmUserEmail(userId: Types.ObjectId, token: string): Promise<UserDocument> {
5757
const tokenValid = await this.usersTokenService.validateToken({
5858
userId,
5959
token,
@@ -68,26 +68,40 @@ export class AuthService {
6868
return this.usersService.setConfirmed(userId, true);
6969
}
7070

71-
async retryConfirmationEmail(userId: Types.ObjectId) {
71+
async resetUserPassword(
72+
userId: Types.ObjectId,
73+
token: string,
74+
password: string,
75+
): Promise<UserDocument> {
76+
const tokenValid = await this.usersTokenService.validateToken({
77+
userId,
78+
token,
79+
type: PASSWORD_RESET_TOKEN,
80+
});
81+
82+
if (!tokenValid) {
83+
throw new BadRequestException('This password reset token is invalid or expired');
84+
}
85+
86+
await this.usersTokenService.invalidateToken(userId, token);
87+
88+
// Confirm email on password reset
89+
await this.usersService.setConfirmed(userId, true);
90+
91+
const hash = await this.hashPassword(password);
92+
return this.usersService.findOneByIdAndUpdate(userId, { 'auth.password': hash });
93+
}
94+
95+
async sendEmailConfirmation(userId: Types.ObjectId) {
7296
const user = await this.usersService.findById(userId);
7397
if (!user) throw new NotFoundException('User not found');
7498

7599
if (user.profile.isConfirmed) {
76100
throw new BadRequestException('This user E-mail address is already confirmed');
77101
}
78102

79-
const lastRetry = await this.usersTokenService.getLastRetry(userId, EMAIL_CONFIRM_TOKEN);
80-
if (lastRetry) {
81-
const minutesDiff = differenceInMinutes(new Date(), lastRetry);
82-
if (minutesDiff < 5) {
83-
throw new BadRequestException('Please wait before sending next confirmation email');
84-
}
85-
}
86-
87-
return this.sendEmailConfirmation(user);
88-
}
103+
await this.validateIfCanSendEmail(userId, EMAIL_CONFIRM_TOKEN);
89104

90-
async sendEmailConfirmation(user: UserDocument) {
91105
const token = await this.usersTokenService.generateAndSaveToken({
92106
userId: user._id,
93107
type: EMAIL_CONFIRM_TOKEN,
@@ -100,4 +114,40 @@ export class AuthService {
100114
text: content.toString(),
101115
});
102116
}
117+
118+
async sendPasswordResetEmail(email: string) {
119+
const user = await this.usersService.findByEmail(email);
120+
if (!user) throw new NotFoundException('This E-mail is not associated with any user account!');
121+
122+
await this.validateIfCanSendEmail(user._id, PASSWORD_RESET_TOKEN);
123+
124+
const token = await this.usersTokenService.generateAndSaveToken({
125+
userId: user._id,
126+
type: PASSWORD_RESET_TOKEN,
127+
});
128+
const content = new PasswordRestTemplate(user._id, token);
129+
130+
return this.emailService.sendEmail({
131+
to: user.profile.email,
132+
subject: '[StockedUp] Password reset request',
133+
text: content.toString(),
134+
});
135+
}
136+
137+
private async validateIfCanSendEmail(userId: Types.ObjectId, token: string): Promise<boolean> {
138+
const lastRetry = await this.usersTokenService.getLastRetry(userId, token);
139+
if (lastRetry) {
140+
const minutesDiff = differenceInMinutes(new Date(), lastRetry);
141+
if (minutesDiff < 10) {
142+
throw new BadRequestException(
143+
'The confirmation email was sent recently, please check your inbox',
144+
);
145+
}
146+
}
147+
return true;
148+
}
149+
150+
private hashPassword(input: string): Promise<string> {
151+
return bcrypt.hash(input, 12);
152+
}
103153
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { IsMongoId, IsString, Length } from 'class-validator';
2+
import { IResetPasswordDto } from 'shared-types';
3+
4+
export class ResetPasswordDto implements IResetPasswordDto {
5+
@IsMongoId()
6+
user: string;
7+
8+
@IsString()
9+
token: string;
10+
11+
@Length(4, 32)
12+
public password: string;
13+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { Types } from 'mongoose';
2+
import { EmailTemplate } from '../../emails/types/email-template.type';
3+
import Utils from '../../helpers/utils';
4+
5+
const BASE_URL = Utils.isProduction() ? 'https://stockedup.dokurno.dev/' : 'http://localhost:5173/';
6+
7+
export class PasswordRestTemplate implements EmailTemplate {
8+
constructor(
9+
private readonly userId: Types.ObjectId,
10+
private readonly token: string,
11+
) {}
12+
13+
toString(): string {
14+
const url = `${BASE_URL}reset-password?user=${this.userId.toString()}&token=${this.token}`;
15+
return (
16+
`Hello,` +
17+
`\r\n` +
18+
`We've received a request to reset the password for StockedUp account associated ` +
19+
`with this e-mail address. No changes were made to your account yet.` +
20+
`\r\n\r\n` +
21+
`You can reset your password by clicking this link:` +
22+
`\r\n` +
23+
url +
24+
`\r\n\r\n` +
25+
`If you didn't request a password reset, you can safely ignore this e-mail. ` +
26+
`No changes to your account will be made.`
27+
);
28+
}
29+
}
+2-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
const EMAIL_CONFIRM_TOKEN = 'EMAIL_CONFIRM_TOKEN';
2+
const PASSWORD_RESET_TOKEN = 'PASSWORD_RESET_TOKEN';
23

3-
export { EMAIL_CONFIRM_TOKEN };
4+
export { EMAIL_CONFIRM_TOKEN, PASSWORD_RESET_TOKEN };

apps/api/src/emails/emails.service.ts

-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ const EMAIL_FOOTER = `
99
1010
Thanks,
1111
StockedUp Team
12-
1312
---
1413
This is automated message sent by StockedUp. Please do not reply to this email.
1514
If you received this email by mistake or believe it is a spam, please forward it to [email protected]`;

apps/api/src/models/users/users.service.ts

+4
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ export class UsersService {
7171
});
7272
}
7373

74+
findByEmail(email: string): Promise<UserDocument | undefined> {
75+
return this.userRepository.findOne({ 'profile.email': email });
76+
}
77+
7478
find(entityFilterQuery: FilterQuery<UserDocument>): Promise<UserDocument[]> {
7579
return this.userRepository.find(entityFilterQuery);
7680
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export interface IResetPasswordDto {
2+
user: string;
3+
token: string;
4+
password: string;
5+
}

packages/shared-types/src/index.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { IImageDto } from './ImageDto';
22
import { OrganizationSecurityRole } from './OrganizationSecurityRole';
33
import { SimpleResponseDto } from './SimpleResponseDto';
4+
import { IResetPasswordDto } from './auth/IResetPasswordDto';
45
import { IUserRegisterDto } from './auth/IUserRegisterDto';
56
import { UserLoginDto } from './auth/UserLoginDto';
67
import { BasicInventoryItemDto } from './inventory/BasicInventoryItemDto';
@@ -65,6 +66,7 @@ export {
6566
SortDirection,
6667
UserDto,
6768
UserLoginDto,
68-
WarehouseDto
69+
WarehouseDto,
70+
IResetPasswordDto
6971
};
7072

0 commit comments

Comments
 (0)