diff --git a/src/controllers/api/account/loginController.js b/src/controllers/api/account/loginController.js index bf75084..2ee3c89 100644 --- a/src/controllers/api/account/loginController.js +++ b/src/controllers/api/account/loginController.js @@ -14,32 +14,58 @@ 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 accountUtils = require('../../../utils/sessionUtils'); +const sessionUtils = require('../../../utils/sessionUtils'); const {exceptionHandler, errorHandler} = require('../../../utils/loggerUtils'); - +const altchaUtils = require('../../../utils/altchaUtils'); +const session = require('express-session'); //api account functions module.exports.post = async function(req, res){ try{ + //Check validation results const validResult = validationResult(req); + //if we don't have errors if(validResult.isEmpty()){ - const data = matchedData(req); - const {user, pass} = data; - + //Pull sanatzied/validated data + const {user, pass} = matchedData(req); + //try to authenticate the session, and return a successful code if it works - await accountUtils.authenticateSession(user, pass, req); + await sessionUtils.authenticateSession(user, pass, req); return res.sendStatus(200); }else{ res.status(400); - res.send({errors: validResult.array()}) + return res.send({errors: validResult.array()}) } }catch(err){ - exceptionHandler(res, err); + //Check validation results + const validResult = validationResult(req); + + //if we don't have errors + if(validResult.isEmpty()){ + //Get login attempts for current user + const {user} = matchedData(req); + const attempts = sessionUtils.getLoginAttempts(user) + + //if we've gone over max attempts and + if(attempts.count > sessionUtils.throttleAttempts){ + //tell client it needs a captcha + return res.sendStatus(429); + } + }else{ + res.status(400); + return res.send({errors: validResult.array()}) + } + + // + return exceptionHandler(res, err); } } \ No newline at end of file diff --git a/src/controllers/loginController.js b/src/controllers/loginController.js new file mode 100644 index 0000000..8e36b30 --- /dev/null +++ b/src/controllers/loginController.js @@ -0,0 +1,64 @@ +/*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 .*/ + +//Config +const config = require('../../config.json'); + +//NPM Imports +const {validationResult, matchedData} = require('express-validator'); + +//Local Imports +const sessionUtils = require('../utils/sessionUtils'); +const altchaUtils = require('../utils/altchaUtils'); + +//register page functions +module.exports.get = async function(req, res){ + //Check for validation errors + const validResult = validationResult(req); + + //If there are none + if(validResult.isEmpty()){ + //Get username from sanatized/validated data + const {user} = matchedData(req); + const attempts = sessionUtils.getLoginAttempts(user); + + //if we have previous attempts for this user + if(attempts != null){ + if(attempts.count > sessionUtils.maxAttempts){ + return res.render('lockedAccount', {instance: config.instanceName, user: req.session.user}); + } + + //If the users login's are being throttled + if(attempts.count > sessionUtils.throttleAttempts){ + //Get diffuculty based on amount of attempts past the max amount + const difficulty = attempts.count - sessionUtils.throttleAttempts; + //Generate challenge unique to specific user, with difficulty set based on failed login attempts + const challenge = await altchaUtils.genCaptcha(difficulty, user); + + //Render page + return res.render('login', {instance: config.instanceName, user: req.session.user, challenge}); + } + //otherwise + }else{ + //Render generic page + return res.render('login', {instance: config.instanceName, user: req.session.user, challenge: null}); + } + //if we received invalid input + }else{ + //Render pretend nothing happened, send out a generic page + return res.render('login', {instance: config.instanceName, user: req.session.user, challenge: null}); + } +} \ No newline at end of file diff --git a/src/routers/loginRouter.js b/src/routers/loginRouter.js new file mode 100644 index 0000000..890e1ba --- /dev/null +++ b/src/routers/loginRouter.js @@ -0,0 +1,30 @@ +/*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 .*/ + +//npm imports +const { Router } = require('express'); + +//local imports +const loginController = require("../controllers/loginController"); +const accountValidator = require("../validators/accountValidator"); + +//globals +const router = Router(); + +//routing functions +router.get('/', accountValidator.user(), loginController.get); + +module.exports = router; diff --git a/src/server.js b/src/server.js index 5c31ca0..8b04a53 100644 --- a/src/server.js +++ b/src/server.js @@ -27,14 +27,19 @@ const mongoose = require('mongoose'); globalThis.crypto = require('node:crypto').webcrypto; //Define Local Imports +//Application const channelManager = require('./app/channel/channelManager'); +//Util const scheduler = require('./utils/scheduler'); +//DB Model const statModel = require('./schemas/statSchema'); const flairModel = require('./schemas/flairSchema'); const emoteModel = require('./schemas/emoteSchema'); const tokeCommandModel = require('./schemas/tokebot/tokeCommandSchema'); +//Router const indexRouter = require('./routers/indexRouter'); const registerRouter = require('./routers/registerRouter'); +const loginRouter = require('./routers/loginRouter'); const profileRouter = require('./routers/profileRouter'); const adminPanelRouter = require('./routers/adminPanelRouter'); const channelRouter = require('./routers/channelRouter'); @@ -95,6 +100,7 @@ io.engine.use(sessionMiddleware); //Humie-Friendly app.use('/', indexRouter); app.use('/register', registerRouter); +app.use('/login', loginRouter); app.use('/profile', profileRouter); app.use('/adminPanel', adminPanelRouter); app.use('/c', channelRouter); diff --git a/src/utils/altchaUtils.js b/src/utils/altchaUtils.js index b7725a3..e53fb92 100644 --- a/src/utils/altchaUtils.js +++ b/src/utils/altchaUtils.js @@ -25,7 +25,7 @@ const spent = []; //Captcha lifetime in minutes const lifetime = 2; -module.exports.genCaptcha = async function(){ +module.exports.genCaptcha = async function(difficulty = 2, uniqueSecret = ''){ //Set altcha expiration date const expiration = new Date(); @@ -34,13 +34,13 @@ module.exports.genCaptcha = async function(){ //Generate Altcha Challenge return await createChallenge({ - hmacKey: config.altchaSecret, - maxNumber: 200000, + hmacKey: [config.altchaSecret, uniqueSecret].join(''), + maxNumber: 100000 * difficulty, expires: expiration }); } -module.exports.verify = async function(payload){ +module.exports.verify = async function(payload, uniqueSecret = ''){ //If we already checked this payload if(spent.indexOf(payload) != -1){ //Fuck off and die @@ -57,5 +57,5 @@ module.exports.verify = async function(payload){ setTimeout(() => {spent.splice(payloadIndex,1);}, lifetime * 60 * 1000); //Return verification results - return await verifySolution(payload, config.altchaSecret); + return await verifySolution(payload, [config.altchaSecret, uniqueSecret].join('')); } \ No newline at end of file diff --git a/src/utils/scheduler.js b/src/utils/scheduler.js index 83803b4..6644fa9 100644 --- a/src/utils/scheduler.js +++ b/src/utils/scheduler.js @@ -21,6 +21,7 @@ const cron = require('node-cron'); const {userModel} = require('../schemas/userSchema'); const userBanModel = require('../schemas/userBanSchema'); const channelModel = require('../schemas/channel/channelSchema'); +const sessionUtils = require('./sessionUtils'); module.exports.schedule = function(){ //Process hashed IP Records that haven't been recorded in a week or more @@ -29,6 +30,8 @@ module.exports.schedule = function(){ cron.schedule('0 0 * * *', ()=>{userBanModel.processExpiredBans()},{scheduled: true, timezone: "UTC"}); //Process expired channel bans every night at midnight 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"}); } module.exports.kickoff = function(){ diff --git a/src/utils/sessionUtils.js b/src/utils/sessionUtils.js index 8f42ab3..dc4d853 100644 --- a/src/utils/sessionUtils.js +++ b/src/utils/sessionUtils.js @@ -17,44 +17,125 @@ along with this program. If not, see .*/ //local imports const {userModel} = require('../schemas/userSchema'); const userBanModel = require('../schemas/userBanSchema') +const altchaUtils = require('../utils/altchaUtils'); + +//Create failed sign-in cache since it's easier and more preformant to implement it this way than adding extra burdon to the database +//Server restarts are far and few between. It would take multiple during a single bruteforce attempt for this to become an issue. +const failedAttempts = new Map(); +const throttleAttempts = 5; +const maxAttempts = 200; //this module is good for keeping wrappers for userModel and other shit in that does more session handling than database access/modification. - module.exports.authenticateSession = async function(user, pass, req){ + //Fuck you yoda + try{ + //Grab previous attempts + const attempt = failedAttempts.get(user); - //Authenticate the session - const userDB = await userModel.authenticate(user, pass); - const banDB = await userBanModel.checkBanByUserDoc(userDB); + //If we have failed attempts + if(attempt != null){ + //If we have more failed attempts than allowed + if(attempt.count > maxAttempts){ + throw new Error("This account has been locked for at 24 hours due to a large amount of failed log-in attempts"); + } - if(banDB){ - //Make the number a little prettier despite the lack of precision since we're not doing calculations here :P - const expiration = banDB.getDaysUntilExpiration() < 1 ? 0 : banDB.getDaysUntilExpiration(); - if(banDB.permanent){ - throw new Error(`Your account has been permanently banned, and will be nuked from the database in: ${expiration} day(s)`); - }else{ - throw new Error(`Your account has been temporarily banned, and will be reinstated in: ${expiration} day(s)`); + //If we're throttling logins + if(attempt.count > throttleAttempts){ + //Verification doesnt get sanatized or checked since that would most likely break the cryptography + //Since we've already got access to the request and dont need to import anything, why bother getting it from a parameter? + if(req.body.verification == null){ + throw new Error("Verification failed!"); + }else if(!altchaUtils.verify(req.body.verification, user)){ + throw new Error("Verification failed!"); + } + } } + + //Authenticate the session + const userDB = await userModel.authenticate(user, pass); + const banDB = await userBanModel.checkBanByUserDoc(userDB); + + //If the user is banned + if(banDB){ + //Make the number a little prettier despite the lack of precision since we're not doing calculations here :P + const expiration = banDB.getDaysUntilExpiration() < 1 ? 0 : banDB.getDaysUntilExpiration(); + if(banDB.permanent){ + throw new Error(`Your account has been permanently banned, and will be nuked from the database in: ${expiration} day(s)`); + }else{ + throw new Error(`Your account has been temporarily banned, and will be reinstated in: ${expiration} day(s)`); + } + } + + //Tattoo the session with user and metadata + //unfortunately store.all() does not return sessions w/ their ID so we had to improvise... + //Not sure if this is just how connect-mongo is implemented or if it's an express issue, but connect-mongodb-session seems to not implement the all() function what so ever... + req.session.seshid = req.session.id; + req.session.authdate = new Date(); + req.session.authip = req.ip; + req.session.user = { + user: userDB.user, + id: userDB.id, + rank: userDB.rank + } + + //Tattoo hashed IP address to user account for seven days + userDB.tattooIPRecord(req.ip); + + //If we got to here then the log-in was successful. We should clear-out any failed attempts. + failedAttempts.delete(user); + + //return user + return userDB.user; + }catch(err){ + //Look for previous failed attempts + var attempt = failedAttempts.get(user); + + //If this is the first attempt + if(attempt == null){ + //Create new attempt object + attempt = { + count: 1, + lastAttempt: new Date() + } + }else{ + //Create updated attempt object + attempt = { + count: attempt.count + 1, + lastAttempt: new Date() + } + } + + //Commit the failed attempt to the failed sign-in cache + failedAttempts.set(user, attempt); + + //y33t + throw err; } - - //Tattoo the session with user and metadata - //unfortunately store.all() does not return sessions w/ their ID so we had to improvise... - //Not sure if this is just how connect-mongo is implemented or if it's an express issue, but connect-mongodb-session seems to not implement the all() function what so ever... - req.session.seshid = req.session.id; - req.session.authdate = new Date(); - req.session.authip = req.ip; - req.session.user = { - user: userDB.user, - id: userDB.id, - rank: userDB.rank - } - - //Tattoo hashed IP address to user account for seven days - userDB.tattooIPRecord(req.ip); - - //return user - return userDB.user; } module.exports.killSession = async function(session){ session.destroy(); -} \ No newline at end of file +} + +module.exports.getLoginAttempts = function(user){ + //Read the code, i'm not explaining this + return failedAttempts.get(user); +} + +module.exports.processExpiredAttempts = function(){ + for(user of failedAttempts.keys()){ + //Get attempt by user + const attempt = failedAttempts.get(user); + //Check how long its been + const daysSinceLastAttempt = ((new Date() - attempt.lastAttempt) / (1000 * 60 * 60 * 24)).toFixed(1); + + //If it's been more than a day since anyones tried to log in as this user + if(daysSinceLastAttempt >= 1){ + //Clear out the attempts so that they don't need to fuck with a captcha anymore + failedAttempts.delete(user); + } + } +} + +module.exports.throttleAttempts = throttleAttempts; +module.exports.maxAttempts = maxAttempts; \ No newline at end of file diff --git a/src/views/lockedAccount.ejs b/src/views/lockedAccount.ejs new file mode 100644 index 0000000..d45c185 --- /dev/null +++ b/src/views/lockedAccount.ejs @@ -0,0 +1,32 @@ + + + + + + <%- include('partial/styles', {instance, user}); %> + + <%= instance %> - Account Locked! + + + <%- include('partial/navbar', {user}); %> +

