diff --git a/config/default.json5 b/config/default.json5 index 40779b3..1e33c57 100644 --- a/config/default.json5 +++ b/config/default.json5 @@ -1,7 +1,8 @@ { app: { name: 'Example App', - contact_email: 'contact@example.net' + contact_email: 'contact@example.net', + display_email_warning: true, }, log: { level: "DEBUG", diff --git a/src/Mails.ts b/src/Mails.ts index 03317f2..b1d619f 100644 --- a/src/Mails.ts +++ b/src/Mails.ts @@ -3,7 +3,9 @@ import {MailTemplate} from "./mail/Mail"; export const MAGIC_LINK_MAIL = new MailTemplate( '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( @@ -13,5 +15,10 @@ export const ACCOUNT_REVIEW_NOTICE_MAIL_TEMPLATE: MailTemplate = new MailTemplat export const PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE: MailTemplate = new MailTemplate( 'pending_account_review', - () => 'A new account is pending review on ' + config.get('app.name'), + () => `A new account is pending review on ${config.get('app.name')}`, +); + +export const ADD_EMAIL_MAIL_TEMPLATE: MailTemplate = new MailTemplate( + 'add_email', + (data) => `Add ${data.email} address to your ${config.get('app.name')} account.`, ); diff --git a/src/TestApp.ts b/src/TestApp.ts index d29595b..e7e594a 100644 --- a/src/TestApp.ts +++ b/src/TestApp.ts @@ -28,6 +28,9 @@ import MailController from "./mail/MailController"; import WebSocketServerComponent from "./components/WebSocketServerComponent"; import Controller from "./Controller"; 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 = [ CreateMigrationsTable, @@ -35,6 +38,8 @@ export const MIGRATIONS = [ AddPasswordToUsersMigration, CreateMagicLinksTableMigration, AddNameToUsersMigration, + MakeMagicLinksSessionNotUniqueMigration, + AddUsedToMagicLinksMigration, ]; export default class TestApp extends Application { @@ -97,6 +102,7 @@ export default class TestApp extends Application { protected registerControllers(): void { this.use(new MailController()); this.use(new AuthController()); + this.use(new AccountController()); this.use(new MagicLinkController(this.as>(MagicLinkWebSocketListener))); diff --git a/src/auth/AccountController.ts b/src/auth/AccountController.ts new file mode 100644 index 0000000..b5ef4ff --- /dev/null +++ b/src/auth/AccountController.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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(); + } +} diff --git a/src/auth/magic_link/AddUsedToMagicLinksMigration.ts b/src/auth/magic_link/AddUsedToMagicLinksMigration.ts new file mode 100644 index 0000000..3e7f639 --- /dev/null +++ b/src/auth/magic_link/AddUsedToMagicLinksMigration.ts @@ -0,0 +1,12 @@ +import Migration from "../../db/Migration"; + +export default class AddUsedToMagicLinksMigration extends Migration { + public async install(): Promise { + await this.query(`ALTER TABLE magic_links + ADD COLUMN used BOOLEAN NOT NULL`); + } + + public async rollback(): Promise { + await this.query('ALTER TABLE magic_links DROP COLUMN IF EXISTS used'); + } +} diff --git a/src/auth/magic_link/AuthMagicLinkActionType.ts b/src/auth/magic_link/AuthMagicLinkActionType.ts index 1ff51ae..7e907cf 100644 --- a/src/auth/magic_link/AuthMagicLinkActionType.ts +++ b/src/auth/magic_link/AuthMagicLinkActionType.ts @@ -1,4 +1,5 @@ export default { LOGIN: 'login', REGISTER: 'register', + ADD_EMAIL: 'add_email', }; diff --git a/src/auth/magic_link/MagicLinkController.ts b/src/auth/magic_link/MagicLinkController.ts index e3d047c..4a73d00 100644 --- a/src/auth/magic_link/MagicLinkController.ts +++ b/src/auth/magic_link/MagicLinkController.ts @@ -17,6 +17,7 @@ import AuthMagicLinkActionType from "./AuthMagicLinkActionType"; import {QueryVariable} from "../../db/MysqlConnectionManager"; import UserNameComponent from "../models/UserNameComponent"; import MagicLinkUserNameComponent from "../models/MagicLinkUserNameComponent"; +import {log} from "../../Logger"; export default class MagicLinkController extends Controller { public static async sendMagicLink( @@ -29,8 +30,10 @@ export default class MagicLinkController extends Controll data: ParsedUrlQueryInput, magicLinkData: Record = {}, ): Promise { - Throttler.throttle('magic_link', 2, MagicLink.validityPeriod(), sessionId, 0, 0); - Throttler.throttle('magic_link', 1, MagicLink.validityPeriod(), email, 0, 0); + Throttler.throttle('magic_link', process.env.NODE_ENV === 'test' ? 10 : 2, MagicLink.validityPeriod(), + sessionId, 0, 0); + Throttler.throttle('magic_link', 1, MagicLink.validityPeriod(), + email, 0, 0); const link = MagicLink.create(Object.assign(magicLinkData, { session_id: sessionId, @@ -118,6 +121,7 @@ export default class MagicLinkController extends Controll const link = await MagicLink.select() .where('session_id', req.getSession().id) .sortBy('authorized') + .where('used', 0) .first(); if (!link) { throw new NotFoundHttpError('magic link', req.url); @@ -130,6 +134,8 @@ export default class MagicLinkController extends Controll } if (await link.isAuthorized()) { + link.use(); + await link.save(); await this.performAction(link, req, res); return; } @@ -190,6 +196,45 @@ export default class MagicLinkController extends Controll } 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; } } } diff --git a/src/auth/magic_link/MakeMagicLinksSessionNotUniqueMigration.ts b/src/auth/magic_link/MakeMagicLinksSessionNotUniqueMigration.ts new file mode 100644 index 0000000..ba6607e --- /dev/null +++ b/src/auth/magic_link/MakeMagicLinksSessionNotUniqueMigration.ts @@ -0,0 +1,12 @@ +import Migration from "../../db/Migration"; + +export default class MakeMagicLinksSessionNotUniqueMigration extends Migration { + public async install(): Promise { + await this.query(`ALTER TABLE magic_links + DROP INDEX IF EXISTS session_id`); + } + + public async rollback(): Promise { + await this.query('ALTER TABLE magic_links ADD CONSTRAINT UNIQUE (session_id)'); + } +} diff --git a/src/auth/models/MagicLink.ts b/src/auth/models/MagicLink.ts index e8c0e45..7af67f4 100644 --- a/src/auth/models/MagicLink.ts +++ b/src/auth/models/MagicLink.ts @@ -20,14 +20,16 @@ export default class MagicLink extends Model implements AuthProof { public readonly original_url?: string = undefined; private generated_at?: Date = undefined; private authorized: boolean = false; + private used: boolean = false; 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('token').defined().length(96); this.setValidation('action_type').defined().maxLength(64); this.setValidation('original_url').defined().maxLength(1745); this.setValidation('authorized').defined(); + this.setValidation('used').defined(); } public async getResource(): Promise { @@ -55,6 +57,14 @@ export default class MagicLink extends Model implements AuthProof { this.authorized = true; } + public isUsed(): boolean { + return this.used; + } + + public use(): void { + this.used = true; + } + public async generateToken(email: string): Promise { const rawToken = crypto.randomBytes(48).toString('base64'); // Raw token length = 64 this.email = email; diff --git a/src/auth/models/User.ts b/src/auth/models/User.ts index 8695137..a08dc69 100644 --- a/src/auth/models/User.ts +++ b/src/auth/models/User.ts @@ -5,6 +5,7 @@ import config from "config"; import {ManyModelRelation} from "../../db/ModelRelation"; import UserEmail from "./UserEmail"; import UserApprovedComponent from "./UserApprovedComponent"; +import UserNameComponent from "./UserNameComponent"; export default class User extends Model { public static isApprovalMode(): boolean { @@ -37,4 +38,16 @@ export default class User extends Model { public isApproved(): boolean { 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; + } } diff --git a/src/auth/password/UserPasswordComponent.ts b/src/auth/password/UserPasswordComponent.ts index da1e295..e1c25a7 100644 --- a/src/auth/password/UserPasswordComponent.ts +++ b/src/auth/password/UserPasswordComponent.ts @@ -13,7 +13,10 @@ export default class UserPasswordComponent extends ModelComponent { } public async setPassword(rawPassword: string, fieldName: string = 'password'): Promise { - await new Validator().defined().minLength(12).maxLength(512) + await new Validator() + .defined() + .minLength(UserPasswordComponent.PASSWORD_MIN_LENGTH) + .maxLength(512) .execute(fieldName, rawPassword, true); this.password = await argon2.hash(rawPassword, { timeCost: 10, @@ -25,8 +28,12 @@ export default class UserPasswordComponent extends ModelComponent { } public async verifyPassword(passwordGuess: string): Promise { - if (!this.password) return false; + if (!this.password || !passwordGuess) return false; return await argon2.verify(this.password, passwordGuess); } + + public hasPassword(): boolean { + return typeof this.password === 'string'; + } } diff --git a/test/Authentication.test.ts b/test/Authentication.test.ts index a9a0220..26308ed 100644 --- a/test/Authentication.test.ts +++ b/test/Authentication.test.ts @@ -9,6 +9,7 @@ import UserPasswordComponent from "../src/auth/password/UserPasswordComponent"; import {popEmail} from "./_mail_server"; import AuthComponent from "../src/auth/AuthComponent"; import {followMagicLinkFromMail, testLogout} from "./_authentication_common"; +import UserEmail from "../src/auth/models/UserEmail"; let app: TestApp; useApp(async (addr, port) => { @@ -645,3 +646,378 @@ describe('Authenticate with email and password (password)', () => { 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); + }); +}); diff --git a/test/_authentication_common.ts b/test/_authentication_common.ts index 55741ad..a0d9c2b 100644 --- a/test/_authentication_common.ts +++ b/test/_authentication_common.ts @@ -4,6 +4,7 @@ import supertest from "supertest"; export async function followMagicLinkFromMail( agent: supertest.SuperTest, cookies: string[], + expectedRedirectUrl: string = '/', ): Promise { const mail: Record | null = await popEmail(); expect(mail).not.toBeNull(); @@ -16,7 +17,7 @@ export async function followMagicLinkFromMail( await agent.get('/magic/lobby') .set('Cookie', cookies) .expect(302) - .expect('Location', '/'); + .expect('Location', expectedRedirectUrl); } export async function testLogout( diff --git a/test/_mail_server.ts b/test/_mail_server.ts index b3aed48..5761401 100644 --- a/test/_mail_server.ts +++ b/test/_mail_server.ts @@ -5,14 +5,14 @@ export const MAIL_SERVER = new MailDev({ }); export async function setupMailServer(): Promise { - await new Promise((resolve, reject) => MAIL_SERVER.listen((err?: Error) => { + await new Promise((resolve, reject) => MAIL_SERVER.listen((err?: Error) => { if (err) reject(err); else resolve(); })); } export async function teardownMailServer(): Promise { - await new Promise((resolve, reject) => MAIL_SERVER.close((err?: Error) => { + await new Promise((resolve, reject) => MAIL_SERVER.close((err?: Error) => { if (err) reject(err); else resolve(); })); diff --git a/test/views/layouts/base.njk b/test/views/layouts/base.njk index 93732f5..a3b9693 100644 --- a/test/views/layouts/base.njk +++ b/test/views/layouts/base.njk @@ -21,8 +21,11 @@
  • Backend
  • {% endif %} -{#
  • #} -{# {{ user.name }}
  • #} +
  • + + {{ user.name }} +
  • +
  • diff --git a/views/auth/account.njk b/views/auth/account.njk new file mode 100644 index 0000000..88c7bf0 --- /dev/null +++ b/views/auth/account.njk @@ -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 %} +
    +
    +

    Personal information

    + + {% 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() %} +

    {{ field.name }}: {{ field.value }}

    + {% endfor %} + +

    Contact email: {{ main_email.email }} More...

    +
    + +
    +

    {% if has_password %}Change{% else %}Set{% endif %} password

    + + + {% 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') }} + + + + {{ macros.csrf(getCsrfToken) }} + +
    + +
    +

    Email addresses

    + + + + + + + + + + + + {% for email in emails %} + {% if email.id == user.main_email_id %} + + + + + + {% endif %} + {% endfor %} + {% for email in emails %} + {% if email.id != user.main_email_id %} + + + + + + {% endif %} + {% endfor %} + +
    TypeAddressActions
    Main{{ email.email }}
    Secondary{{ email.email }} +
    + + + + {{ macros.csrf(getCsrfToken) }} +
    + +
    + + + + {{ macros.csrf(getCsrfToken) }} +
    +
    + +
    +

    Add an email address:

    + + {{ 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') }} + + + + {{ macros.csrf(getCsrfToken) }} +
    +
    +
    +{% endblock %} diff --git a/views/mails/add_email.mjml.njk b/views/mails/add_email.mjml.njk new file mode 100644 index 0000000..fc5e8f0 --- /dev/null +++ b/views/mails/add_email.mjml.njk @@ -0,0 +1,27 @@ +{% extends 'mails/base_layout.mjml.njk' %} + +{% block body %} + + + + Add this email address on {{ app.name }} + + + Someone wants to add {{ mail_to }} to their account. +

    + Do not click on this if this is not you! +
    + + + Add {{ mail_to }} on {{ app.name }} + +
    +
    +{% 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 %} \ No newline at end of file