From a51152a89d818accdbfecdbee5837c8e1dc023cc Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Mon, 30 Dec 2024 09:43:25 -0500 Subject: [PATCH] Finished up with email change backend. Just need to make a prompt on the profile page for our AJAX call. --- src/controllers/404Controller.js | 2 +- .../account/emailChangeRequestController.js | 96 +++++++++++ src/controllers/emailChangeController.js | 57 +++++++ src/routers/api/accountRouter.js | 3 + src/routers/emailChangeController.js | 31 ++++ src/schemas/user/emailChangeSchema.js | 155 ++++++++++++++++++ src/schemas/user/userSchema.js | 4 +- src/server.js | 2 + src/utils/loggerUtils.js | 7 +- src/utils/scheduler.js | 7 +- src/views/404.ejs | 9 +- src/views/emailChange.ejs | 34 ++++ www/css/error.css | 31 +++- www/js/utils.js | 19 +++ 14 files changed, 444 insertions(+), 13 deletions(-) create mode 100644 src/controllers/api/account/emailChangeRequestController.js create mode 100644 src/controllers/emailChangeController.js create mode 100644 src/routers/emailChangeController.js create mode 100644 src/schemas/user/emailChangeSchema.js create mode 100644 src/views/emailChange.ejs diff --git a/src/controllers/404Controller.js b/src/controllers/404Controller.js index 5136b02..471ea16 100644 --- a/src/controllers/404Controller.js +++ b/src/controllers/404Controller.js @@ -21,7 +21,7 @@ const config = require('../../config.json'); const csrfUtils = require('../utils/csrfUtils'); //register page functions -module.exports = async function(req, res, next){ +module.exports = async function(req, res){ //set status res.status(404); diff --git a/src/controllers/api/account/emailChangeRequestController.js b/src/controllers/api/account/emailChangeRequestController.js new file mode 100644 index 0000000..32c853c --- /dev/null +++ b/src/controllers/api/account/emailChangeRequestController.js @@ -0,0 +1,96 @@ + +/*Canopy - The next generation of stoner streaming software +Copyright (C) 2024-2025 Rainbownapkin and the TTN Community + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see .*/ + +//Config +const config = require('../../../../config.json'); + +//NPM Imports +const {validationResult, matchedData} = require('express-validator'); + +//local imports +const {userModel} = require('../../../schemas/user/userSchema'); +const emailChangeModel = require('../../../schemas/user/emailChangeSchema'); +const mailUtils = require('../../../utils/mailUtils'); +const {exceptionHandler, errorHandler} = require('../../../utils/loggerUtils'); + +//Gateway for generating request token and having it emailed to the user +module.exports.post = async function(req, res){ + try{ + //Check for validation errors + const validResult = validationResult(req); + + //If there are none + if(validResult.isEmpty()){ + //Get sanatized/validated data + const {email, pass} = matchedData(req); + + //Check to make sure the user is logged in + if(req.session.user == null){ + errorHandler(res, "Invalid user!"); + } + + //Authenticate and find user model from DB + const userDB = await userModel.authenticate(req.session.user.user, pass, "Bad password."); + + //If we have an invalid user + if(userDB == null){ + errorHandler(res, "Invalid user!"); + } + + if(userDB.email == email){ + errorHandler(res, "Cannot set current email!"); + } + + //Generate the password reset link + const requestDB = await emailChangeModel.create({user: userDB._id, newEmail: email, ipHash: req.ip}); + + //Don't wait on mailer to get back to the browser + res.sendStatus(200); + + //Send the reset url via email + await mailUtils.mailem( + email, + `Email Change Request - ${userDB.user}`, + `

Email Change Request

+

A request to change the email associated with the ${config.instanceName} account '${userDB.user}' to this address has been requested.
+ Click here to confirm this change.

+ If you received this email without request, feel free to ignore and delete it! -Tokebot`, + true + ); + + //If the user has a pre-existing email address + if(userDB.email != null && userDB.email != ""){ + await mailUtils.mailem( + userDB.email, + `Email Change Request - ${userDB.user}`, + `

Email Change Request Notification

+

