User Server Udpate

This commit is contained in:
Josef 2025-11-10 18:41:48 +01:00
parent 185e004839
commit b3fc54ded2
42 changed files with 1947 additions and 5 deletions

View File

@ -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}`;
}
}

View File

@ -7,6 +7,11 @@ export class DateHelper
return date;
}
static nowMS()
{
return this.now().getTime();
}
static today()
{
let date = new Date();

View File

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

32
browser/date/Duration.ts Normal file
View File

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

View File

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

View File

@ -202,4 +202,9 @@ export class ClassFlag
ClassFlag.setClass( element, classString, ! hasClassAssigned );
}
static queryElement( element:Element, classNAme:string )
{
return new ClassFlag( classNAme ).query( element );
}
}

View File

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

View File

@ -0,0 +1,6 @@
import { Message } from "./Message";
export class DataMessage<T> extends Message
{
data:T;
}

14
browser/text/TextTool.ts Normal file
View File

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

View File

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

View File

@ -128,6 +128,14 @@ export class Arrays
Arrays.insert( array, element, 0 );
}
static shiftToSize<T>( array:T[], maxSize:number )
{
while ( array.length > maxSize )
{
array.shift();
}
}
static remove<T>( array:T[], element:T )
{
if ( ! array || ! element )

View File

@ -1,3 +1,5 @@
import { Arrays } from "./Arrays";
export class MapList
{
static add<K,V>( map:Map<K,V[]>, k:K, v:V )
@ -10,5 +12,16 @@ export class MapList
map.get( k ).push( v );
}
static shiftToSize<K,V>( map:Map<K,V[]>, k:K, num:number )
{
if ( ! map.has( k ) )
{
return;
}
let array = map.get( k );
Arrays.shiftToSize( array, num );
}
}

43
browser/xhttp/Request.ts Normal file
View File

@ -0,0 +1,43 @@
export class Request
{
static post<I,O>( url:string, input:I ):Promise<O>
{
let promise = new Promise<O>
(
( 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;
}
}

View File

@ -0,0 +1,5 @@
export class CryptContainer
{
key:string;
data:string;
}

221
node/crypt/CryptIO.ts Normal file
View File

@ -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<string>
{
let hash = await bcrypt.hash( data, 10 );
return Promise.resolve( hash );
}
static async verifyHash( data:string, hashed:string ):Promise<boolean>
{
let isVerified = await bcrypt.compare( data, hashed );
return Promise.resolve( isVerified );
}
static async encrypt( data:string, settings?:CryptSettings ):Promise<string>
{
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<T>( parentPath:string, data:any, onUUID?:( uuid:string, data:T ) => T ):Promise<string>
{
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<T>( parentPath:string, uuid:string ):Promise<T>
{
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<T>( fullPath );
return data;
}
static async deleteWithRandomUUID( parentPath:string, uuid:string ):Promise<boolean>
{
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<void>
{
let fullPath = path.join( parentPath, uuid + this.encryptionSuffix );
await this.saveJSON( fullPath, data );
return Promise.resolve();
}
static async saveJSON( path:string, data:any ):Promise<void>
{
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<void>
{
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<T>( path:string ):Promise<T>
{
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<string>
{
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 );
}
}

View File

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

View File

@ -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<boolean> = null, action:(p:PathReference)=>Promise<void> = null ):Promise<PathReference[]>
{
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<T>( filePath:string, data:T ):Promise<boolean>
static async saveJSON<T>( filePath:string, data:T, pretty:boolean = true ):Promise<boolean>
{
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 );

View File

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

View File

@ -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<void>
{
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<void>
{
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<void>
{
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<void>
{
return Promise.resolve();
};
protected abstract _handle( request:FastifyRequest, reply:FastifyReply ):Promise<void>;
get ip()
{
return this._currentRequest.ip;
}
get userAgent()
{
return this._currentRequest.headers[ "user-agent" ];
}
getLocation():Promise<LocationData>
{
return this._ums.location.getLocation( this.ip );
}
protected sendJSON( obj:any ):Promise<void>
{
this._currentReply.send( obj );
return Promise.resolve();
}
getUser():Promise<UserData>
{
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<void>
{
RJLog.log( info );
return this.sendJSON( Message.Info( info ) );
}
protected sendError( error:string, with400ErrorCode:boolean = true ):Promise<void>
{
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 );
}
}

5
node/users/Session.ts Normal file
View File

@ -0,0 +1,5 @@
export class Session
{
userID:string;
token:string;
}

8
node/users/Token.ts Normal file
View File

@ -0,0 +1,8 @@
import { ISOTimeStamp } from "../../browser/date/ISOTimeStamp";
export class Token
{
id:string;
expires:ISOTimeStamp;
hashedIP:string;
}

60
node/users/TokenDB.ts Normal file
View File

@ -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<string,Token>();
constructor( ums:UserManagementServer )
{
this._ums = ums;
}
async update()
{
}
async create( ip:string, expireDurationInMinutes:number = -1 ):Promise<Token>
{
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<boolean>
{
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 );
}
}

211
node/users/UserDB.ts Normal file
View File

@ -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<string,Token>();
async hasUserWithEmail( email:string ):Promise<boolean>
{
return this.users.findIndex( u => u.email === email ) != -1;
}
async signUp( email:string, password:string, userName:string = "User" ):Promise<UserData>
{
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<Token>
{
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<boolean>
{
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<Token>
{
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<boolean>
{
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<UserDB>
{
let userDB = new UserDB();
userDB._ums = ums;
userDB._path = path;
let userDBExists = await Files.exists( userDB._path );
if ( userDBExists )
{
let data = await Files.loadJSON<SerializedUserDB>( userDB._path );
userDB.users = data.users;
}
return Promise.resolve( userDB );
}
}

25
node/users/UserData.ts Normal file
View File

@ -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[];
}

View File

@ -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<string,Session> = new Map<string,Session>();
_roles = new Map<string,Role>();
_handlers:RequestHandler[] = [];
_settings:UserManagementServerSettings;
_globalRequirements:RequestRequirement[] = [];
get globalRequirements(){ return this._globalRequirements; }
async initialize( settings:UserManagementServerSettings, mailService:EmailService, handlers:RequestHandler[], globalRequirements:RequestRequirement[] = null ):Promise<void>
{
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<void>
{
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<void>
{
return this.email.send( this._settings.emailFrom, to, title, message );
}
async _initializeApp():Promise<void>
{
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<void>
{
this._userDB = await UserDB.load( this, this._settings.userDBPath );
let rolesData = await Files.loadJSON<RolesData>( this._settings.rolesPath );
this._roles = new Map<string,Role>();
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<void>
{
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}` );
}
);
}
}

