diff --git a/src/index.js b/src/index.js index ba853c6..75a2487 100644 --- a/src/index.js +++ b/src/index.js @@ -20,8 +20,8 @@ async function Database(){ } async function WebApp(){ - app.register(formBodyPlugin); - app.register(fastifyMultipart); + app.register(formBodyPlugin); //POST form fields + app.register(fastifyMultipart); //file uploads { //routes, bloody routes const opts = {app, db}; AuthController(opts); @@ -38,7 +38,7 @@ async function WebApp(){ const step = async (func, name) => { const start = Date.now(); const msg = await func(); - console.log(`[${name}] (${(Date.now() - start)} ms) ${msg || ''}`); + console.log(`[${name}] (${(Date.now() - start)} ms) ${msg || 'Done.'}`); }; await step(Database, 'DB'); await step(WebApp, 'Fastify'); diff --git a/src/logic/cache.js b/src/logic/cache.js new file mode 100644 index 0000000..62c8506 --- /dev/null +++ b/src/logic/cache.js @@ -0,0 +1,71 @@ + +export class CacheTable{ + + constructor(threshold){ + this.entries = {}; + this.count = 0; + this.threshold = threshold || 1024; + } + set(key, val){ + if(this.entries[key] === undefined){ + if(this.count < this.threshold){ + this.count++; + }else{ + for(var otherKey in this.entries){ + delete this.entries[otherKey]; + break; + } + } + } + this.entries[key] = val; + return val; + } + remove(key){ + if(this.entries[key] !== undefined){ + delete this.entries[key]; + this.count--; + } + } + get(key){ return this.entries[key]; } + allEntries(){ return this.entries; } +} + + +export class SpamCache extends CacheTable{ + + constructor(){ + super(1024); + } + + /** + * @param string key + * @param {[[maxHits: number, durationMs: number]]} constraints + * @returns + */ + check(key, constraints){ + const now = Date.now(); + let record = this.get(key); + if(record){ + let dingDong = false; + record.forEach(([count, countFrom], index) => { + const [max, duration] = constraints[index]; + if((now - countFrom) > duration){ + //expired + record[index] = [1, now]; + }else{ + if(count >= max){ + //max attempts within timespan + dingDong = true; + }else{ + record[index][0]++; + } + } + }); + return dingDong; + }else{ + record = constraints.map(() => ([1, now])); + this.set(key, record); + return false; + } + } +} diff --git a/src/logic/security.js b/src/logic/security.js index 8abed00..95f580b 100644 --- a/src/logic/security.js +++ b/src/logic/security.js @@ -1,5 +1,6 @@ import bcrypt from 'bcrypt'; import crypto from 'crypto'; +import { days, hours } from './utils.js'; //-- endpoints -- @@ -24,8 +25,7 @@ export function doesPasswordMatch(paswd, hash){ //-- password restore -- -const hours = 60*60*1000; -export const restoreValidity = 12 * hours; +export const restoreValidity = 12*hours; export const restoreAttempts = 8; export function generateRestoreCode(){ @@ -34,9 +34,8 @@ export function generateRestoreCode(){ //-- access tokens -- -const days = 24*60*60*1000; -export const tokenLifetime = 90 * days; -export const tokenReissue = 60 * days; +export const tokenLifetime = 90*days; +export const tokenReissue = 60*days; export async function reissueToken(user){ const token = generateToken(); diff --git a/src/logic/session.js b/src/logic/session.js new file mode 100644 index 0000000..8a726c5 --- /dev/null +++ b/src/logic/session.js @@ -0,0 +1,23 @@ +import { CacheTable, SpamCache } from "./cache.js"; +import { requestIp } from "./utils.js"; + +export class UserSession{ + + constructor(user, request){ + this.user = user; + this.createdAt = Date.now(); + this.ip = requestIp(request); + this.spam = new SpamCache(); + userCache.set(user.token, this); + } + kill(){ + this.dead = true; + userCache.remove(this.user.token); + } + + static find(token){ + return userCache.get(token); + } +} + +const userCache = new CacheTable(8192); diff --git a/src/logic/spam.js b/src/logic/spam.js deleted file mode 100644 index e12262d..0000000 --- a/src/logic/spam.js +++ /dev/null @@ -1,34 +0,0 @@ - -export class CacheTable{ - - constructor(threshold){ - this.entries = {}; - this.count = 0; - this.threshold = threshold || 1024; - } - set(key, val){ - if(this.entries[key] === undefined){ - if(this.count < this.threshold){ - this.count++; - }else{ - for(var otherKey in this.entries){ - delete this.entries[otherKey]; - break; - } - } - } - this.entries[key] = val; - } - remove(key){ - if(this.entries[key] !== undefined){ - delete this.entries[key]; - this.count--; - } - } - get(key){ return this.entries[key]; } - allEntries(){ return this.entries; } -} - -export class SpamTable{ -} - diff --git a/src/logic/utils.js b/src/logic/utils.js index 20423bc..8b35192 100644 --- a/src/logic/utils.js +++ b/src/logic/utils.js @@ -12,6 +12,10 @@ export function errorOut(reply, msg, code){ reply.send(msg || 'Bad request.'); } +export function requestIp(request){ + return request.ip; +} + export function reverseString(str){ return str.split("").reverse().join(""); } @@ -20,6 +24,10 @@ export function randomElement(arr){ return arr[Math.floor(Math.random()*arr.length)]; } +export const minutes = 60*1000; +export const hours = 3600*1000; +export const days = 24*hours; + export function notYet(date){ if(!date) return false; return (new Date() <= date); diff --git a/src/route/auth.controller.js b/src/route/auth.controller.js index c5ff61b..9cbb11d 100644 --- a/src/route/auth.controller.js +++ b/src/route/auth.controller.js @@ -1,7 +1,9 @@ -import { checkStringParam, errorOut, notYet, randomElement, reverseString } from '../logic/utils.js'; +import { checkStringParam, errorOut, notYet, randomElement, requestIp, reverseString, hours, minutes } from '../logic/utils.js'; import { Animals } from '../misc/animals.js'; import { reissueToken, generateToken, newTokenExpiry, hashPassword, doesPasswordMatch, isEndpointAllowedForBannedUsers, isEndpointProtected, generateRestoreCode, restoreValidity, restoreAttempts } from '../logic/security.js'; import { sendRestorationLink } from '../logic/email.js'; +import { UserSession } from '../logic/session.js'; +import { SpamCache } from '../logic/cache.js'; /** * @param {Object} props @@ -12,18 +14,26 @@ import { sendRestorationLink } from '../logic/email.js'; function AuthController({app, db}){ const { Users } = db.models; - { //validate token header and put .user in every request - app.decorateRequest('user', null); + const signInSpam = new SpamCache(); + const signUpSpam = new SpamCache(); + const restoreApplySpam = new SpamCache(); + + { //validate token header and put .session in every request + app.decorateRequest('session', null); app.addHook('preHandler', async (request, reply) => { if(isEndpointProtected(request.url)){ const token = request.headers.authorization; - //todo: local cache - - const user = await Users.findOne({where: {token}}); - const tokenNotExpired = notYet(user?.tokenExpiry); - const userNotBanned = (!notYet(user?.bannedUntil)) || isEndpointAllowedForBannedUsers(request.url); - if(user && tokenNotExpired && userNotBanned){ - request.user = user; + let session = UserSession.find(token); + if(!session){ + const userData = await Users.findOne({where: {token}}); + if(userData){ + session = new UserSession(userData, request); + } + } + const tokenNotExpired = notYet(session?.user.tokenExpiry); + const userNotBanned = (!notYet(session?.user.bannedUntil)) || isEndpointAllowedForBannedUsers(request.url); + if(session?.user && tokenNotExpired && userNotBanned){ + request.session = session; }else{ errorOut(reply, 'Unauthorized', 401); return true; //abort @@ -32,9 +42,12 @@ function AuthController({app, db}){ }); } - app.post('/auth/sign-in', async (request) => { + app.post('/auth/sign-in', async (request, reply) => { const {email, paswd} = request.body || {}; - //todo: spam check + + if(signInSpam.check(requestIp(request), [[5000, 2*hours]])){ + return errorOut(reply, 'error.too_fast'); + } { //form validation if(!isValidEmail(email)) return errorOut(reply, 'auth.bad_email'); @@ -56,7 +69,10 @@ function AuthController({app, db}){ app.post('/auth/sign-up', async (request, reply) => { const {email, username, paswd} = request.body || {}; - //todo: spam check + + if(signUpSpam.check(requestIp(request), [[5000, 2*hours], [100, 10*minutes]])){ + return errorOut(reply, 'error.too_fast'); + } { //form validation if(!isValidEmail(email)) return errorOut(reply, 'auth.bad_email'); @@ -85,8 +101,7 @@ function AuthController({app, db}){ tokenExpiry: newTokenExpiry(), paswd: hashPassword(reverseString(paswd)), role: 'user', - //firstIp: request.ip - firstIp: request.hostname + firstIp: requestIp(request) }; await Users.create(newUser); return {token: newUser.token}; @@ -115,6 +130,10 @@ function AuthController({app, db}){ const {email, code, newpaswd} = request.body || {}; const changeRequested = (newpaswd != null); + if(restoreApplySpam.check(requestIp(request), [[100, 12*hours], [25, 30*minutes]])){ + return errorOut(reply, 'error.too_fast'); + } + if(!isValidEmail(email)) return errorOut(reply, 'auth.bad_email'); if(changeRequested && (!isValidPassword(newpaswd))){ return errorOut(reply, 'auth.bad_paswd'); @@ -160,7 +179,7 @@ function fixUsername(username){ } function isValidEmail(email){ - return checkStringParam(email, 3, 128) && email.match('@'); + return checkStringParam(email, 3, 128) && !!email.match('@'); } function isValidUsername(username){ diff --git a/src/route/user.controller.js b/src/route/user.controller.js index a4b4ae5..66f42f3 100644 --- a/src/route/user.controller.js +++ b/src/route/user.controller.js @@ -10,7 +10,8 @@ function UserController({app, db}){ const { Users } = db.models; app.get('/users/me', async (request, reply) => { - const {user} = request; + const {session} = request; + const user = session.user; const needsReissue = ((user.tokenExpiry - new Date()) < (tokenLifetime - tokenReissue)); let newToken = needsReissue ? await reissueToken(user) : null;