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 @@
+
+
+
+
+
Please complete verification challenge to continue!
+ <% } %>
+
+
+
+
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());
}