A request to change the email associated with the ${config.instanceName} account '${userDB.user}' to another address has been requested.
+ If you received this email without request, you should immediately change your password and contact the server adminsitrator! -Tokebot`, + true + ); + } + + //Clean our hands of the operation + return; + }else{ + res.status(400); + return res.send({errors: validResult.array()}); + } + }catch(err){ + return exceptionHandler(res, err); + } +} \ No newline at end of file diff --git a/src/controllers/emailChangeController.js b/src/controllers/emailChangeController.js new file mode 100644 index 0000000..fb9cabd --- /dev/null +++ b/src/controllers/emailChangeController.js @@ -0,0 +1,57 @@ +/*Canopy - The next generation of stoner streaming software +Copyright (C) 2024-2025 Rainbownapkin and the TTN Community + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see .*/ + +//Config +const config = require('../../config.json'); + +//NPM Imports +const {validationResult, matchedData} = require('express-validator'); + +//local imports +const emailChangeModel = require('../schemas/user/emailChangeSchema'); +const csrfUtils = require('../utils/csrfUtils'); + +//gateway for resetting password +module.exports.get = async function(req, res){ + try{ + //Check for validation errors + const validResult = validationResult(req); + + //If there are none + if(validResult.isEmpty()){ + //Get sanatized/validated data + const {token} = matchedData(req); + + //Consume the password reset token using given input + const requestDB = await emailChangeModel.findOne({token}); + + //If we have an invalid request + if(requestDB == null){ + return res.render('emailChange', {instance: config.instanceName, user: req.session.user, csrfToken: csrfUtils.generateToken(req), valid: false}); + } + + //Speak of our success (don't wait for the emails to be sent) + res.render('emailChange', {instance: config.instanceName, user: req.session.user, csrfToken: csrfUtils.generateToken(req), valid: true}); + + //Consume the request + await requestDB.consume(); + }else{ + return res.render('emailChange', {instance: config.instanceName, user: req.session.user, csrfToken: csrfUtils.generateToken(req), valid: false}); + } + }catch(err){ + return res.render('emailChange', {instance: config.instanceName, user: req.session.user, csrfToken: csrfUtils.generateToken(req), valid: false}); + } +} \ No newline at end of file diff --git a/src/routers/api/accountRouter.js b/src/routers/api/accountRouter.js index f066546..b21fe48 100644 --- a/src/routers/api/accountRouter.js +++ b/src/routers/api/accountRouter.js @@ -26,6 +26,7 @@ const updateController = require("../../controllers/api/account/updateController const rankEnumController = require("../../controllers/api/account/rankEnumController"); const passwordResetRequestController = require("../../controllers/api/account/passwordResetRequestController"); const passwordResetController = require("../../controllers/api/account/passwordResetController"); +const emailChangeRequestController = require('../../controllers/api/account/emailChangeRequestController'); const deleteController = require("../../controllers/api/account/deleteController"); //globals @@ -55,6 +56,8 @@ router.get('/rankEnum', rankEnumController.get); router.post('/passwordResetRequest', accountValidator.user(), passwordResetRequestController.post); //password reset router.post('/passwordReset', accountValidator.securityToken(), accountValidator.securePass(), accountValidator.pass('confirmPass'), passwordResetController.post); +//email change request +router.post('/emailChangeRequest', accountValidator.email(), accountValidator.pass(), emailChangeRequestController.post); //account deletion router.post('/delete', accountValidator.pass(), deleteController.post); diff --git a/src/routers/emailChangeController.js b/src/routers/emailChangeController.js new file mode 100644 index 0000000..7177bfd --- /dev/null +++ b/src/routers/emailChangeController.js @@ -0,0 +1,31 @@ +/*Canopy - The next generation of stoner streaming software +Copyright (C) 2024-2025 Rainbownapkin and the TTN Community + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see .*/ + +//npm imports +const { Router } = require('express'); + + +//local imports +const accountValidator = require('../validators/accountValidator'); +const emailChangeController = require("../controllers/emailChangeController"); + +//globals +const router = Router(); + +//routing functions +router.get('/', accountValidator.securityToken(), emailChangeController.get); + +module.exports = router; diff --git a/src/schemas/user/emailChangeSchema.js b/src/schemas/user/emailChangeSchema.js new file mode 100644 index 0000000..96bf5d2 --- /dev/null +++ b/src/schemas/user/emailChangeSchema.js @@ -0,0 +1,155 @@ +/*Canopy - The next generation of stoner streaming software +Copyright (C) 2024-2025 Rainbownapkin and the TTN Community + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see .*/ + +//You could make an argument for making this part of the userModel +//However, this is so rarely used the preformance benefits aren't worth the extra clutter + +//Config +const config = require('../../../config.json'); + +//Node Imports +const crypto = require("node:crypto"); + +//NPM Imports +const {mongoose} = require('mongoose'); + +//Local Imports +const hashUtil = require('../../utils/hashUtils'); +const mailUtils = require('../../utils/mailUtils'); + +const daysToExpire = 7; + +const emailChangeSchema = new mongoose.Schema({ + user: { + type: mongoose.SchemaTypes.ObjectID, + ref: "user", + required: true + }, + newEmail: { + type: mongoose.SchemaTypes.String, + required: true + }, + token: { + type: mongoose.SchemaTypes.String, + required: true, + //Use a cryptographically secure algorythm to create a random hex string from 16 bytes as our change/cancel token + default: ()=>{return crypto.randomBytes(16).toString('hex')} + }, + ipHash: { + type: mongoose.SchemaTypes.String, + required: true + }, + date: { + type: mongoose.SchemaTypes.Date, + required: true, + default: new Date() + } +}); + + +//Presave function +emailChangeSchema.pre('save', async function (next){ + //If we're saving an ip + if(this.isModified('ipHash')){ + //Hash that sunnuvabitch + this.ipHash = hashUtil.hashIP(this.ipHash); + } + + if(this.isModified('user')){ + //Delete previous requests for the given user + const requests = await this.model().deleteMany({user: this.user._id}); + } + + next(); +}); + +//statics +emailChangeSchema.statics.processExpiredRequests = async function(){ + //Pull all requests from the DB + const requestDB = await this.find({}); + + //Fire em all off at once without waiting for the last one to complete since we don't fuckin' need to + requestDB.forEach(async (request) => { + //If the request hasn't been processed and it's been expired + if(request.getDaysUntilExpiration() <= 0){ + //Delete the request + await this.deleteOne({_id: request._id}); + } + }); +} + +//methods +emailChangeSchema.methods.consume = async function(){ + //Populate the user reference + await this.populate('user'); + + const oldMail = this.user.email; + + //Set the new email + this.user.email = this.newEmail; + + //Save the user + await this.user.save(); + + //Delete the request token now that it has been consumed + await this.deleteOne(); + + //If we had a previous email address + if(oldMail != null && oldMail != ''){ + //Notify it of the change + await mailUtils.mailem( + oldMail, + `Email Change Notification - ${this.user.user}`, + `