Multiple failed attempts detected!

+

Your account has been locked due to detected brute-force attacks!
Your account will be unlocked in 24 hours.

+ +
+ <%- include('partial/scripts', {user}); %> +
+ diff --git a/src/views/login.ejs b/src/views/login.ejs new file mode 100644 index 0000000..69c08e8 --- /dev/null +++ b/src/views/login.ejs @@ -0,0 +1,47 @@ + + + + + + <%- include('partial/styles', {instance, user}); %> + + + <%= instance %> - Log-In + + + <%- include('partial/navbar', {user}); %> + <% if(challenge != null){ %> +

Multiple failed attempts detected!

+

Please complete verification challenge to continue!

+ <% } %> +
+ + + + + <% if(challenge != null){ %> + + <% } %> + +
+ +
+ <%- include('partial/scripts', {user}); %> + + +
+ diff --git a/www/css/login.css b/www/css/login.css new file mode 100644 index 0000000..c2dc9e1 --- /dev/null +++ b/www/css/login.css @@ -0,0 +1,35 @@ +/*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 .*/ +form{ + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5em; + margin: 5% 17%; +} + +.login-page-prompt{ + width: 100% +} + +#login-page-button{ + width: 6em; + height: 2em; +} + +.danger-text{ + text-align: center; +} \ No newline at end of file diff --git a/www/css/theme/movie-night.css b/www/css/theme/movie-night.css index c2db933..2300612 100644 --- a/www/css/theme/movie-night.css +++ b/www/css/theme/movie-night.css @@ -66,8 +66,7 @@ along with this program. If not, see .*/ --altcha-color-base: var(--bg1); --altcha-color-border: var(--accent1); --altcha-color-text: var(--accent1); - --altcha-color-border-focus: currentColor; - --altcha-color-error-text: #f23939; + --altcha-color-error-text: var(--danger0); --altcha-max-width: 260px; } @@ -118,8 +117,25 @@ button:active{ box-shadow: var(--focus-glow0-alt0); } -input{ - accent-color: var(--focus0); +input:focus, textarea:focus{ + outline: none; + box-shadow: var(--focus-glow0); +} + +input:checked{ + accent-color: var(--focus0-alt0); + box-shadow: var(--focus-glow0); +} + +/* NOT! -Wayne */ +input:not([type='checkbox']):not(.navbar-item), textarea { + border-radius: 1em; + border: none; + padding: 0.1em 0.5em; +} + +textarea{ + border-bottom-right-radius: 0; } .danger-button{ @@ -139,7 +155,7 @@ input{ box-shadow: var(--danger-glow0-alt1); } -.danger-link{ +.danger-link, .danger-text{ color: var(--danger0); } @@ -200,6 +216,7 @@ div.control-prompt:focus-within{ input.control-prompt, input.control-prompt:focus{ border: none; outline: none; + box-shadow: none; } diff --git a/www/js/login.js b/www/js/login.js new file mode 100644 index 0000000..f737104 --- /dev/null +++ b/www/js/login.js @@ -0,0 +1,70 @@ +/*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 .*/ + +class registerPrompt{ + constructor(){ + //Grab user prompt + this.user = document.querySelector("#login-page-username"); + //Grab pass prompts + this.pass = document.querySelector("#login-page-password"); + //Grab register button + this.button = document.querySelector("#login-page-button"); + //Grab altcha widget + this.altcha = document.querySelector("altcha-widget"); + //Setup null property to hold verification payload from altcha widget + this.verification = null + + //Run input setup after DOM content has completely loaded to ensure altcha event listeners work + document.addEventListener('DOMContentLoaded', this.setupInput.bind(this)); + } + + setupInput(){ + //If we need verification + if(this.altcha != null){ + //Add verification event listener to altcha widget + this.altcha.addEventListener("verified", this.verify.bind(this)); + } + + //Add register event listener to register button + this.button.addEventListener("click", this.login.bind(this)); + } + + verify(event){ + //pull verification payload from event + this.verification = event.detail.payload; + } + + login(){ + console.log(this.altcha != null) + //If we need verification + if(this.altcha != null){ + //If verification isn't complete + if( this.verification == null){ + //don't bother + console.log("not complete"); + return; + } + + //login with verification + utils.ajax.login(this.user.value , this.pass.value, this.verification); + }else{ + //login + utils.ajax.login(this.user.value, this.pass.value); + } + } +} + +const registerForm = new registerPrompt(); \ No newline at end of file diff --git a/www/js/utils.js b/www/js/utils.js index c45db8a..ac37b0b 100644 --- a/www/js/utils.js +++ b/www/js/utils.js @@ -404,17 +404,19 @@ class canopyAjaxUtils{ } } - async login(user, pass){ + async login(user, pass, verification){ var response = await fetch(`/api/account/login`,{ method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({user, pass}) + body: JSON.stringify(verification ? {user, pass, verification} : {user, pass}) }); if(response.status == 200){ location.reload(); + }else if(response.status == 429){ + location = `/login?user=${user}`; }else{ utils.ux.displayResponseError(await response.json()); }