Skip to content

Instantly share code, notes, and snippets.

@mrmodise
Last active December 22, 2020 08:01
Show Gist options
  • Save mrmodise/150831001ea8d788e4639036ede77079 to your computer and use it in GitHub Desktop.
Save mrmodise/150831001ea8d788e4639036ede77079 to your computer and use it in GitHub Desktop.
LoopBack 4 password reset models and services
// .....
export class UserController {
constructor(
@inject(TokenServiceBindings.TOKEN_SERVICE)
public jwtService: TokenService,
@inject(UserServiceBindings.USER_SERVICE)
public userService: MyUserService,
@inject(SecurityBindings.USER, {optional: true})
public user: UserProfile,
@repository(UserRepository) protected userRepository: UserRepository,
// email service injected here
@inject('services.EmailService')
public emailService: EmailService,
) {}
// ....
}
// inside src/models/email-template.model.ts
import {Model, model, property} from '@loopback/repository';
@model()
export class EmailTemplate extends Model {
@property({
type: 'string',
})
from = 'no_reply@loopback.io';
@property({
type: 'string',
required: true,
})
to: string;
@property({
type: 'string',
required: true,
})
subject: string;
@property({
type: 'string',
required: true,
})
text: string;
@property({
type: 'string',
required: true,
})
html: string;
constructor(data?: Partial<EmailTemplate>) {
super(data);
}
}
// inside src/services/email.service.ts
import {bind, BindingScope} from '@loopback/core';
import {EmailTemplate, NodeMailer, User} from '../models';
import {createTransport} from 'nodemailer';
@bind({scope: BindingScope.TRANSIENT})
export class EmailService {
/**
* If using gmail see https://nodemailer.com/usage/using-gmail/
*/
private static async setupTransporter() {
return createTransport({
host: process.env.SMTP_SERVER,
port: +process.env.SMTP_PORT!,
secure: false, // upgrade later with STARTTLS
auth: {
user: process.env.SMTP_USERNAME,
pass: process.env.SMTP_PASSWORD,
},
});
}
async sendResetPasswordMail(user: User): Promise<NodeMailer> {
const transporter = await EmailService.setupTransporter();
const emailTemplate = new EmailTemplate({
to: user.email,
subject: '[LB4] Reset Password Request',
html: `
<div>
<p>Hi there,</p>
<p style="color: red;">We received a request to reset the password for your account</p>
<p>To reset your password click on the link provided below</p>
<a href="${process.env.APPLICATION_URL}/reset-password-finish.html?resetKey=${user.resetKey}">Reset your password link</a>
<p>If you didn’t request to reset your password, please ignore this email or reset your password to protect your account.</p>
<p>Thanks</p>
<p>LB4 team</p>
</div>
`,
});
return transporter.sendMail(emailTemplate);
}
}
// inside src/models/envelope.model.ts
import {Model, model, property} from '@loopback/repository';
@model()
export class Envelope extends Model {
@property({
type: 'string',
})
from: string;
@property({
type: 'string',
})
to: string;
constructor(data?: Partial<Envelope>) {
super(data);
}
}
export class UserController {
// .....
@put('/reset-password/finish')
async resetPasswordFinish(
@requestBody() keyAndPassword: KeyAndPassword,
): Promise<string> {
// Checks whether password and reset key meet minimum security requirements
const {resetKey, password} = await this.validateKeyPassword(keyAndPassword);
// Search for a user using reset key
const foundUser = await this.userRepository.findOne({
where: {resetKey: resetKey},
});
// No user account found
if (!foundUser) {
throw new HttpErrors.NotFound(
'No associated account for the provided reset key',
);
}
// Encrypt password to avoid storing it as plain text
const passwordHash = await hash(password, await genSalt());
try {
// Update user password with the newly provided password
await this.userRepository
.userCredentials(foundUser.id)
.patch({password: passwordHash});
// Remove reset key from database its no longer valid
foundUser.resetKey = '';
// Update the user removing the reset key
await this.userRepository.updateById(foundUser.id, foundUser);
} catch (e) {
return e;
}
return 'Password reset request completed successfully';
}
async validateKeyPassword(keyAndPassword: KeyAndPassword): Promise<KeyAndPassword> {
if (!keyAndPassword.password || keyAndPassword.password.length < 8) {
throw new HttpErrors.UnprocessableEntity(
'Password must be minimum of 8 characters',
);
}
if (keyAndPassword.password !== keyAndPassword.confirmPassword) {
throw new HttpErrors.UnprocessableEntity(
'Password and confirmation password do not match',
);
}
if (
keyAndPassword.resetKey.length === 0 ||
keyAndPassword.resetKey.trim() === ''
) {
throw new HttpErrors.UnprocessableEntity('Reset key is mandatory');
}
return keyAndPassword;
}
// ....
}
// imports
import {NodeMailer, ResetPasswordInit} from '../models';
import {EmailService} from '../services';
import {v4 as uuidv4} from 'uuid';
import {HttpErrors} from '@loopback/rest';
export class UserController {
// .....
// [line 162] We will add our password reset here
@post('/reset-password/init')
async resetPasswordInit(
@requestBody() resetPasswordInit: ResetPasswordInit,
): Promise<string> {
// checks whether email is valid as per regex pattern provided
const email = await this.validateEmail(resetPasswordInit.email);
// At this point we are dealing with valid email.
// Lets check whether there is an associated account
const foundUser = await this.userRepository.findOne({
where: {email},
});
// No account found
if (!foundUser) {
throw new HttpErrors.NotFound(
'No account associated with the provided email address.',
);
}
// We generate unique reset key to associate with reset request
foundUser.resetKey = uuidv4();
try {
// Updates the user to store their reset key with error handling
await this.userRepository.updateById(foundUser.id, foundUser);
} catch (e) {
return e;
}
// Send an email to the user's email address
const nodeMailer: NodeMailer = await this.emailService.sendResetPasswordMail(
foundUser,
);
// Nodemailer has accepted the request. All good
if (nodeMailer.accepted.length) {
return 'An email with password reset instructions has been sent to the provided email';
}
// Nodemailer did not complete the request alert the user
throw new HttpErrors.InternalServerError(
'Error sending reset password email',
);
}
async validateEmail(email: string): Promise<string> {
const emailRegPattern = /\S+@\S+\.\S+/;
if (!emailRegPattern.test(email)) {
throw new HttpErrors.UnprocessableEntity('Invalid email address');
}
return email;
}
}
// inside src/models/key-and-password.model.ts
import {Model, model, property} from '@loopback/repository';
@model()
export class KeyAndPassword extends Model {
@property({
type: 'string',
})
resetKey: string;
@property({
type: 'string',
})
password: string;
@property({
type: 'string',
})
confirmPassword: string;
constructor(data?: Partial<KeyAndPassword>) {
super(data);
}
}
// inside src/models/node-mailer.model.ts
import {Model, model, property} from '@loopback/repository';
import {Envelope} from './envelope.model';
@model()
export class NodeMailer extends Model {
@property.array({
type: 'string',
})
accepted: string[];
@property.array({
type: 'string',
required: true,
})
rejected: string[];
@property({
type: 'number',
})
envelopeTime: number;
@property({
type: 'number',
})
messageTime: number;
@property({
type: 'number',
})
messageSize: number;
@property({
type: 'string',
})
response: string;
@property(() => Envelope)
envelope: Envelope;
@property({
type: 'string',
})
messageId: string;
constructor(data?: Partial<NodeMailer>) {
super(data);
}
}
// We will add our password reset here
@post('/reset-password/init')
async resetPasswordInit(
@requestBody() resetPasswordInit: ResetPasswordInit,
): Promise<string> {
return "An email has been sent to provided email with password reset instructions";
}
// inside src/models/reset-password-init.model.ts
import {Model, model, property} from '@loopback/repository';
@model()
export class ResetPasswordInit extends Model {
@property({
type: 'string',
required: true,
})
email: string;
constructor(data?: Partial<ResetPasswordInit>) {
super(data);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment