accounts: enable adding/removing recovery email addresses

This commit is contained in:
Alice Gaudon 2020-07-25 18:24:12 +02:00
parent 933d8f1cbb
commit 41fd867193
16 changed files with 269 additions and 103 deletions

View File

@ -1 +1,7 @@
@import "layout";
@import "layout";
td.actions {
form {
padding: 0;
}
}

View File

@ -507,6 +507,10 @@ button, .button {
&.warning {
background-color: $warningColor;
&:hover {
background-color: lighten($warningColor, 10%);
}
}
&.error, &.danger {

View File

@ -25,7 +25,6 @@ import CreateMigrationsTable from "wms-core/migrations/CreateMigrationsTable";
import CreateLogsTable from "wms-core/migrations/CreateLogsTable";
import CreateUsersAndUserEmailsTable from "wms-core/auth/migrations/CreateUsersAndUserEmailsTable";
import AddPasswordToUsers from "./migrations/AddPasswordToUsers";
import CreateUsernamesTable from "./migrations/AddNameToUsers";
import CreateMagicLinksTable from "wms-core/auth/migrations/CreateMagicLinksTable";
import MailController from "wms-core/auth/MailController";
import MagicLinkController from "./controllers/MagicLinkController";
@ -52,7 +51,6 @@ export default class App extends Application {
CreateLogsTable,
CreateUsersAndUserEmailsTable,
AddPasswordToUsers,
CreateUsernamesTable,
CreateMagicLinksTable,
AddApprovedFieldToUsersTable,
FixUserMainEmailRelation,

View File

@ -2,9 +2,9 @@ import ApplicationComponent from "wms-core/ApplicationComponent";
import {Express} from "express";
import ldap, {InvalidCredentialsError, Server} from "ldapjs";
import Logger from "wms-core/Logger";
import Username from "./models/Username";
import {PasswordAuthProof} from "./models/UserPasswordComponent";
import UserPasswordComponent from "./models/UserPasswordComponent";
import Throttler from "wms-core/Throttler";
import User from "wms-core/auth/models/User";
export default class LDAPServerComponent extends ApplicationComponent<void> {
private server?: Server;
@ -35,12 +35,11 @@ export default class LDAPServerComponent extends ApplicationComponent<void> {
return;
}
const user = await Username.getUserFromUsername(username);
const user = await User.select().where('name', username).first();
if (user) {
const email = await user.mainEmail.get();
if (email) {
const authProof = new PasswordAuthProof(email.email!);
if (await authProof.authorize(req.credentials)) {
if (await user.as(UserPasswordComponent).verifyPassword(req.credentials)) {
Logger.debug('Success');
res.end();
return;

6
src/Mails.ts Normal file
View File

@ -0,0 +1,6 @@
import {MailTemplate} from "wms-core/Mail";
export const ADD_RECOVERY_EMAIL_MAIL_TEMPLATE: MailTemplate = new MailTemplate(
'add_recovery_email',
(data) => 'Add ' + data.email + ' as you recovery email.',
);

View File

@ -1,11 +1,20 @@
import Controller from "wms-core/Controller";
import {REQUIRE_AUTH_MIDDLEWARE} from "wms-core/auth/AuthComponent";
import {NextFunction, Request, Response} from "express";
import {ADD_RECOVERY_EMAIL_MAIL_TEMPLATE} from "../Mails";
import Validator, {InvalidFormatValidationError, ValidationBag} from "wms-core/db/Validator";
import {EMAIL_REGEX} from "wms-core/db/Model";
import MagicLinkController from "./MagicLinkController";
import {MagicLinkActionType} from "./MagicLinkActionType";
import UserEmail from "wms-core/auth/models/UserEmail";
import {BadRequestError, ForbiddenHttpError, NotFoundHttpError} from "wms-core/HttpError";
export default class AccountController extends Controller {
routes(): void {
this.get('/account', this.getAccount, 'account', REQUIRE_AUTH_MIDDLEWARE);
this.post('/add-recovery-email', this.addRecoveryEmail, 'add-recovery-email', REQUIRE_AUTH_MIDDLEWARE);
this.post('/set-main-email', this.postSetMainEmail, 'set-main-email', REQUIRE_AUTH_MIDDLEWARE);
this.post('/remove-email', this.postRemoveEmail, 'remove-email', REQUIRE_AUTH_MIDDLEWARE);
}
protected async getAccount(req: Request, res: Response, next: NextFunction): Promise<void> {
@ -15,7 +24,69 @@ export default class AccountController extends Controller {
}
protected async addRecoveryEmail(req: Request, res: Response, next: NextFunction): Promise<void> {
req.flash('warn', 'Not implemented');
res.redirectBack();
await this.validate({
email: new Validator().defined().regexp(EMAIL_REGEX),
}, req.body);
const email = req.body.email;
// Existing email
if (await UserEmail.select().where('email', email).first()) {
const bag = new ValidationBag();
const error = new InvalidFormatValidationError('You already have this email.');
error.thingName = 'email';
bag.addMessage(error);
throw bag;
}
await MagicLinkController.sendMagicLink(
req.sessionID!,
MagicLinkActionType.ADD_RECOVERY_EMAIL,
Controller.route('account'),
email,
ADD_RECOVERY_EMAIL_MAIL_TEMPLATE,
{}
);
res.redirect(Controller.route('magic_link_lobby', undefined, {
redirect_uri: Controller.route('account'),
}));
}
protected async postSetMainEmail(req: Request, res: Response): Promise<void> {
if (!req.body.id)
throw new BadRequestError('Missing id field', 'Check form parameters.', req.url);
const userEmail = await UserEmail.getById(req.body.id);
if (!userEmail)
throw new NotFoundHttpError('email', req.url);
if (userEmail.user_id !== req.models.user!.id)
throw new ForbiddenHttpError('email', req.url);
if (userEmail.id === req.models.user!.main_email_id)
throw new BadRequestError('This address is already your main address', 'Try refreshing the account page.', req.url);
req.models.user!.main_email_id = userEmail.id;
await req.models.user!.save();
req.flash('success', 'This email was successfully set as your main address.');
res.redirect(Controller.route('account'));
}
protected async postRemoveEmail(req: Request, res: Response): Promise<void> {
if (!req.body.id)
throw new BadRequestError('Missing id field', 'Check form parameters.', req.url);
const userEmail = await UserEmail.getById(req.body.id);
if (!userEmail)
throw new NotFoundHttpError('email', req.url);
if (userEmail.user_id !== req.models.user!.id)
throw new ForbiddenHttpError('email', req.url);
if (userEmail.id === req.models.user!.main_email_id)
throw new BadRequestError('Cannot remove main email address', 'Try refreshing the account page.', req.url);
await userEmail.delete();
req.flash('success', 'This email was successfully removed from your account.');
res.redirect(Controller.route('account'));
}
}

View File

@ -1,13 +1,13 @@
import Controller from "wms-core/Controller";
import {REQUIRE_AUTH_MIDDLEWARE, REQUIRE_GUEST_MIDDLEWARE} from "wms-core/auth/AuthComponent";
import {NextFunction, Request, Response} from "express";
import Validator from "wms-core/db/Validator";
import {EMAIL_REGEX} from "wms-core/db/Model";
import {PasswordAuthProof} from "../models/UserPassword";
import UserEmail from "wms-core/auth/models/UserEmail";
import Username, {USERNAME_REGEXP} from "../models/Username";
import Validator, {InvalidFormatValidationError, ValidationBag} from "wms-core/db/Validator";
import UserPasswordComponent, {PasswordAuthProof} from "../models/UserPasswordComponent";
import UserNameComponent, {USERNAME_REGEXP} from "../models/UserNameComponent";
import _AuthController from "wms-core/auth/AuthController";
import {ServerError} from "wms-core/HttpError";
import {AuthError, PendingApprovalAuthError, RegisterCallback} from "wms-core/auth/AuthGuard";
import User from "wms-core/auth/models/User";
export default class AuthController extends _AuthController {
routes(): void {
@ -24,20 +24,38 @@ export default class AuthController extends _AuthController {
protected async postLogin(req: Request, res: Response): Promise<void> {
await this.validate({
email: new Validator().defined().regexp(EMAIL_REGEX),
username: new Validator().defined().exists(User, 'name'),
password: new Validator().acceptUndefined(),
}, req.body);
const passwordAuthProof = new PasswordAuthProof(req.body.email);
const user = await passwordAuthProof.getUser();
const user = await User.select()
.where('name', req.body.username)
.first();
if (!user) {
req.flash('error', 'Unknown email address');
res.redirect(Controller.route('login'));
return;
const bag = new ValidationBag();
const err = new InvalidFormatValidationError('Unknown email address.');
err.thingName = 'email';
bag.addMessage(err)
throw bag;
}
await passwordAuthProof.authorize(req.body.password, req.session);
await req.authGuard.authenticateOrRegister(req.session!, passwordAuthProof);
const passwordAuthProof = PasswordAuthProof.createProofForLogin(req.session!);
passwordAuthProof.setResource(user);
await passwordAuthProof.authorize(req.body.password);
try {
await req.authGuard.authenticateOrRegister(req.session!, passwordAuthProof);
} catch (e) {
if (e instanceof AuthError) {
const bag = new ValidationBag();
const err = new InvalidFormatValidationError('Invalid password.');
err.thingName = 'password';
bag.addMessage(err)
throw bag;
} else {
throw e;
}
}
req.flash('success', `Welcome, ${user.name}.`);
res.redirect(Controller.route('home'));
@ -48,53 +66,41 @@ export default class AuthController extends _AuthController {
}
protected async postRegister(req: Request, res: Response): Promise<void> {
const validationMap: any = {
username: new Validator().defined().between(3, 64).regexp(USERNAME_REGEXP).unique(Username),
await this.validate({
username: new Validator().defined().between(3, 64).regexp(USERNAME_REGEXP).unique(User, 'name'),
password: new Validator().defined().minLength(8),
password_confirmation: new Validator().defined().sameAs('password', req.body.password),
terms: new Validator().defined(),
};
}, req.body);
let email: string;
if (req.body.create_email) {
validationMap['domain'] = new Validator().defined().regexp(/^(toot\.party)$/);
validationMap['recovery_email'] = new Validator().acceptUndefined(true).regexp(EMAIL_REGEX).unique(UserEmail, 'email');
email = req.body.email = req.body.username + '@' + req.body.domain;
validationMap['email'] = new Validator().defined().regexp(EMAIL_REGEX).unique(UserEmail, 'email');
} else {
validationMap['recovery_email'] = new Validator().defined().regexp(EMAIL_REGEX).unique(UserEmail, 'email');
email = req.body.recovery_email;
}
await this.validate(validationMap, req.body);
const passwordAuthProof = PasswordAuthProof.createAuthorizedProofForRegistration(req.session!);
try {
await req.authGuard.authenticateOrRegister(req.session!, passwordAuthProof, undefined, async (connection, user) => {
passwordAuthProof.setResource(user);
const passwordAuthProof = new PasswordAuthProof(email, false);
const userPassword = await passwordAuthProof.register(req.body.password);
await passwordAuthProof.authorize(req.body.password_confirmation, req.session);
await req.authGuard.authenticateOrRegister(req.session!, passwordAuthProof, async (connection, userID) => {
const callbacks: (() => Promise<void>)[] = [];
const callbacks: RegisterCallback[] = [];
// Password
await userPassword.setUser(userID);
await userPassword.save(connection, c => callbacks.push(c));
// Password
await user.as(UserPasswordComponent).setPassword(req.body.password);
// Username
await new Username({user_id: userID, username: req.body.username}).save(connection, c => callbacks.push(c));
// Username
user.as(UserNameComponent).name = req.body.username;
// Email
if (req.body.create_email && req.body.recovery_email) {
await new UserEmail({
user_id: userID,
email: req.body.recovery_email,
main: false,
}).save(connection, c => callbacks.push(c));
return callbacks;
});
} catch (e) {
if (e instanceof PendingApprovalAuthError) {
req.flash('info', `Your account was successfully created and is pending review from an administrator.`);
res.redirect(Controller.route('home'));
return;
} else {
throw e;
}
}
return callbacks;
});
const user = (await passwordAuthProof.getResource())!;
const user = (await passwordAuthProof.getUser())!;
req.flash('success', `Your account was successfully created! Welcome, ${user.name}.`);
req.flash('success', `Your account was successfully created! Welcome, ${user.as(UserNameComponent).name}.`);
res.redirect(Controller.route('home'));
}

View File

@ -6,6 +6,7 @@ import {MagicLinkActionType} from "./MagicLinkActionType";
import Controller from "wms-core/Controller";
import {BadOwnerMagicLink} from "wms-core/auth/magic_link/MagicLinkAuthController";
import UserEmail from "wms-core/auth/models/UserEmail";
import ModelFactory from "wms-core/db/ModelFactory";
export default class MagicLinkController extends _MagicLinkController {
constructor(magicLinkWebSocketListener: MagicLinkWebSocketListener) {
@ -16,16 +17,32 @@ export default class MagicLinkController extends _MagicLinkController {
switch (magicLink.getActionType()) {
case MagicLinkActionType.ADD_RECOVERY_EMAIL:
if (magicLink.getSessionID() !== req.sessionID!) throw new BadOwnerMagicLink();
await magicLink.delete();
const user = await req.authGuard.getUserForSession(req.session!);
if (!user || !(await magicLink.isOwnedBy(user.id!))) throw new BadOwnerMagicLink();
const proof = await req.authGuard.getProofForSession(req.session!);
const user = await proof?.getResource();
if (!user) break;
let userEmail;
await (userEmail = new UserEmail({
const email = await magicLink.getEmail();
// Existing email
if (await UserEmail.select().with('user').where('email', email).first()) {
req.flash('error', 'An account already exists with this email address. Please first remove it there before adding it here.');
res.redirect(Controller.route('account'));
break;
}
const userEmail = ModelFactory.get(UserEmail).make({
user_id: user.id,
email: await magicLink.getEmail(),
email: email,
main: false,
})).save();
});
await userEmail.save();
if (!user.main_email_id) {
user.main_email_id = userEmail.id;
await user.save();
}
req.flash('success', `Recovery email ${userEmail.email} successfully added.`);
res.redirect(Controller.route('account'));

View File

@ -4,7 +4,7 @@ import User from "wms-core/auth/models/User";
import UserNameComponent from "../models/UserNameComponent";
import {Connection} from "mysql";
export default class CreateUserPasswordsTable extends Migration {
export default class AddNameToUsers extends Migration {
public async install(connection: Connection): Promise<void> {
await this.query(`ALTER TABLE users
ADD COLUMN name VARCHAR(64) UNIQUE NOT NULL`, connection);

View File

@ -4,10 +4,11 @@ import ModelComponent from "wms-core/db/ModelComponent";
export const USERNAME_REGEXP = /^[0-9a-z_-]+$/;
export default class UserNameComponent extends ModelComponent<User> {
public name!: string;
public name?: string = undefined;
public init(): void {
this.setValidation('username').defined().between(3, 64).regexp(USERNAME_REGEXP).unique(this._model);
super.init();
this.setValidation('name').defined().between(3, 64).regexp(USERNAME_REGEXP).unique(this._model);
}
}

View File

@ -12,6 +12,7 @@ export default class UserPasswordComponent extends ModelComponent<User> {
}
public init(): void {
super.init();
this.setValidation('password').acceptUndefined().maxLength(128);
}
@ -46,15 +47,19 @@ export class PasswordAuthProof implements AuthProof<User> {
return proofForSession;
}
public static createProofForLogin(session: Express.Session): PasswordAuthProof {
return new PasswordAuthProof(session);
}
private readonly session: Express.Session;
private userID: number | null;
private authorized: boolean;
private userPassword: UserPasswordComponent | null = null;
public constructor(session: Express.Session) {
private constructor(session: Express.Session) {
this.session = session;
this.authorized = session.auth_password_proof.authorized || false;
this.userID = session.auth_password_proof.userID || null;
this.authorized = session.auth_password_proof?.authorized || false;
this.userID = session.auth_password_proof?.userID || null;
}
public async getResource(): Promise<User | null> {
@ -71,7 +76,7 @@ export class PasswordAuthProof implements AuthProof<User> {
}
public async isValid(): Promise<boolean> {
return await this.getUserPassword() !== null;
return await this.isAuthorized() || typeof this.userID === 'number';
}
public async revoke(): Promise<void> {

View File

@ -12,13 +12,56 @@
<section class="sub-panel">
<h2>Email addresses</h2>
{% for email in emails %}
{% if email.main %}
<p>Main email: {{ email.email }}</p>
{% else %}
<p>Recovery email: {{ email.email }}</p>
{% endif %}
{% endfor %}
<table class="data-table">
<thead>
<tr>
<th>Type</th>
<th>Address</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for email in emails %}
{% if email.id == user.main_email_id %}
<tr>
<td>Main</td>
<td>{{ email.email }}</td>
<td></td>
</tr>
{% endif %}
{% endfor %}
{% for email in emails %}
{% if email.id != user.main_email_id %}
<tr>
<td>Secondary</td>
<td>{{ email.email }}</td>
<td class="actions">
<form action="{{ route('set-main-email') }}" method="POST">
<input type="hidden" name="id" value="{{ email.id }}">
<button class="warning"
onclick="return confirm('Are you sure you want to set {{ email.email }} as your main address?');">
<i data-feather="refresh-ccw"></i> Set as main address
</button>
{{ macros.csrf(getCSRFToken) }}
</form>
<form action="{{ route('remove-email') }}" method="POST">
<input type="hidden" name="id" value="{{ email.id }}">
<button class="danger"
onclick="return confirm('Are you sure you want to delete {{ email.email }}?');">
<i data-feather="trash"></i> Remove
</button>
{{ macros.csrf(getCSRFToken) }}
</form>
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
<form action="{{ route('add-recovery-email') }}" method="POST">
{{ macros.field(_locals, 'email', 'email', null, 'Choose a safe email address', 'An email we can use to identify you in case you lose access to your account', 'required') }}

View File

@ -8,7 +8,7 @@
<div class="container">
<div class="panel center">
<form action="{{ route('auth') }}" method="POST">
{{ macros.field(_locals, 'email', 'email', null, 'Your email address', null, 'required') }}
{{ macros.field(_locals, 'text', 'username', null, 'Your username', null, 'required') }}
{{ macros.field(_locals, 'password', 'password', null, 'Your password', null, 'required') }}
<button type="submit"><i data-feather="log-in"></i> Login</button>

View File

@ -0,0 +1,27 @@
{% extends 'mails/base_layout.mjml.njk' %}
{% block body %}
<mj-section>
<mj-column>
<mj-text mj-class="title">
Add this email as recovery for {{ app.name }}
</mj-text>
<mj-text>
Someone wants to add <strong>{{ mail_to }}</strong> as a recovery email to their account.
<br><br>
<strong>Do not click on this if this is not you!</strong>
</mj-text>
<mj-button href="{{ link | safe }}">
Add as recovery email
</mj-button>
</mj-column>
</mj-section>
{% endblock %}
{% block text %}
Hi!
Someone wants to add {{ mail_to }} as a recovery email to their account.
To add it as a recovery email, please follow this link: {{ link|safe }}
{% endblock %}

View File

@ -17,23 +17,6 @@
{{ macros.field(_locals, 'text', 'username', null, 'Choose your username', 'This cannot be changed later.', 'pattern="[0-9a-z_-]+" required') }}
</section>
<section class="sub-panel">
<h2>Email</h2>
{{ macros.field(_locals, 'checkbox', 'create_email', null, 'Create an email address') }}
<div class="inline-fields">
<span id="email_username">@</span>
{{ macros.field(_locals, 'select', 'domain', null, 'Choose your domain', null, 'disabled', ['toot.party']) }}
</div>
{{ macros.fieldError(_locals, 'email') }}
<div class="hint"><i data-feather="info"></i> You won't be able to change this again.</div>
</section>
<section class="sub-panel">
<h2>Recovery email</h2>
{{ macros.field(_locals, 'email', 'recovery_email', null, 'Your email address', 'Optional') }}
</section>
<section class="sub-panel">
<h2>Password</h2>
{{ macros.field(_locals, 'password', 'password', null, 'Choose a password', null, 'required') }}

View File

@ -8023,9 +8023,9 @@ redent@^1.0.0:
strip-indent "^1.0.1"
redis-commands@^1.5.0:
version "1.5.0"
resolved "https://registry.toot.party/redis-commands/-/redis-commands-1.5.0.tgz#80d2e20698fe688f227127ff9e5164a7dd17e785"
integrity sha512-6KxamqpZ468MeQC3bkWmCB1fp56XL64D4Kf0zJSwDZbVLLm7KFkoIcHrgRvQ+sk8dnhySs7+yBg94yIkAK7aJg==
version "1.6.0"
resolved "https://registry.toot.party/redis-commands/-/redis-commands-1.6.0.tgz#36d4ca42ae9ed29815cdb30ad9f97982eba1ce23"
integrity sha512-2jnZ0IkjZxvguITjFTrGiLyzQZcTvaw8DAaCXxZq/dsHXz7KfMQ3OUJy7Tz9vnRtZRVz6VRCPDvruvU8Ts44wQ==
redis-errors@^1.0.0, redis-errors@^1.2.0:
version "1.2.0"
@ -9891,9 +9891,9 @@ widest-line@^3.1.0:
string-width "^4.0.0"
wms-core@^0:
version "0.19.5"
resolved "https://registry.toot.party/wms-core/-/wms-core-0.19.5.tgz#e366f449a6c9c5bdbd2fe8aa8e653cdffe821af7"
integrity sha512-ftXCHxbZlBQpLjXO1xpabX5+lcYYgw3stX/vPQuR1r0XLmFG5kn3U3QhlYAWx51dyOqqWDidP8bJ5EHBT9SByQ==
version "0.19.14"
resolved "https://registry.toot.party/wms-core/-/wms-core-0.19.14.tgz#9c64954f3ccfd0b1287cdbf3f5c646725db55319"
integrity sha512-fwVjNNoKO779ZCVVGinhfOwVMPpg48DoJcz+g+QLhJgqmYqrsdULT1oZiSntE+1cgII1pWouahyGRywRJyOtQA==
dependencies:
argon2 "^0.26.2"
compression "^1.7.4"