/* Espe | Ein schlichtes Werkzeug zur Mitglieder-Verwaltung | Backend Copyright (C) 2024 Christian Fraß This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ namespace _espe.service.member { /** */ var _hooks_change : Array<() => void> = []; /** */ export function listen_change( procedure : () => void ) : void { _hooks_change.push(procedure); } /** */ function signal_change( ) : void { _hooks_change.forEach( procedure => { procedure(); } ); } /** */ function validate_password( password : string ) : Array<{incident : string; details : Record}> { return _espe.helper.password.validate( _espe.conf.get().settings.password_policy, password ); } /** */ function generate_password( ) : string { return _espe.helper.password.generate( _espe.conf.get().settings.password_policy ); } /** * ermittelt den Anmelde-Name des Mitglieds */ export function name_login( object : _espe.type.member_object ) : string { return lib_plankton.string.coin( "{{object}}{{extension}}", { "object": lib_plankton.call.convey( object.name_real_value, [ (x : string) => x.toLowerCase(), (x : string) => x.replace(new RegExp(" ", "g"), "."), (x : string) => x.replace(new RegExp("[äÄ]", "g"), "ae"), (x : string) => x.replace(new RegExp("[öÖ]", "g"), "oe"), (x : string) => x.replace(new RegExp("[üÜ]", "g"), "ue"), (x : string) => x.replace(new RegExp("[ß]", "g"), "ss"), (x : string) => x.replace(new RegExp("[^0-9a-z-\.]", "g"), "_"), ] ), "extension": ( (object.name_real_index <= 1) ? "" : ("." + object.name_real_index.toFixed(0)) ), } ); } /** * ermittelt den Anzeige-Name des Mitglieds */ export function name_display( object : _espe.type.member_object ) : string { return object.name_real_value; } /** * ermittelt die verschleierte E-Mail-Adresse des Mitglieds */ export function email_address_veiled( object : _espe.type.member_object ) : (null | string) { return ( (object.membership_number === null) ? null : lib_plankton.string.coin( "{{prefix}}{{membership_number}}@{{domain}}", { "prefix": _espe.conf.get().settings.misc.prefix_for_veiled_email_addresses, "membership_number": object.membership_number, "domain": _espe.conf.get().settings.organisation.domain, } ) ); } /** * ermittelt die namentliche E-Mail-Adresse des Mitglieds */ export function email_address_nominal( object : _espe.type.member_object ) : string { return lib_plankton.string.coin( "{{user}}@{{domain}}", { "user": name_login(object), "domain": _espe.conf.get().settings.organisation.domain, } ); } /** * ermittelt die allgemein zu verwendende E-Mail-Adresse des Mitglieds */ export function email_address( object : _espe.type.member_object ) : (null | string) { return ( object.email_use_nominal_address ? email_address_nominal(object) : ( object.email_use_veiled_address ? email_address_veiled(object) : object.email_address_private ) ); } /** * ermittelt das Passwort-Abbild anhand des tatsächlichen Passworts */ function password_image( password : (null | string) ) : Promise<(null | string)> { return ( ( (! (password === null)) && (! (password === "")) ) ? _espe.helpers.bcrypt_compute(password) : Promise.resolve<(null | string)>(null) ); } /** */ async function send_activation_email( member_object : _espe.type.member_object, options : { password ?: (null | string); } = {} ) : Promise { options = Object.assign( { "password": null, }, options ); if (! member_object.enabled) { // do nothing } else { if (member_object.email_address_private === null) { // do nothing } else { await _espe.helpers.email_send( [ member_object.email_address_private, ], lib_plankton.string.coin( "{{head}} | {{core}}", { "head": _espe.conf.get().settings.organisation.name, "core": lib_plankton.translate.get("email.activation.subject"), } ), lib_plankton.string.coin( lib_plankton.translate.get("email.activation.body"), { "name_display": name_display(member_object), "name_login": name_login(member_object), "url": (_espe.conf.get().settings.connections.login_url ?? "--"), "password_info": ( ( (options.password === undefined) || (options.password === null) ) ? "" : lib_plankton.string.coin( lib_plankton.translate.get("email.activation.password_info"), { "password": options.password, } ) ), } ) ); } } } /** * gibt die vollständigen Daten aller Mitglieder aus */ export async function dump( ) : Promise< Array< { id : _espe.type.member_id; object : _espe.type.member_object; } > > { return _espe.repository.member.dump(); } /** * gibt eine Auflistung aller Mitgliedr aus */ export async function list( search_term : (null | string) ) : Promise< Array< { id : _espe.type.member_id; preview : { membership_number : string; name_real_value : string; name_real_index : int; }; } > > { return _espe.repository.member.list(search_term); } /** * gibt die Angaben eines bestimmten Mitglieds aus */ export function get( id : _espe.type.member_id ) : Promise<_espe.type.member_object> { return _espe.repository.member.read(id); } /** * legt ein Mitglied an */ export async function project( data : { membership_number : (null | string); name_real_value : string; email_address_private : (null | string); } ) : Promise<_espe.type.member_id> { const name_real_index : int = await _espe.service.name_index.next(data.name_real_value); const object : _espe.type.member_object = { "membership_number": data.membership_number, "name_real_value": data.name_real_value, "name_real_index": name_real_index, "email_address_private": data.email_address_private, "registered": false, "enabled": true, "email_use_veiled_address": false, "email_use_nominal_address": false, "email_redirect_to_private_address": false, "email_allow_sending": false, "password_image": null, "password_change_last_attempt": null, "password_change_token": null, }; const id : _espe.type.member_id = await _espe.repository.member.create(object); signal_change(); return id; } /** * sendet an ein Mitglied eine E-Mail mit Aufforderung zur Registrierung */ export async function summon( member_id : _espe.type.member_id, url_template : string ) : Promise<(null | string)> { _espe.helpers.frontend_url_check(); const member_object : _espe.type.member_object = await get(member_id); if (member_object.email_address_private === null) { return null; } else { const url : (null | string) = _espe.helpers.frontend_url_get( url_template, { "verification": await _espe.helpers.verification_get(member_id), } ); if (url === null) { // do nothing } else { await _espe.helpers.email_send( [ member_object.email_address_private, ], lib_plankton.string.coin( "{{head}} | {{core}}", { "head": _espe.conf.get().settings.organisation.name, "core": lib_plankton.translate.get("email.summon.subject"), } ), lib_plankton.string.coin( lib_plankton.translate.get("email.summon.body"), { "name": name_display(member_object), "url": url, "remark": ( (_espe.conf.get().settings.summon_email.remark === null) ? "" : (_espe.conf.get().settings.summon_email.remark + "\n\n") ), } ) ); } return url; } } /** * gibt Daten über ein Mitglied aus, die relevant für die Registrierung sind */ export async function info( member_id : _espe.type.member_id ) : Promise< ( null | { name_real_value : string; name_real_index : int; name_login : string; email_address_veiled : (null | string); email_address_nominal : string; } ) > { const member_object : _espe.type.member_object = await _espe.repository.member.read(member_id); if (! member_object.registered) { return { "name_real_value": member_object.name_real_value, "name_real_index": member_object.name_real_index, "name_login": name_login(member_object), "email_address_veiled": email_address_veiled(member_object), "email_address_nominal": email_address_nominal(member_object), }; } else { return null; } } /** * führt die Registrierung für ein Mitglied durch */ export async function register( member_id : _espe.type.member_id, data : { email_use_veiled_address : boolean; email_use_nominal_address : boolean; email_redirect_to_private_address : boolean; password : (null | string); }, options : { notification_target_url_template ?: (null | string); } = {} ) : Promise;}>> { options = Object.assign( { "notification_target_url_template": null, }, options ); const member_object : _espe.type.member_object = await get(member_id); let flaws : Array<{incident : string; details : Record;}> = []; let password_value : string; let password_generated : boolean; if (member_object.registered) { flaws.push({"incident": "already_registered", "details": {}}); password_value = ""; password_generated = false; } else { if ( (data.password !== null) && (data.password !== "") ) { flaws = flaws.concat( validate_password(data.password) .map(flaw => ({"incident": ("password_" + flaw.incident), "details": flaw.details})) ); password_value = data.password; password_generated = false; } else { password_value = generate_password(); password_generated = true; } } if (flaws.length > 0) { // do nothing } else { member_object.email_use_veiled_address = data.email_use_veiled_address; member_object.email_use_nominal_address = data.email_use_nominal_address; member_object.email_redirect_to_private_address = data.email_redirect_to_private_address; member_object.password_image = await password_image(password_value); member_object.registered = true; await _espe.repository.member.update(member_id, member_object); signal_change(); { const url : (null | string) = ( ( (options.notification_target_url_template === undefined) || (options.notification_target_url_template === null) ) ? null : _espe.helpers.frontend_url_get( options.notification_target_url_template, { "id": member_id.toFixed(0), } ) ); /*await*/ _espe.helpers.notify_admins( lib_plankton.string.coin( "{{head}} | {{core}}", { "head": _espe.conf.get().settings.organisation.name, "core": lib_plankton.translate.get("email.registration.subject"), } ), lib_plankton.string.coin( lib_plankton.translate.get("email.registration.body"), { "name_display": name_display(member_object), "url": (url ?? "?"), } ) ); } /*await*/ send_activation_email(member_object, {"password": password_generated ? password_value : null}); } return Promise.resolve(flaws); } /** * ändert bestimmte Daten des Mitglied */ export async function modify( member_id : _espe.type.member_id, data : { email_address_private : (null | string); registered : boolean; enabled : boolean; } ) : Promise { const member_object_old : _espe.type.member_object = await get(member_id); const member_object_new : _espe.type.member_object = { "membership_number": member_object_old.membership_number, "name_real_value": member_object_old.name_real_value, "name_real_index": member_object_old.name_real_index, "email_address_private": data.email_address_private, "registered": data.registered, "enabled": data.enabled, "email_use_veiled_address": member_object_old.email_use_veiled_address, "email_use_nominal_address": member_object_old.email_use_nominal_address, "email_redirect_to_private_address": member_object_old.email_redirect_to_private_address, "email_allow_sending": member_object_old.email_allow_sending, "password_image": member_object_old.password_image, "password_change_last_attempt": member_object_old.password_change_last_attempt, "password_change_token": member_object_old.password_change_token, }; await _espe.repository.member.update(member_id, member_object_new); signal_change(); /*await*/ send_activation_email(member_object_new); } /** */ export async function remove( id : _espe.type.member_id ) : Promise { await _espe.repository.member.delete_(id); signal_change(); } /** * bereitet eine Passwort-Rücksetzung für Mitglieder vor * * @todo Zwangs-Pause falls Abklingzeit noch nicht vorbei um vorzugaukeln, dass es geklappt hat? */ export async function password_change_initialize( identifier : string, url_template : string ) : Promise { _espe.helpers.frontend_url_check(); const now : int = lib_plankton.base.get_current_timestamp(true); const cooldown_time : int = _espe.conf.get().settings.password_change.cooldown_time; const member_ids : Array<_espe.type.member_id> = await ( (await _espe.repository.member.dump()) .filter( member_entry => ( member_entry.object.registered && member_entry.object.enabled && ( ( (! (member_entry.object.email_address_private === null)) && (member_entry.object.email_address_private === identifier) ) || (name_login(member_entry.object) === identifier) ) ) ) .map( member_entry => member_entry.id ) ); for await (const member_id of member_ids) { const member_object_old : _espe.type.member_object = await _espe.repository.member.read(member_id); if ( (! (member_object_old.password_change_last_attempt === null)) && ((now - member_object_old.password_change_last_attempt) <= cooldown_time) ) { lib_plankton.log.notice( "member_password_change_cooldown_not_over", { "member_id": member_id, "last_attempt": member_object_old.password_change_last_attempt, "now": now, } ); // do nothing } else { if (member_object_old.email_address_private === null) { lib_plankton.log.notice( "member_password_change_impossible_due_to_missing_private_email_address", { "member_id": member_id, } ); // do nothing } else { // keine echte Verifizierung, der Algorithmus ist aber der passende const token : string = await _espe.helpers.verification_get(Math.floor(Math.random() * (1 << 24))); const member_object_new : _espe.type.member_object = { "membership_number": member_object_old.membership_number, "name_real_value": member_object_old.name_real_value, "name_real_index": member_object_old.name_real_index, "email_address_private": member_object_old.email_address_private, "registered": member_object_old.registered, "enabled": member_object_old.enabled, "email_use_veiled_address": member_object_old.email_use_veiled_address, "email_use_nominal_address": member_object_old.email_use_nominal_address, "email_redirect_to_private_address": member_object_old.email_redirect_to_private_address, "email_allow_sending": member_object_old.email_allow_sending, "password_image": member_object_old.password_image, "password_change_last_attempt": now, "password_change_token": token, }; await _espe.repository.member.update(member_id, member_object_new); // signal_change(); // do NOT wait in order to reduce information for potential attackers const url : (null | string) = _espe.helpers.frontend_url_get( url_template, { "id": member_id.toFixed(0), "token": token, } ); if (url === null) { // do nothing } else { /*await*/ _espe.helpers.email_send( [ member_object_old.email_address_private, ], lib_plankton.string.coin( "{{head}} | {{core}}", { "head": _espe.conf.get().settings.organisation.name, "core": lib_plankton.translate.get("email.password_change.initialization.subject"), } ), lib_plankton.string.coin( lib_plankton.translate.get("email.password_change.initialization.body"), { "name": name_display(member_object_old), "url": url, } ) ); } } } } } /** * führt eine Passwort-Rücksetzung für ein Mitglied durch * * @todo zeitliche Begrenzung? */ export async function password_change_execute( member_id : _espe.type.member_id, token : string, password_new : string ) : Promise;}>> { const member_object_old : _espe.type.member_object = await _espe.repository.member.read(member_id); if (member_object_old.email_address_private === null) { return Promise.reject(new Error("private e-mail address missing")); } else { let flaws : Array<{incident : string; details : Record;}> = []; if ( (member_object_old.password_change_token === null) || (! (token === member_object_old.password_change_token)) ) { lib_plankton.log.notice( "member_password_change_token_invalid", { "member_id": member_id, "token_sent": token, } ); flaws.push({"incident": "token_invalid", "details": {}}); } else { flaws = flaws.concat( validate_password(password_new) .map(flaw => ({"incident": ("password_" + flaw.incident), "details": flaw.details})) ); if (flaws.length > 0) { // do nothing } else { const member_object_new : _espe.type.member_object = { "membership_number": member_object_old.membership_number, "name_real_value": member_object_old.name_real_value, "name_real_index": member_object_old.name_real_index, "email_address_private": member_object_old.email_address_private, "registered": member_object_old.registered, "enabled": member_object_old.enabled, "email_use_veiled_address": member_object_old.email_use_veiled_address, "email_use_nominal_address": member_object_old.email_use_nominal_address, "email_redirect_to_private_address": member_object_old.email_redirect_to_private_address, "email_allow_sending": member_object_old.email_allow_sending, "password_image": await password_image(password_new), "password_change_last_attempt": member_object_old.password_change_last_attempt, "password_change_token": null, }; await _espe.repository.member.update(member_id, member_object_new); signal_change(); await _espe.helpers.email_send( [ member_object_old.email_address_private, ], lib_plankton.string.coin( "{{head}} | {{core}}", { "head": _espe.conf.get().settings.organisation.name, "core": lib_plankton.translate.get("email.password_change.execution.subject"), } ), lib_plankton.string.coin( lib_plankton.translate.get("email.password_change.execution.body"), { "name": name_display(member_object_old), } ) ); } } return flaws; } } /** * @todo check validity (e.g. username characters) */ export async function export_authelia_user_data( options : { custom_data ?: (null | Array<_espe.type.member_object>); } = {} ) : Promise { options = Object.assign( { "custom_data": null, }, options ); type type_entry = { disabled : boolean; displayname : string; email : string; groups : Array; password : string; }; return lib_plankton.call.convey( ( ( (options.custom_data !== undefined) && (options.custom_data !== null) ) ? (options.custom_data.map((member_object, index) => ({"id": index, "object": member_object}))) : await dump() ), [ (x : Array) => x.map( entry => Object.assign( entry, {"email_address": email_address(entry.object)} ) ), (x : Array) => x.filter( entry => ( entry.object.registered && ( (entry.object.password_image !== null) && (entry.object.password_image !== "") ) && (entry.email_address !== null) ) ), (x : Array) => x.map( entry => ([ name_login(entry.object), { "disabled": (! entry.object.enabled), "displayname": name_display(entry.object), "email": entry.email_address, "groups": [], "password": entry.object.password_image, } ]) ), Object.fromEntries, (x : Record) => ({"users": x}), ] ); } /** */ export async function export_authelia_user_file( options : { custom_data ?: (null | Array<_espe.type.member_object>); } = {} ) : Promise { options = Object.assign( { "custom_data": null, }, options ); const nm_yaml = require("yaml"); return nm_yaml.stringify(await export_authelia_user_data(options)); } /** */ export async function output_authelia_file( output_parameters : { path : string; } ) : Promise { await lib_plankton.file.write( output_parameters.path, await _espe.service.member.export_authelia_user_file() ); } /** */ export async function output_http( output_parameters : { scheme : ("http" | "https"); host : string; path : string; method : ("get" | "post" | "put" | "patch" | "delete" | "head" | "options"); query : (null | string); headers : Record; } ) : Promise { const http_request : lib_plankton.http.type_request = { "scheme": output_parameters.scheme, "host": output_parameters.host, "path": output_parameters.path, "version": "HTTP/1.1", "method": ( ( { "get": lib_plankton.http.enum_method.get, "post": lib_plankton.http.enum_method.post, "put": lib_plankton.http.enum_method.put, "patch": lib_plankton.http.enum_method.patch, "delete": lib_plankton.http.enum_method.delete, "head": lib_plankton.http.enum_method.head, "options": lib_plankton.http.enum_method.options, }[output_parameters.method] ) ?? lib_plankton.http.enum_method.post ), "query": output_parameters.query, "headers": output_parameters.headers, "body": lib_plankton.json.encode(await _espe.service.member.dump()), }; const http_response : lib_plankton.http.type_response = await lib_plankton.http.call( http_request ); if (! ((http_response.status_code >= 200) && (http_response.status_code < 400))) { lib_plankton.log.warning( "output_http_failed", { "http_request": http_request, "http_response_status_code": http_response.status_code, } ); } else { // do nothing } } /** */ export async function output_arc( output_parameters : { http_scheme : ("http" | "https"); http_host : string; http_port : int; hash_salt : string; } ) : Promise { const timestamp : float = lib_plankton.base.get_current_timestamp(); const auth : string = lib_plankton.sha256.get( timestamp.toFixed(0) + output_parameters.hash_salt ); const http_request : lib_plankton.http.type_request = { "scheme": output_parameters.http_scheme, "host": lib_plankton.string.coin( "{{host}}:{{port}}", { "host": output_parameters.http_host, "port": output_parameters.http_port.toFixed(0), } ), "path": "/users/set", "version": "HTTP/1.1", "method": lib_plankton.http.enum_method.put, "query": lib_plankton.string.coin( "?timestamp={{timestamp}}&auth={{auth}}", { "timestamp": timestamp.toFixed(0), "auth": auth, } ), "headers": { "Content-Type": "application/json", }, // @ts-ignore "body": Buffer.from( lib_plankton.json.encode( (await _espe.service.member.export_authelia_user_data()) ), "utf8" ), }; const http_response : lib_plankton.http.type_response = await lib_plankton.http.call( http_request ); if (http_response.status_code !== 200) { lib_plankton.log.warning( "output_arcback_failed", { "http_response_status_code": http_response.status_code, } ); } else { // do nothing } } }