View File

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

View File

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

View File

@ -0,0 +1,5 @@
export abstract class EmailService
{
abstract send( from:string, to:string, title:string, message:string ):Promise<void>;
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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 =
`
<b>Hi {{${SignUpHandler.USER_NAME}}}!</b>
<br>
Please confirm your signup by clicking the link:
<br>
<a href="{{${SignUpHandler.CONFIRMATION_LINK}}}">SIGN UP CONFIRMATION</a>
<br>
<br>
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<void>
{
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();
}
}

View File

@ -0,0 +1,6 @@
export class LocationData
{
country:string;
city:string;
km_range:number;
}

View File

@ -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<CityResponse>;
static async create( path:string, accountID:string, licenseKey:string, url = null ):Promise<LocationService>
{
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<void>
{
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<void>(
( 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<LocationData>
{
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;
}
}

View File

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

View File

@ -0,0 +1,8 @@
import { Permission } from "./Permission";
export class Role
{
id:string;
inherits:string;
permissions:Permission[];
}

View File

@ -0,0 +1,6 @@
import { Role } from "./Role";
export class RolesData
{
roles:Role[];
}

View File

@ -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<void>
{
this._isGlobal = false;
this._handler = handler;
return Promise.resolve();
}
initializeGlobal( ums:UserManagementServer ):Promise<void>
{
this._isGlobal = true;
this._ums = ums;
return Promise.resolve();
}
abstract handle( request:FastifyRequest, reply:FastifyReply ):Promise<boolean>
}

View File

@ -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<string,number[]>();
_maxRequests = 50;
_duration = Duration.fromMinutes( 2 );
_watchList = new Set<string>();
_blockList = new Map<string,number>()
_blockDuration = Duration.fromHours( 4 );
async handle( request:FastifyRequest, reply:FastifyReply ):Promise<boolean>
{
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<boolean>
{
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 );
}
}

View File

@ -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<boolean>
{
let user = await this._handler.getUser();
let userDB = this._handler.userDB;
return userDB.hasPermission( user, this._permissionID );
}
}

View File

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