[add] Funktionalität zur Passwort-Änderung

This commit is contained in:
roydfalk 2024-05-20 12:20:59 +02:00
parent 08442edb12
commit aeda24c6fb
9 changed files with 355 additions and 7 deletions

View file

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

View file

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

View file

@ -33,6 +33,11 @@ namespace _espe.api
_espe.api.register_member_read(rest_subject); _espe.api.register_member_read(rest_subject);
_espe.api.register_member_modify(rest_subject); _espe.api.register_member_modify(rest_subject);
// _espe.api.register_member_delete(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);
}
} }

View file

@ -90,6 +90,7 @@ namespace _espe.conf
}; };
settings : { settings : {
target_domain : string; target_domain : string;
frontend_url_base : (null | string);
prefix_for_numberbased_email_addresses : string; prefix_for_numberbased_email_addresses : string;
registration_email : { registration_email : {
subject : string; subject : string;
@ -102,6 +103,17 @@ namespace _espe.conf
must_contain_number : boolean; must_contain_number : boolean;
must_contain_special_character : 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 : { name_index : {
veil : boolean; veil : boolean;
salt : (null | string); salt : (null | string);
@ -229,6 +241,7 @@ namespace _espe.conf
"settings": ( "settings": (
((node_settings) => ({ ((node_settings) => ({
"target_domain": (node_settings["target_domain"] ?? "example.org"), "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-"), "prefix_for_numberbased_email_addresses": (node_settings["prefix_for_numberbased_email_addresses"] ?? "member-"),
"registration_email": { "registration_email": {
"subject": ((node_settings["registration_email"] ?? {})["subject"] ?? "Registration"), "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), "must_contain_special_character": (node_settings_password_policy["must_contain_special_character"] ?? true),
})) (node_settings["password_policy"] ?? {}) })) (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": ( "name_index": (
((node_settings_password_policy) => ({ ((node_settings_password_policy) => ({
"veil": (node_settings_password_policy["veil"] ?? false), "veil": (node_settings_password_policy["veil"] ?? false),

View file

@ -4,7 +4,7 @@ namespace _espe.database
/** /**
*/ */
const _compatible_revisions : Array<string> = [ const _compatible_revisions : Array<string> = [
"r2", "r3",
]; ];

View file

@ -61,6 +61,8 @@ namespace _espe.repository.member
"email_redirect_to_private_address": (object.email_redirect_to_private_address ? 1 : 0), "email_redirect_to_private_address": (object.email_redirect_to_private_address ? 1 : 0),
"email_allow_sending": (object.email_allow_sending ? 1 : 0), "email_allow_sending": (object.email_allow_sending ? 1 : 0),
"password_image": object.password_image, "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_redirect_to_private_address": (row["email_redirect_to_private_address"] > 0),
"email_allow_sending": (row["email_allow_sending"] > 0), "email_allow_sending": (row["email_allow_sending"] > 0),
"password_image": row["password_image"], "password_image": row["password_image"],
"password_change_last_attempt": row["password_change_last_attempt"],
"password_change_token": row["password_change_token"],
}; };
} }

View file

@ -207,6 +207,24 @@ namespace _espe.service.member
} }
/**
* ermittelt das Passwort-Abbild anhand des tatsächlichen Passworts
*/
function password_image(
password : (null | string)
) : Promise<string>
{
return (
(
(! (password === null))
&&
(! (password === ""))
)
? _espe.helpers.bcrypt_compute(password)
: null
);
}
/** /**
* gibt die vollständigen Daten aller Mitglieder aus * gibt die vollständigen Daten aller Mitglieder aus
*/ */
@ -282,6 +300,8 @@ namespace _espe.service.member
"email_redirect_to_private_address": false, "email_redirect_to_private_address": false,
"email_allow_sending": false, "email_allow_sending": false,
"password_image": null, "password_image": null,
"password_change_last_attempt": null,
"password_change_token": null,
}; };
const id : _espe.type.member_id = await _espe.repository.member.create(object); const id : _espe.type.member_id = await _espe.repository.member.create(object);
notify_change(); notify_change();
@ -291,6 +311,8 @@ namespace _espe.service.member
/** /**
* sendet an ein Mitglied eine E-Mail mit Aufforderung zur Registrierung * sendet an ein Mitglied eine E-Mail mit Aufforderung zur Registrierung
*
* @todo conf:settings.frontend_url_base verwenden
*/ */
export async function summon( export async function summon(
member_id : _espe.type.member_id, 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_veiled_address = data.email_use_veiled_address
member_object.email_use_nominal_address = data.email_use_nominal_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.email_redirect_to_private_address = data.email_redirect_to_private_address;
member_object.password_image = ( member_object.password_image = await password_image(data.password);
password_set
? await _espe.helpers.bcrypt_compute(data.password)
: null
);
member_object.registered = true; member_object.registered = true;
await _espe.repository.member.update(member_id, member_object); await _espe.repository.member.update(member_id, member_object);
@ -432,7 +450,7 @@ namespace _espe.service.member
.map(admin => admin.email_address) .map(admin => admin.email_address)
.filter(x => (x !== null)) .filter(x => (x !== null))
), ),
"Eingegangene Registrierung", "Eingegangene Registrierung", // TODO
("Member-ID: " + member_id.toFixed(0)) ("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_redirect_to_private_address": member_object_old.email_redirect_to_private_address,
"email_allow_sending": member_object_old.email_allow_sending, "email_allow_sending": member_object_old.email_allow_sending,
"password_image": member_object_old.password_image, "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); await _espe.repository.member.update(member_id, member_object_new);
notify_change(); 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<void>
{
if (_espe.conf.get().settings.frontend_url_base === null) {
return Promise.reject<void>(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<void>
{
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( export async function remove(

View file

@ -20,6 +20,8 @@ namespace _espe.type
email_redirect_to_private_address : boolean; email_redirect_to_private_address : boolean;
email_allow_sending : boolean; email_allow_sending : boolean;
password_image : (null | string); password_image : (null | string);
password_change_last_attempt : (null | int);
password_change_token : (null | string);
}; };
} }

View file

@ -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_list.ts \
${dir_source}/api/actions/member_read.ts \ ${dir_source}/api/actions/member_read.ts \
${dir_source}/api/actions/member_modify.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}/api/functions.ts \
${dir_source}/conf.ts ${dir_source}/conf.ts
@ ${cmd_log} "compile | core …" @ ${cmd_log} "compile | core …"