From aeda24c6fb7d6540f57c0e02dcdfa3a5ffb2a6e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Fra=C3=9F?= Date: Mon, 20 May 2024 12:20:59 +0200 Subject: [PATCH] =?UTF-8?q?[add]=20Funktionalit=C3=A4t=20zur=20Passwort-?= =?UTF-8?q?=C3=84nderung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../actions/member_password_change_execute.ts | 63 ++++++ .../member_password_change_initialize.ts | 60 ++++++ source/api/functions.ts | 5 + source/conf.ts | 26 +++ source/database.ts | 2 +- source/repositories/member.ts | 4 + source/services/member.ts | 198 +++++++++++++++++- source/types.ts | 2 + tools/makefile | 2 + 9 files changed, 355 insertions(+), 7 deletions(-) create mode 100644 source/api/actions/member_password_change_execute.ts create mode 100644 source/api/actions/member_password_change_initialize.ts diff --git a/source/api/actions/member_password_change_execute.ts b/source/api/actions/member_password_change_execute.ts new file mode 100644 index 0000000..2e699f0 --- /dev/null +++ b/source/api/actions/member_password_change_execute.ts @@ -0,0 +1,63 @@ +namespace _espe.api +{ + + /** + * @todo ausgeklügelte Durchsatzratenbegrenzung + */ + export function register_member_password_change_execute( + rest_subject : lib_plankton.rest.type_rest + ) : void + { + register< + { + token : string; + password_new : string; + }, + null + >( + rest_subject, + lib_plankton.http.enum_method.patch, + "/member/password_change/execute/:id", + { + "description": "Führt eine Passwort-Änderung für ein Mitglied durch", + "input_schema": () => ({ + "nullable": false, + "type": "object", + "properties": { + "token": { + "nullable": false, + "type": "string" + }, + "password_new": { + "nullable": false, + "type": "string", + "description": "das neue Passwort" + }, + }, + "additionalProperties": false, + "required": [ + "token", + "password_new", + ] + }), + "output_schema": () => ({ + "nullable": true + }), + "restriction": restriction_none, + "execution": async ({"path_parameters": path_parameters, "input": input}) => { + const member_id : _espe.type.member_id = parseInt(path_parameters["id"]); + await _espe.service.member.password_change_execute( + member_id, + input.token, + input.password_new + ); + return Promise.resolve({ + "status_code": 200, + "data": null + }); + }, + } + ) + } + +} diff --git a/source/api/actions/member_password_change_initialize.ts b/source/api/actions/member_password_change_initialize.ts new file mode 100644 index 0000000..55b5e01 --- /dev/null +++ b/source/api/actions/member_password_change_initialize.ts @@ -0,0 +1,60 @@ +namespace _espe.api +{ + + /** + * @todo ausgeklügelte Durchsatzratenbegrenzung + * @todo captcha + */ + export function register_member_password_change_initialize( + rest_subject : lib_plankton.rest.type_rest + ) : void + { + register< + { + identifier : string; + url_template : string; + }, + null + >( + rest_subject, + lib_plankton.http.enum_method.post, + "/member/password_change/initialize", + { + "description": "Versucht dem gegebenen Identifikator ein Mitglied zuzuordnen und sendet dem ermittelten Mitglied einen Passwort-Änderungs-Verweis an die hinterlegte private E-Mail-Adresse", + "input_schema": () => ({ + "nullable": false, + "type": "object", + "properties": { + "identifier": { + "nullable": false, + "type": "string", + "description": "Anmelde-Name oder persönliche E-Mail-Adresse des Mitglieds" + }, + "url_template": { + "nullable": false, + "type": "string", + "description": "Schablone für URL; Platz-Halter: id,token" + }, + }, + "additionalProperties": false, + "required": [ + "identifier", + "url_template", + ] + }), + "output_schema": () => ({ + "nullable": true + }), + "restriction": restriction_none, + "execution": async ({"input": input}) => { + await _espe.service.member.password_change_initialize(input.identifier, input.url_template); + return Promise.resolve({ + "status_code": 200, + "data": null + }); + }, + } + ) + } + +} diff --git a/source/api/functions.ts b/source/api/functions.ts index 6abc96b..cedf0b8 100644 --- a/source/api/functions.ts +++ b/source/api/functions.ts @@ -33,6 +33,11 @@ namespace _espe.api _espe.api.register_member_read(rest_subject); _espe.api.register_member_modify(rest_subject); // _espe.api.register_member_delete(rest_subject); + // password_change + { + _espe.api.register_member_password_change_initialize(rest_subject); + _espe.api.register_member_password_change_execute(rest_subject); + } } diff --git a/source/conf.ts b/source/conf.ts index e0b0e8d..a291587 100644 --- a/source/conf.ts +++ b/source/conf.ts @@ -90,6 +90,7 @@ namespace _espe.conf }; settings : { target_domain : string; + frontend_url_base : (null | string); prefix_for_numberbased_email_addresses : string; registration_email : { subject : string; @@ -102,6 +103,17 @@ namespace _espe.conf must_contain_number : boolean; must_contain_special_character : boolean; }; + password_change : { + cooldown_time : int; + initialization_email : { + subject : string; + body : string; + }; + execution_email : { + subject : string; + body : string; + }; + }; name_index : { veil : boolean; salt : (null | string); @@ -229,6 +241,7 @@ namespace _espe.conf "settings": ( ((node_settings) => ({ "target_domain": (node_settings["target_domain"] ?? "example.org"), + "frontend_url_base": (node_settings["frontend_url_base"] ?? null), // TODO: mandatory? "prefix_for_numberbased_email_addresses": (node_settings["prefix_for_numberbased_email_addresses"] ?? "member-"), "registration_email": { "subject": ((node_settings["registration_email"] ?? {})["subject"] ?? "Registration"), @@ -251,6 +264,19 @@ namespace _espe.conf "must_contain_special_character": (node_settings_password_policy["must_contain_special_character"] ?? true), })) (node_settings["password_policy"] ?? {}) ), + "password_change": ( + ((node_settings_password_change) => ({ + "cooldown_time": (node_settings_password_change["cooldown_time"] ?? 86400), + "initialization_email": { + "subject": ((node_settings_password_change["initialization_email"] ?? {})["subject"] ?? "Password change initialized"), + "body": ((node_settings_password_change["initialization_email"] ?? {})["body"] ?? "{{url}}"), + }, + "execution_email": { + "subject": ((node_settings_password_change["execution_email"] ?? {})["subject"] ?? "Password changed"), + "body": ((node_settings_password_change["execution_email"] ?? {})["body"] ?? ""), + }, + })) (node_settings["password_change"] ?? {}) + ), "name_index": ( ((node_settings_password_policy) => ({ "veil": (node_settings_password_policy["veil"] ?? false), diff --git a/source/database.ts b/source/database.ts index db1de77..28d3d78 100644 --- a/source/database.ts +++ b/source/database.ts @@ -4,7 +4,7 @@ namespace _espe.database /** */ const _compatible_revisions : Array = [ - "r2", + "r3", ]; diff --git a/source/repositories/member.ts b/source/repositories/member.ts index 14f4312..2f54039 100644 --- a/source/repositories/member.ts +++ b/source/repositories/member.ts @@ -61,6 +61,8 @@ namespace _espe.repository.member "email_redirect_to_private_address": (object.email_redirect_to_private_address ? 1 : 0), "email_allow_sending": (object.email_allow_sending ? 1 : 0), "password_image": object.password_image, + "password_change_last_attempt": object.password_change_last_attempt, + "password_change_token": object.password_change_token, }; } @@ -83,6 +85,8 @@ namespace _espe.repository.member "email_redirect_to_private_address": (row["email_redirect_to_private_address"] > 0), "email_allow_sending": (row["email_allow_sending"] > 0), "password_image": row["password_image"], + "password_change_last_attempt": row["password_change_last_attempt"], + "password_change_token": row["password_change_token"], }; } diff --git a/source/services/member.ts b/source/services/member.ts index fe6dd59..81d7b53 100644 --- a/source/services/member.ts +++ b/source/services/member.ts @@ -207,6 +207,24 @@ namespace _espe.service.member } + /** + * ermittelt das Passwort-Abbild anhand des tatsächlichen Passworts + */ + function password_image( + password : (null | string) + ) : Promise + { + return ( + ( + (! (password === null)) + && + (! (password === "")) + ) + ? _espe.helpers.bcrypt_compute(password) + : null + ); + } + /** * gibt die vollständigen Daten aller Mitglieder aus */ @@ -282,6 +300,8 @@ namespace _espe.service.member "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); notify_change(); @@ -291,6 +311,8 @@ namespace _espe.service.member /** * sendet an ein Mitglied eine E-Mail mit Aufforderung zur Registrierung + * + * @todo conf:settings.frontend_url_base verwenden */ export async function summon( member_id : _espe.type.member_id, @@ -412,11 +434,7 @@ namespace _espe.service.member 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 = ( - password_set - ? await _espe.helpers.bcrypt_compute(data.password) - : null - ); + member_object.password_image = await password_image(data.password); member_object.registered = true; await _espe.repository.member.update(member_id, member_object); @@ -432,7 +450,7 @@ namespace _espe.service.member .map(admin => admin.email_address) .filter(x => (x !== null)) ), - "Eingegangene Registrierung", + "Eingegangene Registrierung", // TODO ("Member-ID: " + member_id.toFixed(0)) ); } @@ -467,12 +485,180 @@ namespace _espe.service.member "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); notify_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 + { + if (_espe.conf.get().settings.frontend_url_base === null) { + return Promise.reject(new Error("no frontend url base set; add in conf as 'settings.frontend_url_base'!")); + } + else { + 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.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); + // notify_change(); + await _espe.helpers.email_send( + [ + member_object_old.email_address_private, + ], + _espe.conf.get().settings.password_change.initialization_email.subject, + lib_plankton.string.coin( + _espe.conf.get().settings.password_change.initialization_email.body, + { + "name": member_object_old.name_real_value, + "url": lib_plankton.string.coin( + "{{base}}{{rest}}", + { + "base": _espe.conf.get().settings.frontend_url_base, + "rest": lib_plankton.string.coin( + url_template, + { + "id": member_id.toFixed(0), + "token": token, + } + ), + } + ), + } + ) + ); + } + } + } + } + } + + + /** + * 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 (! (token === member_object_old.password_change_token)) { + lib_plankton.log.notice( + "member_password_change_token_invalid", + { + "member_id": member_id, + "token_sent": token, + } + ); + throw (new Error("password change token is invalid")); + } + 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); + notify_change(); + await _espe.helpers.email_send( + [ + member_object_old.email_address_private, + ], + _espe.conf.get().settings.password_change.execution_email.subject, + lib_plankton.string.coin( + _espe.conf.get().settings.password_change.execution_email.body, + { + } + ) + ); + } + } + /* export async function remove( diff --git a/source/types.ts b/source/types.ts index 1daf9a5..88d07b8 100644 --- a/source/types.ts +++ b/source/types.ts @@ -20,6 +20,8 @@ namespace _espe.type email_redirect_to_private_address : boolean; email_allow_sending : boolean; password_image : (null | string); + password_change_last_attempt : (null | int); + password_change_token : (null | string); }; } diff --git a/tools/makefile b/tools/makefile index 2c83ddc..6df059b 100644 --- a/tools/makefile +++ b/tools/makefile @@ -46,6 +46,8 @@ ${dir_temp}/espe-core.js ${dir_temp}/espe-core.d.ts: \ ${dir_source}/api/actions/member_list.ts \ ${dir_source}/api/actions/member_read.ts \ ${dir_source}/api/actions/member_modify.ts \ + ${dir_source}/api/actions/member_password_change_initialize.ts \ + ${dir_source}/api/actions/member_password_change_execute.ts \ ${dir_source}/api/functions.ts \ ${dir_source}/conf.ts @ ${cmd_log} "compile | core …"