Implemented Profile Migration Backend

This commit is contained in:
rainbow napkin 2025-10-16 05:25:08 -04:00
parent da9428205f
commit 6cbb726764
8 changed files with 228 additions and 17 deletions

View file

@ -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 <https://www.gnu.org/licenses/>.*/
//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('<br>'), '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);
}
}

View file

@ -22,6 +22,7 @@ const accountValidator = require("../../validators/accountValidator");
const loginController = require("../../controllers/api/account/loginController"); const loginController = require("../../controllers/api/account/loginController");
const logoutController = require("../../controllers/api/account/logoutController"); const logoutController = require("../../controllers/api/account/logoutController");
const registerController = require("../../controllers/api/account/registerController"); const registerController = require("../../controllers/api/account/registerController");
const migrationController = require("../../controllers/api/account/migrationController");
const updateController = require("../../controllers/api/account/updateController"); const updateController = require("../../controllers/api/account/updateController");
const rankEnumController = require("../../controllers/api/account/rankEnumController"); const rankEnumController = require("../../controllers/api/account/rankEnumController");
const passwordResetRequestController = require("../../controllers/api/account/passwordResetRequestController"); const passwordResetRequestController = require("../../controllers/api/account/passwordResetRequestController");
@ -38,18 +39,32 @@ router.post('/login', accountValidator.user(), accountValidator.pass(), loginCon
//logout //logout
router.post('/logout', logoutController.post); router.post('/logout', logoutController.post);
//register //register
router.post('/register', accountValidator.user(), router.post('/register',
accountValidator.user(),
accountValidator.securePass(), accountValidator.securePass(),
accountValidator.pass('passConfirm'), 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 //update profile
router.post('/update', accountValidator.img(), router.post('/update',
accountValidator.img(),
accountValidator.bio(), accountValidator.bio(),
accountValidator.signature(), accountValidator.signature(),
accountValidator.pronouns(), accountValidator.pronouns(),
accountValidator.pass('passChange.oldPass'), accountValidator.pass('passChange.oldPass'),
accountValidator.securePass('passChange.newPass'), accountValidator.securePass('passChange.newPass'),
accountValidator.pass('passChange.confirmPass'), updateController.post); accountValidator.pass('passChange.confirmPass'),
updateController.post);
//rankEnum //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 //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); router.get('/rankEnum', rankEnumController.get);

View file

@ -25,7 +25,11 @@ const config = require('../../../config.json');
const {userModel} = require('../user/userSchema'); const {userModel} = require('../user/userSchema');
const permissionModel = require('../permissionSchema'); const permissionModel = require('../permissionSchema');
const tokeModel = require('../tokebot/tokeSchema'); const tokeModel = require('../tokebot/tokeSchema');
const statModel = require('../statSchema');
const emailChangeModel = require('../user/emailChangeSchema');
const loggerUtils = require('../../utils/loggerUtils'); 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 * 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 //Methods
/** /**
* Consumes a migration profile and creates a new, modern canopy profile from the original. * 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} oldPass - Original password to authenticate migration against
* @param {String} newPass - New password to re-hash with modern hashing algo * @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); module.exports = mongoose.model("migration", migrationSchema);

View file

@ -289,7 +289,7 @@ userSchema.statics.register = async function(userObj, ip){
if(email != null){ if(email != null){
const requestDB = await emailChangeModel.create({user: newUser._id, newEmail: email, ipHash: ip}); 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{ }else{

View file

@ -17,9 +17,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.*/
//NPM Imports //NPM Imports
const { csrfSync } = require('csrf-sync'); const { csrfSync } = require('csrf-sync');
//Local Imports
const {errorHandler} = require('./loggerUtils');
//Pull needed methods from csrfSync //Pull needed methods from csrfSync
const {generateToken, revokeToken, csrfSynchronisedProtection, isRequestValid} = csrfSync(); const {generateToken, revokeToken, csrfSynchronisedProtection, isRequestValid} = csrfSync();

View file

@ -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} pass - Plaintext Password
* @param {String} hash - Salty Hash * @param {String} hash - Salty Hash
* @returns {Boolean} True if authentication success * @returns {Boolean} True if authentication success
@ -43,6 +43,16 @@ module.exports.comparePassword = function(pass, hash){
return bcrypt.compareSync(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 * Site-wide IP hashing/salting function
* *

View file

@ -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} 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 {Mongoose.Document} userDB - DB Document Object for the user we're verifying email against
* @param {String} newEmail - New email address to send to * @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 = `<h1>email change request</h1>
<p>a request to change the email associated with the ${config.instanceName} account '${userDB.user}' to this address has been requested.<br>
<a href="${requestDB.getChangeURL()}">click here</a> to confirm this change.</p>
<sup>if you received this email without request, feel free to ignore and delete it! -tokebot</sup>`;
if(newUser){
subject = `New User Email Confirmation - ${userDB.user}`;
content = `<h1>New user email confirmation</h1>
<p>a new ${config.instanceName} account '${userDB.user}' was created with this email address.<br>
<a href="${requestDB.getChangeURL()}">click here</a> to confirm this change.</p>
<sup>if you received this email without request, feel free to ignore and delete it! -tokebot</sup>`;
}
if(migration){
subject = `User Migration Email Confirmation - ${userDB.user}`;
content = `<h1>User migration email confirmation</h1>
<p>The ${config.instanceName} account '${userDB.user}' was successfully migrated to our <a href="https://git.ourfore.st/rainbownapkin/canopy">fancy new codebase</a>.<br>
<a href="${requestDB.getChangeURL()}">click here</a> to confirm this change.</p>
<sup>if you received this email without request, reach out to an admin, as your old account might be getting jacked! -tokebot</sup>`;
}
//Send the reset url via email //Send the reset url via email
await module.exports.mailem( await module.exports.mailem(
newEmail, newEmail,
`Email Change Request - ${userDB.user}`, subject,
`<h1>Email Change Request</h1> content,
<p>A request to change the email associated with the ${config.instanceName} account '${userDB.user}' to this address has been requested.<br>
<a href="${requestDB.getChangeURL()}">Click here</a> to confirm this change.</p>
<sup>If you received this email without request, feel free to ignore and delete it! -Tokebot</sup>`,
true true
); );

View file

@ -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){ async login(user, pass, verification){
var response = await fetch(`/api/account/login`,{ var response = await fetch(`/api/account/login`,{
method: "POST", method: "POST",