merchant

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

commit aebef7a38b7e41f31d20baf5b220c006f9ce596f
parent 3f5911b0a72c41282d860c56cb92a5205eaf06cb
Author: Florian Dold <dold@taler.net>
Date:   Fri,  4 Sep 2026 21:36:34 +0200

merchant backend: optionally require MFA for password changes

Issue: https://bugs.taler.net/n/11771

Diffstat:
Msrc/backend/merchant.conf | 4++++
Msrc/backend/taler-merchant-httpd.c | 27+++++++++++++++++++++++++++
Msrc/backend/taler-merchant-httpd.h | 6++++++
Msrc/backend/taler-merchant-httpd_post-management-instances-INSTANCE-auth.c | 108++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Msrc/testing/test_merchant_mfa.conf | 1+
Msrc/testing/test_merchant_mfa.sh | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 192 insertions(+), 28 deletions(-)

diff --git a/src/backend/merchant.conf b/src/backend/merchant.conf @@ -66,6 +66,10 @@ TYPST_TEMPLATES = @taler-merchant # Where is the webui installed? BACKOFFICE_SPA_DIR = $PREFIX/share/taler-merchant-webui/ +# Require one usable channel from MANDATORY_TAN_CHANNELS in addition to the +# current password when an instance changes its own password. +PASSWORD_CHANGE_MFA = NO + [merchant-kyccheck] # How long do we wait between AML status requests to the diff --git a/src/backend/taler-merchant-httpd.c b/src/backend/taler-merchant-httpd.c @@ -113,6 +113,8 @@ int TMH_auth_disabled; int TMH_have_self_provisioning; +int TMH_password_change_mfa; + enum TEH_TanChannelSet TEH_mandatory_tan_channels; struct GNUNET_TIME_Relative TMH_legal_expiration; @@ -1257,6 +1259,18 @@ run (void *cls, TMH_have_self_provisioning = GNUNET_NO; } + if (GNUNET_SYSERR == + (TMH_password_change_mfa + = GNUNET_CONFIGURATION_get_value_yesno (TMH_cfg, + "merchant", + "PASSWORD_CHANGE_MFA"))) + { + GNUNET_log_config_missing (GNUNET_ERROR_TYPE_INFO, + "merchant", + "PASSWORD_CHANGE_MFA"); + TMH_password_change_mfa = GNUNET_NO; + } + if (GNUNET_OK != GNUNET_CONFIGURATION_get_value_time (TMH_cfg, "merchant", @@ -1539,6 +1553,19 @@ run (void *cls, } } + if ( (GNUNET_YES == TMH_password_change_mfa) && + (TMH_TCS_NONE == TEH_mandatory_tan_channels) ) + { + GNUNET_log_config_invalid ( + GNUNET_ERROR_TYPE_ERROR, + "merchant", + "PASSWORD_CHANGE_MFA", + "requires at least one MANDATORY_TAN_CHANNELS entry"); + global_ret = EXIT_NOTCONFIGURED; + GNUNET_SCHEDULER_shutdown (); + return; + } + if (GNUNET_OK == GNUNET_CONFIGURATION_get_value_string (TMH_cfg, "merchant", diff --git a/src/backend/taler-merchant-httpd.h b/src/backend/taler-merchant-httpd.h @@ -839,6 +839,12 @@ extern int TMH_auth_disabled; extern int TMH_have_self_provisioning; /** + * #GNUNET_YES if changing an instance password requires MFA. + * (Default is #GNUNET_NO) + */ +extern int TMH_password_change_mfa; + +/** * Set of TAN channels. */ enum TEH_TanChannelSet 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 @@ -95,6 +95,7 @@ reply_with_login_token ( * @param require_old_password require the current password when the * instance currently uses password authentication * @param tcs set of multi-factor authorizations required + * @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 * @return MHD result code @@ -106,6 +107,7 @@ post_instances_ID_auth (struct TMH_MerchantInstance *mi, bool auth_override, bool require_old_password, enum TEH_TanChannelSet tcs, + bool mfa_combi_and, struct GNUNET_TIME_Relative login_token_duration) { struct TALER_MERCHANTDB_InstanceAuthSettings ias; @@ -157,31 +159,76 @@ post_instances_ID_auth (struct TMH_MerchantInstance *mi, return (GNUNET_NO == ret) ? MHD_YES : MHD_NO; } - if ( (0 != (tcs & TMH_TCS_SMS) && - ( (NULL == mi->settings.phone) || - (NULL == TMH_helper_sms) || - (! mi->settings.phone_validated) ) ) ) { - GNUNET_log (GNUNET_ERROR_TYPE_WARNING, - "Cannot reset password: SMS factor not available\n"); - return TALER_MHD_reply_with_error ( - connection, - MHD_HTTP_FORBIDDEN, - TALER_EC_MERCHANT_GENERIC_MFA_MISSING, - "phone_number"); - } - if ( (0 != (tcs & TMH_TCS_EMAIL) && - ( (NULL == mi->settings.email) || - (NULL == TMH_helper_email) || - (! mi->settings.email_validated) ) ) ) - { - GNUNET_log (GNUNET_ERROR_TYPE_WARNING, - "Cannot reset password: E-mail factor not available\n"); - return TALER_MHD_reply_with_error ( - connection, - MHD_HTTP_FORBIDDEN, - TALER_EC_MERCHANT_GENERIC_MFA_MISSING, - "email"); + enum TEH_TanChannelSet available_tcs = TMH_TCS_NONE; + bool have_sms = (NULL != mi->settings.phone) && + (NULL != TMH_helper_sms) && + mi->settings.phone_validated; + bool have_email = (NULL != mi->settings.email) && + (NULL != TMH_helper_email) && + mi->settings.email_validated; + + if (have_sms && + (0 != (tcs & TMH_TCS_SMS))) + available_tcs |= TMH_TCS_SMS; + if (have_email && + (0 != (tcs & TMH_TCS_EMAIL))) + available_tcs |= TMH_TCS_EMAIL; + + if (mfa_combi_and && + (0 != (tcs & TMH_TCS_SMS)) && + (! have_sms)) + { + GNUNET_log (GNUNET_ERROR_TYPE_WARNING, + "Cannot change authentication: SMS factor not available\n"); + return TALER_MHD_reply_with_error ( + connection, + MHD_HTTP_FORBIDDEN, + TALER_EC_MERCHANT_GENERIC_MFA_MISSING, + "phone_number"); + } + if (mfa_combi_and && + (0 != (tcs & TMH_TCS_EMAIL)) && + (! have_email)) + { + GNUNET_log (GNUNET_ERROR_TYPE_WARNING, + "Cannot change authentication: E-mail factor not available\n"); + return TALER_MHD_reply_with_error ( + connection, + MHD_HTTP_FORBIDDEN, + TALER_EC_MERCHANT_GENERIC_MFA_MISSING, + "email"); + } + if ( (TMH_TCS_NONE != tcs) && + (TMH_TCS_NONE == available_tcs) ) + { + const char *missing_factor; + + switch (tcs) + { + case TMH_TCS_SMS: + missing_factor = "phone_number"; + break; + case TMH_TCS_EMAIL: + missing_factor = "email"; + break; + case TMH_TCS_EMAIL_AND_SMS: + missing_factor = "phone_number or email"; + break; + case TMH_TCS_NONE: + GNUNET_assert (0); + missing_factor = NULL; + break; + } + GNUNET_log (GNUNET_ERROR_TYPE_WARNING, + "Cannot change authentication: no MFA factor available\n"); + return TALER_MHD_reply_with_error ( + connection, + MHD_HTTP_FORBIDDEN, + TALER_EC_MERCHANT_GENERIC_MFA_MISSING, + missing_factor); + } + tcs = available_tcs; } if (! auth_override) { @@ -196,7 +243,7 @@ post_instances_ID_auth (struct TMH_MerchantInstance *mi, ret = TMH_mfa_challenges_do (hc, mi->settings.id, TALER_MERCHANT_MFA_CO_AUTH_CONFIGURATION, - true, + mfa_combi_and, TALER_MERCHANT_MFA_CHANNEL_SMS, mi->settings.phone, TALER_MERCHANT_MFA_CHANNEL_NONE); @@ -205,7 +252,7 @@ post_instances_ID_auth (struct TMH_MerchantInstance *mi, ret = TMH_mfa_challenges_do (hc, mi->settings.id, TALER_MERCHANT_MFA_CO_AUTH_CONFIGURATION, - true, + mfa_combi_and, TALER_MERCHANT_MFA_CHANNEL_EMAIL, mi->settings.email, TALER_MERCHANT_MFA_CHANNEL_NONE); @@ -214,7 +261,7 @@ post_instances_ID_auth (struct TMH_MerchantInstance *mi, ret = TMH_mfa_challenges_do (hc, mi->settings.id, TALER_MERCHANT_MFA_CO_AUTH_CONFIGURATION, - true, + mfa_combi_and, TALER_MERCHANT_MFA_CHANNEL_EMAIL, mi->settings.email, TALER_MERCHANT_MFA_CHANNEL_SMS, @@ -418,7 +465,10 @@ TMH_private_post_instances_ID_auth (const struct TMH_RequestHandler *rh, hc, false, true, - TMH_TCS_NONE, + (GNUNET_YES == TMH_password_change_mfa) + ? TEH_mandatory_tan_channels + : TMH_TCS_NONE, + false, GNUNET_TIME_UNIT_ZERO); } @@ -480,6 +530,7 @@ TMH_public_post_instances_ID_auth (const struct TMH_RequestHandler *rh, false, false, TEH_mandatory_tan_channels, + true, token_duration); } @@ -508,6 +559,7 @@ TMH_private_post_instances_default_ID_auth ( true, false, TMH_TCS_NONE, + false, GNUNET_TIME_UNIT_ZERO); return ret; } diff --git a/src/testing/test_merchant_mfa.conf b/src/testing/test_merchant_mfa.conf @@ -17,6 +17,7 @@ DB = postgres HELPER_SMS = ./test_sms_helper.sh HELPER_EMAIL = ./test_email_helper.sh MANDATORY_TAN_CHANNELS = sms email +PASSWORD_CHANGE_MFA = YES ENABLE_SELF_PROVISIONING = YES # This specifies which database the postgres backend uses. diff --git a/src/testing/test_merchant_mfa.sh b/src/testing/test_merchant_mfa.sh @@ -21,6 +21,28 @@ set -eu . setup.sh +echo -n "Reject password-change MFA without mandatory TAN channels " +INVALID_CONFIG=$(mktemp -p "${TMPDIR:-/tmp}" test_merchant_mfa.conf-XXXXXX) +INVALID_LOG=$(mktemp -p "${TMPDIR:-/tmp}" test_merchant_mfa.log-XXXXXX) +cp test_merchant_mfa.conf "$INVALID_CONFIG" +sed -i '/^MANDATORY_TAN_CHANNELS[[:space:]]*=/d' "$INVALID_CONFIG" +STATUS=0 +timeout 10 taler-merchant-httpd -L INFO -c "$INVALID_CONFIG" \ + > "$INVALID_LOG" 2>&1 || STATUS=$? +if [ "$STATUS" = "0" ] || + [ "$STATUS" = "124" ] || + ! grep -q \ + 'requires at least one MANDATORY_TAN_CHANNELS entry' \ + "$INVALID_LOG" +then + cat "$INVALID_LOG" + rm -f "$INVALID_CONFIG" "$INVALID_LOG" + exit_fail "Expected invalid password-change MFA configuration to fail" +fi +rm -f "$INVALID_CONFIG" "$INVALID_LOG" +echo "OK" + + # Launch system. setup \ -c "test_merchant_mfa.conf" \ @@ -575,6 +597,58 @@ fi echo "OK" +echo -n "Require one additional factor for a password change " +PASSWORD_CHANGE_BODY='{"method":"token","password":"recovered","old_password":"recovered"}' +STATUS=$(curl \ + -X POST \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $RESET_TOKEN" \ + http://localhost:9966/instances/self/private/auth \ + -d "$PASSWORD_CHANGE_BODY" \ + -w "%{http_code}" -s \ + -o "$LAST_RESPONSE") + +if [ "$STATUS" != "202" ] || + [ "$(jq -r .combi_and < "$LAST_RESPONSE")" != "false" ] || + [ "$(jq -r '.challenges | length' < "$LAST_RESPONSE")" != "2" ] +then + jq < "$LAST_RESPONSE" + exit_fail "Expected password change to offer either MFA channel. Got: $STATUS" +fi + +C1=$(jq -r .challenges[0].challenge_id < "$LAST_RESPONSE") +CHANNEL=$(jq -r .challenges[0].tan_channel < "$LAST_RESPONSE") +case "$CHANNEL" in +email) + EXPECTED_ADDRESS="self@example.com" + ;; +sms) + EXPECTED_ADDRESS="+4171234" + ;; +*) + exit_fail "Unexpected TAN channel: $CHANNEL" + ;; +esac +solve_challenge "$C1" "$CHANNEL" "$EXPECTED_ADDRESS" + +STATUS=$(curl \ + -X POST \ + -H "Content-Type: application/json" \ + -H "Taler-Challenge-Ids: $C1" \ + -H "Authorization: Bearer $RESET_TOKEN" \ + http://localhost:9966/instances/self/private/auth \ + -d "$PASSWORD_CHANGE_BODY" \ + -w "%{http_code}" -s \ + -o "$LAST_RESPONSE") + +if [ "$STATUS" != "204" ] +then + jq < "$LAST_RESPONSE" + exit_fail "Expected one solved challenge to authorize password change. Got: $STATUS" +fi +echo "OK" + + echo -n "Self-provision second instance for instance-binding test " OTHER_INSTANCE_BODY='{"auth":{"method":"token","password":"recovered"},"id":"other","name":"other","phone_number":"+4171234","email":"self@example.com","address":{},"jurisdiction":{},"use_stefan":true,"default_wire_transfer_delay":{"d_us":50000000},"default_pay_delay":{"d_us":60000000}}' STATUS=$(curl -H "Content-Type: application/json" -X POST \