merchant

Merchant backend to process payments, run by merchants
Log | Files | Refs | Submodules | README | LICENSE

commit 385888369d04884a0a95c7299c5825836d96ba70
parent 8260a2cb4a8953cedb11b326a1df5dacb492b7ea
Author: Christian Grothoff <christian@grothoff.org>
Date:   Sat, 10 Oct 2026 17:50:56 +0200

limit unauthenticated password reset attempts to one every 15 minutes

Diffstat:
Msrc/backend/taler-merchant-httpd_mfa.c | 157++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Msrc/backend/taler-merchant-httpd_mfa.h | 27+++++++++++++++++++++++++++
Msrc/backend/taler-merchant-httpd_post-management-instances-INSTANCE-auth.c | 60+++++++++++++++++++++++++++++++++---------------------------
Msrc/backenddb/meson.build | 1+
Msrc/backenddb/sql-schema/merchant-0049.sql | 7+++++++
Msrc/backenddb/test_merchantdb.c | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/backenddb/update_instance_password_reset.c | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/include/merchant-database/all.h | 1+
Asrc/include/merchant-database/update_instance_password_reset.h | 50++++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 367 insertions(+), 36 deletions(-)

diff --git a/src/backend/taler-merchant-httpd_mfa.c b/src/backend/taler-merchant-httpd_mfa.c @@ -27,6 +27,7 @@ #include "taler-merchant-httpd_mfa.h" #include "merchant-database/insert_mfa_challenge.h" #include "merchant-database/get_mfa_challenge.h" +#include "merchant-database/update_instance_password_reset.h" /** @@ -39,6 +40,13 @@ */ #define CHALLENGE_LIFETIME GNUNET_TIME_UNIT_DAYS +/** + * How long after a password reset of an instance was started can + * the next one be started at the earliest? + */ +#define PASSWORD_RESET_DELAY \ + GNUNET_TIME_relative_multiply (GNUNET_TIME_UNIT_MINUTES, 15) + enum GNUNET_GenericReturnValue TMH_mfa_parse_challenge_id (struct TMH_HandlerContext *hc, @@ -415,27 +423,99 @@ get_hint (enum TALER_MERCHANT_MFA_Channel channel, /** + * New challenges are about to be started for a password reset of + * @a instance_name. A password reset is unauthenticated, so without a + * limit anyone could start one reset after the other and guess the TAN + * sent for each. Allow it only if the last password reset of the + * instance was started at least #PASSWORD_RESET_DELAY ago, and record + * this one. + * + * @param[in,out] hc handler context with the connection to the client + * @param instance_name instance whose password is being reset + * @return #GNUNET_OK if new challenges may be started, + * #GNUNET_NO if an error message was returned to the client + * #GNUNET_SYSERR to just close the connection + */ +static enum GNUNET_GenericReturnValue +password_reset_start (struct TMH_HandlerContext *hc, + const char *instance_name) +{ + struct GNUNET_TIME_Absolute now = GNUNET_TIME_absolute_get (); + enum GNUNET_DB_QueryStatus qs; + + qs = TALER_MERCHANTDB_update_instance_password_reset ( + TMH_db, + instance_name, + now, + GNUNET_TIME_absolute_add (now, + PASSWORD_RESET_DELAY)); + switch (qs) + { + case GNUNET_DB_STATUS_HARD_ERROR: + GNUNET_break (0); + return (MHD_NO == + TALER_MHD_reply_with_error (hc->connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_STORE_FAILED, + "update_instance_password_reset")) + ? GNUNET_SYSERR + : GNUNET_NO; + case GNUNET_DB_STATUS_SOFT_ERROR: + GNUNET_break (0); + return (MHD_NO == + TALER_MHD_reply_with_error (hc->connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_SOFT_FAILURE, + "update_instance_password_reset")) + ? GNUNET_SYSERR + : GNUNET_NO; + case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS: + GNUNET_log (GNUNET_ERROR_TYPE_WARNING, + "Refusing password reset of instance `%s': the last one was started less than %s ago\n", + instance_name, + GNUNET_TIME_relative2s (PASSWORD_RESET_DELAY, + false)); + return (MHD_NO == + TALER_MHD_reply_with_error (hc->connection, + MHD_HTTP_TOO_MANY_REQUESTS, + TALER_EC_MERCHANT_TAN_TOO_EARLY, + GNUNET_TIME_relative2s ( + PASSWORD_RESET_DELAY, + false))) + ? GNUNET_SYSERR + : GNUNET_NO; + case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT: + break; + } + return GNUNET_OK; +} + + +/** * Check that a set of MFA challenges has been satisfied by the * client for the request in @a hc. * * @param[in,out] hc handler context with the connection to the client * @param instance_name instance name to use in the message to the customer * @param op operation for which we should check challenges for + * @param password_reset true if @a op is an unauthenticated password + * reset of @a instance_name, see #password_reset_start() * @param combi_and true to tell the client to solve all challenges (AND), * false means that any of the challenges will do (OR) - * @param ... pairs of channel and address, terminated by + * @param ap pairs of channel and address, terminated by * #TALER_MERCHANT_MFA_CHANNEL_NONE * @return #GNUNET_OK on success (challenges satisfied) * #GNUNET_NO if an error message was returned to the client * #GNUNET_SYSERR to just close the connection */ -enum GNUNET_GenericReturnValue -TMH_mfa_challenges_do ( +static enum GNUNET_GenericReturnValue +mfa_challenges_do ( struct TMH_HandlerContext *hc, const char *instance_name, enum TALER_MERCHANT_MFA_CriticalOperation op, + bool password_reset, bool combi_and, - ...) + va_list ap) { struct Challenge challenges[MAX_CHALLENGES]; const char *challenge_ids[MAX_CHALLENGES]; @@ -445,10 +525,6 @@ TMH_mfa_challenges_do ( enum GNUNET_GenericReturnValue ret; { - va_list ap; - - va_start (ap, - combi_and); num_challenges = 0; while (num_challenges < MAX_CHALLENGES) { @@ -470,7 +546,6 @@ TMH_mfa_challenges_do ( challenges[num_challenges].solvable = true; num_challenges++; } - va_end (ap); } if (0 == num_challenges) @@ -558,6 +633,22 @@ TMH_mfa_challenges_do ( GNUNET_free (target_address); } + if (password_reset) + { + bool start_new = false; + + for (size_t i = 0; i < num_challenges; i++) + if (NULL == challenges[i].challenge_id) + start_new = true; + if (start_new) + { + ret = password_reset_start (hc, + instance_name); + if (GNUNET_OK != ret) + goto cleanup; + } + } + { struct GNUNET_TIME_Absolute expiration_date = GNUNET_TIME_relative_to_absolute (CHALLENGE_LIFETIME); @@ -674,6 +765,54 @@ cleanup: enum GNUNET_GenericReturnValue +TMH_mfa_challenges_do ( + struct TMH_HandlerContext *hc, + const char *instance_name, + enum TALER_MERCHANT_MFA_CriticalOperation op, + bool combi_and, + ...) +{ + va_list ap; + enum GNUNET_GenericReturnValue ret; + + va_start (ap, + combi_and); + ret = mfa_challenges_do (hc, + instance_name, + op, + false, + combi_and, + ap); + va_end (ap); + return ret; +} + + +enum GNUNET_GenericReturnValue +TMH_mfa_auth_challenges_do ( + struct TMH_HandlerContext *hc, + const char *instance_name, + bool password_reset, + bool combi_and, + ...) +{ + va_list ap; + enum GNUNET_GenericReturnValue ret; + + va_start (ap, + combi_and); + ret = mfa_challenges_do (hc, + instance_name, + TALER_MERCHANT_MFA_CO_AUTH_CONFIGURATION, + password_reset, + combi_and, + ap); + va_end (ap); + return ret; +} + + +enum GNUNET_GenericReturnValue TMH_mfa_check_simple ( struct TMH_HandlerContext *hc, enum TALER_MERCHANT_MFA_CriticalOperation op, diff --git a/src/backend/taler-merchant-httpd_mfa.h b/src/backend/taler-merchant-httpd_mfa.h @@ -72,6 +72,33 @@ TMH_mfa_challenges_do ( /** + * Like #TMH_mfa_challenges_do() for the operation + * #TALER_MERCHANT_MFA_CO_AUTH_CONFIGURATION. + * + * @param[in,out] hc handler context with the connection to the client + * @param instance_name instance name to use in the message to the customer + * @param password_reset true if this is an unauthenticated password + * reset (forgot-password): then new challenges are only started + * if the last password reset of @a instance_name was started at + * least 15 minutes ago, otherwise the client gets a 429 + * @param combi_and true to tell the client to solve all challenges (AND), + * false means that any of the challenges will do (OR) + * @param ... pairs of channel and address, terminated by + * #TALER_MERCHANT_MFA_CHANNEL_NONE + * @return #GNUNET_OK on success (challenges satisfied) + * #GNUNET_NO if an error message was returned to the client + * #GNUNET_SYSERR to just close the connection + */ +enum GNUNET_GenericReturnValue +TMH_mfa_auth_challenges_do ( + struct TMH_HandlerContext *hc, + const char *instance_name, + bool password_reset, + bool combi_and, + ...); + + +/** * Check MFA for a simple operation that simply requires * a single additional factor (if any are configured). * diff --git a/src/backend/taler-merchant-httpd_post-management-instances-INSTANCE-auth.c b/src/backend/taler-merchant-httpd_post-management-instances-INSTANCE-auth.c @@ -98,6 +98,8 @@ reply_with_login_token ( * @param mfa_combi_and require all MFA channels in @a tcs instead of any one * @param login_token_duration how long a login token returned after the * update should remain valid; zero means do not create a token + * @param password_reset true if this is an unauthenticated password + * reset, which may only be started once per 15 minutes per instance * @return MHD result code */ static enum MHD_Result @@ -108,7 +110,8 @@ post_instances_ID_auth (struct TMH_MerchantInstance *mi, bool require_old_password, enum TEH_TanChannelSet tcs, bool mfa_combi_and, - struct GNUNET_TIME_Relative login_token_duration) + struct GNUNET_TIME_Relative login_token_duration, + bool password_reset) { struct TALER_MERCHANTDB_InstanceAuthSettings ias; struct TALER_MERCHANTDB_LoginTokenP login_token; @@ -240,33 +243,33 @@ post_instances_ID_auth (struct TMH_MerchantInstance *mi, ret = GNUNET_OK; break; case TMH_TCS_SMS: - ret = TMH_mfa_challenges_do (hc, - mi->settings.id, - TALER_MERCHANT_MFA_CO_AUTH_CONFIGURATION, - mfa_combi_and, - TALER_MERCHANT_MFA_CHANNEL_SMS, - mi->settings.phone, - TALER_MERCHANT_MFA_CHANNEL_NONE); + ret = TMH_mfa_auth_challenges_do (hc, + mi->settings.id, + password_reset, + mfa_combi_and, + TALER_MERCHANT_MFA_CHANNEL_SMS, + mi->settings.phone, + TALER_MERCHANT_MFA_CHANNEL_NONE); break; case TMH_TCS_EMAIL: - ret = TMH_mfa_challenges_do (hc, - mi->settings.id, - TALER_MERCHANT_MFA_CO_AUTH_CONFIGURATION, - mfa_combi_and, - TALER_MERCHANT_MFA_CHANNEL_EMAIL, - mi->settings.email, - TALER_MERCHANT_MFA_CHANNEL_NONE); + ret = TMH_mfa_auth_challenges_do (hc, + mi->settings.id, + password_reset, + mfa_combi_and, + TALER_MERCHANT_MFA_CHANNEL_EMAIL, + mi->settings.email, + TALER_MERCHANT_MFA_CHANNEL_NONE); break; case TMH_TCS_EMAIL_AND_SMS: - ret = TMH_mfa_challenges_do (hc, - mi->settings.id, - TALER_MERCHANT_MFA_CO_AUTH_CONFIGURATION, - mfa_combi_and, - TALER_MERCHANT_MFA_CHANNEL_EMAIL, - mi->settings.email, - TALER_MERCHANT_MFA_CHANNEL_SMS, - mi->settings.phone, - TALER_MERCHANT_MFA_CHANNEL_NONE); + ret = TMH_mfa_auth_challenges_do (hc, + mi->settings.id, + password_reset, + mfa_combi_and, + TALER_MERCHANT_MFA_CHANNEL_EMAIL, + mi->settings.email, + TALER_MERCHANT_MFA_CHANNEL_SMS, + mi->settings.phone, + TALER_MERCHANT_MFA_CHANNEL_NONE); break; } if (GNUNET_OK != ret) @@ -469,7 +472,8 @@ TMH_private_post_instances_ID_auth (const struct TMH_RequestHandler *rh, ? TEH_mandatory_tan_channels : TMH_TCS_NONE, false, - GNUNET_TIME_UNIT_ZERO); + GNUNET_TIME_UNIT_ZERO, + false); } @@ -531,7 +535,8 @@ TMH_public_post_instances_ID_auth (const struct TMH_RequestHandler *rh, false, TEH_mandatory_tan_channels, true, - token_duration); + token_duration, + true); } @@ -560,7 +565,8 @@ TMH_private_post_instances_default_ID_auth ( false, TMH_TCS_NONE, false, - GNUNET_TIME_UNIT_ZERO); + GNUNET_TIME_UNIT_ZERO, + false); return ret; } diff --git a/src/backenddb/meson.build b/src/backenddb/meson.build @@ -193,6 +193,7 @@ libtalermerchantdb = library( 'update_donau_instance_receipts_amount.c', 'update_instance.c', 'update_instance_auth.c', + 'update_instance_password_reset.c', 'update_otp_device.c', 'update_pending_webhook.c', 'update_product.c', diff --git a/src/backenddb/sql-schema/merchant-0049.sql b/src/backenddb/sql-schema/merchant-0049.sql @@ -37,4 +37,11 @@ UPDATE tan_challenges WHERE retransmission_date = 0 AND confirmation_date IS NULL; +ALTER TABLE merchant_instances + ADD COLUMN earliest_password_reset INT8 NOT NULL DEFAULT 0; + +COMMENT ON COLUMN merchant_instances.earliest_password_reset + IS 'Earliest time at which a password reset (forgot-password) may start new MFA challenges for this instance; limits how often the TAN can be guessed'; + + COMMIT; diff --git a/src/backenddb/test_merchantdb.c b/src/backenddb/test_merchantdb.c @@ -111,6 +111,7 @@ #include "merchant-database/iterate_webhooks.h" #include "merchant-database/get_exchange_wire_fee.h" #include "merchant-database/get_instance_auth.h" +#include "merchant-database/update_instance_password_reset.h" #include "merchant-database/update_to_contract_terms_paid.h" #include "merchant-database/update_to_contract_terms_wired.h" #include "merchant-database/insert_refund_by_coin.h" @@ -968,6 +969,54 @@ test_get_instance_auth_null (const struct InstanceData *instance) /** + * Tests that a password reset of an instance can only be started + * again once the earliest time for the next one has passed. + * + * @param instance the instance to run the test against. + * @return 0 on success, 1 otherwise. + */ +static int +test_update_instance_password_reset (const struct InstanceData *instance) +{ + struct GNUNET_TIME_Absolute now = GNUNET_TIME_absolute_get (); + struct GNUNET_TIME_Absolute next + = GNUNET_TIME_absolute_add (now, + GNUNET_TIME_UNIT_MINUTES); + + TEST_COND_RET_ON_FAIL (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + TALER_MERCHANTDB_update_instance_password_reset ( + pg, + instance->instance.id, + now, + next), + "First password reset was refused\n"); + TEST_COND_RET_ON_FAIL (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS == + TALER_MERCHANTDB_update_instance_password_reset ( + pg, + instance->instance.id, + now, + next), + "Second password reset was not refused\n"); + TEST_COND_RET_ON_FAIL (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + TALER_MERCHANTDB_update_instance_password_reset ( + pg, + instance->instance.id, + next, + GNUNET_TIME_absolute_add (next, + GNUNET_TIME_UNIT_MINUTES)), + "Password reset after the delay was refused\n"); + TEST_COND_RET_ON_FAIL (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS == + TALER_MERCHANTDB_update_instance_password_reset ( + pg, + "no-such-instance", + now, + next), + "Password reset of unknown instance succeeded\n"); + return 0; +} + + +/** * Tests that storing a report always yields a fresh serial. * * 'merchant_reports' has no unique key other than the identity @@ -1129,6 +1178,8 @@ run_test_instances (struct TestInstances_Closure *cls) TEST_RET_ON_FAIL (test_insert_report_serial (&cls->instances[0])); /* Test that a NULL authentication hash/salt is not an error */ TEST_RET_ON_FAIL (test_get_instance_auth_null (&cls->instances[0])); + /* Test the limit on starting password resets */ + TEST_RET_ON_FAIL (test_update_instance_password_reset (&cls->instances[0])); /* Test instance private key deletion */ TEST_RET_ON_FAIL (test_delete_instance_private_key (&cls->instances[0], GNUNET_DB_STATUS_SUCCESS_ONE_RESULT)); diff --git a/src/backenddb/update_instance_password_reset.c b/src/backenddb/update_instance_password_reset.c @@ -0,0 +1,49 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER 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, or (at your option) any later version. + + TALER 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 + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +/** + * @file src/backenddb/update_instance_password_reset.c + * @brief Implementation of the update_instance_password_reset function for Postgres + */ +#include "platform.h" +#include <taler/taler_pq_lib.h> +#include "merchant-database/update_instance_password_reset.h" +#include "helper.h" + + +enum GNUNET_DB_QueryStatus +TALER_MERCHANTDB_update_instance_password_reset ( + struct TALER_MERCHANTDB_PostgresContext *pg, + const char *merchant_id, + struct GNUNET_TIME_Absolute now, + struct GNUNET_TIME_Absolute next_reset) +{ + struct GNUNET_PQ_QueryParam params[] = { + GNUNET_PQ_query_param_string (merchant_id), + GNUNET_PQ_query_param_absolute_time (&now), + GNUNET_PQ_query_param_absolute_time (&next_reset), + GNUNET_PQ_query_param_end + }; + + PREPARE (pg, + "update_instance_password_reset", + "UPDATE merchant.merchant_instances SET" + " earliest_password_reset=$3" + " WHERE merchant_id=$1" + " AND earliest_password_reset <= $2"); + return GNUNET_PQ_eval_prepared_non_select (pg->conn, + "update_instance_password_reset", + params); +} diff --git a/src/include/merchant-database/all.h b/src/include/merchant-database/all.h @@ -198,6 +198,7 @@ #include "merchant-database/update_donau_instance_receipts_amount.h" #include "merchant-database/update_instance.h" #include "merchant-database/update_instance_auth.h" +#include "merchant-database/update_instance_password_reset.h" #include "merchant-database/update_mfa_challenge.h" #include "merchant-database/update_money_pot.h" #include "merchant-database/update_otp_device.h" diff --git a/src/include/merchant-database/update_instance_password_reset.h b/src/include/merchant-database/update_instance_password_reset.h @@ -0,0 +1,50 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER 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, or (at your option) any later version. + + TALER 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 + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +/** + * @file src/include/merchant-database/update_instance_password_reset.h + * @brief implementation of the update_instance_password_reset function for Postgres + */ +#ifndef MERCHANT_DATABASE_UPDATE_INSTANCE_PASSWORD_RESET_H +#define MERCHANT_DATABASE_UPDATE_INSTANCE_PASSWORD_RESET_H + +#include <taler/taler_util.h> +#include "merchantdb_lib.h" + + +struct TALER_MERCHANTDB_PostgresContext; +/** + * Start a password reset of an instance if the last one was started + * long enough ago: if the earliest time for the next password reset + * of @a merchant_id is not after @a now, set it to @a next_reset. + * + * @param pg database context + * @param merchant_id identity of the instance + * @param now current time + * @param next_reset earliest time at which the password reset after + * this one may be started + * @return #GNUNET_DB_STATUS_SUCCESS_ONE_RESULT if the password reset + * may be started now, + * #GNUNET_DB_STATUS_SUCCESS_NO_RESULTS if it is too early + * (or the instance does not exist) + */ +enum GNUNET_DB_QueryStatus +TALER_MERCHANTDB_update_instance_password_reset ( + struct TALER_MERCHANTDB_PostgresContext *pg, + const char *merchant_id, + struct GNUNET_TIME_Absolute now, + struct GNUNET_TIME_Absolute next_reset); + +#endif