Basic brute force detection added. Accounts throttle by captcha after 5 failed attempts, and locked out for 24 hours after 200 attempts.

This commit is contained in:
rainbow napkin 2024-12-26 17:46:35 -05:00
parent e0f53df176
commit 9c18c23ad5
13 changed files with 463 additions and 50 deletions

View file

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

View file

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

View file

@ -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 <https://www.gnu.org/licenses/>.*/
//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;

View file

@ -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);

View file

@ -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(''));
}

View file

@ -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(){

View file

@ -17,15 +17,45 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.*/
//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);
//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 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();
@ -51,10 +81,61 @@ module.exports.authenticateSession = async function(user, pass, req){
//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;
}
}
module.exports.killSession = async function(session){
session.destroy();
}
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;

View file

@ -0,0 +1,32 @@
<!--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/login.css">
<title><%= instance %> - Account Locked!</title>
</head>
<body>
<%- include('partial/navbar', {user}); %>
<h3 class="danger-text">Multiple failed attempts detected!</h3>
<p class="danger-text">Your account has been locked due to detected brute-force attacks!<br>Your account will be unlocked in 24 hours.</p>
</body>
<footer>
<%- include('partial/scripts', {user}); %>
</footer>
</html>

47
src/views/login.ejs Normal file
View file

@ -0,0 +1,47 @@
<!--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/login.css">
<link rel="stylesheet" type="text/css" href="/lib/altcha/altcha.css">
<title><%= instance %> - Log-In</title>
</head>
<body>
<%- include('partial/navbar', {user}); %>
<% if(challenge != null){ %>
<h3 class="danger-text">Multiple failed attempts detected!</h3>
<p class="danger-text">Please complete verification challenge to continue!</p>
<% } %>
<form action="javascript:">
<label>Username:</label>
<input class="login-page-prompt" id="login-page-username" placeholder="Required">
<label>Password:</label>
<input class="login-page-prompt" id="login-page-password" placeholder="Required" type="password">
<% if(challenge != null){ %>
<altcha-widget challengejson="<%= JSON.stringify(challenge) %>"></altcha-widget>
<% } %>
<button id="login-page-button" class='positive-button'>Login</button>
</form>
</body>
<footer>
<%- include('partial/scripts', {user}); %>
<script src="/js/login.js"></script>
<script src="/lib/altcha/altcha.js" type="module"></script>
</footer>
</html>

35
www/css/login.css Normal file
View file

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

View file

@ -66,8 +66,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.*/
--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;
}

70
www/js/login.js Normal file
View file

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

View file

@ -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());
}