diff --git a/packages/backend/src/routers/change_email.js b/packages/backend/src/routers/change_email.js index aa8456ab9..5a6f81edd 100644 --- a/packages/backend/src/routers/change_email.js +++ b/packages/backend/src/routers/change_email.js @@ -28,72 +28,6 @@ const config = require('../config.js'); const jwt = require('jsonwebtoken'); const { invalidate_cached_user_by_id } = require('../helpers.js'); -const CHANGE_EMAIL_START = eggspress('/change_email/start', { - subdomain: 'api', - auth: true, - verified: true, - allowedMethods: ['POST'], -}, async (req, res, next) => { - const user = req.user; - const new_email = req.body.new_email; - - // TODO: DRY: signup.js - // validation - if( ! new_email ) { - throw APIError.create('field_missing', null, { key: 'new_email' }); - } - if ( typeof new_email !== 'string' ) { - throw APIError.create('field_invalid', null, { - key: 'new_email', expected: 'a valid email address' }); - } - if ( ! validator.isEmail(new_email) ) { - throw APIError.create('field_invalid', null, { - key: 'new_email', expected: 'a valid email address' }); - } - - const svc_edgeRateLimit = req.services.get('edge-rate-limit'); - if ( ! svc_edgeRateLimit.check('change-email-start') ) { - return res.status(429).send('Too many requests.'); - } - - // check if email is already in use - const db = req.services.get('database').get(DB_WRITE, 'auth'); - const rows = await db.read( - 'SELECT COUNT(*) AS `count` FROM `user` WHERE `email` = ?', - [new_email] - ); - if ( rows[0].count > 0 ) { - throw APIError.create('email_already_in_use', null, { email: new_email }); - } - - // generate confirmation token - const token = crypto.randomBytes(4).toString('hex'); - const jwt_token = jwt.sign({ - user_id: user.id, - token, - }, config.jwt_secret, { expiresIn: '24h' }); - - // send confirmation email - const svc_email = req.services.get('email'); - await svc_email.send_email({ email: new_email }, 'email_change_request', { - confirm_url: `${config.origin}/change_email/confirm?token=${jwt_token}`, - username: user.username, - }); - const old_email = user.email; - // TODO: NotificationService - await svc_email.send_email({ email: old_email }, 'email_change_notification', { - new_email: new_email, - }); - - // update user - await db.write( - 'UPDATE `user` SET `unconfirmed_change_email` = ?, `change_email_confirm_token` = ? WHERE `id` = ?', - [new_email, token, user.id] - ); - - res.send({ success: true }); -}); - const CHANGE_EMAIL_CONFIRM = eggspress('/change_email/confirm', { allowedMethods: ['GET'], }, async (req, res, next) => { @@ -137,6 +71,5 @@ const CHANGE_EMAIL_CONFIRM = eggspress('/change_email/confirm', { }); module.exports = app => { - app.use(CHANGE_EMAIL_START); app.use(CHANGE_EMAIL_CONFIRM); } diff --git a/packages/backend/src/routers/user-protected/change-email.js b/packages/backend/src/routers/user-protected/change-email.js new file mode 100644 index 000000000..c4195712c --- /dev/null +++ b/packages/backend/src/routers/user-protected/change-email.js @@ -0,0 +1,68 @@ +const APIError = require("../../api/APIError"); +const { DB_WRITE } = require("../../services/database/consts"); +const jwt = require('jsonwebtoken'); +const validator = require('validator'); +const crypto = require('crypto'); +const config = require("../../config"); + +module.exports = { + route: '/change-email', + methods: ['POST'], + handler: async (req, res, next) => { + const user = req.user; + const new_email = req.body.new_email; + + console.log('DID REACH HERE'); + + // TODO: DRY: signup.js + // validation + if( ! new_email ) { + throw APIError.create('field_missing', null, { key: 'new_email' }); + } + if ( typeof new_email !== 'string' ) { + throw APIError.create('field_invalid', null, { + key: 'new_email', expected: 'a valid email address' }); + } + if ( ! validator.isEmail(new_email) ) { + throw APIError.create('field_invalid', null, { + key: 'new_email', expected: 'a valid email address' }); + } + + // check if email is already in use + const db = req.services.get('database').get(DB_WRITE, 'auth'); + const rows = await db.read( + 'SELECT COUNT(*) AS `count` FROM `user` WHERE `email` = ?', + [new_email] + ); + if ( rows[0].count > 0 ) { + throw APIError.create('email_already_in_use', null, { email: new_email }); + } + + // generate confirmation token + const token = crypto.randomBytes(4).toString('hex'); + const jwt_token = jwt.sign({ + user_id: user.id, + token, + }, config.jwt_secret, { expiresIn: '24h' }); + + // send confirmation email + const svc_email = req.services.get('email'); + await svc_email.send_email({ email: new_email }, 'email_change_request', { + confirm_url: `${config.origin}/change_email/confirm?token=${jwt_token}`, + username: user.username, + }); + const old_email = user.email; + // TODO: NotificationService + await svc_email.send_email({ email: old_email }, 'email_change_notification', { + new_email: new_email, + }); + + // update user + await db.write( + 'UPDATE `user` SET `unconfirmed_change_email` = ?, `change_email_confirm_token` = ? WHERE `id` = ?', + [new_email, token, user.id] + ); + + res.send({ success: true }); + } +}; diff --git a/packages/backend/src/services/abuse-prevention/EdgeRateLimitService.js b/packages/backend/src/services/abuse-prevention/EdgeRateLimitService.js index c5852c3aa..7efe8f778 100644 --- a/packages/backend/src/services/abuse-prevention/EdgeRateLimitService.js +++ b/packages/backend/src/services/abuse-prevention/EdgeRateLimitService.js @@ -5,6 +5,12 @@ const { quot } = require("../../util/strutil"); const { MINUTE, HOUR } = require('../../util/time.js'); const BaseService = require("../BaseService"); +/* INCREMENTAL CHANGES + The first scopes are of the form 'name-of-endpoint', but later it was + decided that they're of the form `/path/to/endpoint`. New scopes should + follow the latter form. +*/ + class EdgeRateLimitService extends BaseService { _construct () { this.scopes = { @@ -60,6 +66,10 @@ class EdgeRateLimitService extends BaseService { limit: 10, window: HOUR, }, + ['/user-protected/change-email']: { + limit: 10, + window: HOUR, + }, ['login-otp']: { limit: 15, window: 30 * MINUTE, diff --git a/packages/backend/src/services/web/UserProtectedEndpointsService.js b/packages/backend/src/services/web/UserProtectedEndpointsService.js index ae9acd059..d9413b775 100644 --- a/packages/backend/src/services/web/UserProtectedEndpointsService.js +++ b/packages/backend/src/services/web/UserProtectedEndpointsService.js @@ -82,7 +82,11 @@ class UserProtectedEndpointsService extends BaseService { }); Endpoint( - require('../../routers/user-protected/change-password.js') + require('../../routers/user-protected/change-password.js'), + ).attach(router); + + Endpoint( + require('../../routers/user-protected/change-email.js'), ).attach(router); } } diff --git a/src/UI/Settings/UIWindowChangeEmail.js b/src/UI/Settings/UIWindowChangeEmail.js index 1b28c0aee..787bcc893 100644 --- a/src/UI/Settings/UIWindowChangeEmail.js +++ b/src/UI/Settings/UIWindowChangeEmail.js @@ -17,11 +17,18 @@ * along with this program. If not, see . */ +import Placeholder from '../../util/Placeholder.js'; +import PasswordEntry from '../Components/PasswordEntry.js'; import UIWindow from '../UIWindow.js' +// TODO: DRY: We could specify a validator and endpoint instead of writing +// a DOM tree and event handlers for each of these. (low priority) async function UIWindowChangeEmail(options){ options = options ?? {}; + const password_entry = new PasswordEntry({}); + const place_password_entry = Placeholder(); + const internal_id = window.uuidv4(); let h = ''; h += `
`; @@ -34,6 +41,11 @@ async function UIWindowChangeEmail(options){ h += ``; h += ``; h += `
`; + // password confirmation + h += `
`; + h += ``; + h += `${place_password_entry.html}`; + h += `
`; // Change Email h += ``; @@ -73,11 +85,14 @@ async function UIWindowChangeEmail(options){ ...options.window_options }) + password_entry.attach(place_password_entry); + $(el_window).find('.change-email-btn').on('click', function(e){ // hide previous error/success msg $(el_window).find('.form-success-msg, .form-success-msg').hide(); const new_email = $(el_window).find('.new-email').val(); + const password = $(el_window).find('.password').val(); if(!new_email){ $(el_window).find('.form-error-msg').html(i18n('all_fields_required')); @@ -93,7 +108,7 @@ async function UIWindowChangeEmail(options){ $(el_window).find('.new-email').attr('disabled', true); $.ajax({ - url: window.api_origin + "/change_email/start", + url: window.api_origin + "/user-protected/change-email", type: 'POST', async: true, headers: { @@ -102,6 +117,7 @@ async function UIWindowChangeEmail(options){ contentType: "application/json", data: JSON.stringify({ new_email: new_email, + password: password_entry.get('value'), }), success: function (data){ $(el_window).find('.form-success-msg').html(i18n('email_change_confirmation_sent')); diff --git a/src/i18n/translations/en.js b/src/i18n/translations/en.js index dfb507a71..3ec4eb672 100644 --- a/src/i18n/translations/en.js +++ b/src/i18n/translations/en.js @@ -23,6 +23,7 @@ const en = { dictionary: { about: "About", account: "Account", + account_password: "Verify Account Password", access_granted_to: "Access Granted To", add_existing_account: "Add Existing Account", all_fields_required: 'All fields are required.',