feat: Added brute force prevention. Closes #3

This commit is contained in:
2025-05-16 00:39:55 +02:00
parent 9efeba0010
commit b708fe8c18
3 changed files with 101 additions and 6 deletions

53
src/helpers/security.ts Normal file
View File

@@ -0,0 +1,53 @@
import { Job, scheduleJob } from "node-schedule";
import usettings from "./usettings";
import { Types } from "mongoose";
interface IAccTimeout {
firstAttempt: Date;
expire: Job;
attempts: number;
}
class SecurityHelper {
private timeouts = new Map<string, IAccTimeout>();
private onTimeout = new Map<string, Job>(); // key: user id, value: unlock date
constructor () { }
addAttempt (userId: Types.ObjectId) {
var uid = userId.toString()
if (this.timeouts.has(uid)) {
var t = this.timeouts.get(uid)
t.attempts += 1
if (t.attempts > usettings.settings.security.loginTimeout.attempts) {
this.onTimeout.set(uid, scheduleJob(new Date(Date.now() + usettings.settings.security.loginTimeout.lockout * 1000), () => {
this.onTimeout.get(uid).cancel()
this.onTimeout.delete(uid)
}))
} else {
this.timeouts.set(uid, t)
}
} else {
this.timeouts.set(uid, {
attempts: 1,
firstAttempt: new Date(),
expire: scheduleJob(new Date(Date.now() + usettings.settings.security.loginTimeout.time * 1000), () => {
this.timeouts.get(uid).expire.cancel()
this.timeouts.delete(uid)
})
})
}
}
check(userId: Types.ObjectId) {
var timeout = this.onTimeout.get(userId.toString())
if (timeout) {
// @ts-ignore
return timeout.nextInvocation().toDate().valueOf() - Date.now().valueOf()
} else {
return false
}
}
}
export default new SecurityHelper()

View File

@@ -10,6 +10,7 @@ import mongoose from "mongoose"
import User from "./schemas/User"; import User from "./schemas/User";
import routes from "./routes/index"; import routes from "./routes/index";
import process from "node:process" import process from "node:process"
import security from "./helpers/security";
const connectionString = process.env.ATLAS_URI || "mongodb://mongodb:27017/ipwa"; const connectionString = process.env.ATLAS_URI || "mongodb://mongodb:27017/ipwa";
if (!process.env.DOMAIN) { if (!process.env.DOMAIN) {
@@ -55,12 +56,20 @@ app.use(passport.session())
passport.use("normal",new LocalStrategy(async function verify(uname,pass,done) { passport.use("normal",new LocalStrategy(async function verify(uname,pass,done) {
let query = await User.findOne({uname: uname.toLowerCase()}) let query = await User.findOne({uname: uname.toLowerCase()})
if (query) { if (query) {
if (query.locked == true) return done(null, false) if (query.locked == true) return done({type: "locked", message: "Twoje konto jest zablokowane. Skontaktuj się z administratorem."}, false)
var timeout = security.check(query._id)
if (timeout) {
timeout = Math.ceil(timeout / 1000 / 60)
return done({type: "timeout", message: `Zbyt wiele nieudanych prób logowania. Odczekaj ${timeout} minut lub skontaktuj się z administratorem.`}, false)
}
if (await bcrypt.compare(pass, query.pass)) { if (await bcrypt.compare(pass, query.pass)) {
return done(null, query) return done(null, query)
} else done(null, false) } else {
security.addAttempt(query._id)
done({type: "unf"}, false)
}
} else { } else {
done(null, false) done({type: "unf"}, false)
} }
})) }))
//#endregion //#endregion

View File

@@ -6,12 +6,45 @@ import bcrypt from "bcryptjs"
import cap from "@/helpers/capability"; import cap from "@/helpers/capability";
import usettings from "@/helpers/usettings"; import usettings from "@/helpers/usettings";
import vapidKeys from "@/vapidKeys"; import vapidKeys from "@/vapidKeys";
import { IVerifyOptions } from "passport-local";
const authRouter = Router() const authRouter = Router()
authRouter.post("/login", passport.authenticate('normal'), (req, res) => { authRouter.post("/login", (req, res) => {
if (req.user.admin != null) res.send({status: 200, admin: req.user.admin}) passport.authenticate('normal', (err: {type: string, message: string} | null, user?: Express.User | false, options?: IVerifyOptions) => {
else res.send({status: 200}) if (user) {
req.login(user, (error) => {
if (error) {
res.status(500).send(error)
} else {
if (req.user.admin != null) {
res.send({status: 200, admin: req.user.admin})
} else {
res.send({status: 200})
}
}
})
} else {
if (err) {
switch (err.type) {
case "unf":
res.status(404).send({status: 404, message: "Zła nazwa użytkownika lub hasło."})
break;
case "timeout":
res.status(403).send({status: 403, message: err.message})
break;
case "locked":
res.status(403).send({status: 403, message: err.message})
break;
default:
res.status(500).send({status: 500, message: err.message})
break;
}
} else {
res.status(403).send({status: 403, message: "Brak hasła lub loginu."})
}
}
})(req, res)
}) })
authRouter.post("/chpass", islogged, async (req,res) => { authRouter.post("/chpass", islogged, async (req,res) => {