Started work on URL-Token based password reset system. Email not yet implemented.

This commit is contained in:
rainbow napkin 2024-12-28 04:30:08 -05:00
parent 8ee92541de
commit ed698f40c7
22 changed files with 580 additions and 16 deletions

View file

@ -40,7 +40,6 @@ module.exports = class commandPreprocessor{
//If we don't pass sanatization/validation turn this car around
if(!this.sanatizeCommand()){
console.log('test');
return;
}

View file

@ -0,0 +1,67 @@
/*Canopy - The next generation of stoner streaming software
Copyright (C) 2024 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 passwordResetModel = require('../../../schemas/passwordResetSchema');
const altchaUtils = require('../../../utils/altchaUtils');
const sessionUtils = require('../../../utils/sessionUtils');
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 {token, pass, confirmPass} = 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');
}
//Kill users session since it *might* be the logged in user.
//Though realisitcally this shouldn't matter since most people wouldn't be logged in when resetting passwords
sessionUtils.killSession(req.session);
//Consume the password reset token using given input
const requestDB = await passwordResetModel.findOne({token});
//If we have an invalid request
if(requestDB == null){
return errorHandler(res, 'Invalid request token!', 'unauthorized');
}
await requestDB.consume(pass, confirmPass);
return res.sendStatus(200);
}else{
res.status(400);
return res.send({errors: validResult.array()});
}
}catch(err){
return exceptionHandler(res, err);
}
}

View file

@ -67,7 +67,7 @@ module.exports.post = async function(req, res){
if(data.passChange){
//kill active session to prevent connect-mongo from freaking out
accountUtils.killSession(req.session);
await userDB.passwordReset(data.passChange);
await userDB.changePassword(data.passChange);
}
await userDB.save();

View file

@ -0,0 +1,57 @@
/*Canopy - The next generation of stoner streaming software
Copyright (C) 2024 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/>.*/
//npm imports
const {validationResult, matchedData} = require('express-validator');
//local imports
const {userModel} = require('../../../schemas/userSchema');
const passwordResetModel = require("../../../schemas/passwordResetSchema");
const {exceptionHandler, errorHandler} = require('../../../utils/loggerUtils');
module.exports.post = async function(req, res){
try{
//check for validation errors
const validResult = validationResult(req);
//if none
if(validResult.isEmpty()){
//grab validated/sanatized data
const {user} = matchedData(req);
//Find user from input
const userDB = await userModel.findOne({user});
//If there is no user
if(userDB == null){
//Scream
return errorHandler(res, "User not found.", "Bad Query.");
}
//Generate the password reset link
const requestDB = await passwordResetModel.generateResetToken(userDB);
//send successful response
res.status(200);
return res.send({url: requestDB.getResetURL()});
//otherwise scream
}else{
res.status(400);
return res.send({errors: validResult.array()})
}
}catch(err){
return exceptionHandler(res, err);
}
}

View file

@ -0,0 +1,58 @@
/*Canopy - The next generation of stoner streaming software
Copyright (C) 2024 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 altchaUtils = require('../utils/altchaUtils');
//register page functions
module.exports.get = async function(req, res){
try{
//check for validation errors
const validResult = validationResult(req);
//Generate captcha
const challenge = await altchaUtils.genCaptcha();
//if none
if(validResult.isEmpty()){
//grab validated/sanatized data
const {token} = matchedData(req);
/*
The decision to not check the token against the database here is a conscious security decision that should be kept.
This way, attackers would only be able to detect valid keys by requesting password resets against them.
A process which, unlike fetching this page, is checked against a captcha.
Instead we should render this page, so long as the token fits the formatting rules for a token, regardless of DB presence.
*/
//Render page
return res.render('passwordReset', {instance: config.instanceName, user: req.session.user, challenge, token});
//If we didn't get a valid token
}else{
//otherwise render generic page
return res.render('passwordReset', {instance: config.instanceName, user: req.session.user, challenge, token: null});
}
}catch(err){
return exceptionHandler(res, err);
}
}

View file

