Add account management (email addresses management, password management)

Closes #8
Closes #9
This commit is contained in:
Alice Gaudon 2021-01-21 15:58:03 +01:00
parent 784f2c976c
commit 49168b5391
17 changed files with 785 additions and 13 deletions

View File

@ -1,7 +1,8 @@
{ {
app: { app: {
name: 'Example App', name: 'Example App',
contact_email: 'contact@example.net' contact_email: 'contact@example.net',
display_email_warning: true,
}, },
log: { log: {
level: "DEBUG", level: "DEBUG",

View File

@ -3,7 +3,9 @@ import {MailTemplate} from "./mail/Mail";
export const MAGIC_LINK_MAIL = new MailTemplate( export const MAGIC_LINK_MAIL = new MailTemplate(
'magic_link', 'magic_link',
data => data.type === 'register' ? 'Registration' : 'Login magic link', data => data.type === 'register' ?
'Registration' :
'Login magic link',
); );
export const ACCOUNT_REVIEW_NOTICE_MAIL_TEMPLATE: MailTemplate = new MailTemplate( export const ACCOUNT_REVIEW_NOTICE_MAIL_TEMPLATE: MailTemplate = new MailTemplate(
@ -13,5 +15,10 @@ export const ACCOUNT_REVIEW_NOTICE_MAIL_TEMPLATE: MailTemplate = new MailTemplat
export const PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE: MailTemplate = new MailTemplate( export const PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE: MailTemplate = new MailTemplate(
'pending_account_review', 'pending_account_review',
() => 'A new account is pending review on ' + config.get<string>('app.name'), () => `A new account is pending review on ${config.get<string>('app.name')}`,
);
export const ADD_EMAIL_MAIL_TEMPLATE: MailTemplate = new MailTemplate(
'add_email',
(data) => `Add ${data.email} address to your ${config.get<string>('app.name')} account.`,
); );

View File

@ -28,6 +28,9 @@ import MailController from "./mail/MailController";
import WebSocketServerComponent from "./components/WebSocketServerComponent"; import WebSocketServerComponent from "./components/WebSocketServerComponent";
import Controller from "./Controller"; import Controller from "./Controller";
import packageJson = require('../package.json'); import packageJson = require('../package.json');
import AccountController from "./auth/AccountController";
import MakeMagicLinksSessionNotUniqueMigration from "./auth/magic_link/MakeMagicLinksSessionNotUniqueMigration";
import AddUsedToMagicLinksMigration from "./auth/magic_link/AddUsedToMagicLinksMigration";
export const MIGRATIONS = [ export const MIGRATIONS = [
CreateMigrationsTable, CreateMigrationsTable,
@ -35,6 +38,8 @@ export const MIGRATIONS = [
AddPasswordToUsersMigration, AddPasswordToUsersMigration,
CreateMagicLinksTableMigration, CreateMagicLinksTableMigration,
AddNameToUsersMigration, AddNameToUsersMigration,
MakeMagicLinksSessionNotUniqueMigration,
AddUsedToMagicLinksMigration,
]; ];
export default class TestApp extends Application { export default class TestApp extends Application {
@ -97,6 +102,7 @@ export default class TestApp extends Application {
protected registerControllers(): void { protected registerControllers(): void {
this.use(new MailController()); this.use(new MailController());
this.use(new AuthController()); this.use(new AuthController());
this.use(new AccountController());
this.use(new MagicLinkController(this.as<MagicLinkWebSocketListener<this>>(MagicLinkWebSocketListener))); this.use(new MagicLinkController(this.as<MagicLinkWebSocketListener<this>>(MagicLinkWebSocketListener)));

View File

@ -0,0 +1,147 @@
import Controller from "../Controller";
import {RequireAuthMiddleware} from "./AuthComponent";
import {Request, Response} from "express";
import {BadRequestError, ForbiddenHttpError, NotFoundHttpError} from "../HttpError";
import config from "config";
import Validator, {EMAIL_REGEX, InvalidFormatValidationError} from "../db/Validator";
import UserPasswordComponent from "./password/UserPasswordComponent";
import User from "./models/User";
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 {log} from "../Logger";
import AuthMagicLinkActionType from "./magic_link/AuthMagicLinkActionType";
export default class AccountController extends Controller {
private readonly addEmailMailTemplate: MailTemplate;
public constructor(addEmailMailTemplate: MailTemplate = ADD_EMAIL_MAIL_TEMPLATE) {
super();
this.addEmailMailTemplate = addEmailMailTemplate;
}
public getRoutesPrefix(): string {
return '/account';
}
public routes(): void {
this.get('/', this.getAccount, 'account', RequireAuthMiddleware);
if (ModelFactory.get(User).hasComponent(UserPasswordComponent)) {
this.post('/change-password', this.postChangePassword, 'change-password', RequireAuthMiddleware);
}
this.post('/add-email', this.addEmail, 'add-email', RequireAuthMiddleware);
this.post('/set-main-email', this.postSetMainEmail, 'set-main-email', RequireAuthMiddleware);
this.post('/remove-email', this.postRemoveEmail, 'remove-email', RequireAuthMiddleware);
}
protected async getAccount(req: Request, res: Response): Promise<void> {
const user = req.as(RequireAuthMiddleware).getUser();
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(),
});
}
protected async postChangePassword(req: Request, res: Response): Promise<void> {
const validationMap = {
'new_password': new Validator().defined(),
'new_password_confirmation': new Validator().sameAs('new_password', req.body.new_password),
};
await Validator.validate(validationMap, req.body);
const user = req.as(RequireAuthMiddleware).getUser();
const passwordComponent = user.as(UserPasswordComponent);
if (passwordComponent.hasPassword() && !await passwordComponent.verifyPassword(req.body.current_password)) {
req.flash('error', 'Invalid current password.');
res.redirectBack(Controller.route('account'));
return;
}
await passwordComponent.setPassword(req.body.new_password, 'new_password');
await user.save();
log.debug('user ' + user.id + ' changed their password and was saved.');
req.flash('success', 'Password changed successfully.');
res.redirectBack(Controller.route('account'));
}
protected async addEmail(req: Request, res: Response): Promise<void> {
await Validator.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 error = new InvalidFormatValidationError('You already have this email.');
error.thingName = 'email';
throw error;
}
await MagicLinkController.sendMagicLink(
this.getApp(),
req.getSession().id,
AuthMagicLinkActionType.ADD_EMAIL,
Controller.route('account'),
email,
this.addEmailMailTemplate,
{
email: email,
},
);
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 user = req.as(RequireAuthMiddleware).getUser();
const userEmail = await UserEmail.getById(req.body.id);
if (!userEmail)
throw new NotFoundHttpError('email', req.url);
if (userEmail.user_id !== user.id)
throw new ForbiddenHttpError('email', req.url);
if (userEmail.id === user.main_email_id)
throw new BadRequestError('This address is already your main address',
'Try refreshing the account page.', req.url);
user.main_email_id = userEmail.id;
await user.save();
req.flash('success', 'This email was successfully set as your main address.');
res.redirectBack();
}
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 user = req.as(RequireAuthMiddleware).getUser();
const userEmail = await UserEmail.getById(req.body.id);
if (!userEmail)
throw new NotFoundHttpError('email', req.url);
if (userEmail.user_id !== user.id)
throw new ForbiddenHttpError('email', req.url);
if (userEmail.id === 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.redirectBack();
}
}

View File

@ -0,0 +1,12 @@
import Migration from "../../db/Migration";
export default class AddUsedToMagicLinksMigration extends Migration {
public async install(): Promise<void> {
await this.query(`ALTER TABLE magic_links
ADD COLUMN used BOOLEAN NOT NULL`);
}
public async rollback(): Promise<void> {
await this.query('ALTER TABLE magic_links DROP COLUMN IF EXISTS used');
}
}

View File

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

View File

@ -17,6 +17,7 @@ import AuthMagicLinkActionType from "./AuthMagicLinkActionType";
import {QueryVariable} from "../../db/MysqlConnectionManager"; import {QueryVariable} from "../../db/MysqlConnectionManager";
import UserNameComponent from "../models/UserNameComponent"; import UserNameComponent from "../models/UserNameComponent";
import MagicLinkUserNameComponent from "../models/MagicLinkUserNameComponent"; import MagicLinkUserNameComponent from "../models/MagicLinkUserNameComponent";
import {log} from "../../Logger";
export default class MagicLinkController<A extends Application> extends Controller { export default class MagicLinkController<A extends Application> extends Controller {
public static async sendMagicLink( public static async sendMagicLink(
@ -29,8 +30,10 @@ export default class MagicLinkController<A extends Application> extends Controll
data: ParsedUrlQueryInput, data: ParsedUrlQueryInput,
magicLinkData: Record<string, QueryVariable> = {}, magicLinkData: Record<string, QueryVariable> = {},
): Promise<void> { ): Promise<void> {
Throttler.throttle('magic_link', 2, MagicLink.validityPeriod(), sessionId, 0, 0); Throttler.throttle('magic_link', process.env.NODE_ENV === 'test' ? 10 : 2, MagicLink.validityPeriod(),
Throttler.throttle('magic_link', 1, MagicLink.validityPeriod(), email, 0, 0); sessionId, 0, 0);
Throttler.throttle('magic_link', 1, MagicLink.validityPeriod(),
email, 0, 0);
const link = MagicLink.create(Object.assign(magicLinkData, { const link = MagicLink.create(Object.assign(magicLinkData, {
session_id: sessionId, session_id: sessionId,
@ -118,6 +121,7 @@ export default class MagicLinkController<A extends Application> extends Controll
const link = await MagicLink.select() const link = await MagicLink.select()
.where('session_id', req.getSession().id) .where('session_id', req.getSession().id)
.sortBy('authorized') .sortBy('authorized')
.where('used', 0)
.first(); .first();
if (!link) { if (!link) {
throw new NotFoundHttpError('magic link', req.url); throw new NotFoundHttpError('magic link', req.url);
@ -130,6 +134,8 @@ export default class MagicLinkController<A extends Application> extends Controll
} }
if (await link.isAuthorized()) { if (await link.isAuthorized()) {
link.use();
await link.save();
await this.performAction(link, req, res); await this.performAction(link, req, res);
return; return;
} }
@ -190,6 +196,45 @@ export default class MagicLinkController<A extends Application> extends Controll
} }
break; break;
} }
case AuthMagicLinkActionType.ADD_EMAIL: {
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 email = await magicLink.getOrFail('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'));
return;
}
const userEmail = UserEmail.create({
user_id: user.id,
email: email,
main: false,
});
await userEmail.save();
if (!user.main_email_id) {
user.main_email_id = userEmail.id;
await user.save();
}
req.flash('success', `Email address ${userEmail.email} successfully added.`);
res.redirect(Controller.route('account'));
break;
}
default:
log.warn('Unknown magic link action type ' + magicLink.action_type);
break;
} }
} }
} }

View File

@ -0,0 +1,12 @@
import Migration from "../../db/Migration";
export default class MakeMagicLinksSessionNotUniqueMigration extends Migration {
public async install(): Promise<void> {
await this.query(`ALTER TABLE magic_links
DROP INDEX IF EXISTS session_id`);
}
public async rollback(): Promise<void> {
await this.query('ALTER TABLE magic_links ADD CONSTRAINT UNIQUE (session_id)');
}
}

View File

@ -20,14 +20,16 @@ export default class MagicLink extends Model implements AuthProof<User> {
public readonly original_url?: string = undefined; public readonly original_url?: string = undefined;
private generated_at?: Date = undefined; private generated_at?: Date = undefined;
private authorized: boolean = false; private authorized: boolean = false;
private used: boolean = false;
protected init(): void { protected init(): void {
this.setValidation('session_id').defined().length(32).unique(this); this.setValidation('session_id').defined().length(32);
this.setValidation('email').defined().regexp(EMAIL_REGEX); this.setValidation('email').defined().regexp(EMAIL_REGEX);
this.setValidation('token').defined().length(96); this.setValidation('token').defined().length(96);
this.setValidation('action_type').defined().maxLength(64); this.setValidation('action_type').defined().maxLength(64);
this.setValidation('original_url').defined().maxLength(1745); this.setValidation('original_url').defined().maxLength(1745);
this.setValidation('authorized').defined(); this.setValidation('authorized').defined();
this.setValidation('used').defined();
} }
public async getResource(): Promise<User | null> { public async getResource(): Promise<User | null> {
@ -55,6 +57,14 @@ export default class MagicLink extends Model implements AuthProof<User> {
this.authorized = true; this.authorized = true;
} }
public isUsed(): boolean {
return this.used;
}
public use(): void {
this.used = true;
}
public async generateToken(email: string): Promise<string> { public async generateToken(email: string): Promise<string> {
const rawToken = crypto.randomBytes(48).toString('base64'); // Raw token length = 64 const rawToken = crypto.randomBytes(48).toString('base64'); // Raw token length = 64
this.email = email; this.email = email;

View File

@ -5,6 +5,7 @@ import config from "config";
import {ManyModelRelation} from "../../db/ModelRelation"; import {ManyModelRelation} from "../../db/ModelRelation";
import UserEmail from "./UserEmail"; import UserEmail from "./UserEmail";
import UserApprovedComponent from "./UserApprovedComponent"; import UserApprovedComponent from "./UserApprovedComponent";
import UserNameComponent from "./UserNameComponent";
export default class User extends Model { export default class User extends Model {
public static isApprovalMode(): boolean { public static isApprovalMode(): boolean {
@ -37,4 +38,16 @@ export default class User extends Model {
public isApproved(): boolean { public isApproved(): boolean {
return !User.isApprovalMode() || this.as(UserApprovedComponent).approved; return !User.isApprovalMode() || this.as(UserApprovedComponent).approved;
} }
protected getPersonalInfoFields(): { name: string, value: string }[] {
const fields: { name: string, value: string }[] = [];
const nameComponent = this.asOptional(UserNameComponent);
if (nameComponent && nameComponent.name) {
fields.push({
name: 'Name',
value: nameComponent.name,
});
}
return fields;
}
} }

View File

@ -13,7 +13,10 @@ export default class UserPasswordComponent extends ModelComponent<User> {
} }
public async setPassword(rawPassword: string, fieldName: string = 'password'): Promise<void> { public async setPassword(rawPassword: string, fieldName: string = 'password'): Promise<void> {
await new Validator<string>().defined().minLength(12).maxLength(512) await new Validator<string>()
.defined()
.minLength(UserPasswordComponent.PASSWORD_MIN_LENGTH)
.maxLength(512)
.execute(fieldName, rawPassword, true); .execute(fieldName, rawPassword, true);
this.password = await argon2.hash(rawPassword, { this.password = await argon2.hash(rawPassword, {
timeCost: 10, timeCost: 10,
@ -25,8 +28,12 @@ export default class UserPasswordComponent extends ModelComponent<User> {
} }
public async verifyPassword(passwordGuess: string): Promise<boolean> { public async verifyPassword(passwordGuess: string): Promise<boolean> {
if (!this.password) return false; if (!this.password || !passwordGuess) return false;
return await argon2.verify(this.password, passwordGuess); return await argon2.verify(this.password, passwordGuess);
} }
public hasPassword(): boolean {
return typeof this.password === 'string';
}
} }

View File

@ -9,6 +9,7 @@ import UserPasswordComponent from "../src/auth/password/UserPasswordComponent";
import {popEmail} from "./_mail_server"; import {popEmail} from "./_mail_server";
import AuthComponent from "../src/auth/AuthComponent"; import AuthComponent from "../src/auth/AuthComponent";
import {followMagicLinkFromMail, testLogout} from "./_authentication_common"; import {followMagicLinkFromMail, testLogout} from "./_authentication_common";
import UserEmail from "../src/auth/models/UserEmail";
let app: TestApp; let app: TestApp;
useApp(async (addr, port) => { useApp(async (addr, port) => {
@ -645,3 +646,378 @@ describe('Authenticate with email and password (password)', () => {
await testLogout(agent, cookies, csrf); await testLogout(agent, cookies, csrf);
}); });
}); });
describe('Change password', () => {
let cookies: string[], csrf: string;
test('Prepare user', async () => {
const res = await agent.get('/csrf').expect(200);
cookies = res.get('Set-Cookie');
csrf = res.text;
await agent.post('/auth/register')
.set('Cookie', cookies)
.send({
csrf: csrf,
auth_method: 'magic_link',
identifier: 'aang@example.org',
name: 'aang',
})
.expect(302)
.expect('Location', '/magic/lobby?redirect_uri=%2Fcsrf');
await followMagicLinkFromMail(agent, cookies);
});
test('Set password to blank from blank', async () => {
await agent.post('/account/change-password')
.set('Cookie', cookies)
.send({
csrf: csrf,
'current_password': '',
'new_password': '',
'new_password_confirmation': '',
})
.expect(400);
const user = await User.select()
.where('name', 'aang')
.first();
expect(user).toBeDefined();
expect(user?.as(UserPasswordComponent).hasPassword()).toBeFalsy();
expect(await user?.as(UserPasswordComponent).verifyPassword('')).toBeFalsy();
expect(await user?.as(UserPasswordComponent).verifyPassword('bad_password')).toBeFalsy();
});
test('Set password to something from blank', async () => {
await agent.post('/account/change-password')
.set('Cookie', cookies)
.send({
csrf: csrf,
'current_password': '',
'new_password': 'a_very_strong_password',
'new_password_confirmation': 'a_very_strong_password',
})
.expect(302)
.expect('Location', '/csrf'); // TODO: because of buggy RedirectBackComponent, change to /account once fixed.
const user = await User.select()
.where('name', 'aang')
.first();
expect(user).toBeDefined();
expect(user?.as(UserPasswordComponent).hasPassword()).toBeTruthy();
expect(await user?.as(UserPasswordComponent).verifyPassword('')).toBeFalsy();
expect(await user?.as(UserPasswordComponent).verifyPassword('bad_password')).toBeFalsy();
expect(await user?.as(UserPasswordComponent).verifyPassword('a_very_strong_password')).toBeTruthy();
});
test('Set password to blank from something', async () => {
await agent.post('/account/change-password')
.set('Cookie', cookies)
.send({
csrf: csrf,
'current_password': 'a_very_strong_password',
'new_password': '',
})
.expect(400);
const user = await User.select()
.where('name', 'aang')
.first();
expect(user).toBeDefined();
expect(user?.as(UserPasswordComponent).hasPassword()).toBeTruthy();
expect(await user?.as(UserPasswordComponent).verifyPassword('')).toBeFalsy();
expect(await user?.as(UserPasswordComponent).verifyPassword('bad_password')).toBeFalsy();
expect(await user?.as(UserPasswordComponent).verifyPassword('a_very_strong_password')).toBeTruthy();
});
test('Set password to something from something', async () => {
await agent.post('/account/change-password')
.set('Cookie', cookies)
.send({
csrf: csrf,
'current_password': 'a_very_strong_password',
'new_password': 'a_very_strong_password_but_different',
'new_password_confirmation': 'a_very_strong_password_but_different',
})
.expect(302)
.expect('Location', '/csrf'); // TODO: because of buggy RedirectBackComponent, change to /account once fixed.
const user = await User.select()
.where('name', 'aang')
.first();
expect(user).toBeDefined();
expect(user?.as(UserPasswordComponent).hasPassword()).toBeTruthy();
expect(await user?.as(UserPasswordComponent).verifyPassword('')).toBeFalsy();
expect(await user?.as(UserPasswordComponent).verifyPassword('bad_password')).toBeFalsy();
expect(await user?.as(UserPasswordComponent).verifyPassword('a_very_strong_password')).toBeFalsy();
expect(await user?.as(UserPasswordComponent).verifyPassword('a_very_strong_password_but_different')).toBeTruthy();
});
test('Set password to unconfirmed', async () => {
await agent.post('/account/change-password')
.set('Cookie', cookies)
.send({
csrf: csrf,
'current_password': 'a_very_strong_password_but_different',
'new_password': 'a_very_strong_password',
'new_password_confirmation': '',
})
.expect(400);
await agent.post('/account/change-password')
.set('Cookie', cookies)
.send({
csrf: csrf,
'current_password': 'a_very_strong_password_but_different',
'new_password': 'a_very_strong_password',
'new_password_confirmation': 'a_very_strong_password_but_different2',
})
.expect(400);
const user = await User.select()
.where('name', 'aang')
.first();
expect(user).toBeDefined();
expect(user?.as(UserPasswordComponent).hasPassword()).toBeTruthy();
expect(await user?.as(UserPasswordComponent).verifyPassword('bad_password')).toBeFalsy();
expect(await user?.as(UserPasswordComponent).verifyPassword('a_very_strong_password')).toBeFalsy();
expect(await user?.as(UserPasswordComponent).verifyPassword('a_very_strong_password_but_different')).toBeTruthy();
expect(await user?.as(UserPasswordComponent).verifyPassword('a_very_strong_password_but_different2')).toBeFalsy();
});
test('Set password to too short password', async () => {
await agent.post('/account/change-password')
.set('Cookie', cookies)
.send({
csrf: csrf,
'current_password': 'a_very_strong_password_but_different',
'new_password': '123',
'new_password_confirmation': '123',
})
.expect(400);
const user = await User.select()
.where('name', 'aang')
.first();
expect(user).toBeDefined();
expect(user?.as(UserPasswordComponent).hasPassword()).toBeTruthy();
expect(await user?.as(UserPasswordComponent).verifyPassword('a_very_strong_password_but_different')).toBeTruthy();
expect(await user?.as(UserPasswordComponent).verifyPassword('123')).toBeFalsy();
});
});
describe('Manage email addresses', () => {
let cookies: string[], csrf: string;
test('Prepare user', async () => {
const res = await agent.get('/csrf').expect(200);
cookies = res.get('Set-Cookie');
csrf = res.text;
await agent.post('/auth/register')
.set('Cookie', cookies)
.send({
csrf: csrf,
auth_method: 'magic_link',
identifier: 'katara@example.org',
name: 'katara',
})
.expect(302)
.expect('Location', '/magic/lobby?redirect_uri=%2Fcsrf');
await followMagicLinkFromMail(agent, cookies);
// Add fake, not owned secondary email
const user = User.create({
name: 'not_katara',
});
await user.save();
await UserEmail.create({
email: 'not_katara@example.org',
user_id: user.id,
}).save();
});
test('Add invalid email addresses', async () => {
await agent.post('/account/add-email')
.set('Cookie', cookies)
.send({
csrf: csrf,
})
.expect(400);
await agent.post('/account/add-email')
.set('Cookie', cookies)
.send({
csrf: csrf,
email: '',
})
.expect(400);
await agent.post('/account/add-email')
.set('Cookie', cookies)
.send({
csrf: csrf,
email: 'katara@example.org',
})
.expect(400);
expect(await UserEmail.select().where('email', 'katara@example.org').count()).toBe(1);
});
test('Add valid email', async () => {
const expectedUserId = (await User.select('id').where('name', 'katara').first())?.id;
for (const email of [
'katara2@example.org',
'katara3@example.org',
'katara4@example.org',
]) {
await agent.post('/account/add-email')
.set('Cookie', cookies)
.send({
csrf: csrf,
email: email,
})
.expect(302)
.expect('Location', '/magic/lobby?redirect_uri=%2Faccount%2F');
await followMagicLinkFromMail(agent, cookies, '/account/');
const userEmail = await UserEmail.select().where('email', email).first();
expect(userEmail).not.toBeNull();
expect(userEmail?.user_id).toBe(expectedUserId);
}
});
async function testMainSecondaryState(main: string, secondary: string) {
const user = await User.select('main_email_id').where('name', 'katara').first();
const mainEmail = await UserEmail.select().where('email', main).first();
expect(mainEmail).not.toBeNull();
expect(user?.main_email_id).toBe(mainEmail?.id);
const secondaryEmail = await UserEmail.select().where('email', secondary).first();
expect(secondaryEmail).not.toBeNull();
expect(user?.main_email_id).not.toBe(secondaryEmail?.id);
return secondaryEmail;
}
test('Set main email address as main email address', async () => {
await testMainSecondaryState('katara@example.org', 'katara3@example.org');
// Set secondary as main
await agent.post('/account/set-main-email')
.set('Cookie', cookies)
.send({
csrf: csrf,
id: (await UserEmail.select().where('email', 'katara@example.org').first())?.id,
})
.expect(400);
await testMainSecondaryState('katara@example.org', 'katara3@example.org');
});
test('Set secondary email address as main email address', async () => {
const beforeSecondaryEmail = await testMainSecondaryState('katara@example.org', 'katara3@example.org');
// Set secondary as main
await agent.post('/account/set-main-email')
.set('Cookie', cookies)
.send({
csrf: csrf,
id: beforeSecondaryEmail?.id,
})
.expect(302)
.expect('Location', '/csrf'); // TODO: because of buggy RedirectBackComponent, change to /account once fixed.
await testMainSecondaryState('katara3@example.org', 'katara@example.org');
});
test('Set non-owned address as main email address', async () => {
const beforeSecondaryEmail = await testMainSecondaryState('katara3@example.org', 'not_katara@example.org');
// Set secondary as main
await agent.post('/account/set-main-email')
.set('Cookie', cookies)
.send({
csrf: csrf,
id: beforeSecondaryEmail?.id,
})
.expect(403);
await testMainSecondaryState('katara3@example.org', 'not_katara@example.org');
});
test('Set non-existing address as main email address', async () => {
await testMainSecondaryState('katara3@example.org', 'katara4@example.org');
// Set secondary as main
await agent.post('/account/set-main-email')
.set('Cookie', cookies)
.send({
csrf: csrf,
id: 999,
})
.expect(404);
await testMainSecondaryState('katara3@example.org', 'katara4@example.org');
});
test('Remove secondary email address', async () => {
expect(await UserEmail.select().where('email', 'katara2@example.org').count()).toBe(1);
// Set secondary as main
await agent.post('/account/remove-email')
.set('Cookie', cookies)
.send({
csrf: csrf,
id: (await UserEmail.select().where('email', 'katara2@example.org').first())?.id,
})
.expect(302)
.expect('Location', '/csrf'); // TODO: because of buggy RedirectBackComponent, change to /account once fixed.
expect(await UserEmail.select().where('email', 'katara2@example.org').count()).toBe(0);
});
test('Remove non-owned email address', async () => {
expect(await UserEmail.select().where('email', 'not_katara@example.org').count()).toBe(1);
// Set secondary as main
await agent.post('/account/remove-email')
.set('Cookie', cookies)
.send({
csrf: csrf,
id: (await UserEmail.select().where('email', 'not_katara@example.org').first())?.id,
})
.expect(403);
expect(await UserEmail.select().where('email', 'not_katara@example.org').count()).toBe(1);
});
test('Remove non-existing email address', async () => {
// Set secondary as main
await agent.post('/account/remove-email')
.set('Cookie', cookies)
.send({
csrf: csrf,
id: 999,
})
.expect(404);
});
test('Remove main email address', async () => {
expect(await UserEmail.select().where('email', 'katara3@example.org').count()).toBe(1);
// Set secondary as main
await agent.post('/account/remove-email')
.set('Cookie', cookies)
.send({
csrf: csrf,
id: (await UserEmail.select().where('email', 'katara3@example.org').first())?.id,
})
.expect(400);
expect(await UserEmail.select().where('email', 'katara3@example.org').count()).toBe(1);
});
});

View File

@ -4,6 +4,7 @@ import supertest from "supertest";
export async function followMagicLinkFromMail( export async function followMagicLinkFromMail(
agent: supertest.SuperTest<supertest.Test>, agent: supertest.SuperTest<supertest.Test>,
cookies: string[], cookies: string[],
expectedRedirectUrl: string = '/',
): Promise<void> { ): Promise<void> {
const mail: Record<string, unknown> | null = await popEmail(); const mail: Record<string, unknown> | null = await popEmail();
expect(mail).not.toBeNull(); expect(mail).not.toBeNull();
@ -16,7 +17,7 @@ export async function followMagicLinkFromMail(
await agent.get('/magic/lobby') await agent.get('/magic/lobby')
.set('Cookie', cookies) .set('Cookie', cookies)
.expect(302) .expect(302)
.expect('Location', '/'); .expect('Location', expectedRedirectUrl);
} }
export async function testLogout( export async function testLogout(

View File

@ -5,14 +5,14 @@ export const MAIL_SERVER = new MailDev({
}); });
export async function setupMailServer(): Promise<void> { export async function setupMailServer(): Promise<void> {
await new Promise((resolve, reject) => MAIL_SERVER.listen((err?: Error) => { await new Promise<void>((resolve, reject) => MAIL_SERVER.listen((err?: Error) => {
if (err) reject(err); if (err) reject(err);
else resolve(); else resolve();
})); }));
} }
export async function teardownMailServer(): Promise<void> { export async function teardownMailServer(): Promise<void> {
await new Promise((resolve, reject) => MAIL_SERVER.close((err?: Error) => { await new Promise<void>((resolve, reject) => MAIL_SERVER.close((err?: Error) => {
if (err) reject(err); if (err) reject(err);
else resolve(); else resolve();
})); }));

View File

@ -21,8 +21,11 @@
<li><a href="{{ route('backend') }}"><i data-feather="settings"></i> <span class="tip">Backend</span></a></li> <li><a href="{{ route('backend') }}"><i data-feather="settings"></i> <span class="tip">Backend</span></a></li>
{% endif %} {% endif %}
{# <li><a href="{{ route('account') }}"><i data-feather="user"></i>#} <li>
{# <span class="tip">{{ user.name }}</span></a></li>#} <a href="{{ route('account') }}"><i data-feather="user"></i>
<span class="tip">{{ user.name }}</span></a>
</li>
<li> <li>
<form action="{{ route('logout') }}" method="POST"> <form action="{{ route('logout') }}" method="POST">
<button><i data-feather="log-out"></i> <span class="tip">Logout</span></button> <button><i data-feather="log-out"></i> <span class="tip">Logout</span></button>

104
views/auth/account.njk Normal file
View File

@ -0,0 +1,104 @@
{% extends 'layouts/base.njk' %}
{% import 'macros.njk' as macros %}
{% set title = 'Account' %}
{% set decription = 'Manage your account settings and data.' %}
{% block body %}
<div class="container">
<div class="panel">
<h2><i class="fas fa-user"></i> Personal information</h2>
{% if display_email_warning and emails | length <= 0 %}
{{ macros.message('warning', 'To avoid losing access to your account, please add an email address.') }}
{% endif %}
{% for field in user.getPersonalInfoFields() %}
<p>{{ field.name }}: {{ field.value }}</p>
{% endfor %}
<p>Contact email: {{ main_email.email }} <a href="#emails">More...</a></p>
</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>
<section class="panel">
<h2 id="emails"><i data-feather="shield"></i> Email addresses</h2>
<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> <span class="tip">Set as main address</span>
</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> <span class="tip">Remove</span>
</button>
{{ macros.csrf(getCsrfToken) }}
</form>
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
<form action="{{ route('add-email') }}" method="POST" class="sub-panel">
<h3>Add an email address:</h3>
{{ macros.field(_locals, 'email', 'email', null, 'Choose a safe email address', 'An email address we can use to identify you in case you lose access to your account', 'required') }}
<button><i data-feather="plus"></i> Add email address</button>
{{ macros.csrf(getCsrfToken) }}
</form>
</section>
</div>
{% endblock %}

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 address on {{ app.name }}
</mj-text>
<mj-text>
Someone wants to add <strong>{{ mail_to }}</strong> 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 <strong>{{ mail_to }}</strong> on {{ app.name }}
</mj-button>
</mj-column>
</mj-section>
{% endblock %}
{% block text %}
Hi!
Someone wants to add {{ mail_to }} to their account.
To add this email address, please follow this link: {{ link|safe }}
{% endblock %}