Allow users to remove their password in case they forget it

Closes #23
This commit is contained in:
Alice Gaudon 2021-02-20 20:18:03 +01:00
parent cdac3c5f68
commit 562431449b
8 changed files with 144 additions and 21 deletions

View File

@ -22,3 +22,8 @@ export const ADD_EMAIL_MAIL_TEMPLATE: MailTemplate = new MailTemplate(
'add_email',
(data) => `Add ${data.email} address to your ${config.get<string>('app.name')} account.`,
);
export const REMOVE_PASSWORD_MAIL_TEMPLATE: MailTemplate = new MailTemplate(
'remove_password',
() => `Remove password from your ${config.get<string>('app.name')} account.`,
);

View File

@ -10,15 +10,16 @@ import ModelFactory from "../db/ModelFactory";
import UserEmail from "./models/UserEmail";
import MagicLinkController from "./magic_link/MagicLinkController";
import {MailTemplate} from "../mail/Mail";
import {ADD_EMAIL_MAIL_TEMPLATE} from "../Mails";
import {ADD_EMAIL_MAIL_TEMPLATE, REMOVE_PASSWORD_MAIL_TEMPLATE} from "../Mails";
import AuthMagicLinkActionType from "./magic_link/AuthMagicLinkActionType";
export default class AccountController extends Controller {
private readonly addEmailMailTemplate: MailTemplate;
public constructor(addEmailMailTemplate: MailTemplate = ADD_EMAIL_MAIL_TEMPLATE) {
public constructor(
private readonly addEmailMailTemplate: MailTemplate = ADD_EMAIL_MAIL_TEMPLATE,
private readonly removePasswordMailTemplate: MailTemplate = REMOVE_PASSWORD_MAIL_TEMPLATE,
) {
super();
this.addEmailMailTemplate = addEmailMailTemplate;
}
public getRoutesPrefix(): string {
@ -30,6 +31,7 @@ export default class AccountController extends Controller {
if (ModelFactory.get(User).hasComponent(UserPasswordComponent)) {
this.post('/change-password', this.postChangePassword, 'change-password', RequireAuthMiddleware);
this.post('/remove-password', this.postRemovePassword, 'remove-password', RequireAuthMiddleware);
}
this.post('/add-email', this.addEmail, 'add-email', RequireAuthMiddleware);
@ -40,11 +42,13 @@ export default class AccountController extends Controller {
protected async getAccount(req: Request, res: Response): Promise<void> {
const user = req.as(RequireAuthMiddleware).getUser();
const passwordComponent = user.asOptional(UserPasswordComponent);
res.render('auth/account', {
main_email: await user.mainEmail.get(),
emails: await user.emails.get(),
display_email_warning: config.get('app.display_email_warning'),
has_password: user.asOptional(UserPasswordComponent)?.hasPassword(),
has_password_component: !!passwordComponent,
has_password: passwordComponent?.hasPassword(),
});
}
@ -69,6 +73,31 @@ export default class AccountController extends Controller {
res.redirect(Controller.route('account'));
}
protected async postRemovePassword(req: Request, res: Response): Promise<void> {
const user = req.as(RequireAuthMiddleware).getUser();
const mainEmail = await user.mainEmail.get();
if (!mainEmail || !mainEmail.email) {
req.flash('error', 'You can\'t remove your password without adding an email address first.');
res.redirect(Controller.route('account'));
return;
}
await MagicLinkController.sendMagicLink(
this.getApp(),
req.getSession().id,
AuthMagicLinkActionType.REMOVE_PASSWORD,
Controller.route('account'),
mainEmail.email,
this.removePasswordMailTemplate,
{},
);
res.redirect(Controller.route('magic_link_lobby', undefined, {
redirect_uri: Controller.route('account'),
}));
}
protected async addEmail(req: Request, res: Response): Promise<void> {
await Validator.validate({

View File

@ -2,4 +2,5 @@ export default {
LOGIN: 'login',
REGISTER: 'register',
ADD_EMAIL: 'add_email',
REMOVE_PASSWORD: 'remove_password',
};

View File

@ -18,6 +18,7 @@ import {QueryVariable} from "../../db/MysqlConnectionManager";
import UserNameComponent from "../models/UserNameComponent";
import MagicLinkUserNameComponent from "../models/MagicLinkUserNameComponent";
import {logger} from "../../Logger";
import UserPasswordComponent from "../password/UserPasswordComponent";
export default class MagicLinkController<A extends Application> extends Controller {
public static async sendMagicLink(
@ -233,6 +234,29 @@ export default class MagicLinkController<A extends Application> extends Controll
res.redirect(Controller.route('account'));
break;
}
case AuthMagicLinkActionType.REMOVE_PASSWORD: {
const session = req.getSessionOptional();
if (!session || magicLink.session_id !== session.id) throw new BadOwnerMagicLink();
await magicLink.delete();
const authGuard = this.getApp().as(AuthComponent).getAuthGuard();
const proofs = await authGuard.getProofsForSession(session);
const user = await proofs[0]?.getResource();
if (!user) return;
const passwordComponent = user.asOptional(UserPasswordComponent);
if (passwordComponent) {
passwordComponent.removePassword();
await user.save();
}
req.flash('success', `Password successfully removed.`);
res.redirect(Controller.route('account'));
break;
}
default:
logger.warn('Unknown magic link action type ' + magicLink.action_type);
break;

View File

@ -6,7 +6,7 @@ import Validator from "../../db/Validator";
export default class UserPasswordComponent extends ModelComponent<User> {
public static readonly PASSWORD_MIN_LENGTH = 12;
private password?: string = undefined;
private password?: string | null = undefined;
public init(): void {
this.setValidation('password').acceptUndefined().maxLength(128);
@ -36,4 +36,8 @@ export default class UserPasswordComponent extends ModelComponent<User> {
public hasPassword(): boolean {
return typeof this.password === 'string';
}
public removePassword(): void {
this.password = null;
}
}

View File

@ -22,21 +22,9 @@
{% endif %}
</div>
<section class="panel">
<h2><i data-feather="key"></i> {% if has_password %}Change{% else %}Set{% endif %} password</h2>
<form action="{{ route('change-password') }}" method="POST">
{% if has_password %}
{{ macros.field(_locals, 'password', 'current_password', null, 'Current password') }}
{% endif %}
{{ macros.field(_locals, 'password', 'new_password', null, 'New password') }}
{{ macros.field(_locals, 'password', 'new_password_confirmation', null, 'New password confirmation') }}
<button type="submit"><i data-feather="save"></i> Save</button>
{{ macros.csrf(getCsrfToken) }}
</form>
</section>
{% if has_password_component %}
{% include './password_panel.njk' %}
{% endif %}
<section class="panel">
<h2 id="emails"><i data-feather="shield"></i> Email addresses</h2>

View File

@ -0,0 +1,45 @@
<section class="panel">
<h2><i data-feather="key"></i> {% if has_password %}Change{% else %}Set{% endif %} password</h2>
<form action="{{ route('change-password') }}" method="POST" id="change-password-form">
{% if has_password %}
{{ macros.field(_locals, 'password', 'current_password', null, 'Current password') }}
<p><a href="javascript: void(0);" class="switch-form-link">Forgot your password?</a></p>
{% endif %}
{{ macros.field(_locals, 'password', 'new_password', null, 'New password') }}
{{ macros.field(_locals, 'password', 'new_password_confirmation', null, 'New password confirmation') }}
<button type="submit"><i data-feather="save"></i> Save</button>
{{ macros.csrf(getCsrfToken) }}
</form>
{% if has_password %}
<form action="{{ route('remove-password') }}" method="POST" id="remove-password-form" class="hidden">
<p><a href="javascript: void(0);" class="switch-form-link">Go back</a></p>
<button type="submit" class="danger"><i data-feather="trash"></i> Remove password</button>
{{ macros.csrf(getCsrfToken) }}
</form>
<script>
document.addEventListener('DOMContentLoaded', () => {
const changePasswordForm = document.getElementById('change-password-form');
const removePasswordLink = changePasswordForm.querySelector('a.switch-form-link');
const removePasswordForm = document.getElementById('remove-password-form');
const changePasswordLink = removePasswordForm.querySelector('a.switch-form-link');
removePasswordLink.addEventListener('click', () => {
changePasswordForm.classList.add('hidden');
removePasswordForm.classList.remove('hidden');
});
changePasswordLink.addEventListener('click', () => {
removePasswordForm.classList.add('hidden');
changePasswordForm.classList.remove('hidden');
});
});
</script>
{% endif %}
</section>

View File

@ -0,0 +1,27 @@
{% extends 'mails/base_layout.mjml.njk' %}
{% block body %}
<mj-section>
<mj-column>
<mj-text mj-class="title">
Remove your password on your {{ app.name }} account
</mj-text>
<mj-text>
Someone wants to remove your password from your account.
<br><br>
<strong>Do not click on this if this is not you!</strong>
</mj-text>
<mj-button href="{{ link | safe }}">
Remove my {{ app.name }} password
</mj-button>
</mj-column>
</mj-section>
{% endblock %}
{% block text %}
Hi!
Someone wants to remove your password from your {{ app.name }} account.
To confirm this action and remove your password, please follow this link: {{ link|safe }}
{% endblock %}