feat(users): add reset password flow (#772)

This commit is contained in:
Jakob Ankarhem
2021-02-05 15:23:57 +01:00
committed by GitHub
parent c0ea2bd189
commit e5966bd3fb
18 changed files with 734 additions and 29 deletions

View File

@@ -21,6 +21,7 @@ import logger from '../logger';
import { getSettings } from '../lib/settings';
import { default as generatePassword } from 'secure-random-password';
import { UserType } from '../constants/user';
import { v4 as uuid } from 'uuid';
@Entity()
export class User {
@@ -28,7 +29,11 @@ export class User {
return users.map((u) => u.filter());
}
static readonly filteredFields: string[] = ['plexToken', 'password'];
static readonly filteredFields: string[] = [
'plexToken',
'password',
'resetPasswordGuid',
];
public displayName: string;
@@ -47,6 +52,12 @@ export class User {
@Column({ nullable: true, select: false })
public password?: string;
@Column({ nullable: true, select: false })
public resetPasswordGuid?: string;
@Column({ type: 'date', nullable: true })
public recoveryLinkExpirationDate?: Date | null;
@Column({ type: 'integer', default: UserType.PLEX })
public userType: UserType;
@@ -111,18 +122,18 @@ export class User {
this.password = hashedPassword;
}
public async resetPassword(): Promise<void> {
public async generatePassword(): Promise<void> {
const password = generatePassword.randomPassword({ length: 16 });
this.setPassword(password);
const applicationUrl = getSettings().main.applicationUrl;
try {
logger.info(`Sending password email for ${this.email}`, {
label: 'User creation',
logger.info(`Sending generated password email for ${this.email}`, {
label: 'User Management',
});
const email = new PreparedEmail();
await email.send({
template: path.join(__dirname, '../templates/email/password'),
template: path.join(__dirname, '../templates/email/generatedpassword'),
message: {
to: this.email,
},
@@ -132,8 +143,43 @@ export class User {
},
});
} catch (e) {
logger.error('Failed to send out password email', {
label: 'User creation',
logger.error('Failed to send out generated password email', {
label: 'User Management',
message: e.message,
});
}
}
public async resetPassword(): Promise<void> {
const guid = uuid();
this.resetPasswordGuid = guid;
// 24 hours into the future
const targetDate = new Date();
targetDate.setDate(targetDate.getDate() + 1);
this.recoveryLinkExpirationDate = targetDate;
const applicationUrl = getSettings().main.applicationUrl;
const resetPasswordLink = `${applicationUrl}/resetpassword/${guid}`;
try {
logger.info(`Sending reset password email for ${this.email}`, {
label: 'User Management',
});
const email = new PreparedEmail();
await email.send({
template: path.join(__dirname, '../templates/email/resetpassword'),
message: {
to: this.email,
},
locals: {
resetPasswordLink,
applicationUrl: resetPasswordLink,
},
});
} catch (e) {
logger.error('Failed to send out reset password email', {
label: 'User Management',
message: e.message,
});
}

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddResetPasswordGuidAndExpiryDate1612482778137
implements MigrationInterface {
name = 'AddResetPasswordGuidAndExpiryDate1612482778137';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
);
await queryRunner.query(
`INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername" FROM "user"`
);
await queryRunner.query(`DROP TABLE "user"`);
await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`);
await queryRunner.query(
`CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
);
await queryRunner.query(
`INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername" FROM "temporary_user"`
);
await queryRunner.query(`DROP TABLE "temporary_user"`);
}
}

View File

@@ -197,4 +197,80 @@ authRoutes.get('/logout', (req, res, next) => {
});
});
authRoutes.post('/reset-password', async (req, res) => {
const userRepository = getRepository(User);
const body = req.body as { email?: string };
if (!body.email) {
return res.status(500).json({ error: 'You must provide an email' });
}
const user = await userRepository.findOne({
where: { email: body.email },
});
if (user) {
await user.resetPassword();
userRepository.save(user);
logger.info('Successful request made for recovery link', {
label: 'User Management',
context: { ip: req.ip, email: body.email },
});
} else {
logger.info('Failed request made to reset a password', {
label: 'User Management',
context: { ip: req.ip, email: body.email },
});
}
return res.status(200).json({ status: 'ok' });
});
authRoutes.post('/reset-password/:guid', async (req, res, next) => {
const userRepository = getRepository(User);
try {
if (!req.body.password || req.body.password?.length < 8) {
const message =
'Failed to reset password. Password must be atleast 8 characters long.';
logger.info(message, {
label: 'User Management',
context: { ip: req.ip, guid: req.params.guid },
});
return next({ status: 500, message: message });
}
const user = await userRepository.findOne({
where: { resetPasswordGuid: req.params.guid },
});
if (!user) {
throw new Error('Guid invalid.');
}
if (
!user.recoveryLinkExpirationDate ||
user.recoveryLinkExpirationDate <= new Date()
) {
throw new Error('Recovery link expired.');
}
await user.setPassword(req.body.password);
user.recoveryLinkExpirationDate = null;
userRepository.save(user);
logger.info(`Successfully reset password`, {
label: 'User Management',
context: { ip: req.ip, guid: req.params.guid, email: user.email },
});
return res.status(200).json({ status: 'ok' });
} catch (e) {
logger.info(`Failed to reset password. ${e.message}`, {
label: 'User Management',
context: { ip: req.ip, guid: req.params.guid },
});
return res.status(200).json({ status: 'ok' });
}
});
export default authRoutes;

View File

@@ -46,7 +46,7 @@ router.post('/', async (req, res, next) => {
if (passedExplicitPassword) {
await user?.setPassword(body.password);
} else {
await user?.resetPassword();
await user?.generatePassword();
}
await userRepository.save(user);

View File

@@ -0,0 +1 @@
= `Account Information - ${applicationTitle}`

View File

@@ -0,0 +1,100 @@
doctype html
head
meta(charset='utf-8')
meta(name='x-apple-disable-message-reformatting')
meta(http-equiv='x-ua-compatible' content='ie=edge')
meta(name='viewport' content='width=device-width, initial-scale=1')
meta(name='format-detection' content='telephone=no, date=no, address=no, email=no')
link(href='https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&amp;display=swap' rel='stylesheet' media='screen')
//if mso
xml
o:officedocumentsettings
o:pixelsperinch 96
style.
td,
th,
div,
p,
a,
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: 'Segoe UI', sans-serif;
mso-line-height-rule: exactly;
}
style.
@media (max-width: 600px) {
.sm-w-full {
width: 100% !important;
}
}
div(role='article' aria-roledescription='email' aria-label='' lang='en')
table(style="\
background-color: #f2f4f6;\
font-family: 'Nunito Sans', -apple-system, 'Segoe UI', sans-serif;\
width: 100%;\
" width='100%' bgcolor='#f2f4f6' cellpadding='0' cellspacing='0' role='presentation')
tr
td(align='center')
table(style='width: 100%' width='100%' cellpadding='0' cellspacing='0' role='presentation')
tr
td(align='center' style='\
font-size: 16px;\
padding-top: 25px;\
padding-bottom: 25px;\
text-align: center;\
')
a(href=applicationUrl style='\
text-shadow: 0 1px 0 #ffffff;\
font-weight: 700;\
font-size: 16px;\
color: #a8aaaf;\
text-decoration: none;\
')
| #{applicationTitle}
tr
td(style='width: 100%' width='100%')
table.sm-w-full(align='center' style='\
background-color: #ffffff;\
margin-left: auto;\
margin-right: auto;\
width: 570px;\
' width='570' bgcolor='#ffffff' cellpadding='0' cellspacing='0' role='presentation')
tr
td(style='padding: 45px')
div(style='font-size: 16px; text-align: center; padding-bottom: 14px;')
| A request to reset the password was made. Click
a(href=applicationUrl style='color: #3869d4; padding: 0px 5px;') here
| to set a new password.
div(style='font-size: 16px; text-align: center; padding-bottom: 14px;')
| If you did not request this recovery link you can safely ignore this email.
p(style='\
font-size: 13px;\
line-height: 24px;\
margin-top: 6px;\
margin-bottom: 20px;\
color: #51545e;\
')
a(href=applicationUrl style='color: #3869d4') Open #{applicationTitle}
tr
td
table.sm-w-full(align='center' style='\
margin-left: auto;\
margin-right: auto;\
text-align: center;\
width: 570px;\
' width='570' cellpadding='0' cellspacing='0' role='presentation')
tr
td(align='center' style='font-size: 16px; padding: 45px')
p(style='\
font-size: 13px;\
line-height: 24px;\
margin-top: 6px;\
margin-bottom: 20px;\
text-align: center;\
color: #a8aaaf;\
')
| #{applicationTitle}.