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:
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 \