feat(users): add reset password flow (#772)
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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"`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
1
server/templates/email/generatedpassword/subject.pug
Normal file
1
server/templates/email/generatedpassword/subject.pug
Normal file
@@ -0,0 +1 @@
|
||||
= `Account Information - ${applicationTitle}`
|
||||
100
server/templates/email/resetpassword/html.pug
Normal file
100
server/templates/email/resetpassword/html.pug
Normal 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&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}.
|
||||
Reference in New Issue
Block a user