merchant

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

commit 540dff0e1170c3e0f6da60f449e0056f3997f3e6
parent 221a8fc339ce58d7e5718ba0a4f840812779ffcb
Author: Florian Dold <dold@taler.net>
Date:   Sat, 29 Aug 2026 17:04:09 +0200

merchant authentication: return login token after password reset

Diffstat:
Msrc/backend/taler-merchant-httpd_get-config.c | 2+-
Msrc/backend/taler-merchant-httpd_post-management-instances-INSTANCE-auth.c | 122++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/lib/merchant_api_get-config.c | 4++--
Msrc/testing/test_merchant_mfa.sh | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
4 files changed, 206 insertions(+), 11 deletions(-)

diff --git a/src/backend/taler-merchant-httpd_get-config.c b/src/backend/taler-merchant-httpd_get-config.c @@ -44,7 +44,7 @@ * #MERCHANT_PROTOCOL_CURRENT and #MERCHANT_PROTOCOL_AGE in * merchant_api_get_config.c! */ -#define MERCHANT_PROTOCOL_VERSION "38:0:26" +#define MERCHANT_PROTOCOL_VERSION "39:0:27" /** 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 @@ -30,6 +30,7 @@ #include "taler-merchant-httpd_mfa.h" #include <taler/taler_json_lib.h> #include "merchant-database/get_instance_auth.h" +#include "merchant-database/insert_login_token.h" #include "merchant-database/update_instance_auth.h" #include "merchant-database/start.h" @@ -41,6 +42,48 @@ /** + * Return a login token created as part of a password reset. + * + * @param connection connection to respond on + * @param token binary value of the token + * @param expiration_time when the token expires + * @return MHD result code + */ +static enum MHD_Result +reply_with_login_token ( + struct MHD_Connection *connection, + const struct TALER_MERCHANTDB_LoginTokenP *token, + struct GNUNET_TIME_Timestamp expiration_time) +{ + char *token_data; + char *access_token; + enum MHD_Result ret; + + token_data = GNUNET_STRINGS_data_to_string_alloc (token, + sizeof (*token)); + GNUNET_asprintf (&access_token, + RFC_8959_PREFIX "%s", + token_data); + GNUNET_free (token_data); + ret = TALER_MHD_REPLY_JSON_PACK ( + connection, + MHD_HTTP_OK, + GNUNET_JSON_pack_string ("access_token", + access_token), + GNUNET_JSON_pack_string ("token", + access_token), + GNUNET_JSON_pack_string ("scope", + "spa"), + GNUNET_JSON_pack_bool ("refreshable", + true), + GNUNET_JSON_pack_timestamp ("expiration", + expiration_time)); + GNUNET_free (access_token); + return ret; +} + + +/** * Change the authentication settings of an instance. * * @param mi instance to modify settings of @@ -50,6 +93,8 @@ * do not apply due to administrative action. Do not check * against the DB value when updating the auth token. * @param tcs set of multi-factor authorizations required + * @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 */ static enum MHD_Result @@ -57,11 +102,26 @@ post_instances_ID_auth (struct TMH_MerchantInstance *mi, struct MHD_Connection *connection, struct TMH_HandlerContext *hc, bool auth_override, - enum TEH_TanChannelSet tcs) + enum TEH_TanChannelSet tcs, + struct GNUNET_TIME_Relative login_token_duration) { struct TALER_MERCHANTDB_InstanceAuthSettings ias; + struct TALER_MERCHANTDB_LoginTokenP login_token; + struct GNUNET_TIME_Timestamp token_creation_time; + struct GNUNET_TIME_Timestamp token_expiration_time; const char *auth_pw = NULL; json_t *jauth = hc->request_body; + bool issue_login_token + = ! GNUNET_TIME_relative_is_zero (login_token_duration); + + if (issue_login_token) + { + GNUNET_CRYPTO_random_block (&login_token, + sizeof (login_token)); + token_creation_time = GNUNET_TIME_timestamp_get (); + token_expiration_time + = GNUNET_TIME_relative_to_timestamp (login_token_duration); + } { enum GNUNET_GenericReturnValue ret; @@ -251,6 +311,34 @@ post_instances_ID_auth (struct TMH_MerchantInstance *mi, } goto retry; } + if (issue_login_token) + { + qs = TALER_MERCHANTDB_insert_login_token ( + TMH_db, + mi->settings.id, + &login_token, + token_creation_time, + token_expiration_time, + TMH_AS_REFRESHABLE | TMH_AS_SPA, + "login token from password reset"); + switch (qs) + { + case GNUNET_DB_STATUS_SOFT_ERROR: + TALER_MERCHANTDB_rollback (TMH_db); + goto retry; + case GNUNET_DB_STATUS_HARD_ERROR: + case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS: + GNUNET_break (0); + TALER_MERCHANTDB_rollback (TMH_db); + return TALER_MHD_reply_with_error ( + connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_STORE_FAILED, + "insert_login_token"); + case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT: + break; + } + } qs = TALER_MERCHANTDB_commit (TMH_db); if (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS == qs) qs = GNUNET_DB_STATUS_SUCCESS_ONE_RESULT; @@ -269,6 +357,10 @@ retry: mi->auth = ias; } TMH_reload_instances (mi->settings.id); + if (issue_login_token) + return reply_with_login_token (connection, + &login_token, + token_expiration_time); return TALER_MHD_reply_static (connection, MHD_HTTP_NO_CONTENT, NULL, @@ -288,7 +380,8 @@ TMH_private_post_instances_ID_auth (const struct TMH_RequestHandler *rh, connection, hc, false, - TMH_TCS_NONE); + TMH_TCS_NONE, + GNUNET_TIME_UNIT_ZERO); } @@ -298,6 +391,25 @@ TMH_public_post_instances_ID_auth (const struct TMH_RequestHandler *rh, struct TMH_HandlerContext *hc) { struct TMH_MerchantInstance *mi = hc->instance; + struct GNUNET_TIME_Relative token_duration = GNUNET_TIME_UNIT_ZERO; + struct GNUNET_JSON_Specification spec[] = { + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_relative_time ("token_duration", + &token_duration), + NULL), + GNUNET_JSON_spec_end () + }; + + { + enum GNUNET_GenericReturnValue res; + + res = TALER_MHD_parse_json_data (connection, + hc->request_body, + spec); + if (GNUNET_OK != res) + return (GNUNET_NO == res) ? MHD_YES : MHD_NO; + } + GNUNET_JSON_parse_free (spec); if (0 == strcmp ("admin", mi->settings.id)) @@ -328,7 +440,8 @@ TMH_public_post_instances_ID_auth (const struct TMH_RequestHandler *rh, connection, hc, false, - TEH_mandatory_tan_channels); + TEH_mandatory_tan_channels, + token_duration); } @@ -354,7 +467,8 @@ TMH_private_post_instances_default_ID_auth ( connection, hc, true, - TMH_TCS_NONE); + TMH_TCS_NONE, + GNUNET_TIME_UNIT_ZERO); return ret; } diff --git a/src/lib/merchant_api_get-config.c b/src/lib/merchant_api_get-config.c @@ -34,12 +34,12 @@ * Which version of the Taler protocol is implemented * by this library? Used to determine compatibility. */ -#define MERCHANT_PROTOCOL_CURRENT 38 +#define MERCHANT_PROTOCOL_CURRENT 39 /** * How many configs are we backwards-compatible with? */ -#define MERCHANT_PROTOCOL_AGE 14 +#define MERCHANT_PROTOCOL_AGE 15 /** * How many exchanges do we allow at most per merchant? diff --git a/src/testing/test_merchant_mfa.sh b/src/testing/test_merchant_mfa.sh @@ -477,8 +477,89 @@ echo " OK" +echo -n "Begin password reset with login token issuance " +TOKEN_RESET_BODY='{"method":"token","password":"recovered","token_duration":{"d_us":600000000}}' +STATUS=$(curl \ + -X POST \ + -H "Content-Type: application/json" \ + http://localhost:9966/instances/self/forgot-password \ + -d "$TOKEN_RESET_BODY" \ + -w "%{http_code}" -s \ + -o "$LAST_RESPONSE") + +if [ "$STATUS" != "202" ] +then + jq < "$LAST_RESPONSE" + exit_fail "Expected 202 Accepted. Got: $STATUS" +fi +cp "$LAST_RESPONSE" "$LAST_RESPONSE.challenges" +RESET_CHALLENGE_IDS=$(jq -r '[.challenges[].challenge_id] | join(",")' \ + < "$LAST_RESPONSE") +solve_response_challenges "$LAST_RESPONSE.challenges" +echo "OK" + + +echo -n "Reject solved challenges for a modified token duration " +STATUS=$(curl \ + -X POST \ + -H "Content-Type: application/json" \ + -H "Taler-Challenge-Ids: $RESET_CHALLENGE_IDS" \ + http://localhost:9966/instances/self/forgot-password \ + -d '{"method":"token","password":"recovered","token_duration":{"d_us":300000000}}' \ + -w "%{http_code}" -s \ + -o "$LAST_RESPONSE") + +if [ "$STATUS" != "202" ] +then + jq < "$LAST_RESPONSE" + exit_fail "Expected changed request body to require new challenges. Got: $STATUS" +fi +echo "OK" + + +echo -n "Complete password reset and receive login token " +STATUS=$(curl \ + -X POST \ + -H "Content-Type: application/json" \ + -H "Taler-Challenge-Ids: $RESET_CHALLENGE_IDS" \ + http://localhost:9966/instances/self/forgot-password \ + -d "$TOKEN_RESET_BODY" \ + -w "%{http_code}" -s \ + -o "$LAST_RESPONSE") + +if [ "$STATUS" != "200" ] +then + jq < "$LAST_RESPONSE" + exit_fail "Expected 200 OK with login token. Got: $STATUS" +fi +RESET_TOKEN=$(jq -er \ + 'select(.scope == "spa" and .refreshable == true) | .access_token' \ + < "$LAST_RESPONSE") +if [ "$(jq -r .token < "$LAST_RESPONSE")" != "$RESET_TOKEN" ] +then + exit_fail "Expected token and access_token response fields to match" +fi +echo "OK" + + +echo -n "Use login token returned by password reset " +STATUS=$(curl \ + -X GET \ + -H "Authorization: Bearer $RESET_TOKEN" \ + http://localhost:9966/instances/self/private/products \ + -w "%{http_code}" -s \ + -o "$LAST_RESPONSE") + +if [ "$STATUS" != "200" ] +then + jq < "$LAST_RESPONSE" + exit_fail "Expected reset login token to authorize SPA access. Got: $STATUS" +fi +echo "OK" + + echo -n "Self-provision second instance for instance-binding test " -OTHER_INSTANCE_BODY='{"auth":{"method":"token","password":"amnesia"},"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}}' +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 \ http://localhost:9966/instances \ -d "$OTHER_INSTANCE_BODY" \ @@ -512,7 +593,7 @@ echo "OK" echo -n "Solve token-creation challenge for first instance " TOKEN_BODY='{"scope":"spa","duration":{"d_us":600000000},"refreshable":true}' STATUS=$(curl -H "Content-Type: application/json" -X POST \ - -H 'Authorization: Bearer secret-token:amnesia' \ + -H 'Authorization: Bearer secret-token:recovered' \ http://localhost:9966/instances/self/private/token \ -d "$TOKEN_BODY" \ -w "%{http_code}" -s \ @@ -543,7 +624,7 @@ echo "OK" echo -n "Reject solved challenge at a different instance " STATUS=$(curl -H "Content-Type: application/json" -X POST \ - -H 'Authorization: Bearer secret-token:amnesia' \ + -H 'Authorization: Bearer secret-token:recovered' \ -H "Taler-Challenge-Ids: $INSTANCE_CHALLENGE_ID" \ http://localhost:9966/instances/other/private/token \ -d "$TOKEN_BODY" \ @@ -564,7 +645,7 @@ echo "OK" echo -n "Accept solved challenge at its original instance " STATUS=$(curl -H "Content-Type: application/json" -X POST \ - -H 'Authorization: Bearer secret-token:amnesia' \ + -H 'Authorization: Bearer secret-token:recovered' \ -H "Taler-Challenge-Ids: $INSTANCE_CHALLENGE_ID" \ http://localhost:9966/instances/self/private/token \ -d "$TOKEN_BODY" \