Browse Source

sessions and spam protection

main
Nikky 4 years ago
parent
commit
a84358df0a
  1. 6
      src/index.js
  2. 71
      src/logic/cache.js
  3. 9
      src/logic/security.js
  4. 23
      src/logic/session.js
  5. 34
      src/logic/spam.js
  6. 8
      src/logic/utils.js
  7. 51
      src/route/auth.controller.js
  8. 3
      src/route/user.controller.js

6
src/index.js

@ -20,8 +20,8 @@ async function Database(){
} }
async function WebApp(){ async function WebApp(){
app.register(formBodyPlugin); app.register(formBodyPlugin); //POST form fields
app.register(fastifyMultipart); app.register(fastifyMultipart); //file uploads
{ //routes, bloody routes { //routes, bloody routes
const opts = {app, db}; const opts = {app, db};
AuthController(opts); AuthController(opts);
@ -38,7 +38,7 @@ async function WebApp(){
const step = async (func, name) => { const step = async (func, name) => {
const start = Date.now(); const start = Date.now();
const msg = await func(); 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(Database, 'DB');
await step(WebApp, 'Fastify'); await step(WebApp, 'Fastify');

71
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;
}
}
}

9
src/logic/security.js

@ -1,5 +1,6 @@
import bcrypt from 'bcrypt'; import bcrypt from 'bcrypt';
import crypto from 'crypto'; import crypto from 'crypto';
import { days, hours } from './utils.js';
//-- endpoints -- //-- endpoints --
@ -24,8 +25,7 @@ export function doesPasswordMatch(paswd, hash){
//-- password restore -- //-- password restore --
const hours = 60*60*1000; export const restoreValidity = 12*hours;
export const restoreValidity = 12 * hours;
export const restoreAttempts = 8; export const restoreAttempts = 8;
export function generateRestoreCode(){ export function generateRestoreCode(){
@ -34,9 +34,8 @@ export function generateRestoreCode(){
//-- access tokens -- //-- access tokens --
const days = 24*60*60*1000; export const tokenLifetime = 90*days;
export const tokenLifetime = 90 * days; export const tokenReissue = 60*days;
export const tokenReissue = 60 * days;
export async function reissueToken(user){ export async function reissueToken(user){
const token = generateToken(); const token = generateToken();

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

34
src/logic/spam.js

@ -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{
}

8
src/logic/utils.js

@ -12,6 +12,10 @@ export function errorOut(reply, msg, code){
reply.send(msg || 'Bad request.'); reply.send(msg || 'Bad request.');
} }
export function requestIp(request){
return request.ip;
}
export function reverseString(str){ export function reverseString(str){
return str.split("").reverse().join(""); return str.split("").reverse().join("");
} }
@ -20,6 +24,10 @@ export function randomElement(arr){
return arr[Math.floor(Math.random()*arr.length)]; 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){ export function notYet(date){
if(!date) return false; if(!date) return false;
return (new Date() <= date); return (new Date() <= date);

51
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 { Animals } from '../misc/animals.js';
import { reissueToken, generateToken, newTokenExpiry, hashPassword, doesPasswordMatch, isEndpointAllowedForBannedUsers, isEndpointProtected, generateRestoreCode, restoreValidity, restoreAttempts } from '../logic/security.js'; import { reissueToken, generateToken, newTokenExpiry, hashPassword, doesPasswordMatch, isEndpointAllowedForBannedUsers, isEndpointProtected, generateRestoreCode, restoreValidity, restoreAttempts } from '../logic/security.js';
import { sendRestorationLink } from '../logic/email.js'; import { sendRestorationLink } from '../logic/email.js';
import { UserSession } from '../logic/session.js';
import { SpamCache } from '../logic/cache.js';
/** /**
* @param {Object} props * @param {Object} props
@ -12,18 +14,26 @@ import { sendRestorationLink } from '../logic/email.js';
function AuthController({app, db}){ function AuthController({app, db}){
const { Users } = db.models; const { Users } = db.models;
{ //validate token header and put .user in every request const signInSpam = new SpamCache();
app.decorateRequest('user', null); 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) => { app.addHook('preHandler', async (request, reply) => {
if(isEndpointProtected(request.url)){ if(isEndpointProtected(request.url)){
const token = request.headers.authorization; const token = request.headers.authorization;
//todo: local cache let session = UserSession.find(token);
if(!session){
const user = await Users.findOne({where: {token}}); const userData = await Users.findOne({where: {token}});
const tokenNotExpired = notYet(user?.tokenExpiry); if(userData){
const userNotBanned = (!notYet(user?.bannedUntil)) || isEndpointAllowedForBannedUsers(request.url); session = new UserSession(userData, request);
if(user && tokenNotExpired && userNotBanned){ }
request.user = user; }
const tokenNotExpired = notYet(session?.user.tokenExpiry);
const userNotBanned = (!notYet(session?.user.bannedUntil)) || isEndpointAllowedForBannedUsers(request.url);
if(session?.user && tokenNotExpired && userNotBanned){
request.session = session;
}else{ }else{
errorOut(reply, 'Unauthorized', 401); errorOut(reply, 'Unauthorized', 401);
return true; //abort 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 || {}; const {email, paswd} = request.body || {};
//todo: spam check
if(signInSpam.check(requestIp(request), [[5000, 2*hours]])){
return errorOut(reply, 'error.too_fast');
}
{ //form validation { //form validation
if(!isValidEmail(email)) return errorOut(reply, 'auth.bad_email'); 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) => { app.post('/auth/sign-up', async (request, reply) => {
const {email, username, paswd} = request.body || {}; 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 { //form validation
if(!isValidEmail(email)) return errorOut(reply, 'auth.bad_email'); if(!isValidEmail(email)) return errorOut(reply, 'auth.bad_email');
@ -85,8 +101,7 @@ function AuthController({app, db}){
tokenExpiry: newTokenExpiry(), tokenExpiry: newTokenExpiry(),
paswd: hashPassword(reverseString(paswd)), paswd: hashPassword(reverseString(paswd)),
role: 'user', role: 'user',
//firstIp: request.ip firstIp: requestIp(request)
firstIp: request.hostname
}; };
await Users.create(newUser); await Users.create(newUser);
return {token: newUser.token}; return {token: newUser.token};
@ -115,6 +130,10 @@ function AuthController({app, db}){
const {email, code, newpaswd} = request.body || {}; const {email, code, newpaswd} = request.body || {};
const changeRequested = (newpaswd != null); 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(!isValidEmail(email)) return errorOut(reply, 'auth.bad_email');
if(changeRequested && (!isValidPassword(newpaswd))){ if(changeRequested && (!isValidPassword(newpaswd))){
return errorOut(reply, 'auth.bad_paswd'); return errorOut(reply, 'auth.bad_paswd');
@ -160,7 +179,7 @@ function fixUsername(username){
} }
function isValidEmail(email){ function isValidEmail(email){
return checkStringParam(email, 3, 128) && email.match('@'); return checkStringParam(email, 3, 128) && !!email.match('@');
} }
function isValidUsername(username){ function isValidUsername(username){

3
src/route/user.controller.js

@ -10,7 +10,8 @@ function UserController({app, db}){
const { Users } = db.models; const { Users } = db.models;
app.get('/users/me', async (request, reply) => { 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)); const needsReissue = ((user.tokenExpiry - new Date()) < (tokenLifetime - tokenReissue));
let newToken = needsReissue ? await reissueToken(user) : null; let newToken = needsReissue ? await reissueToken(user) : null;

Loading…
Cancel
Save