diff --git a/src/controllers/api/account/migrationController.js b/src/controllers/api/account/migrationController.js new file mode 100644 index 0000000..61762ed --- /dev/null +++ b/src/controllers/api/account/migrationController.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 userBanModel = require('../../../schemas/user/userBanSchema'); +const altchaUtils = require('../../../utils/altchaUtils'); +const migrationModel = require('../../../schemas/user/migrationSchema'); +const {exceptionHandler, errorHandler} = require('../../../utils/loggerUtils'); + +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 migration = matchedData(req); + //Verify Altcha Payload + const verified = await altchaUtils.verify(req.body.verification); + + //If altcha verification failed + if(!verified){ + return errorHandler(res, 'Altcha verification failed, Please refresh the page!', 'unauthorized'); + } + + //If we're proxied use passthrough IP + const ip = config.proxied ? req.headers['x-forwarded-for'] : req.ip; + + //Look for ban by IP + const ipBanDB = await userBanModel.checkBanByIP(ip); + + //If this ip is randy bobandy + if(ipBanDB != null){ + //Make the number a little prettier despite the lack of precision since we're not doing calculations here :P + const expiration = ipBanDB.getDaysUntilExpiration() < 1 ? 0 : ipBanDB.getDaysUntilExpiration(); + let banMsg = []; + + //If the ban is permanent + if(ipBanDB.permanent){ + //tell it to fuck off + //Make the code and message look pretty (kinda) at the same time + banMsg = [ + 'The IP address you are trying to migrate an account from has been permanently banned.', + 'Your cleartext IP has been saved to the database.', + `Any accounts associated will be nuked in ${expiration} day(s).`, + 'If you beleive this to be an error feel free to reach out to your server administrator.', + 'Otherwise, fuck off :)' + ]; + }else{ + //tell it to fuck off + //Make the code and message look pretty (kinda) at the same time + banMsg = [ + 'The IP address you are trying to migrate an account from has been temporarily banned.', + `Your cleartext IP has been saved to the database until the ban expires in ${expiration} day(s).`, + 'If you beleive this to be an error feel free to reach out to your server administrator.', + 'Otherwise, fuck off :)' + ]; + } + + //tell it to fuck off + return errorHandler(res, banMsg.join('
'), 'unauthorized'); + } + + //Find and consume migration document + await migrationModel.consumeByUsername(ip, migration); + + //tell of our success + return res.sendStatus(200); + }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/routers/api/accountRouter.js b/src/routers/api/accountRouter.js index 95aafd5..5975453 100644 --- a/src/routers/api/accountRouter.js +++ b/src/routers/api/accountRouter.js @@ -22,6 +22,7 @@ const accountValidator = require("../../validators/accountValidator"); const loginController = require("../../controllers/api/account/loginController"); const logoutController = require("../../controllers/api/account/logoutController"); const registerController = require("../../controllers/api/account/registerController"); +const migrationController = require("../../controllers/api/account/migrationController"); const updateController = require("../../controllers/api/account/updateController"); const rankEnumController = require("../../controllers/api/account/rankEnumController"); const passwordResetRequestController = require("../../controllers/api/account/passwordResetRequestController"); @@ -38,18 +39,32 @@ router.post('/login', accountValidator.user(), accountValidator.pass(), loginCon //logout router.post('/logout', logoutController.post); //register -router.post('/register', accountValidator.user(), +router.post('/register', + accountValidator.user(), accountValidator.securePass(), accountValidator.pass('passConfirm'), - accountValidator.email(), registerController.post); + accountValidator.email(), + registerController.post); + +//migrate legacy profile +router.post('/migrate', + accountValidator.user(), + accountValidator.pass('oldPass'), + accountValidator.securePass('newPass'), + accountValidator.pass('passConfirm'), + migrationController.post); + //update profile -router.post('/update', accountValidator.img(), +router.post('/update', + accountValidator.img(), accountValidator.bio(), accountValidator.signature(), accountValidator.pronouns(), accountValidator.pass('passChange.oldPass'), accountValidator.securePass('passChange.newPass'), - accountValidator.pass('passChange.confirmPass'), updateController.post); + accountValidator.pass('passChange.confirmPass'), + updateController.post); + //rankEnum //This might seem silly, but it allows us to cleanly get the current rank list to compare against, without storing it in multiple places router.get('/rankEnum', rankEnumController.get); diff --git a/src/schemas/user/migrationSchema.js b/src/schemas/user/migrationSchema.js index 2856094..1cc3b1a 100644 --- a/src/schemas/user/migrationSchema.js +++ b/src/schemas/user/migrationSchema.js @@ -25,7 +25,11 @@ const config = require('../../../config.json'); const {userModel} = require('../user/userSchema'); const permissionModel = require('../permissionSchema'); const tokeModel = require('../tokebot/tokeSchema'); +const statModel = require('../statSchema'); +const emailChangeModel = require('../user/emailChangeSchema'); const loggerUtils = require('../../utils/loggerUtils'); +const hashUtils = require('../../utils/hashUtils'); +const mailUtils = require('../../utils/mailUtils'); /** * DB Schema for documents representing legacy fore.st migration data for a single user account @@ -307,15 +311,62 @@ migrationSchema.statics.buildMigrationCache = async function(){ } } +migrationSchema.statics.consumeByUsername = async function(ip, migration){ + //Pull migration doc by case-insensitive username + const migrationDB = await this.findOne({user: new RegExp(migration.user, 'i')}); + + //Wait on the miration DB token to be consumed + await migrationDB.consume(ip, migration); +} + //Methods /** * Consumes a migration profile and creates a new, modern canopy profile from the original. * @param {String} oldPass - Original password to authenticate migration against * @param {String} newPass - New password to re-hash with modern hashing algo - * @param {String} confirmPass - Confirmation for the new pass + * @param {String} passConfirm - Confirmation for the new pass */ -migrationSchema.methods.consume = async function(oldPass, newPass, confirmPass){ +migrationSchema.methods.consume = async function(ip, migration){ + //If we where handed a bad password + if(!hashUtils.compareLegacyPassword(migration.oldPass, this.pass)){ + //Complain + throw loggerUtils.exceptionSmith("Incorrect username/password.", "migration"); + } + //If we where handed a mismatched confirmation password + if(migration.newPass != migration.passConfirm){ + //Complain + throw loggerUtils.exceptionSmith("New password does not match confirmation password.", "migration"); + } + + //Increment user count + const id = await statModel.incrementUserCount(); + + //Create new user from profile info + const newUser = await userModel.create({ + id, + user: this.user, + pass: migration.newPass, + rank: permissionModel.rankEnum[this.rank], + bio: this.bio, + img: this.image, + date: this.date, + tokes: new Map([["Legacy Tokes", this.tokes]]) + }); + + //Tattoo hashed IP use to migrate to the new user account + await newUser.tattooIPRecord(ip); + + //if we submitted an email + if(this.email != null && this.email != ''){ + //Generate new request + const requestDB = await emailChangeModel.create({user: newUser._id, newEmail: this.email, ipHash: ip}); + + //Send confirmation email + mailUtils.sendAddressVerification(requestDB, newUser, this.email, false, true); + } + + await this.deleteOne(); } module.exports = mongoose.model("migration", migrationSchema); \ No newline at end of file diff --git a/src/schemas/user/userSchema.js b/src/schemas/user/userSchema.js index 003f2e6..bac2434 100644 --- a/src/schemas/user/userSchema.js +++ b/src/schemas/user/userSchema.js @@ -289,7 +289,7 @@ userSchema.statics.register = async function(userObj, ip){ if(email != null){ const requestDB = await emailChangeModel.create({user: newUser._id, newEmail: email, ipHash: ip}); - await mailUtil.sendAddressVerification(requestDB, newUser, email) + await mailUtil.sendAddressVerification(requestDB, newUser, email, true); } } }else{ diff --git a/src/utils/csrfUtils.js b/src/utils/csrfUtils.js index 5a898b3..457e48b 100644 --- a/src/utils/csrfUtils.js +++ b/src/utils/csrfUtils.js @@ -17,9 +17,6 @@ along with this program. If not, see .*/ //NPM Imports const { csrfSync } = require('csrf-sync'); -//Local Imports -const {errorHandler} = require('./loggerUtils'); - //Pull needed methods from csrfSync const {generateToken, revokeToken, csrfSynchronisedProtection, isRequestValid} = csrfSync(); diff --git a/src/utils/hashUtils.js b/src/utils/hashUtils.js index 082cd73..95aaf71 100644 --- a/src/utils/hashUtils.js +++ b/src/utils/hashUtils.js @@ -34,7 +34,7 @@ module.exports.hashPassword = function(pass){ } /** - * Sitewide password for authenticating/comparing passwords agianst hashes + * Sitewide method for authenticating/comparing passwords agianst hashes * @param {String} pass - Plaintext Password * @param {String} hash - Salty Hash * @returns {Boolean} True if authentication success @@ -43,6 +43,16 @@ module.exports.comparePassword = function(pass, hash){ return bcrypt.compareSync(pass, hash); } +/** + * Sitewide method for authenticating/comparing passwords agianst hashes for legacy profiles + * @param {String} pass - Plaintext Password + * @param {String} hash - Salty Hash + * @returns {Boolean} True if authentication success + */ +module.exports.compareLegacyPassword = function(pass, hash){ + return bcrypt.compareSync(pass, hash); +} + /** * Site-wide IP hashing/salting function * diff --git a/src/utils/mailUtils.js b/src/utils/mailUtils.js index 3df123b..ec825a1 100644 --- a/src/utils/mailUtils.js +++ b/src/utils/mailUtils.js @@ -72,16 +72,40 @@ module.exports.mailem = async function(to, subject, body, htmlBody = false){ * @param {Mongoose.Document} requestDB - DB Document Object for the current email change request token * @param {Mongoose.Document} userDB - DB Document Object for the user we're verifying email against * @param {String} newEmail - New email address to send to + * @param {Boolean} newUser - Denotes an email going out to a new account + * @param {Boolean} migration - Denotes an email going out to an account which was just mirated */ -module.exports.sendAddressVerification = async function(requestDB, userDB, newEmail){ +module.exports.sendAddressVerification = async function(requestDB, userDB, newEmail, newUser = false, migration = false,){ + let subject = `Email Change Request - ${userDB.user}`; + let content = `

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`; + + if(newUser){ + subject = `New User Email Confirmation - ${userDB.user}`; + + content = `

New user email confirmation

+

a new ${config.instanceName} account '${userDB.user}' was created with this email address.
+ click here to confirm this change.

+ if you received this email without request, feel free to ignore and delete it! -tokebot`; + } + + if(migration){ + subject = `User Migration Email Confirmation - ${userDB.user}`; + + content = `

User migration email confirmation

+

The ${config.instanceName} account '${userDB.user}' was successfully migrated to our fancy new codebase.
+ click here to confirm this change.

+ if you received this email without request, reach out to an admin, as your old account might be getting jacked! -tokebot`; + } + + //Send the reset url via email await module.exports.mailem( newEmail, - `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`, + subject, + content, true ); diff --git a/www/js/utils.js b/www/js/utils.js index 9f652b1..a2c0d8b 100644 --- a/www/js/utils.js +++ b/www/js/utils.js @@ -737,6 +737,24 @@ class canopyAjaxUtils{ } } + async migrate(user, oldPass, newPass, passConfirm, verification){ + var response = await fetch(`/api/account/migrate`,{ + method: "POST", + headers: { + "Content-Type": "application/json", + //It's either this or find and bind all event listeners :P + "x-csrf-token": utils.ajax.getCSRFToken() + }, + body: JSON.stringify({user, oldPass, newPass, passConfirm, verification}) + }); + + if(response.ok){ + location = "/"; + }else{ + utils.ux.displayResponseError(await response.json()); + } + } + async login(user, pass, verification){ var response = await fetch(`/api/account/login`,{ method: "POST",