diff --git a/browser/date/DateFormatter.ts b/browser/date/DateFormatter.ts new file mode 100644 index 0000000..30cb217 --- /dev/null +++ b/browser/date/DateFormatter.ts @@ -0,0 +1,30 @@ +import { TextTool } from "../text/TextTool"; + +export class DateFormatter +{ + static YMD_HMS( date:Date ):string + { + let ye = ( date.getFullYear() + "" ).substring( 2 ); + let mo = TextTool.prependZeros( ( date.getMonth() + 1 ) ); + let da = TextTool.prependZeros( date.getDate() ); + + let h = TextTool.prependZeros( date.getHours() ); + let m = TextTool.prependZeros( date.getMinutes() ); + let s = TextTool.prependZeros( date.getSeconds() ); + + return `${ye}-${mo}-${da} ${h}-${m}-${s}`; + } + + static forUsers( date:Date ):string + { + let ye = ( date.getFullYear() + "" ); + let mo = TextTool.prependZeros( ( date.getMonth() + 1 ) ); + let da = TextTool.prependZeros( date.getDate() ); + + let h = TextTool.prependZeros( date.getHours() ); + let m = TextTool.prependZeros( date.getMinutes() ); + let s = TextTool.prependZeros( date.getSeconds() ); + + return `${ye}-${mo}-${da} ${h}:${m}:${s}`; + } +} \ No newline at end of file diff --git a/browser/date/DateHelper.ts b/browser/date/DateHelper.ts index 42183bc..266e433 100644 --- a/browser/date/DateHelper.ts +++ b/browser/date/DateHelper.ts @@ -7,6 +7,11 @@ export class DateHelper return date; } + static nowMS() + { + return this.now().getTime(); + } + static today() { let date = new Date(); diff --git a/browser/date/DateMath.ts b/browser/date/DateMath.ts index 2ddb783..cd52523 100644 --- a/browser/date/DateMath.ts +++ b/browser/date/DateMath.ts @@ -120,6 +120,11 @@ export class DateMath return this.addMilliseconds( a, duration * 1000 ); } + static durationToHours( duration:number ) + { + + } + static addMinutes( a:Date, durationMinutes:number ) { return this.addSeconds( a, durationMinutes * 60 ); diff --git a/browser/date/Duration.ts b/browser/date/Duration.ts new file mode 100644 index 0000000..2db4fdb --- /dev/null +++ b/browser/date/Duration.ts @@ -0,0 +1,32 @@ +export class Duration +{ + static toMinutes( duration:number ) + { + return duration / 60; + } + + static fromMinutes( minutes:number ) + { + return minutes * 60; + } + + static toHours( duration:number ) + { + return this.toMinutes( duration ) / 60; + } + + static fromHours( hours:number ) + { + return this.fromMinutes( hours * 60 ); + } + + static toDays( duration:number ) + { + return this.toHours( duration ) / 24; + } + + static fromDays( days:number ) + { + return this.fromHours( days * 24 ); + } +} \ No newline at end of file diff --git a/browser/date/ISOTimeStamp.ts b/browser/date/ISOTimeStamp.ts new file mode 100644 index 0000000..b7d6ca0 --- /dev/null +++ b/browser/date/ISOTimeStamp.ts @@ -0,0 +1,23 @@ +import { DateHelper } from "./DateHelper"; + +export class ISOTimeStamp +{ + value:string; + + static toDate( stamp:ISOTimeStamp ):Date + { + return new Date( stamp.value ); + } + + static fromDate( date:Date ):ISOTimeStamp + { + let stamp = new ISOTimeStamp(); + stamp.value = date.toISOString(); + return stamp; + } + + static now():ISOTimeStamp + { + return ISOTimeStamp.fromDate( DateHelper.now() ); + } +} diff --git a/browser/dom/ClassFlag.ts b/browser/dom/ClassFlag.ts index 3a84e92..d1a2a9b 100644 --- a/browser/dom/ClassFlag.ts +++ b/browser/dom/ClassFlag.ts @@ -202,4 +202,9 @@ export class ClassFlag ClassFlag.setClass( element, classString, ! hasClassAssigned ); } + + static queryElement( element:Element, classNAme:string ) + { + return new ClassFlag( classNAme ).query( element ); + } } \ No newline at end of file diff --git a/browser/dom/DOMEditor.ts b/browser/dom/DOMEditor.ts index 566c721..bedd3b6 100644 --- a/browser/dom/DOMEditor.ts +++ b/browser/dom/DOMEditor.ts @@ -3,6 +3,13 @@ import { TreeWalker } from "../graphs/TreeWalker"; export class DOMEditor { + static setText( element:Element, text:string, doc:Document = null ) + { + doc = doc || document; + element.innerHTML = ""; + element.appendChild( doc.createTextNode( text ) ); + } + static nodeListToArray( list:NodeList ) { return Array.prototype.slice.call( list ); diff --git a/browser/messages/DataMessage.ts b/browser/messages/DataMessage.ts new file mode 100644 index 0000000..c24b4cc --- /dev/null +++ b/browser/messages/DataMessage.ts @@ -0,0 +1,6 @@ +import { Message } from "./Message"; + +export class DataMessage extends Message +{ + data:T; +} \ No newline at end of file diff --git a/browser/text/TextTool.ts b/browser/text/TextTool.ts new file mode 100644 index 0000000..4c9d263 --- /dev/null +++ b/browser/text/TextTool.ts @@ -0,0 +1,14 @@ +export class TextTool +{ + static prependZeros( anySource:string|number, minimumLength:number = 2 ) + { + let source = anySource + ""; + + while ( source.length < minimumLength ) + { + source = "0" + source; + } + + return source; + } +} \ No newline at end of file diff --git a/browser/text/replacing/VariableReplacer.ts b/browser/text/replacing/VariableReplacer.ts index a14c1be..2543e1a 100644 --- a/browser/text/replacing/VariableReplacer.ts +++ b/browser/text/replacing/VariableReplacer.ts @@ -3,11 +3,11 @@ import { RegExpUtility } from "../RegExpUtitlity"; export type Variables = {[index:string]:string } export class VariableReplacer { - static replace( source:string, variables:Variables ) + static replace( source:string, variables:Variables, prefix = "${", postfix = "}" ) { for ( let it in variables ) { - let regexSource = RegExpUtility.toRegexSource( "${" + it + "}" ); + let regexSource = RegExpUtility.toRegexSource( prefix + it + postfix ); source = source.replace( new RegExp( regexSource, "g" ), variables[ it ] ); } diff --git a/browser/tools/Arrays.ts b/browser/tools/Arrays.ts index d88ef9b..fcf8577 100644 --- a/browser/tools/Arrays.ts +++ b/browser/tools/Arrays.ts @@ -128,6 +128,14 @@ export class Arrays Arrays.insert( array, element, 0 ); } + static shiftToSize( array:T[], maxSize:number ) + { + while ( array.length > maxSize ) + { + array.shift(); + } + } + static remove( array:T[], element:T ) { if ( ! array || ! element ) diff --git a/browser/tools/MapList.ts b/browser/tools/MapList.ts index de2729e..9190b95 100644 --- a/browser/tools/MapList.ts +++ b/browser/tools/MapList.ts @@ -1,3 +1,5 @@ +import { Arrays } from "./Arrays"; + export class MapList { static add( map:Map, k:K, v:V ) @@ -10,5 +12,16 @@ export class MapList map.get( k ).push( v ); } + static shiftToSize( map:Map, k:K, num:number ) + { + if ( ! map.has( k ) ) + { + return; + } + + let array = map.get( k ); + + Arrays.shiftToSize( array, num ); + } } \ No newline at end of file diff --git a/browser/xhttp/Request.ts b/browser/xhttp/Request.ts new file mode 100644 index 0000000..d8fd255 --- /dev/null +++ b/browser/xhttp/Request.ts @@ -0,0 +1,43 @@ +export class Request +{ + static post( url:string, input:I ):Promise + { + let promise = new Promise + ( + ( resolve, reject ) => + { + let xhr = new XMLHttpRequest(); + + console.log( "post", url, ">>", input); + xhr.open( "POST", url, true ); + + xhr.responseType = "text"; + + xhr.onload= + () => + { + console.log( xhr.responseURL, xhr.responseText ); + + if ( xhr.status !== 200 || xhr.responseText.startsWith( "ERROR:" ) ) + { + reject( xhr.responseText ) + } + else + { + resolve( JSON.parse( xhr.responseText ) as O ); + } + + }; + + xhr.onerror=(e)=> + { + reject( e ); + } + + xhr.send( JSON.stringify( input ) ); + } + ); + + return promise; + } +} \ No newline at end of file diff --git a/node/crypt/CryptContainer.ts b/node/crypt/CryptContainer.ts new file mode 100644 index 0000000..f2ecbea --- /dev/null +++ b/node/crypt/CryptContainer.ts @@ -0,0 +1,5 @@ +export class CryptContainer +{ + key:string; + data:string; +} \ No newline at end of file diff --git a/node/crypt/CryptIO.ts b/node/crypt/CryptIO.ts new file mode 100644 index 0000000..480918a --- /dev/null +++ b/node/crypt/CryptIO.ts @@ -0,0 +1,221 @@ +import { promises as fs } from "fs"; + +import { CryptContainer } from "./CryptContainer"; +import * as crypto from "crypto"; +import * as path from "path"; +import * as bcrypt from "bcrypt"; + +import { CryptSettings } from "./CryptSettings"; +import { Files } from "../files/Files"; +import { RJLog } from "../log/RJLog"; + + +export class CryptIO +{ + static readonly encryptionSuffix = ".crypt"; + static readonly debugOutput = true; + static readonly debugOutputSuffix = ".crypt.json"; + + private static _cryptSettings:CryptSettings; + + static set cryptSettings( cryptSettings:CryptSettings ) + { + this._cryptSettings = cryptSettings; + } + + static createUUID() + { + return crypto.randomUUID(); + } + + static isDebugFilePath( filePath:string ):boolean + { + return filePath.endsWith( this.debugOutputSuffix ); + } + + static async hash( data:string ):Promise + { + let hash = await bcrypt.hash( data, 10 ); + return Promise.resolve( hash ); + } + + static async verifyHash( data:string, hashed:string ):Promise + { + let isVerified = await bcrypt.compare( data, hashed ); + return Promise.resolve( isVerified ); + } + + static async encrypt( data:string, settings?:CryptSettings ):Promise + { + settings = settings || CryptIO._cryptSettings; + + const algorithm = 'aes-192-cbc'; + + const key = crypto.scryptSync( settings.publicKey, 'salt', 24 ); + + const cipher = crypto.createCipheriv( algorithm, key, settings.iv ); + + let encrypted = cipher.update( data, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + + return Promise.resolve( encrypted ); + } + + static async decrypt( data:string, settings?:CryptSettings ) + { + settings = settings || CryptIO._cryptSettings; + + const algorithm = 'aes-192-cbc'; + + const key = crypto.scryptSync( settings.publicKey, 'salt', 24 ); + + const decipher = crypto.createDecipheriv( algorithm, key, settings.iv ); + + let decrypted = decipher.update( data, 'hex', 'utf8' ); + + decrypted += decipher.final('utf8'); + + return decrypted; + } + + static async createWithRandomUUID( parentPath:string, data:any, onUUID?:( uuid:string, data:T ) => T ):Promise + { + let uuid = CryptIO.createUUID(); + let fullPath = path.join( parentPath, uuid + this.encryptionSuffix ); + + let pathExists = await Files.exists( fullPath ); + + while ( pathExists ) + { + uuid = CryptIO.createUUID(); + fullPath = path.join( parentPath, uuid + this.encryptionSuffix ); + pathExists = await Files.exists( fullPath ); + } + + if ( onUUID ) + { + data = onUUID( uuid, data ); + } + + await CryptIO.saveJSON( fullPath, data ); + + + + return Promise.resolve( uuid ); + } + + static async loadWithRandomUUID( parentPath:string, uuid:string ):Promise + { + let fullPath = path.join( parentPath, uuid + this.encryptionSuffix ); + + let fileExsits = await Files.exists( fullPath ); + + if ( ! fileExsits ) + { + return Promise.resolve( null ); + } + + let data = await this.loadJSON( fullPath ); + + return data; + } + + static async deleteWithRandomUUID( parentPath:string, uuid:string ):Promise + { + let fullPath = path.join( parentPath, uuid + this.encryptionSuffix ); + + let exists = await Files.exists( fullPath ); + + if ( ! exists ) + { + return Promise.resolve( false ); + } + + await Files.deleteFile( fullPath ); + + return Promise.resolve( true ); + } + + static async updateWithRandomUUID( parentPath:string, uuid:string, data:any ):Promise + { + let fullPath = path.join( parentPath, uuid + this.encryptionSuffix ); + await this.saveJSON( fullPath, data ); + + return Promise.resolve(); + } + + static async saveJSON( path:string, data:any ):Promise + { + let jsonString = JSON.stringify( data ); + + RJLog.log( "Saving ", data, ">>", jsonString, ); + + let container:CryptContainer = + { + key:"", + data: await this.encrypt( jsonString ) + }; + + let containerString = await this.encrypt( JSON.stringify( container ) ); + + await fs.writeFile( path, containerString ); + + if ( this.debugOutput ) + { + await fs.writeFile( path + this.debugOutputSuffix, jsonString ); + } + + return Promise.resolve(); + } + + static async saveUTF8( path:string, jsonString:string ):Promise + { + RJLog.log( "Saving ", jsonString, ); + + let container:CryptContainer = + { + key:"", + data: await this.encrypt( jsonString ) + }; + + let containerString = await this.encrypt( JSON.stringify( container ) ); + + await fs.writeFile( path, containerString ); + + if ( this.debugOutput ) + { + await fs.writeFile( path + this.debugOutputSuffix, jsonString ); + } + + return Promise.resolve(); + } + + + static async loadJSON( path:string ):Promise + { + let encryptedContainerString = await fs.readFile( path ); + let containerString = await this.decrypt( encryptedContainerString.toString() ); + + let container = JSON.parse( containerString ) as CryptContainer; + let encryptedData = container.data; + let jsonString = await this.decrypt( encryptedData ); + + let json = JSON.parse( jsonString ) as T; + + return Promise.resolve( json ); + } + + static async loadUTF8( path:string ):Promise + { + let encryptedContainerString = await fs.readFile( path ); + let containerString = await this.decrypt( encryptedContainerString.toString() ); + + let container = JSON.parse( containerString ) as CryptContainer; + let encryptedData = container.data; + let jsonString = await this.decrypt( encryptedData ); + + return Promise.resolve( jsonString ); + } + + +} \ No newline at end of file diff --git a/node/crypt/CryptSettings.ts b/node/crypt/CryptSettings.ts new file mode 100644 index 0000000..3037634 --- /dev/null +++ b/node/crypt/CryptSettings.ts @@ -0,0 +1,53 @@ +export class CryptSettings +{ + publicKey:Buffer; + iv:Buffer; + + static from( serializedSettings:SerializedCryptSettings ) + { + let settings = new CryptSettings(); + + settings.iv = Buffer.alloc( serializedSettings.iv.length ); + settings.publicKey = Buffer.alloc( serializedSettings.publicKey.length ); + + + for ( let i = 0; i < serializedSettings.publicKey.length; i++ ) + { + settings.publicKey[ i ] = serializedSettings.publicKey[ i ] + } + + for ( let i = 0; i < serializedSettings.iv.length; i++ ) + { + settings.iv[ i ] = serializedSettings.iv[ i ] + } + + return settings; + } +} + +export class SerializedCryptSettings +{ + publicKey:number[]; + iv:number[]; + + static from( cryptSettings:CryptSettings ) + { + let serialized = new SerializedCryptSettings(); + serialized.iv = []; + serialized.publicKey = []; + + for ( let i = 0; i < cryptSettings.publicKey.byteLength; i++ ) + { + let value = cryptSettings.publicKey[ i ] + serialized.publicKey.push( value ); + } + + for ( let i = 0; i < cryptSettings.iv.byteLength; i++ ) + { + let value = cryptSettings.iv[ i ] + serialized.iv.push( value ); + } + + return serialized; + } +} \ No newline at end of file diff --git a/node/files/Files.ts b/node/files/Files.ts index c61d229..62ce13c 100644 --- a/node/files/Files.ts +++ b/node/files/Files.ts @@ -8,11 +8,53 @@ import { DateMath } from "../../browser/date/DateMath"; export class Files { + static escapePathFragment( unescapedPath: string, replacementCharacter: string = "_" ): string + { + + let invalidChars = /[\\/:*?"<>|]/g; + + let safe = unescapedPath.replace( invalidChars, replacementCharacter ) + + let reserved = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i; + + if ( reserved.test( safe ) ) + { + safe = `_${safe}_`; + } + + return safe; + } + static parentPath( filePath:string ) { return path.dirname( filePath ); } + static async copy( from:string, to:string ) + { + let stat = await fs.stat(from); + + if ( stat.isDirectory() ) + { + await fs.mkdir( to, { recursive: true } ); + + let entries = await fs.readdir( from ); + + for ( let entry of entries) + { + let srcPath = path.join( from, entry ); + let destPath = path.join( to, entry ); + + await Files.copy(srcPath, destPath); + } + } + else + { + await fs.copyFile(from, to); + } + } + + static async forAllIn( filePath:string, filter:(p:PathReference)=>Promise = null, action:(p:PathReference)=>Promise = null ):Promise { let files = await fs.readdir( filePath ); @@ -217,7 +259,7 @@ export class Files return Promise.resolve(); } - await fs.mkdir( path ); + await fs.mkdir( path, { recursive:true } ); return Promise.resolve(); } @@ -309,11 +351,11 @@ export class Files } - static async saveJSON( filePath:string, data:T ):Promise + static async saveJSON( filePath:string, data:T, pretty:boolean = true ):Promise { try { - let jsonData = JSON.stringify( data ); + let jsonData = pretty ? JSON.stringify( data, null, " " ) : JSON.stringify( data ); let result = await Files.saveUTF8( filePath, jsonData ); return Promise.resolve( result ); diff --git a/node/files/PathReference.ts b/node/files/PathReference.ts index 3281b57..68140e8 100644 --- a/node/files/PathReference.ts +++ b/node/files/PathReference.ts @@ -129,6 +129,11 @@ export class PathReference return Files.loadUTF8( this.absolutePath ); } + async saveUTF8( text:string ) + { + return Files.saveUTF8( this.absolutePath, text ); + } + async loadHTML() { return Files.loadHTML( this.absolutePath ); diff --git a/node/users/RequestHandler.ts b/node/users/RequestHandler.ts new file mode 100644 index 0000000..eb8db50 --- /dev/null +++ b/node/users/RequestHandler.ts @@ -0,0 +1,190 @@ + +import { RJLog } from "../log/RJLog"; +import { RequestRequirement } from "./requirements/RequestRequirement"; +import { UserManagementServer } from "./UserManagementServer"; +import { FastifyRequest, FastifyReply } from 'fastify'; +import { Message } from "../../browser/messages/Message"; +import { UserData } from "./UserData"; +import { UAParser } from 'ua-parser-js'; +import { LocationData } from "./location/LocationData"; + +export enum RequestType +{ + GET, + POST +} + +export abstract class RequestHandler +{ + + _ums:UserManagementServer; + _type:RequestType; + _url:string; + + + requirements:RequestRequirement[] = []; + + get app(){ return this._ums.app }; + get userDB(){ return this._ums.userDB; } + + constructor( rt:RequestType, url:string, requirements:RequestRequirement[] = [] ) + { + this._url = url; + this._type = rt; + this.requirements = requirements; + } + + + async initialize( ums:UserManagementServer ):Promise + { + this._ums = ums; + + await this._initialize(); + + await this._register(); + + for ( let r of this.requirements ) + { + await r.initialize( this ); + } + + return Promise.resolve(); + } + + protected _register():Promise + { + RJLog.log( RequestType[ this._type ], this._url ); + + if ( RequestType.GET == this._type ) + { + this.app.get( this._url, ( request, reply ) => { this.handle( request, reply ); } ); + } + else if ( RequestType.POST == this._type ) + { + this.app.post( this._url, ( request, reply ) => { this.handle( request, reply ); } ); + } + + return Promise.resolve(); + } + + protected _currentRequest:FastifyRequest; + protected _currentReply:FastifyReply; + + async handle( request:FastifyRequest, reply:FastifyReply ):Promise + { + if ( ! request || ! reply ) + { + RJLog.warn( "Aborting request:", "Request:", ! request, "Reply:", ! reply ); + return Promise.resolve(); + } + + this._currentRequest = request; + this._currentReply = reply; + + RJLog.log( "Processing request:", "Request:", request.url, request.ip ); + + for ( let r of this._ums.globalRequirements ) + { + let fullfilled = await r.handle( request, reply ); + + if ( ! fullfilled ) + { + RJLog.log( "Global Requirement not fullfilled", r ); + + return this.sendError( "Error during global requirements check" ); + } + } + + for ( let r of this.requirements ) + { + let fullfilled = await r.handle( request, reply ); + + if ( ! fullfilled ) + { + RJLog.log( "Requirement not fullfilled", r ); + + return this.sendError( "Error during requirements check" ); + } + } + + + await this._handle( request, reply ); + + this._currentRequest = null; + this._currentReply = null; + + return Promise.resolve(); + } + + protected _initialize():Promise + { + return Promise.resolve(); + }; + + protected abstract _handle( request:FastifyRequest, reply:FastifyReply ):Promise; + + + + get ip() + { + return this._currentRequest.ip; + } + + + get userAgent() + { + return this._currentRequest.headers[ "user-agent" ]; + } + + getLocation():Promise + { + return this._ums.location.getLocation( this.ip ); + } + + protected sendJSON( obj:any ):Promise + { + this._currentReply.send( obj ); + return Promise.resolve(); + } + + getUser():Promise + { + let request = this._currentRequest; + let requestBody = JSON.parse( request.body as string ); + let tokenData = requestBody as { token:string }; + let tokenID = tokenData.token; + + let session = this._ums._sessions.get( tokenID ); + return this._ums.userDB.byID( session.userID ); + } + + protected sendInfo( info:string ):Promise + { + RJLog.log( info ); + return this.sendJSON( Message.Info( info ) ); + } + + protected sendError( error:string, with400ErrorCode:boolean = true ):Promise + { + RJLog.log( error ); + + if ( with400ErrorCode ) + { + this._currentReply.code( 400 ); + } + + return this.sendJSON( Message.Error( error ) ); + } + + protected sendEmail( to:string, title:string, message:string ) + { + this._ums.sendEmail( to, title, message ); + } + + protected sendDataInfo( info:string, data:any ) + { + let json = Message.Info( info ) as any; + json.data = data; + return this.sendJSON( json ); + } +} \ No newline at end of file diff --git a/node/users/Session.ts b/node/users/Session.ts new file mode 100644 index 0000000..bda0ba0 --- /dev/null +++ b/node/users/Session.ts @@ -0,0 +1,5 @@ +export class Session +{ + userID:string; + token:string; +} \ No newline at end of file diff --git a/node/users/Token.ts b/node/users/Token.ts new file mode 100644 index 0000000..170f2ac --- /dev/null +++ b/node/users/Token.ts @@ -0,0 +1,8 @@ +import { ISOTimeStamp } from "../../browser/date/ISOTimeStamp"; + +export class Token +{ + id:string; + expires:ISOTimeStamp; + hashedIP:string; +} \ No newline at end of file diff --git a/node/users/TokenDB.ts b/node/users/TokenDB.ts new file mode 100644 index 0000000..2ef0b9f --- /dev/null +++ b/node/users/TokenDB.ts @@ -0,0 +1,60 @@ +import { DateHelper } from "../../browser/date/DateHelper"; +import { DateMath } from "../../browser/date/DateMath"; +import { ISOTimeStamp } from "../../browser/date/ISOTimeStamp"; +import { CryptIO } from "../crypt/CryptIO"; +import { Token } from "./Token"; +import { UserManagementServer } from "./UserManagementServer"; + +export class TokenDB +{ + static _defaultExpirationDurationMinutes = 20; + + _ums:UserManagementServer; + _tokens = new Map(); + + constructor( ums:UserManagementServer ) + { + this._ums = ums; + } + + async update() + { + + } + + async create( ip:string, expireDurationInMinutes:number = -1 ):Promise + { + expireDurationInMinutes = expireDurationInMinutes == -1 ? TokenDB._defaultExpirationDurationMinutes : expireDurationInMinutes; + + let token = new Token(); + token.id = CryptIO.createUUID(); + token.hashedIP = await CryptIO.hash( ip ); + token.expires = ISOTimeStamp.fromDate( DateMath.fromNowAddMinutes( expireDurationInMinutes ) ); + + this._tokens.set( token.id, token ); + + return Promise.resolve( token ); + } + + async validate( token:Token, ip:string ):Promise + { + if ( this._tokens.get( token.id ) != token ) + { + return Promise.resolve( false ); + } + + if ( DateMath.isExpired( ISOTimeStamp.toDate( token.expires ) ) ) + { + return Promise.resolve( false ); + } + + let isVerified = await CryptIO.verifyHash( ip, token.hashedIP ); + + if ( isVerified ) + { + return true; + } + + return Promise.resolve( false ); + } +} \ No newline at end of file diff --git a/node/users/UserDB.ts b/node/users/UserDB.ts new file mode 100644 index 0000000..92d1cf9 --- /dev/null +++ b/node/users/UserDB.ts @@ -0,0 +1,211 @@ +import { Arrays } from "../../browser/tools/Arrays"; +import { CryptIO } from "../crypt/CryptIO"; +import { Files } from "../files/Files"; +import { Permission } from "./permissions/Permission"; +import { Role } from "./permissions/Role"; +import { Token } from "./Token"; +import { TokenDB } from "./TokenDB"; +import { UserData } from "./UserData"; +import { UserManagementServer } from "./UserManagementServer"; + +export class SerializedUserDB +{ + users:UserData[]; +} + +export class UserDB +{ + _ums:UserManagementServer; + _path:string; + + users:UserData[] = []; + _pendingUsers:UserData[] = []; + + _signUpTokens = new Map(); + + + + + async hasUserWithEmail( email:string ):Promise + { + return this.users.findIndex( u => u.email === email ) != -1; + } + + async signUp( email:string, password:string, userName:string = "User" ):Promise + { + let userData = this._pendingUsers.find( u => u.email === email ); + + if ( ! userData ) + { + userData = new UserData(); + userData.id = CryptIO.createUUID(); + userData.email = email; + userData.hashedPassword = await CryptIO.hash( password ); + userData.name = userName; + + this._pendingUsers.push( userData ); + + await this.save(); + + } + + + return Promise.resolve( userData ); + } + + async createSignUpConfirmation( userData:UserData, ip:string ):Promise + { + let token = await this._ums.tokenDB.create( ip ); + + this._signUpTokens.set( userData.id, token ); + + return Promise.resolve( token ); + } + + async byID( id:string ) + { + return this.users.find( u => u.id === id ); + } + + async byEmail( email:string ) + { + return this.users.find( u => u.email === email ); + } + + async confirmUserSignUp( userID:string, tokenID:string, ip:string ):Promise + { + let user = this._pendingUsers.find( u => u.id == userID ); + + if ( ! user ) + { + return Promise.resolve( false ); + } + + if ( ! this._signUpTokens.has( userID ) ) + { + return Promise.resolve( false ); + } + + let token = this._signUpTokens.get( userID ); + + if ( token.id != tokenID ) + { + return Promise.resolve( false ); + } + + let isValid = await this._ums.tokenDB.validate( token, ip ); + + if ( isValid ) + { + Arrays.remove( this._pendingUsers, user ); + this.users.push( user ); + + await this.save(); + + } + + return Promise.resolve( isValid ); + } + + async login( email:string, password:string, ip:string ):Promise + { + let user = await this.byEmail( email ); + + if ( ! user ) + { + return Promise.resolve( null ); + } + + let passwordValid = await CryptIO.verifyHash( password, user.hashedPassword ); + + if ( ! passwordValid ) + { + return Promise.resolve( null ); + } + + let hour = 60; + let day = hour * 24; + let week = day * 7; + + let token = await this._ums.tokenDB.create( ip, week ); + + return Promise.resolve( token ); + } + + async hasPermission( ud:UserData, permissionID:string ) + { + let hasOwnPermission = await this._hasPermission( permissionID, ud.permissions ); + + if ( hasOwnPermission ) + { + return Promise.resolve( true ); + } + + let roleID = ud.role; + + while ( roleID ) + { + let role = this._ums._roles.get( roleID ); + + if ( ! role ) + { + return Promise.resolve( false ); + } + + let hasRolePermission = await this._hasPermission( permissionID, role.permissions ); + + if ( hasRolePermission ) + { + return Promise.resolve( true ); + } + + roleID = role.inherits; + } + + return Promise.resolve( false ); + } + + async _hasPermission( permissionID:string, permissions:Permission[] ):Promise + { + for ( let p of permissions ) + { + if ( Permission.isMatching( permissionID, p ) ) + { + return Promise.resolve( true ); + } + } + + return Promise.resolve( false ); + } + + async save() + { + let serializedDB = new SerializedUserDB(); + serializedDB.users = this.users; + + await Files.saveJSON( this._path, serializedDB ); + + return Promise.resolve(); + } + + static async load( ums:UserManagementServer, path:string ):Promise + { + let userDB = new UserDB(); + userDB._ums = ums; + userDB._path = path; + + let userDBExists = await Files.exists( userDB._path ); + + if ( userDBExists ) + { + let data = await Files.loadJSON( userDB._path ); + userDB.users = data.users; + } + + + return Promise.resolve( userDB ); + } + + + +} \ No newline at end of file diff --git a/node/users/UserData.ts b/node/users/UserData.ts new file mode 100644 index 0000000..c272887 --- /dev/null +++ b/node/users/UserData.ts @@ -0,0 +1,25 @@ +import { UAParser } from "ua-parser-js"; +import { ISOTimeStamp } from "../../browser/date/ISOTimeStamp"; +import { Permission } from "./permissions/Permission"; +import { Role } from "./permissions/Role"; +import { LocationData } from "./location/LocationData"; + + +export class UserLoginData +{ + timeStamp:ISOTimeStamp; + location:LocationData; + userAgent:string; +} + +export class UserData +{ + id:string; + email:string; + hashedPassword:string; + name:string; + lastLogins:UserLoginData[]; + role:string; + permissions:Permission[]; + +} \ No newline at end of file diff --git a/node/users/UserManagementServer.ts b/node/users/UserManagementServer.ts new file mode 100644 index 0000000..f322fe2 --- /dev/null +++ b/node/users/UserManagementServer.ts @@ -0,0 +1,170 @@ +import Fastify, { FastifyInstance } from "fastify"; +import fastifyMultipart from "@fastify/multipart"; +import cors from '@fastify/cors'; + +import { UserDB } from "./UserDB"; +import { RequestHandler } from "./RequestHandler"; +import { EmailService as EmailService } from "./email/EmailService"; +import { TokenDB } from "./TokenDB"; +import { UserManagementServerSettings } from "./UserManagementServerSettings"; +import { RJLog } from "../log/RJLog"; +import { Session } from "./Session"; +import { Role } from "./permissions/Role"; +import { RolesData } from "./permissions/RolesData"; +import { Files } from "../files/Files"; +import { LocationService } from "./location/LocationService"; +import { RequestRequirement } from "./requirements/RequestRequirement"; +import { NotTooManyRequests } from "./requirements/security/NotTooManyRequests"; + +export class UserManagementServer +{ + _app:FastifyInstance; + get app(){ return this._app; } + + _userDB:UserDB; + get userDB(){ return this._userDB; } + + _tokenDB:TokenDB; + get tokenDB(){ return this._tokenDB;} + + _emailService:EmailService; + get email(){ return this._emailService; } + + _locationService:LocationService; + get location(){ return this._locationService; } + + _sessions:Map = new Map(); + _roles = new Map(); + + _handlers:RequestHandler[] = []; + + _settings:UserManagementServerSettings; + _globalRequirements:RequestRequirement[] = []; + get globalRequirements(){ return this._globalRequirements; } + + + async initialize( settings:UserManagementServerSettings, mailService:EmailService, handlers:RequestHandler[], globalRequirements:RequestRequirement[] = null ):Promise + { + this._settings = settings; + + UserManagementServerSettings.makeAllPathsAbsolute( settings ); + + await this._initializeApp(); + await this._addGlobalRequirements( globalRequirements || UserManagementServer.DefaultGlobalRequirements() ); + await this._addServices( mailService ); + await this._addHandlers( handlers ); + + await this._startServer(); + + return Promise.resolve(); + + } + + static DefaultGlobalRequirements():RequestRequirement[] + { + let globalDefaults:RequestRequirement[] = + [ + new NotTooManyRequests() + ]; + + return globalDefaults; + } + + async _addGlobalRequirements( globalRequirements:RequestRequirement[] ):Promise + { + this._globalRequirements = globalRequirements; + + for ( let gr of this._globalRequirements ) + { + await gr.initializeGlobal( this ); + } + + return Promise.resolve(); + } + + async sendEmail( to:string, title:string, message:string ):Promise + { + return this.email.send( this._settings.emailFrom, to, title, message ); + } + + async _initializeApp():Promise + { + + this._app = Fastify(); + this._app.register( fastifyMultipart ); + + for ( let corsURL of this._settings.corsURLs ) + { + RJLog.log( "Adding cors:", corsURL ); + + await this._app.register( cors, + { + origin: corsURL, + credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'] + } + ); + } + + return Promise.resolve(); + } + + async _addServices( mailService:EmailService ):Promise + { + this._userDB = await UserDB.load( this, this._settings.userDBPath ); + + let rolesData = await Files.loadJSON( this._settings.rolesPath ); + this._roles = new Map(); + rolesData.roles.forEach( r => this._roles.set( r.id, r ) ); + + this._tokenDB = new TokenDB( this ); + this._emailService = mailService; + + this._locationService = await LocationService.create( + this._settings.geoLocationPath, + this._settings.geoAccountID, + this._settings.geoLicenseKey + ); + + let ipToCheck = "2a02:3100:25e5:2500:65d7:61b7:33f7:9d7f"; + let location = await this._locationService.getLocation( ipToCheck ); + + RJLog.log( "IP", ipToCheck, "Location:", location ); + + return Promise.resolve(); + } + + async _addHandlers( handlers:RequestHandler[] ):Promise + { + + + for ( let handler of handlers ) + { + await handler.initialize( this ); + this._handlers.push( handler ); + } + + return Promise.resolve(); + } + + async _startServer() + { + this.app.listen( + + { port: this._settings.port }, + + ( error, address ) => + { + if ( error ) + { + RJLog.error( error ); + throw error; + } + + RJLog.log( `Server running on ${address}` ); + } + + ); + } + +} \ No newline at end of file diff --git a/node/users/UserManagementServerSettings.ts b/node/users/UserManagementServerSettings.ts new file mode 100644 index 0000000..dedccfb --- /dev/null +++ b/node/users/UserManagementServerSettings.ts @@ -0,0 +1,45 @@ +import { RegExpUtility } from "../../browser/text/RegExpUtitlity"; + +export class UserManagementServerSettings +{ + // Server settings + url:string; + port:number = 8084; + corsURLs:string[] = []; + + // Paths + rootPath:string; + userDBPath:string; + rolesPath:string; + + // Geo-Lite Lib + geoLocationPath:string; + geoAccountID:string; + geoLicenseKey:string; + + // Email + emailFrom:string; + + // User settings + maxLogins:number = 5; + + + static makeAllPathsAbsolute( settings:UserManagementServerSettings ) + { + settings.userDBPath = this._makePathAbsolute( settings.userDBPath, settings.rootPath ); + settings.rolesPath = this._makePathAbsolute( settings.rolesPath, settings.rootPath ); + settings.geoLocationPath = this._makePathAbsolute( settings.geoLocationPath, settings.rootPath ); + } + + protected static _makePathAbsolute( path:string, rootPath:string ) + { + if ( path.indexOf( rootPath ) === 0 ) + { + return path; + } + + return RegExpUtility.joinPaths( [ rootPath, path ] ); + } + +} + diff --git a/node/users/email/DebugEmail.ts b/node/users/email/DebugEmail.ts new file mode 100644 index 0000000..9b48f33 --- /dev/null +++ b/node/users/email/DebugEmail.ts @@ -0,0 +1,26 @@ +import { DateHelper } from "../../../browser/date/DateHelper"; +import { DateFormatter } from "../../../browser/date/DateFormatter"; +import { ISOTimeStamp } from "../../../browser/date/ISOTimeStamp"; +import { Files } from "../../files/Files"; +import { PathReference } from "../../files/PathReference"; +import { EmailService } from "./EmailService"; + +export class DebugEmail extends EmailService +{ + path:string; + + async send( from:string, to:string, title:string, message:string ) + { + let userName = Files.escapePathFragment( to ); + let dateInfo = DateFormatter.YMD_HMS( DateHelper.now() ); + let emailFileName = Files.escapePathFragment( dateInfo + " - " + title ); + + let userPath = new PathReference( this.path ).createRelative( userName ); + + let titlePath = userPath.createRelative( emailFileName + ".html"); + + await Files.ensureParentDirectoryExists( titlePath.absolutePath ); + + titlePath.saveUTF8( message ); + } +} \ No newline at end of file diff --git a/node/users/email/EmailService.ts b/node/users/email/EmailService.ts new file mode 100644 index 0000000..c9fcb7a --- /dev/null +++ b/node/users/email/EmailService.ts @@ -0,0 +1,5 @@ +export abstract class EmailService +{ + abstract send( from:string, to:string, title:string, message:string ):Promise; + +} \ No newline at end of file diff --git a/node/users/handlers/confirm-signup.ts b/node/users/handlers/confirm-signup.ts new file mode 100644 index 0000000..5885480 --- /dev/null +++ b/node/users/handlers/confirm-signup.ts @@ -0,0 +1,32 @@ +import { CryptIO } from "../../crypt/CryptIO"; +import { RJLog } from "../../log/RJLog"; +import { RequestHandler, RequestType } from "../RequestHandler"; +import { FastifyRequest, FastifyReply } from 'fastify'; + +export class ConfirmSignUpHandler extends RequestHandler +{ + static url = "/confirm-signup"; + constructor(){ super( RequestType.GET, ConfirmSignUpHandler.url ); } + + async _handle( request:FastifyRequest, reply:FastifyReply ) + { + const { id, token } = request.query as { id?: string; token?: string }; + + if ( ! id || ! token ) + { + return this.sendError( "Missing id or token" ); + } + + let result = await this.userDB.confirmUserSignUp( id, token, this.ip ); + + if ( ! result ) + { + return this.sendError( "User signup confimration failed" ); + } + + return this.sendInfo( "User creation confirmed" ); + } + + + +} \ No newline at end of file diff --git a/node/users/handlers/info.ts b/node/users/handlers/info.ts new file mode 100644 index 0000000..294a4a4 --- /dev/null +++ b/node/users/handlers/info.ts @@ -0,0 +1,63 @@ +import { RegExpUtility } from "../../../browser/text/RegExpUtitlity"; +import { CryptIO } from "../../crypt/CryptIO"; +import { RJLog } from "../../log/RJLog"; +import { RequestHandler, RequestType } from "../RequestHandler"; +import { FastifyRequest, FastifyReply } from 'fastify'; +import { VariableReplacer, Variables } from "../../../browser/text/replacing/VariableReplacer"; +import { ConfirmSignUpHandler } from "./confirm-signup"; +import { Session } from "../Session"; +import { LocationData } from "../location/LocationData"; +import { ISOTimeStamp } from "../../../browser/date/ISOTimeStamp"; +import { DateFormatter } from "../../../browser/date/DateFormatter"; +import { UAParser } from "ua-parser-js"; +import { UserIsLoggedIn } from "../requirements/user/UserIsLoggedIn"; + +export class UserLoginDataInfo +{ + time:string; + location:LocationData; + app:string; + os:string; + deviceType:string; +} + + +export class InfoHandler extends RequestHandler +{ + static url = "/info"; + constructor() + { + super( RequestType.POST, InfoHandler.url, [ new UserIsLoggedIn() ] ); + } + + async _handle( request:FastifyRequest, reply:FastifyReply ) + { + let requestBody = JSON.parse( request.body as string ); + let tokenData = requestBody as { token:string }; + let tokenID = tokenData.token; + + let session = this._ums._sessions.get( tokenID ); + let user = await this._ums.userDB.byID( session.userID ); + + let lastLogins = user.lastLogins.map( + ( ld )=> + { + let ui = new UserLoginDataInfo(); + ui.time = DateFormatter.forUsers( ISOTimeStamp.toDate( ld.timeStamp ) ); + ui.location = ld.location; + + let parser = new UAParser( ld.userAgent ); + let result = parser.getResult(); + ui.app = result?.browser?.name || "-"; + ui.os = result?.os?.name || "-"; + ui.deviceType = result?.device?.type || "desktop"; + + return ui; + } + ); + + let info = { name:user.name, email:user.email, lastLogins:lastLogins }; + + return this.sendDataInfo( "User Info", info ); + } +} \ No newline at end of file diff --git a/node/users/handlers/login.ts b/node/users/handlers/login.ts new file mode 100644 index 0000000..90574e3 --- /dev/null +++ b/node/users/handlers/login.ts @@ -0,0 +1,61 @@ +import { RegExpUtility } from "../../../browser/text/RegExpUtitlity"; +import { CryptIO } from "../../crypt/CryptIO"; +import { RJLog } from "../../log/RJLog"; +import { RequestHandler, RequestType } from "../RequestHandler"; +import { FastifyRequest, FastifyReply } from 'fastify'; +import { VariableReplacer, Variables } from "../../../browser/text/replacing/VariableReplacer"; +import { ConfirmSignUpHandler } from "./confirm-signup"; +import { Session } from "../Session"; +import { UserLoginData } from "../UserData"; +import { ISOTimeStamp } from "../../../browser/date/ISOTimeStamp"; +import { Arrays } from "../../../browser/tools/Arrays"; + +export class LoginHandler extends RequestHandler +{ + static url = "/login"; + constructor(){ super( RequestType.POST, LoginHandler.url ); } + + async _handle( request:FastifyRequest, reply:FastifyReply ) + { + let requestBody = JSON.parse( request.body as string ); + let { email, password, userName } = requestBody as { email: string; password: string; userName: string }; + + if ( ! email || ! password ) + { + return this.sendError( "Missing email or password:" + `"${requestBody}"` ); + } + + let loginToken = await this.userDB.login( email, password, this.ip ); + + if ( ! loginToken ) + { + return this.sendError( "Login failed" ); + } + + let user = await this.userDB.byEmail( email ); + + let loginData = new UserLoginData(); + loginData.timeStamp = ISOTimeStamp.now(); + loginData.location = await this.getLocation(); + loginData.userAgent = this.userAgent; + + user.lastLogins = user.lastLogins || []; + user.lastLogins.push( loginData ); + + Arrays.shiftToSize( user.lastLogins, this._ums._settings.maxLogins ); + + + let session = new Session(); + session.token = loginToken.id; + session.userID = user.id; + + this._ums._sessions.set( session.token, session ); + + await this.userDB.save(); + + + return this.sendDataInfo( "Login successfull", { token: session.token } ); + } + + +} \ No newline at end of file diff --git a/node/users/handlers/logout.ts b/node/users/handlers/logout.ts new file mode 100644 index 0000000..933479a --- /dev/null +++ b/node/users/handlers/logout.ts @@ -0,0 +1,32 @@ +import { RegExpUtility } from "../../../browser/text/RegExpUtitlity"; +import { CryptIO } from "../../crypt/CryptIO"; +import { RJLog } from "../../log/RJLog"; +import { RequestHandler, RequestType } from "../RequestHandler"; +import { FastifyRequest, FastifyReply } from 'fastify'; +import { VariableReplacer, Variables } from "../../../browser/text/replacing/VariableReplacer"; +import { ConfirmSignUpHandler } from "./confirm-signup"; +import { Session } from "../Session"; +import { UserIsLoggedIn } from "../requirements/user/UserIsLoggedIn"; + +export class LogoutHandler extends RequestHandler +{ + static url = "/logout"; + constructor() + { + super( RequestType.POST, LogoutHandler.url, [ new UserIsLoggedIn() ] ); + } + + async _handle( request:FastifyRequest, reply:FastifyReply ) + { + let requestBody = JSON.parse( request.body as string ); + let tokenData = requestBody as { token:string }; + let tokenID = tokenData.token; + + this._ums._sessions.delete( tokenID ); + this._ums.tokenDB._tokens.delete( tokenID ); + + return this.sendInfo( "Logout successfull" ); + } + + +} \ No newline at end of file diff --git a/node/users/handlers/signup.ts b/node/users/handlers/signup.ts new file mode 100644 index 0000000..aa227d2 --- /dev/null +++ b/node/users/handlers/signup.ts @@ -0,0 +1,89 @@ +import { RegExpUtility } from "../../../browser/text/RegExpUtitlity"; +import { CryptIO } from "../../crypt/CryptIO"; +import { RJLog } from "../../log/RJLog"; +import { RequestHandler, RequestType } from "../RequestHandler"; +import { FastifyRequest, FastifyReply } from 'fastify'; +import { VariableReplacer, Variables } from "../../../browser/text/replacing/VariableReplacer"; +import { ConfirmSignUpHandler } from "./confirm-signup"; + +export class SignUpHandler extends RequestHandler +{ + static url = "/signup"; + constructor(){ super( RequestType.POST, SignUpHandler.url ); } + + static USER_NAME = "USER_NAME"; + static CONFIRMATION_LINK = "CONFIRMATION_LINK"; + + static EMAIL_TITLE = "You signed up"; + static EMAIL_MESSAGE = + ` + Hi {{${SignUpHandler.USER_NAME}}}! +
+ Please confirm your signup by clicking the link: +
+ SIGN UP CONFIRMATION +
+
+ Cheers! + + ` + + async _handle( request:FastifyRequest, reply:FastifyReply ) + { + let requestBody = JSON.parse( request.body as string ); + let { email, password, userName } = requestBody as { email: string; password: string; userName: string }; + + if ( ! email || ! password ) + { + return this.sendError( "Missing email or password:" + `"${requestBody}"` ); + } + + let emailInUse = await this.userDB.hasUserWithEmail( email ); + + if ( emailInUse ) + { + return this.sendError( "User already exists" ); + } + + let userData = await this.userDB.byEmail( email ); + + if ( userData == null ) + { + userData = await this.userDB.signUp( email, password, userName ); + } + + let token = await this.userDB.createSignUpConfirmation( userData, this.ip ); + + let confirmationURL = this._ums._settings.url + ConfirmSignUpHandler.url; + + let id = userData.id; + + let link = `${confirmationURL}?id=${id}&token=${token.id}`; + + await this.sendEmail( email, userName, link ); + + + return this.sendInfo( "User created, email sent" ); + } + + async sendEmail( email:string, userName:string, link:string ):Promise + { + + let variables:Variables = { + [ SignUpHandler.USER_NAME ] : userName, + [ SignUpHandler.CONFIRMATION_LINK ] : link + }; + + let message = VariableReplacer.replace( SignUpHandler.EMAIL_MESSAGE, variables, "{{", "}}" ); + let title = VariableReplacer.replace( SignUpHandler.EMAIL_TITLE, variables, "{{", "}}" ); + + await this._ums.sendEmail( email,title, message ); + + return Promise.resolve(); + } + + + + + +} \ No newline at end of file diff --git a/node/users/location/LocationData.ts b/node/users/location/LocationData.ts new file mode 100644 index 0000000..9b23293 --- /dev/null +++ b/node/users/location/LocationData.ts @@ -0,0 +1,6 @@ +export class LocationData +{ + country:string; + city:string; + km_range:number; +} \ No newline at end of file diff --git a/node/users/location/LocationService.ts b/node/users/location/LocationService.ts new file mode 100644 index 0000000..5fe916e --- /dev/null +++ b/node/users/location/LocationService.ts @@ -0,0 +1,191 @@ +import { Files } from "../../files/Files"; +import { LocationData } from "./LocationData"; +import * as https from "https"; +import * as zlib from "zlib"; +import * as tar from "tar"; +import maxmind, { CityResponse, Reader, Response } from "maxmind"; +import { RJLog } from "../../log/RJLog"; + +export class LocationService +{ + _dbPath:string; + _accountID:string; + _licenseKey:string; + _url:string = "https://download.maxmind.com/geoip/databases/GeoLite2-City/download?suffix=tar.gz"; + _lookUpDB:Reader; + + static async create( path:string, accountID:string, licenseKey:string, url = null ):Promise + { + + + let ipToL = new LocationService(); + ipToL._dbPath = path; + ipToL._accountID = accountID; + ipToL._licenseKey = licenseKey; + + if ( url ) + { + ipToL._url = url; + } + + RJLog.log( "Starting Location Service", ipToL._dbPath, ipToL._accountID, ipToL._licenseKey, ipToL._url ); + + await ipToL.update(); + + if ( ipToL._lookUpDB == null ) + { + await ipToL._loadDB(); + } + + return ipToL; + } + + async update( forceUpdate:boolean = false ):Promise + { + let needsUpdate = forceUpdate || ( await this._needsUpdate() ); + + if ( ! needsUpdate ) + { + return Promise.resolve(); + } + + try + { + await this._downloadDB(); + await this._loadDB(); + } + catch( e ) + { + RJLog.error( "Updating failed:", e ); + } + + return Promise.resolve(); + + } + + async _loadDB() + { + this._lookUpDB = await maxmind.open( this._dbPath ); + } + + async _needsUpdate( maxAgeDays = 7 ) + { + try + { + let stats = await Files.getStatistics( this._dbPath ); + let ageDays = ( Date.now() - stats.mtimeMs ) / (1000 * 60 * 60 * 24); + + return Promise.resolve( ageDays > maxAgeDays ); + } + catch ( error ) + { + return Promise.resolve( true ); + } + } + + async _downloadDB() + { + RJLog.log( "Downloading DB" ); + + + await Files.ensureParentDirectoryExists( this._dbPath ); + + let authenticification = `${this._accountID}:${this._licenseKey}`; + + let promise = new Promise( + + ( resolve, reject ) => + { + let sendRequest = ( url:string, writeAuthHeader:boolean ) => + { + let options:any = { + headers: + { + "Accept": "application/gzip" + } + }; + + if ( writeAuthHeader ) + { + let authHeader = "Basic " + Buffer.from( authenticification ).toString( "base64" ); + + options = + { + headers: + { + "Authorization": authHeader, + "Accept": "application/gzip" + } + }; + } + + + RJLog.log( "Downloading at ", url, options ); + + https.get( url, options, + + ( result ) => + { + + if ( result.statusCode === 302 || result.statusCode == 301 ) + { + let redirectUrl = result.headers.location; + console.log(`Redirecting to ${redirectUrl}`); + result.resume(); + return sendRequest( redirectUrl, false ); + } + + if ( result.statusCode !== 200 ) + { + RJLog.log( `Location db download failed: ${result.statusCode} ${result.statusMessage}` ); + reject( new Error( `Location db download failed: ${result.statusCode} ${result.statusMessage}` ) ); + return; + } + + // pipe through gunzip then tar extractor, writing .mmdb to DB_DIR + let gunzip = zlib.createGunzip(); + let extractor = tar.x( + { + cwd: Files.parentPath( this._dbPath ), + filter: ( tarPath ) => tarPath.endsWith( ".mmdb" ), + strip: 1 + } + ); + + result.pipe( gunzip ).pipe( extractor ); + + extractor.on( "finish", () => resolve()); + extractor.on( "error", ( error ) => reject( error ) ); + result.on( "error", ( error ) => reject( error ) ); + } + ).on("error", (error) => reject(error)); + } + + sendRequest( this._url, true ); + + + } + ); + + return promise; + } + + async getLocation( ip:string ):Promise + { + let locationData = new LocationData(); + locationData.country = "Unknown"; + locationData.city = "Unknown"; + locationData.km_range = 20000; + + if ( this._lookUpDB !== null ) + { + let geo = this._lookUpDB.get( ip ); + locationData.country = geo?.country?.names?.en ?? "Unknown"; + locationData.city = geo?.city?.names?.en ?? "Unknown"; + locationData.km_range = geo?.location?.accuracy_radius ?? 20000; + } + + return locationData; + } + +} \ No newline at end of file diff --git a/node/users/permissions/Permission.ts b/node/users/permissions/Permission.ts new file mode 100644 index 0000000..bfabba2 --- /dev/null +++ b/node/users/permissions/Permission.ts @@ -0,0 +1,31 @@ +import { DateMath } from "../../../browser/date/DateMath"; +import { ISOTimeStamp } from "../../../browser/date/ISOTimeStamp"; + +export class Permission +{ + id:string; + by:string; + granted:ISOTimeStamp; + expires:ISOTimeStamp; + revokableBy:string[]; + + + static isMatching( permissionID:string, permission:Permission ) + { + return permission.id === permissionID && Permission.isValid( permission ); + } + + static isValid( permission:Permission ) + { + let isExpired = true; + + let expiresInfo = permission.expires; + + if ( ! expiresInfo || DateMath.isExpired( ISOTimeStamp.toDate( expiresInfo ) ) ) + { + isExpired = false; + } + + return ! isExpired; + } +} \ No newline at end of file diff --git a/node/users/permissions/Role.ts b/node/users/permissions/Role.ts new file mode 100644 index 0000000..5ee4897 --- /dev/null +++ b/node/users/permissions/Role.ts @@ -0,0 +1,8 @@ +import { Permission } from "./Permission"; + +export class Role +{ + id:string; + inherits:string; + permissions:Permission[]; +} \ No newline at end of file diff --git a/node/users/permissions/RolesData.ts b/node/users/permissions/RolesData.ts new file mode 100644 index 0000000..afb08a4 --- /dev/null +++ b/node/users/permissions/RolesData.ts @@ -0,0 +1,6 @@ +import { Role } from "./Role"; + +export class RolesData +{ + roles:Role[]; +} \ No newline at end of file diff --git a/node/users/requirements/RequestRequirement.ts b/node/users/requirements/RequestRequirement.ts new file mode 100644 index 0000000..8880c66 --- /dev/null +++ b/node/users/requirements/RequestRequirement.ts @@ -0,0 +1,29 @@ +import { RequestHandler } from "../RequestHandler"; +import { FastifyRequest, FastifyReply } from 'fastify'; +import { UserManagementServer } from "../UserManagementServer"; + +export abstract class RequestRequirement +{ + _isGlobal:boolean = false; + _ums:UserManagementServer; + _handler:RequestHandler = null; + + + initialize( handler:RequestHandler ):Promise + { + this._isGlobal = false; + this._handler = handler; + return Promise.resolve(); + } + + initializeGlobal( ums:UserManagementServer ):Promise + { + this._isGlobal = true; + this._ums = ums; + return Promise.resolve(); + } + + + abstract handle( request:FastifyRequest, reply:FastifyReply ):Promise + +} \ No newline at end of file diff --git a/node/users/requirements/security/NotTooManyRequests.ts b/node/users/requirements/security/NotTooManyRequests.ts new file mode 100644 index 0000000..5672e77 --- /dev/null +++ b/node/users/requirements/security/NotTooManyRequests.ts @@ -0,0 +1,71 @@ +import { FastifyReply, FastifyRequest } from "fastify"; +import { RequestRequirement } from "../RequestRequirement"; +import { UserManagementServer } from "../../UserManagementServer"; +import { MapList } from "../../../../browser/tools/MapList"; +import { DateHelper } from "../../../../browser/date/DateHelper"; +import { Duration } from "../../../../browser/date/Duration"; +import { DateMath } from "../../../../browser/date/DateMath"; + + +export class NotTooManyRequests extends RequestRequirement +{ + _trackedIPs = new Map(); + _maxRequests = 50; + _duration = Duration.fromMinutes( 2 ); + + _watchList = new Set(); + _blockList = new Map() + _blockDuration = Duration.fromHours( 4 ); + + async handle( request:FastifyRequest, reply:FastifyReply ):Promise + { + if ( this._blockList.has( request.ip ) ) + { + let blockTime = this._blockList.get( request.ip ); + + if ( ! DateMath.isExpired( new Date( blockTime + this._blockDuration ) ) ) + { + return Promise.resolve( false ); + } + } + + let valid = this.updateIP( request.ip ); + + return Promise.resolve( valid ); + } + + async updateIP( ip:string ):Promise + { + MapList.add( this._trackedIPs, ip, DateHelper.nowMS() ); + MapList.shiftToSize( this._trackedIPs, ip, this._maxRequests ); + + let lastRelevantDate = DateMath.addSeconds( DateHelper.now(), -this._duration ); + + let timeStamps = this._trackedIPs.get( ip ); + let filteredStamps = timeStamps.filter( ts => DateMath.isAfter( lastRelevantDate, new Date( ts ) ) ); + + + if ( filteredStamps.length < this._maxRequests ) + { + this._trackedIPs.set( ip, filteredStamps ); + return Promise.resolve( true ); + } + + this._trackedIPs.delete( ip ); + + if ( this._watchList.has( ip ) ) + { + this._blockList.set( ip, DateHelper.nowMS() ); + this._watchList.delete( ip ); + } + else + { + this._watchList.add( ip ); + } + + return Promise.resolve( false ); + + } + + +} \ No newline at end of file diff --git a/node/users/requirements/user/UserHasPermission.ts b/node/users/requirements/user/UserHasPermission.ts new file mode 100644 index 0000000..847f2b8 --- /dev/null +++ b/node/users/requirements/user/UserHasPermission.ts @@ -0,0 +1,23 @@ + +import { FastifyRequest, FastifyReply } from 'fastify'; +import { RequestRequirement } from '../RequestRequirement'; + +export class UserHasPermission extends RequestRequirement +{ + private _permissionID:string = ""; + + constructor( permissionID:string ) + { + super(); + this._permissionID = permissionID; + } + + async handle( request:FastifyRequest, reply:FastifyReply ):Promise + { + let user = await this._handler.getUser(); + + let userDB = this._handler.userDB; + + return userDB.hasPermission( user, this._permissionID ); + } +} \ No newline at end of file diff --git a/node/users/requirements/user/UserIsLoggedIn.ts b/node/users/requirements/user/UserIsLoggedIn.ts new file mode 100644 index 0000000..059d3cb --- /dev/null +++ b/node/users/requirements/user/UserIsLoggedIn.ts @@ -0,0 +1,38 @@ + +import { FastifyRequest, FastifyReply } from 'fastify'; +import { RequestRequirement } from '../RequestRequirement'; + +export class UserIsLoggedIn extends RequestRequirement +{ + + async handle( request:FastifyRequest, reply:FastifyReply ):Promise + { + let requestBody = JSON.parse( request.body as string ); + let tokenData = requestBody as { token:string }; + + if ( ! tokenData ) + { + return Promise.resolve( false ); + } + + let tokenID = tokenData.token; + + let session = this._handler._ums._sessions.get( tokenID ); + + if ( ! session ) + { + return Promise.resolve( false ); + } + + let token = this._handler._ums.tokenDB._tokens.get( tokenID ); + + let isValid = await this._handler._ums.tokenDB.validate( token, request.ip ); + + if ( ! isValid ) + { + return Promise.resolve( false ); + } + + return Promise.resolve( true ); + } +}