@ -24,6 +24,7 @@ const logoutController = require("../../controllers/api/account/logoutController
const registerController = require("../../controllers/api/account/registerController");
const updateController = require("../../controllers/api/account/updateController");
const rankEnumController = require("../../controllers/api/account/rankEnumController");
const passwordResetController = require("../../controllers/api/account/passwordResetController");
const deleteController = require("../../controllers/api/account/deleteController");
//globals
@ -50,6 +51,8 @@ router.post('/update', accountValidator.img(),
//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.post('/passwordReset', accountValidator.securityToken(), accountValidator.securePass(), accountValidator.pass('confirmPass'), passwordResetController.post)
router.post('/delete', accountValidator.pass(), deleteController.post);
module.exports = router;

View file

@ -32,6 +32,7 @@ const permissionsController = require("../../controllers/api/admin/permissionsCo
const banController = require("../../controllers/api/admin/banController");
const tokeCommandController = require('../../controllers/api/admin/tokeCommandController');
const emoteController = require('../../controllers/api/admin/emoteController');
const passwordResetController = require('../../controllers/api/admin/passwordResetController');
//globals
const router = Router();
@ -59,5 +60,7 @@ router.delete('/tokeCommands', permissionSchema.reqPermCheck("editTokeCommands")
router.get('/emote', permissionSchema.reqPermCheck('adminPanel'), emoteController.get);
router.post('/emote', permissionSchema.reqPermCheck('editEmotes'), emoteValidator.name(), emoteValidator.link(), emoteController.post);
router.delete('/emote', permissionSchema.reqPermCheck('editEmotes'), emoteValidator.name(), emoteController.delete);
//passwordReset
router.post('/genPasswordReset', permissionSchema.reqPermCheck('genPasswordReset'), accountValidator.user(), passwordResetController.post);
module.exports = router;

View file

@ -0,0 +1,31 @@
/*Canopy - The next generation of stoner streaming software
Copyright (C) 2024 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/>.*/
//npm imports
const { Router } = require('express');
//local imports
const accountValidator = require('../validators/accountValidator');
const passwordResetController = require("../controllers/passwordResetController");
//globals
const router = Router();
//routing functions
router.get('/', accountValidator.securityToken(), passwordResetController.get);
module.exports = router;

View file

@ -0,0 +1,121 @@
/*Canopy - The next generation of stoner streaming software
Copyright (C) 2024 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/>.*/
//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');
const daysToExpire = 7;
const passwordResetSchema = new mongoose.Schema({
user: {
type: mongoose.SchemaTypes.ObjectID,
ref: "user",
required: true
},
token: {
type: mongoose.SchemaTypes.String,
required: true
},
date: {
type: mongoose.SchemaTypes.Date,
required: true,
default: new Date()
}
});
//statics
passwordResetSchema.statics.generateResetToken = async function(userDB){
//Use a cryptographically secure algorythm to create a random hex string from 16 bytes as our reset token
const token = crypto.randomBytes(16).toString('hex');
//Create request object
const request = {
user: userDB._id,
token,
date: new Date()
}
//Create the request entry in the DB and return the newly created record
return await this.create(request);
}
passwordResetSchema.statics.processExpiredRequests = async function(){
//Pull all requests from the DB
const requestDB = await this.find({});
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
passwordResetSchema.methods.consume = async function(pass, confirmPass){
//Check confirmation pass
if(pass != confirmPass){
throw new Error("Confirmation password does not match!");
}
//Populate the user reference
await this.populate('user');
//Set the users password
this.user.pass = pass;
//Save the user
await this.user.save();
//Kill all authed sessions for security purposes
await this.user.killAllSessions("Your password has been reset.");
//Delete the request token now that it has been consumed
await this.deleteOne();
}
passwordResetSchema.methods.getResetURL = 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}/passwordReset?token=${this.token}`;
}else{
//Return path
return `${config.protocol}://${config.domain}:${config.port}/passwordReset?token=${this.token}`;
}
}
passwordResetSchema.methods.getDaysUntilExpiration = function(){
//Get ban date
const expirationDate = new Date(this.date);
//Get expiration days and calculate expiration date
expirationDate.setDate(expirationDate.getDate() + daysToExpire);
//Calculate and return days until ban expiration
return ((expirationDate - new Date()) / (1000 * 60 * 60 * 24)).toFixed(1);
}
module.exports = mongoose.model("passwordReset", passwordResetSchema);

View file

@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.*/
//Built-In Imports
const crypto = require('crypto');
const crypto = require('node:crypto');
//NPM Imports
const {mongoose} = require('mongoose');
@ -604,7 +604,7 @@ userSchema.methods.killAllSessions = async function(reason = "A full log-out fro
server.channelManager.kickConnections(this.user, reason);
}
userSchema.methods.passwordReset = async function(passChange){
userSchema.methods.changePassword = async function(passChange){
if(this.checkPass(passChange.oldPass)){
if(passChange.newPass == passChange.confirmPass){
//Note: We don't have to worry about hashing here because the schema is written to do it auto-magically

View file

@ -37,6 +37,7 @@ const flairModel = require('./schemas/flairSchema');
const emoteModel = require('./schemas/emoteSchema');
const tokeCommandModel = require('./schemas/tokebot/tokeCommandSchema');
//Router
//Humie-Friendly
const indexRouter = require('./routers/indexRouter');
const registerRouter = require('./routers/registerRouter');
const loginRouter = require('./routers/loginRouter');
@ -44,17 +45,22 @@ const profileRouter = require('./routers/profileRouter');
const adminPanelRouter = require('./routers/adminPanelRouter');
const channelRouter = require('./routers/channelRouter');
const newChannelRouter = require('./routers/newChannelRouter');
const passwordResetRouter = require('./routers/passwordResetRouter');
//Panel
const panelRouter = require('./routers/panelRouter');
//Popup
const popupRouter = require('./routers/popupRouter');
//Tooltip
const tooltipRouter = require('./routers/tooltipRouter');
//Api
const apiRouter = require('./routers/apiRouter');
//Define Config
//Define Config variables
const config = require('../config.json');
const port = config.port;
const dbUrl = `mongodb://${config.db.user}:${config.db.pass}@${config.db.address}:${config.db.port}/${config.db.database}`;
//Define Node JS
//Define express
const app = express();
//Define session-store (exported so we can kill sessions from user schema)
@ -72,6 +78,10 @@ const sessionMiddleware = session({
const httpServer = createServer(app);
const io = new Server(httpServer, {});
if(config.protocol == 'http'){
console.warn("Starting in HTTP mode. This server should be used for development purposes only!");
}
//Connect mongoose to the database
mongoose.set("sanitizeFilter", true).connect(dbUrl).then(() => {
console.log("Connected to DB");
@ -105,6 +115,7 @@ app.use('/profile', profileRouter);
app.use('/adminPanel', adminPanelRouter);
app.use('/c', channelRouter);
app.use('/newChannel', newChannelRouter);
app.use('/passwordReset', passwordResetRouter);
//Panel
app.use('/panel', panelRouter);
//Popup
@ -127,7 +138,7 @@ statModel.incrementLaunchCount();
//Load default flairs
flairModel.loadDefaults();
//Load default emots
//Load default emotes
emoteModel.loadDefaults();
//Load default toke commands

View file

@ -20,6 +20,7 @@ const cron = require('node-cron');
//Local Imports
const {userModel} = require('../schemas/userSchema');
const userBanModel = require('../schemas/userBanSchema');
const passwordResetModel = require('../schemas/passwordResetSchema');
const channelModel = require('../schemas/channel/channelSchema');
const sessionUtils = require('./sessionUtils');
@ -32,6 +33,8 @@ module.exports.schedule = function(){
cron.schedule('0 0 * * *', ()=>{channelModel.processExpiredBans()},{scheduled: true, timezone: "UTC"});
//Process expired failed login attempts every night at midnight
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"});
}
module.exports.kickoff = function(){
@ -39,8 +42,11 @@ module.exports.kickoff = function(){
userModel.processAgedIPRecords();
//Process expired global bans that may have expired since last restart
userBanModel.processExpiredBans()
//Process expired channel bans that may haven ot expired since last restart
//Process expired channel bans that may have expired since last restart
channelModel.processExpiredBans();
//Process expired password reset requests that may have expired since last restart
passwordResetModel.processExpiredRequests();
//Schedule jobs
module.exports.schedule();

View file

@ -36,5 +36,7 @@ module.exports = {
bio: (field = 'bio') => body(field).optional().escape().trim().isLength({min: 1, max: 1000}),
rank: (field = 'rank') => body(field).escape().trim().custom(isRank)
rank: (field = 'rank') => body(field).escape().trim().custom(isRank),
securityToken: (field = 'token') => check(field).escape().trim().isHexadecimal().isLength({min:32, max:32})
}

View file

@ -0,0 +1,54 @@
<!--Canopy - The next generation of stoner streaming software
Copyright (C) 2024 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/>.-->
<!DOCTYPE html>
<html>
<head>
<%- include('partial/styles', {instance, user}); %>
<link rel="stylesheet" type="text/css" href="/css/passwordReset.css">
<link rel="stylesheet" type="text/css" href="/lib/altcha/altcha.css">
<title><%= instance %></title>
</head>
<body>
<%- include('partial/navbar', {user}); %>
<h3>Password Reset</h3>
<!-- if we received a valid reset token -->
<% if(token != null){ %>
<p>Enter new password below.</p>
<form action="javascript:">
<label>New Password:</label>
<input class="reset-pass-prompt" id="reset-pass-prompt" type="password">
<label>Confirm New Password:</label>
<input class="reset-pass-prompt" id="reset-pass-confirm-prompt" type="password">
<altcha-widget floating challengejson="<%= JSON.stringify(challenge) %>"></altcha-widget>
<button id="reset-pass-button" class='positive-button'>Change Password</button>
</form>
<!-- Otherwise -->
<% }else{ %>
<p>Enter username to initiate password reset.</p>
<form action="javascript:">
<label>Username:</label>
<input class="reset-pass-prompt" id="reset-pass-username-prompt" placeholder="username">
<altcha-widget floating challengejson="<%= JSON.stringify(challenge) %>"></altcha-widget>
<button id="reset-pass-button" class='positive-button'>Send Email</button>
</form>
<% } %>
</body>
<footer>
<%- include('partial/scripts', {user}); %>
<script src="/lib/altcha/altcha.js" type="module"></script>
<script src="/js/passwordReset.js"></script>
</footer>
</html>