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