diff --git a/misc/conf.example.json b/misc/conf.example.json index 806201f..0d14cc6 100644 --- a/misc/conf.example.json +++ b/misc/conf.example.json @@ -56,13 +56,6 @@ "login_url": null } }, - "admins": [ - { - "name": "admin", - "password_image": "$2b$12$xOa6iWPOMjiwJ3oIOZWDGu/w2Ca/eKLHWE7aDItkNsP/79nJk065i", - "email_address": "espe-admin@example.org" - } - ], "output": { "authelia": "/tmp/authelia-users.yml" } diff --git a/source/api/actions/session_begin.ts b/source/api/actions/session_begin.ts index 392a319..f5c7e2b 100644 --- a/source/api/actions/session_begin.ts +++ b/source/api/actions/session_begin.ts @@ -64,15 +64,15 @@ namespace _espe.api return Promise.reject(new Error("impossible")); } else { - const admin : (null | _espe.service.admin.type_value) = await _espe.service.admin.login(input.name, input.password); - if (admin === null) { + const admin_entry : (null | _espe.service.admin.type_value) = await _espe.service.admin.login(input.name, input.password); + if (admin_entry === null) { return Promise.resolve({ "status_code": 403, "data": null, }); } else { - const session_key : string = await lib_plankton.session.begin(admin.name); + const session_key : string = await lib_plankton.session.begin(admin_entry.object.name); return Promise.resolve({ "status_code": 201, "data": session_key, diff --git a/source/conf.ts b/source/conf.ts index a662638..7caf727 100644 --- a/source/conf.ts +++ b/source/conf.ts @@ -169,14 +169,6 @@ namespace _espe.conf login_url : (null | string); }; }; - // TODO: evtl. in Datenbank verlagern - admins : Array< - { - name : string; - password_image : string; - email_address : (null | string); - } - >; outputs : Array< { kind : "authelia_file"; @@ -221,7 +213,7 @@ namespace _espe.conf conf_raw : any ) : void { - const version : int = (conf_raw["version"] ?? 4); + const version : int = (conf_raw["version"] ?? 5); _data = { "general": ( ((node_general) => ({ @@ -245,7 +237,8 @@ namespace _espe.conf } case 2: case 3: - case 4: { + case 4: + case 5: { const node_log = ( conf_raw["log"] ?? @@ -286,7 +279,8 @@ namespace _espe.conf return "::"; break; } - case 4: { + case 4: + case 5: { return (node_server["host"] ?? "::"); break } @@ -398,7 +392,7 @@ namespace _espe.conf "remark": (node_settings_summon_email["remark"] ?? null), })) (node_settings["summon_email"] ?? {}) ), - "password_policy": ( + "password_policy": ( ((node_settings_password_policy) => ({ "minimum_length": ( ("minimum_length" in node_settings_password_policy) @@ -434,7 +428,6 @@ namespace _espe.conf ), })) (conf_raw["settings"] ?? {}) ), - "admins": (conf_raw["admins"] ?? []), "outputs": (() => { switch (version) { case 1: diff --git a/source/data/localization/deu.loc.json b/source/data/localization/deu.loc.json index 16cac04..45b7840 100644 --- a/source/data/localization/deu.loc.json +++ b/source/data/localization/deu.loc.json @@ -20,6 +20,7 @@ "help.args.action.options.email_test": "eine Test-E-Mail senden", "help.args.action.options.expose_conf": "Vollständige Konfiguration ausgeben", "help.args.action.options.password_image": "Passwort-Abbild errechnen und auf Standard-Ausgabe schreiben", + "help.args.action.options.admin_add": "Einen Administrator-Zugang anlegen", "help.args.action.options.export_authelia": "Export der Nutzer-Datenbank im Authelia-user-Datei-Format auf Standard-Ausgabe schreiben", "help.args.action.options.help": "Diese Hilfe auf Standard-Ausgabe schreiben", "help.args.conf_path.description": "Pfad zur Konfigurations-Datei", diff --git a/source/data/localization/eng.loc.json b/source/data/localization/eng.loc.json index 2121971..f9e73af 100644 --- a/source/data/localization/eng.loc.json +++ b/source/data/localization/eng.loc.json @@ -20,6 +20,7 @@ "help.args.action.options.email_test": "send a test e-mail", "help.args.action.options.expose_conf": "write complete configuration to stdout", "help.args.action.options.password_image": "compute password image and write to stdout", + "help.args.action.options.admin_add": "create an admin account", "help.args.action.options.export_authelia": "export user database in Authelia user file format and write to stdout", "help.args.action.options.help": "write this help to stdout", "help.args.conf_path.description": "path to configuration file", diff --git a/source/database.ts b/source/database.ts index 079f54a..a0945a5 100644 --- a/source/database.ts +++ b/source/database.ts @@ -19,7 +19,7 @@ namespace _espe.database /** */ const _compatible_revisions : Array = [ - "r5", + "r6", ]; diff --git a/source/helpers.ts b/source/helpers.ts index 134e811..f5d6b43 100644 --- a/source/helpers.ts +++ b/source/helpers.ts @@ -248,25 +248,4 @@ namespace _espe.helpers } } - - /** - */ - export async function notify_admins( - subject : string, - body : string - ) : Promise - { - await _espe.helpers.email_send( - ( - ( - _espe.conf.get().admins - .map(admin => admin.email_address) - .filter(x => (x !== null)) - ) as Array - ), - subject, - body - ); - } - } diff --git a/source/main.ts b/source/main.ts index 9839147..363e078 100644 --- a/source/main.ts +++ b/source/main.ts @@ -112,6 +112,10 @@ async function main( "name": "password-image", "description": lib_plankton.translate.get("help.args.action.options.password_image") }, + { + "name": "admin-add", + "description": lib_plankton.translate.get("help.args.action.options.admin_add") + }, { "name": "export-authelia", "description": lib_plankton.translate.get("help.args.action.options.export_authelia") @@ -153,6 +157,15 @@ async function main( "name": "arg2", "hidden": true, }), + "arg3": lib_plankton.args.class_argument.positional({ + "index": 3, + "type": lib_plankton.args.enum_type.string, + "mode": lib_plankton.args.enum_mode.replace, + "default": null, + // "info": null, + "name": "arg3", + "hidden": true, + }), "conf_path": lib_plankton.args.class_argument.volatile({ "indicators_long": ["conf_path"], "indicators_short": ["c"], @@ -257,13 +270,7 @@ async function main( ( (args["arg1"] !== null) ? [args["arg1"]] - : ( - ( - _espe.conf.get().admins - .map(admin => admin.email_address) - .filter(x => (x !== null)) - ) as Array - ) + : (await _espe.service.admin.email_addresses()) ), lib_plankton.string.coin( "{{head}} | Test", @@ -275,6 +282,23 @@ async function main( ); break; } + case "admin-add": { + const name : (null | string) = args["arg1"]; + const email_address : (null | string) = args["arg2"]; + const password : (null | string) = args["arg3"]; + if ((name === null) || (email_address === null) || (password === null)) { + throw (new Error("SYNTAX: admin-add ")); + } + else { + const admin_id : _espe.type.admin_id = await _espe.service.admin.add( + name, + email_address, + password + ); + process.stdout.write(admin_id.toFixed(0) + "\n") + } + break; + } case "serve": { // prepare database await _espe.database.check(); diff --git a/source/repositories/admin.ts b/source/repositories/admin.ts new file mode 100644 index 0000000..860c939 --- /dev/null +++ b/source/repositories/admin.ts @@ -0,0 +1,171 @@ +/* +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.repository.admin +{ + + /** + */ + var _store : ( + null + | + lib_plankton.storage.type_store< + _espe.type.admin_id, + Record, + {}, + lib_plankton.storage.type_sql_table_autokey_search_term, + Record + > + ) = null; + + + /** + */ + function get_store( + ) : lib_plankton.storage.type_store< + _espe.type.admin_id, + Record, + {}, + lib_plankton.storage.type_sql_table_autokey_search_term, + Record + > + { + if (_store === null) { + _store = lib_plankton.storage.sql_table_autokey_store( + { + "database_implementation": _espe.helpers.database_implementation(), + "table_name": "admins", + "key_name": "id", + } + ); + } + else { + // do nothing + } + return _store; + } + + + /** + */ + type type_dispersal = Record; + + + /** + */ + function encode( + object : _espe.type.admin_object + ) : type_dispersal + { + return { + "name": object.name, + "email_address": object.email_address, + "password_image": object.password_image, + "password_fail_count": object.password_fail_count, + }; + } + + + /** + */ + function decode( + dispersal : type_dispersal + ) : _espe.type.admin_object + { + return { + "name": dispersal["name"], + "email_address": dispersal["email_address"], + "password_image": dispersal["password_image"], + "password_fail_count": dispersal["password_fail_count"], + }; + } + + + /** + */ + export async function list( + search_term : (null | string) + ) : Promise< + Array< + { + id : _espe.type.admin_id; + preview : _espe.type.admin_object; + } + > + > + { + return ( + (await get_store().search(null)) + .filter( + ({"key": key, "preview": preview}) => ( + ( + (search_term === null) + || + (search_term.length <= 1) + ) + ? + true + : + (preview["name"].toLowerCase().includes(search_term.toLowerCase())) + ) + ) + .map( + ({"key": key, "preview": preview}) => ({ + "id": key, + "preview": decode(preview), + }) + ) + ); + } + + /** + */ + export async function read( + id : _espe.type.admin_id + ) : Promise<_espe.type.admin_object> + { + const row : Record = await get_store().read(id); + const dispersal : type_dispersal = row; + + return decode(dispersal); + } + + + /** + */ + export function create( + value : _espe.type.admin_object + ) : Promise<_espe.type.admin_id> + { + const dispersal : type_dispersal = encode(value); + + return get_store().create(dispersal); + } + + + /** + */ + export function update( + id : _espe.type.admin_id, + value : _espe.type.admin_object + ) : Promise + { + const dispersal : type_dispersal = encode(value); + + return get_store().update(id, dispersal); + } + +} + diff --git a/source/service-admin.ts b/source/service-admin.ts deleted file mode 100644 index e25e105..0000000 --- a/source/service-admin.ts +++ /dev/null @@ -1,79 +0,0 @@ -/* -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.admin -{ - - /** - */ - export type type_value = { - name : string; - password_image : string; - }; - - - /** - */ - function get( - name : string - ) : (null | type_value) - { - const admins : Array<{name : string; password_image : string;}> = _espe.conf.get().admins; - const entry : (undefined | {name : string; password_image : string;}) = admins.find(entry_ => (entry_.name === name)); - return ( - (entry === undefined) - ? null - : { - "name": entry.name, - "password_image": entry.password_image, - } - ); - } - - - /** - */ - async function check_password( - value : type_value, - password_given : string - ) : Promise - { - return _espe.helpers.bcrypt_compare(value.password_image, password_given); - } - - - /** - */ - export async function login( - name : string, - password : string - ) : Promise<(null | type_value)> - { - const value : (null | type_value) = get(name); - if (value === null) { - return null; - } - else { - if (! (await check_password(value, password))) { - return null; - } - else { - return value; - } - } - } - -} - diff --git a/source/services/admin.ts b/source/services/admin.ts new file mode 100644 index 0000000..dfd70b6 --- /dev/null +++ b/source/services/admin.ts @@ -0,0 +1,163 @@ +/* +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.admin +{ + + /** + */ + export type type_value = { + id : _espe.type.admin_id; + object : _espe.type.admin_object; + }; + + + /** + */ + export async function add( + name : string, + email_address : (null | string), + password : string + ) : Promise<_espe.type.admin_id> + { + const admin_object : _espe.type.admin_object = { + "name": name, + "email_address": email_address, + "password_image": (await _espe.helpers.bcrypt_compute(password)), + "password_fail_count": 0, + }; + return _espe.repository.admin.create(admin_object); + } + + + /** + */ + async function get( + name : string + ) : Promise<(null | type_value)> + { + const hits : Array< + { + id : _espe.type.admin_id; + preview : _espe.type.admin_object; + } + > = await _espe.repository.admin.list(name); + return Promise.resolve<(null | type_value)>( + (hits.length === 1) + ? + {"id": hits[0].id, "object": hits[0].preview} + : + null + ); + } + + + /** + */ + async function check_password( + value : type_value, + password_given : string + ) : Promise + { + return _espe.helpers.bcrypt_compare(value.object.password_image, password_given); + } + + + /** + */ + export async function login( + name : string, + password : string + ) : Promise<(null | type_value)> + { + const value : (null | type_value) = await get(name); + if (value === null) { + return null; + } + else { + /** + * @todo outsource fail count threshold? + */ + const password_fail_threshold : int = 3; + if (value.object.password_fail_count >= password_fail_threshold) { + lib_plankton.log.notice( + "admin_password_fail_threshold_exceeded", + { + "id": value.id, + "name": value.object.name, + "password_fail_count": (value.object.password_fail_count + 1) + } + ); + await _espe.repository.admin.update( + value.id, + { + "name": value.object.name, + "email_address": value.object.email_address, + "password_image": value.object.password_image, + "password_fail_count": (value.object.password_fail_count + 1), + } + ); + return null; + } + else { + if (! (await check_password(value, password))) { + await _espe.repository.admin.update( + value.id, + { + "name": value.object.name, + "email_address": value.object.email_address, + "password_image": value.object.password_image, + "password_fail_count": (value.object.password_fail_count + 1), + } + ); + return null; + } + else { + return value; + } + } + } + } + + /** + */ + export async function email_addresses( + ) : Promise> + { + return ( + ( + (await _espe.repository.admin.list(null)) + .map(hit => hit.preview["email_address"]) + .filter(x => (x !== null)) + ) as Array + ); + } + + + /** + */ + export async function notify_all( + subject : string, + body : string + ) : Promise + { + return _espe.helpers.email_send( + await email_addresses(), + subject, + body + ); + } + +} diff --git a/source/services/member.ts b/source/services/member.ts index e2c47d3..9ee771c 100644 --- a/source/services/member.ts +++ b/source/services/member.ts @@ -503,7 +503,7 @@ namespace _espe.service.member } ) ); - /*await*/ _espe.helpers.notify_admins( + /*await*/ _espe.service.admin.notify_all( lib_plankton.string.coin( "{{head}} | {{core}}", { diff --git a/source/types.ts b/source/types.ts index bae22e5..aa8c6c4 100644 --- a/source/types.ts +++ b/source/types.ts @@ -16,6 +16,21 @@ You should have received a copy of the GNU General Public License along with thi namespace _espe.type { + /** + */ + export type admin_id = int; + + + /** + */ + export type admin_object = { + name : string; + email_address : (null | string); + password_image : string; + password_fail_count : int; + }; + + /** */ export type member_id = int; diff --git a/tools/makefile b/tools/makefile index b1d276b..9a44cc8 100644 --- a/tools/makefile +++ b/tools/makefile @@ -48,11 +48,12 @@ ${dir_temp}/espe-core.js ${dir_temp}/espe-core.d.ts: \ ${dir_source}/helpers/password.ts \ ${dir_source}/database.ts \ ${dir_source}/types.ts \ + ${dir_source}/repositories/admin.ts \ ${dir_source}/repositories/name_index.ts \ ${dir_source}/repositories/member.ts \ ${dir_source}/services/name_index.ts \ ${dir_source}/services/member.ts \ - ${dir_source}/service-admin.ts \ + ${dir_source}/services/admin.ts \ ${dir_source}/api/base.ts \ ${dir_source}/api/actions/meta_ping.ts \ ${dir_source}/api/actions/meta_spec.ts \