Email Change Notification

+

The ${config.instanceName} account '${this.user.user}' is no longer associated with this email address.
+ If you received this email without request, you should immediately change your password and contact the server adminsitrator! -Tokebot`, + true + ); + } + + //Notify the new inbox of the change + await mailUtils.mailem( + this.newEmail, + `Email Change Notification - ${this.user.user}`, + `

Email Change Notification

+

The ${config.instanceName} account '${this.user.user}' is now associated with this email address.
+ If you received this email without request, you should immediately check who's been inside your inbox! -Tokebot`, + true + ); + +} + +emailChangeSchema.methods.getChangeURL = function(){ + //Check for default port based on protocol + if((config.protocol == 'http' && config.port == 80) || (config.protocol == 'https' && config.port == 443)){ + //Return path + return `${config.protocol}://${config.domain}/emailChange?token=${this.token}`; + }else{ + //Return path + return `${config.protocol}://${config.domain}:${config.port}/emailChange?token=${this.token}`; + } +} + +emailChangeSchema.methods.getDaysUntilExpiration = function(){ + //Get request date + const expirationDate = new Date(this.date); + //Get expiration days and calculate expiration date + expirationDate.setDate(expirationDate.getDate() + daysToExpire); + //Calculate and return days until request expiration + return ((expirationDate - new Date()) / (1000 * 60 * 60 * 24)).toFixed(1); +} + +module.exports = mongoose.model("emailChange", emailChangeSchema); diff --git a/src/schemas/user/userSchema.js b/src/schemas/user/userSchema.js index e170704..f88e3f5 100644 --- a/src/schemas/user/userSchema.js +++ b/src/schemas/user/userSchema.js @@ -240,7 +240,7 @@ userSchema.statics.register = async function(userObj, ip){ } } -userSchema.statics.authenticate = async function(user, pass){ +userSchema.statics.authenticate = async function(user, pass, failLine = "Bad Username or Password."){ //check for missing pass if(!user || !pass){ throw new Error("Missing user/pass."); @@ -264,7 +264,7 @@ userSchema.statics.authenticate = async function(user, pass){ //standardize bad login response so it's unknowin which is bad for security reasons. function badLogin(){ - throw new Error("Bad Username or Password."); + throw new Error(failLine); } } diff --git a/src/server.js b/src/server.js index 5a53293..a6fb18e 100644 --- a/src/server.js +++ b/src/server.js @@ -50,6 +50,7 @@ const adminPanelRouter = require('./routers/adminPanelRouter'); const channelRouter = require('./routers/channelRouter'); const newChannelRouter = require('./routers/newChannelRouter'); const passwordResetRouter = require('./routers/passwordResetRouter'); +const emailChangeRouter = require('./routers/emailChangeController'); //Panel const panelRouter = require('./routers/panelRouter'); //Popup @@ -119,6 +120,7 @@ app.use('/adminPanel', adminPanelRouter); app.use('/c', channelRouter); app.use('/newChannel', newChannelRouter); app.use('/passwordReset', passwordResetRouter); +app.use('/emailChange', emailChangeRouter); //Panel app.use('/panel', panelRouter); //Popup diff --git a/src/utils/loggerUtils.js b/src/utils/loggerUtils.js index a62b196..4ed4fa6 100644 --- a/src/utils/loggerUtils.js +++ b/src/utils/loggerUtils.js @@ -16,8 +16,11 @@ along with this program. If not, see .*/ //At some point this will be a bit more advanced, right now it's just a placeholder :P module.exports.errorHandler = function(res, msg, type = "Generic", status = 400){ - res.status(status); - return res.send({errors: [{type, msg, date: new Date()}]}); + //Some controllers do things after sending headers, for those, we should remain silent + if(!res.headersSent){ + res.status(status); + return res.send({errors: [{type, msg, date: new Date()}]}); + } } module.exports.exceptionHandler = function(res, err){ diff --git a/src/utils/scheduler.js b/src/utils/scheduler.js index d783766..71aa672 100644 --- a/src/utils/scheduler.js +++ b/src/utils/scheduler.js @@ -21,8 +21,10 @@ const cron = require('node-cron'); const {userModel} = require('../schemas/user/userSchema'); const userBanModel = require('../schemas/user/userBanSchema'); const passwordResetModel = require('../schemas/user/passwordResetSchema'); +const emailChangeModel = require('../schemas/user/emailChangeSchema'); const channelModel = require('../schemas/channel/channelSchema'); const sessionUtils = require('./sessionUtils'); +const { email } = require('../validators/accountValidator'); module.exports.schedule = function(){ //Process hashed IP Records that haven't been recorded in a week or more @@ -35,6 +37,8 @@ module.exports.schedule = function(){ cron.schedule('0 0 * * *', ()=>{sessionUtils.processExpiredAttempts()},{scheduled: true, timezone: "UTC"}); //Process expired password reset requests every night at midnight cron.schedule('0 0 * * *', ()=>{passwordResetModel.processExpiredRequests()},{scheduled: true, timezone: "UTC"}); + //Process expired email change requests every night at midnight + cron.schedule('0 0 * * *', ()=>{emailChangeModel.processExpiredRequests()},{scheduled: true, timezone: "UTC"}); } module.exports.kickoff = function(){ @@ -46,7 +50,8 @@ module.exports.kickoff = function(){ channelModel.processExpiredBans(); //Process expired password reset requests that may have expired since last restart passwordResetModel.processExpiredRequests(); - + //Process expired email change requests that may have expired since last restart + emailChangeModel.processExpiredRequests(); //Schedule jobs module.exports.schedule(); diff --git a/src/views/404.ejs b/src/views/404.ejs index 227885d..e067747 100644 --- a/src/views/404.ejs +++ b/src/views/404.ejs @@ -23,9 +23,12 @@ along with this program. If not, see . %> <%- include('partial/navbar', {user}); %> -

404

-

Congratulations, you've found a dead link!

- +
+

404

+

Congratulations, you've found a dead link!

+ + Return to Home +