From d3e4798b11746b38b0658fcbd5dcf27370784299 Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Mon, 27 Jul 2026 18:40:40 +0200 Subject: [PATCH] Complete subuser notification and recovery lifecycle (#325) ## Summary - notify customers by SMS with approve/deny links when a subuser requests access - notify subusers by SMS after approval or denial, including manual grant changes - support subuser password reset and authenticated password changes - add read-only token previews followed by explicit POST confirmation - store short-lived one-time purpose-bound action tokens only as SHA-256 digests - serialize grant decisions transactionally to prevent conflicting concurrent actions - document the API contract in OpenAPI ## Security - generic reset responses reduce account enumeration - URL tokens are removed from browser history after frontend bootstrap - approval previews never mutate state - concurrent decisions lock the exact grant row - SMS failures remain non-fatal and are returned as delivery status Residual risk: existing subuser sessions cannot all be centrally invalidated after password reset because there is no per-subuser session index; they expire normally within the existing session lifetime. ## Verification - backend Pest: 14 tests, 91 assertions - PHP syntax checks passed - focused PHPStan passed - OpenAPI YAML parsed successfully - `git diff --check` passed Database-backed API integration tests were unavailable because the local environment lacks the required database configuration. ## Paired delivery Paired Frontend PR: https://github.com/copenhagentruckwash/pleno-vue/pull/231 Both PRs are required before completion. The frontend PR contains the responsive visual comparisons. Co-authored-by: Jeppe Bundgaard --- .../classes/subuser_action_token_service.php | 208 ++++++++++++ .../app/classes/subusers_schema_bootstrap.php | 31 ++ services/nginx/app/openapi.yaml | 114 +++++++ services/nginx/app/routes/subusersRoute.php | 310 +++++++++++++++++- .../tests/Support/Api/ApiSchemaBootstrap.php | 17 + .../SubuserActionTokenServiceContractTest.php | 37 +++ .../SubusersRouteManagementContractTest.php | 28 ++ 7 files changed, 740 insertions(+), 5 deletions(-) create mode 100644 services/nginx/app/classes/subuser_action_token_service.php create mode 100644 services/nginx/app/tests/Unit/Subusers/SubuserActionTokenServiceContractTest.php diff --git a/services/nginx/app/classes/subuser_action_token_service.php b/services/nginx/app/classes/subuser_action_token_service.php new file mode 100644 index 00000000..697686ca --- /dev/null +++ b/services/nginx/app/classes/subuser_action_token_service.php @@ -0,0 +1,208 @@ +assertPurpose($purpose); + if ($subuserId <= 0) { + throw new Exception('Invalid subuser action token subject'); + } + $token = bin2hex(random_bytes(self::TOKEN_BYTES)); + $tokenHash = hash('sha256', $token); + $ttlSeconds ??= $purpose === self::PURPOSE_PASSWORD_RESET + ? self::PASSWORD_RESET_TTL_SECONDS + : self::GRANT_DECISION_TTL_SECONDS; + $expiresAt = gmdate('Y-m-d H:i:s', time() + max(60, $ttlSeconds)); + $statement = $db->conn->prepare( + 'INSERT INTO subuser_action_tokens ' + . '(token_hash, purpose, subuser_id, grant_id, customer_number, expires_at) VALUES (?, ?, ?, ?, ?, ?)' + ); + if ($statement === false) { + throw new Exception('Failed to prepare subuser action token'); + } + $statement->bind_param('ssiiis', $tokenHash, $purpose, $subuserId, $grantId, $customerNumber, $expiresAt); + $statement->execute(); + $statement->close(); + return $token; + } + + public function inspect(string $token, ?string $expectedPurpose = null): ?array + { + global $db; + $token = strtolower($token); + if (!preg_match('/^[a-f0-9]{64}$/', $token)) { + return null; + } + if ($expectedPurpose !== null) { + $this->assertPurpose($expectedPurpose); + } + $tokenHash = hash('sha256', $token); + $sql = 'SELECT id, purpose, subuser_id, grant_id, customer_number, expires_at FROM subuser_action_tokens ' + . "WHERE token_hash = '" . $db->escape_string($tokenHash) . "' " + . 'AND used_at IS NULL AND expires_at > UTC_TIMESTAMP()'; + if ($expectedPurpose !== null) { + $sql .= " AND purpose = '" . $db->escape_string($expectedPurpose) . "'"; + } + $result = $db->query($sql . ' LIMIT 1'); + if ($result === false || $result->num_rows === 0) { + return null; + } + $row = $result->fetch_assoc(); + return [ + 'id' => (int)$row['id'], + 'purpose' => (string)$row['purpose'], + 'subuser_id' => (int)$row['subuser_id'], + 'grant_id' => $row['grant_id'] === null ? null : (int)$row['grant_id'], + 'customer_number' => $row['customer_number'] === null ? null : (int)$row['customer_number'], + 'expires_at' => (string)$row['expires_at'], + ]; + } + + public function consume(string $token, string $expectedPurpose): ?array + { + global $db; + $record = $this->inspect($token, $expectedPurpose); + if ($record === null) { + return null; + } + $statement = $db->conn->prepare( + 'UPDATE subuser_action_tokens SET used_at = UTC_TIMESTAMP() ' + . 'WHERE id = ? AND used_at IS NULL AND expires_at > UTC_TIMESTAMP()' + ); + if ($statement === false) { + throw new Exception('Failed to consume subuser action token'); + } + $id = (int)$record['id']; + $statement->bind_param('i', $id); + $statement->execute(); + $consumed = $statement->affected_rows === 1; + $statement->close(); + return $consumed ? $record : null; + } + + public function consumeGrantDecision(string $token): ?array + { + global $db; + + $preview = $this->inspect($token); + if ( + $preview === null + || $preview['grant_id'] === null + || !in_array($preview['purpose'], [ + self::PURPOSE_GRANT_APPROVE, + self::PURPOSE_GRANT_DENY, + ], true) + ) { + return null; + } + + $db->conn->begin_transaction(); + try { + // Serializing on the grant row prevents simultaneous approve and + // deny links from racing and applying opposite final states. + $lock = $db->conn->prepare( + 'SELECT id FROM subuser_grants ' + . 'WHERE id = ? AND subuser = ? AND billing_customer_number = ? AND deleted_at IS NULL ' + . 'FOR UPDATE' + ); + if ($lock === false) { + throw new Exception('Failed to lock subuser grant decision'); + } + $grantId = (int)$preview['grant_id']; + $subuserId = (int)$preview['subuser_id']; + $customerNumber = (int)$preview['customer_number']; + $lock->bind_param('iii', $grantId, $subuserId, $customerNumber); + $lock->execute(); + $lock->store_result(); + $grantExists = $lock->num_rows === 1; + $lock->close(); + if (!$grantExists) { + $db->conn->rollback(); + return null; + } + + $record = $this->consume($token, (string)$preview['purpose']); + if ($record === null) { + $db->conn->rollback(); + return null; + } + $enabled = $record['purpose'] === self::PURPOSE_GRANT_APPROVE ? 1 : 0; + $update = $db->conn->prepare('UPDATE subuser_grants SET enabled = ? WHERE id = ?'); + if ($update === false) { + throw new Exception('Failed to apply subuser grant decision'); + } + $update->bind_param('ii', $enabled, $grantId); + $update->execute(); + $applied = $update->affected_rows === 1 || $update->warning_count === 0; + $update->close(); + if (!$applied) { + throw new Exception('Failed to apply subuser grant decision'); + } + $this->revokeGrantDecisions($grantId); + $db->conn->commit(); + return $record; + } catch (Exception $exception) { + $db->conn->rollback(); + throw $exception; + } + } + + public function revokeForSubuser(int $subuserId, string $purpose): void + { + global $db; + $this->assertPurpose($purpose); + $statement = $db->conn->prepare( + 'UPDATE subuser_action_tokens SET used_at = UTC_TIMESTAMP() WHERE subuser_id = ? AND purpose = ? AND used_at IS NULL' + ); + if ($statement === false) { + throw new Exception('Failed to revoke subuser action tokens'); + } + $statement->bind_param('is', $subuserId, $purpose); + $statement->execute(); + $statement->close(); + } + + public function revokeGrantDecisions(int $grantId): void + { + global $db; + $statement = $db->conn->prepare( + 'UPDATE subuser_action_tokens SET used_at = UTC_TIMESTAMP() ' + . 'WHERE grant_id = ? AND purpose IN (?, ?) AND used_at IS NULL' + ); + if ($statement === false) { + throw new Exception('Failed to revoke grant decision tokens'); + } + $approve = self::PURPOSE_GRANT_APPROVE; + $deny = self::PURPOSE_GRANT_DENY; + $statement->bind_param('iss', $grantId, $approve, $deny); + $statement->execute(); + $statement->close(); + } + + private function assertPurpose(string $purpose): void + { + if (!in_array($purpose, [ + self::PURPOSE_GRANT_APPROVE, + self::PURPOSE_GRANT_DENY, + self::PURPOSE_PASSWORD_RESET, + ], true)) { + throw new Exception('Invalid subuser action token purpose'); + } + } +} diff --git a/services/nginx/app/classes/subusers_schema_bootstrap.php b/services/nginx/app/classes/subusers_schema_bootstrap.php index 232cad20..a0a19cb0 100644 --- a/services/nginx/app/classes/subusers_schema_bootstrap.php +++ b/services/nginx/app/classes/subusers_schema_bootstrap.php @@ -38,10 +38,41 @@ class subusers_schema_bootstrap 'email_verified_at', 'DATETIME NULL AFTER `email`' ); + self::ensureTable( + 'subuser_action_tokens', + <<<'SQL' +CREATE TABLE IF NOT EXISTS `subuser_action_tokens` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `token_hash` CHAR(64) NOT NULL, + `purpose` VARCHAR(32) NOT NULL, + `subuser_id` INT UNSIGNED NOT NULL, + `grant_id` INT UNSIGNED NULL, + `customer_number` INT NULL, + `expires_at` DATETIME NOT NULL, + `used_at` DATETIME NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_subuser_action_token_hash` (`token_hash`), + KEY `idx_subuser_action_token_subject` (`subuser_id`, `purpose`, `used_at`), + KEY `idx_subuser_action_token_expiry` (`expires_at`, `used_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL + ); self::$initialized = true; } + private static function ensureTable(string $table, string $definition): void + { + global $db; + + $table = preg_replace('/[^a-zA-Z0-9_]/', '', $table); + if ($table === '') { + return; + } + $db->query($definition); + } + private static function ensureColumn(string $table, string $column, string $definition): void { global $db; diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml index 89018725..a8ea8fe9 100644 --- a/services/nginx/app/openapi.yaml +++ b/services/nginx/app/openapi.yaml @@ -2406,6 +2406,120 @@ paths: '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/InternalServerError' } + /subusers/me/password: + post: + tags: [Subusers] + summary: Change the authenticated subuser password + operationId: changeCurrentSubuserPassword + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [current_password, password] + properties: + current_password: { type: string, format: password } + password: + type: string + format: password + minLength: 8 + maxLength: 255 + responses: + '200': { description: Password updated } + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + + /subusers/password-reset/request: + post: + tags: [Subusers] + summary: Request a subuser password-reset SMS + description: Always returns the same success response regardless of account or delivery state. + operationId: requestSubuserPasswordReset + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [phone_country_code, phone] + properties: + phone_country_code: { type: integer, minimum: 1, maximum: 999 } + phone: { type: integer, minimum: 1000 } + responses: + '200': { description: Generic reset-request acknowledgement } + '400': { $ref: '#/components/responses/BadRequest' } + + /subusers/password-reset/validate: + get: + tags: [Subusers] + summary: Validate a subuser password-reset token + operationId: validateSubuserPasswordReset + security: [] + parameters: + - in: query + name: token + required: true + schema: { type: string, pattern: '^[a-f0-9]{64}$' } + responses: + '200': { description: Token is valid } + '404': { $ref: '#/components/responses/NotFound' } + + /subusers/password-reset/set: + post: + tags: [Subusers] + summary: Set a subuser password with a one-time token + operationId: setSubuserPasswordFromReset + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [token, password] + properties: + token: { type: string, pattern: '^[a-f0-9]{64}$' } + password: { type: string, format: password, minLength: 8, maxLength: 255 } + responses: + '200': { description: Password updated } + '400': { $ref: '#/components/responses/BadRequest' } + '404': { $ref: '#/components/responses/NotFound' } + + /subusers/access-decision: + get: + tags: [Subusers] + summary: Preview a pre-authorized grant decision + description: Read-only preview used before the customer explicitly confirms the decision. + operationId: previewSubuserAccessDecision + security: [] + parameters: + - in: query + name: token + required: true + schema: { type: string, pattern: '^[a-f0-9]{64}$' } + responses: + '200': { description: Decision preview } + '404': { $ref: '#/components/responses/NotFound' } + post: + tags: [Subusers] + summary: Apply a pre-authorized one-time grant decision + operationId: applySubuserAccessDecision + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [token] + properties: + token: { type: string, pattern: '^[a-f0-9]{64}$' } + responses: + '200': { description: Grant approved or denied and subuser notified } + '404': { $ref: '#/components/responses/NotFound' } + /subusers/grants: get: tags: diff --git a/services/nginx/app/routes/subusersRoute.php b/services/nginx/app/routes/subusersRoute.php index f150e006..b98cf369 100644 --- a/services/nginx/app/routes/subusersRoute.php +++ b/services/nginx/app/routes/subusersRoute.php @@ -8,6 +8,7 @@ use classes\economic; use classes\email; use classes\gatewayapi; use classes\response; +use classes\subuser_action_token_service; use classes\subuser_contact_verification_service; use classes\subusers_schema_bootstrap; use classes\subuser_permission_templates_service; @@ -833,6 +834,76 @@ class subusersRoute ]; } + private function deliverSms(?string $destination, string $message): array + { + if ($destination === null || trim($destination) === '') { + return $this->subuserLinkDelivery('sms', 'missing_destination', 'SMS-modtager mangler.'); + } + + try { + $gateway = new gatewayapi(); + if (!$gateway->isEnabled()) { + return $this->subuserLinkDelivery('sms', 'unavailable', 'SMS-afsendelse er ikke konfigureret.'); + } + $gateway->send([$destination], $message); + return $this->subuserLinkDelivery('sms', 'sent', 'SMS er sendt.'); + } catch (Exception) { + return $this->subuserLinkDelivery('sms', 'failed', 'SMS kunne ikke sendes.'); + } + } + + private function subuserPhoneDestination(subusers_o $subuser): ?string + { + $countryCode = trim((string)($subuser->phone_country_code->value() ?? '')); + $phone = trim((string)($subuser->phone->value() ?? '')); + return $countryCode !== '' && $phone !== '' ? $countryCode . $phone : null; + } + + private function notifySubuserGrantDecision(subusers_o $subuser, int $customerNumber, bool $approved): array + { + $customerName = $this->resolveCustomerName($customerNumber) ?: ('kunde #' . $customerNumber); + $message = $approved + ? 'Truck Wash: Din adgang til ' . $customerName . ' er godkendt.' + : 'Truck Wash: Din anmodning om adgang til ' . $customerName . ' er afvist eller deaktiveret.'; + return $this->deliverSms($this->subuserPhoneDestination($subuser), $message); + } + + private function notifyCustomerOfGrantRequest( + subuser_grants_o $grant, + subusers_o $subuser, + int $customerNumber + ): array { + $customer = (new users_o())->getUserByCustomerNumber($customerNumber); + if (!$customer->exists()) { + return $this->subuserLinkDelivery('sms', 'missing_destination', 'Kundekontoen blev ikke fundet.'); + } + $customer->getObjectProperties(); + $countryCode = trim((string)($customer->phone_country_code->value() ?? '')); + $phone = trim((string)($customer->phone->value() ?? '')); + $destination = $countryCode !== '' && $phone !== '' ? $countryCode . $phone : null; + + $tokens = new subuser_action_token_service(); + $approveToken = $tokens->issue( + subuser_action_token_service::PURPOSE_GRANT_APPROVE, + (int)$subuser->id, + (int)$grant->id, + $customerNumber + ); + $denyToken = $tokens->issue( + subuser_action_token_service::PURPOSE_GRANT_DENY, + (int)$subuser->id, + (int)$grant->id, + $customerNumber + ); + $name = trim((string)($subuser->name->value() ?? '')) ?: 'En chauffør'; + $base = $this->frontendBaseUrl() . '/subuser-access/decision?token='; + $message = 'Truck Wash: ' . $name . ' anmoder om adgang. ' + . 'Godkend: ' . $base . rawurlencode($approveToken) . ' ' + . 'Afvis: ' . $base . rawurlencode($denyToken); + + return $this->deliverSms($destination, $message); + } + private function buildSubuserManagementPayload(subusers_o $subuser, int $customerNumber, ?string $customerName = null): array { $grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer((int)$subuser->id, $customerNumber, true); @@ -1469,6 +1540,7 @@ class subusersRoute global $response; $grant = $this->getGrantForScopedUserOrFail($grantId, $customerNumber); + $wasEnabled = (bool)$grant->enabled->value(); $this->rejectBlockedSubuser((int)$grant->subuser->value()); $updates = []; $templateAccess = $this->parseAccessTemplatePayload(); @@ -1518,10 +1590,17 @@ class subusersRoute $updatedGrant->getObjectProperties(); $subuser = (new subusers_o())->select((int)$updatedGrant->subuser->value()); $subuser->getObjectProperties(); + $delivery = null; + $isEnabled = (bool)$updatedGrant->enabled->value(); + if ($isEnabled !== $wasEnabled) { + (new subuser_action_token_service())->revokeGrantDecisions($grantId); + $delivery = $this->notifySubuserGrantDecision($subuser, $customerNumber, $isEnabled); + } $response->success([ 'subuser' => $this->buildSubuserManagementPayload($subuser, $customerNumber), 'grant' => $updatedGrant->asArray(), + 'decision_notification' => $delivery, ]); } @@ -1809,6 +1888,7 @@ class subusersRoute if (!$grant->exists()) { $response->error('Grant not found', 404); } + $wasEnabled = (bool)$grant->enabled->value(); $this->rejectBlockedSubuser((int)$grant->subuser->value()); $targetCustomer = (int)$grant->billing_customer_number->value(); @@ -1851,7 +1931,17 @@ class subusersRoute $targetCustomer )); } - $response->success($grant->asArray()); + $delivery = null; + $isEnabled = (bool)$grant->enabled->value(); + if ($isEnabled !== $wasEnabled) { + (new subuser_action_token_service())->revokeGrantDecisions((int)$grant->id); + $subuser = $this->loadSubuserOrFail((int)$grant->subuser->value()); + $delivery = $this->notifySubuserGrantDecision($subuser, $targetCustomer, $isEnabled); + } + $response->success([ + ...$grant->asArray(), + 'decision_notification' => $delivery, + ]); }, [ 'manage_subuser_grants' => 'Edit subuser grants for any customer (admin).', @@ -1981,11 +2071,21 @@ class subusersRoute // Add the grant request $subuser_grants_o = new subuser_grants_o(); try { - $subuser_grants_o->add($results[0]->customerNumber, $subuser->id, false, null); + $grant = $subuser_grants_o->add($results[0]->customerNumber, $subuser->id, false, null); } catch (Exception $e) { $response->error('Failed to add subuser grant', 500); } - $response->success(['cvr' => $cvr, 'customer_number' => $results[0]->customerNumber, 'invite' => $invite]); + $customerNotification = $this->notifyCustomerOfGrantRequest( + $grant, + $subuser, + (int)$results[0]->customerNumber + ); + $response->success([ + 'cvr' => $cvr, + 'customer_number' => $results[0]->customerNumber, + 'invite' => $invite, + 'customer_notification' => $customerNotification, + ]); // Code for creating a new subuser would go here }); $this->get('/subusers/setup', function () { @@ -2066,6 +2166,170 @@ class subusersRoute } }); + $this->post('/subusers/password-reset/request', function () { + global $response; + + $this->requireRecaptcha(); + self::requireParameters(['phone_country_code', 'phone']); + $countryCode = (int)self::getParameter('phone_country_code'); + $phone = (int)self::getParameter('phone'); + self::requireType($countryCode, self::type_int()); + self::requireType($phone, self::type_int()); + self::requireMinLength('phone_country_code', 1); + self::requireMaxLength('phone_country_code', 3); + self::requireMinLength('phone', 4); + self::requireMaxLength('phone', 15); + + $generic = ['message' => 'Hvis chaufførkontoen findes, er et nulstillingslink sendt.']; + $this->recordThrottleAttempt( + 'subuser_password_reset', + 'phone:' . $countryCode . ':' . $phone, + 5, + 15 * 60 + ); + $subuser = (new subusers_o())->getSubuserByPhone($countryCode, $phone); + if ( + $subuser === null + || $subuser->requiresSetup() + || account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id) + ) { + $response->success($generic); + } + + try { + $tokens = new subuser_action_token_service(); + $tokens->revokeForSubuser( + (int)$subuser->id, + subuser_action_token_service::PURPOSE_PASSWORD_RESET + ); + $token = $tokens->issue( + subuser_action_token_service::PURPOSE_PASSWORD_RESET, + (int)$subuser->id + ); + $link = $this->frontendBaseUrl() + . '/auth/password-reset/' . rawurlencode($token) + . '?type=subuser'; + $this->deliverSms( + $this->subuserPhoneDestination($subuser), + 'Truck Wash: Nulstil din chaufføradgangskode her: ' . $link + ); + } catch (Exception) { + // Preserve the same response for every account and delivery state. + } + + $response->success($generic); + }); + + $this->get('/subusers/password-reset/validate', function () { + global $response; + self::requireParameters(['token']); + $record = (new subuser_action_token_service())->inspect( + (string)self::getParameter('token'), + subuser_action_token_service::PURPOSE_PASSWORD_RESET + ); + if ($record === null) { + $response->error('Invalid or expired token', 404); + } + $response->success(['valid' => true]); + }); + + $this->post('/subusers/password-reset/set', function () { + global $response; + $this->requireRecaptcha(); + self::requireParameters(['token', 'password']); + $password = (string)self::getParameter('password'); + $this->requireSubuserPasswordPolicy($password); + + $tokens = new subuser_action_token_service(); + $record = $tokens->consume( + (string)self::getParameter('token'), + subuser_action_token_service::PURPOSE_PASSWORD_RESET + ); + if ($record === null) { + $response->error('Invalid or expired token', 404); + } + $subuser = $this->loadSubuserOrFail((int)$record['subuser_id']); + try { + $subuser->setPassword($password); + $subuser->invalidateCurrentSetupToken(); + } catch (Exception $exception) { + $response->error($exception->getMessage(), 400); + } + $response->success(['message' => 'Password updated successfully']); + }); + + $this->get('/subusers/access-decision', function () { + global $response; + self::requireParameters(['token']); + $record = (new subuser_action_token_service())->inspect((string)self::getParameter('token')); + if ( + $record === null + || !in_array($record['purpose'], [ + subuser_action_token_service::PURPOSE_GRANT_APPROVE, + subuser_action_token_service::PURPOSE_GRANT_DENY, + ], true) + ) { + $response->error('Invalid or expired token', 404); + } + $grant = (new subuser_grants_o())->select((int)$record['grant_id']); + if (!$grant->exists()) { + $response->error('Invalid or expired token', 404); + } + $grant->getObjectProperties(); + if ( + (int)$grant->subuser->value() !== (int)$record['subuser_id'] + || (int)$grant->billing_customer_number->value() !== (int)$record['customer_number'] + || $grant->deleted_at->value() !== null + ) { + $response->error('Invalid or expired token', 404); + } + $subuser = $this->loadSubuserOrFail((int)$record['subuser_id']); + $response->success([ + 'action' => $record['purpose'] === subuser_action_token_service::PURPOSE_GRANT_APPROVE + ? 'approve' + : 'deny', + 'subuser_name' => trim((string)($subuser->name->value() ?? '')) ?: 'Chauffør', + 'customer_name' => $this->resolveCustomerName((int)$record['customer_number']), + 'expires_at' => $record['expires_at'], + ]); + }); + + $this->post('/subusers/access-decision', function () { + global $response; + self::requireParameters(['token']); + $token = (string)self::getParameter('token'); + $service = new subuser_action_token_service(); + $record = $service->consumeGrantDecision($token); + if ($record === null) { + $response->error('Invalid or expired token', 404); + } + + $grant = (new subuser_grants_o())->select((int)$record['grant_id']); + if (!$grant->exists()) { + $response->error('Invalid or expired token', 404); + } + $grant->getObjectProperties(); + if ( + (int)$grant->subuser->value() !== (int)$record['subuser_id'] + || (int)$grant->billing_customer_number->value() !== (int)$record['customer_number'] + || $grant->deleted_at->value() !== null + ) { + $response->error('Invalid or expired token', 404); + } + + $approved = $record['purpose'] === subuser_action_token_service::PURPOSE_GRANT_APPROVE; + $subuser = $this->loadSubuserOrFail((int)$record['subuser_id']); + $delivery = $this->notifySubuserGrantDecision( + $subuser, + (int)$record['customer_number'], + $approved + ); + $response->success([ + 'decision' => $approved ? 'approved' : 'denied', + 'delivery' => $delivery, + ]); + }); + $this->post('/subusers/auth/password', function () { global $response; /** @@ -2144,6 +2408,32 @@ class subusersRoute } }); + $this->post('/subusers/me/password', function () { + global $response; + $subuser = (new authentication())->get_subuser(); + if ($subuser === false) { + $response->error('Unauthorized', 401); + } + self::requireParameters(['current_password', 'password']); + $currentPassword = (string)self::getParameter('current_password'); + $password = (string)self::getParameter('password'); + $this->requireSubuserPasswordPolicy($password); + $passwordHash = $subuser->password->value(); + if (!is_string($passwordHash) || !password_verify($currentPassword, $passwordHash)) { + $response->error('Current password is incorrect', 400); + } + try { + $subuser->setPassword($password); + (new subuser_action_token_service())->revokeForSubuser( + (int)$subuser->id, + subuser_action_token_service::PURPOSE_PASSWORD_RESET + ); + } catch (Exception $exception) { + $response->error($exception->getMessage(), 400); + } + $response->success(['message' => 'Password updated successfully']); + }, []); + // ============================= // Subusers - List & Get (with grant visibility) // ============================= @@ -2685,12 +2975,22 @@ class subusersRoute // Create a pending grant request for the company $subuser_grants_o = new subuser_grants_o(); try { - $subuser_grants_o->add($results[0]->customerNumber, $subuser->id, false, null); + $grant = $subuser_grants_o->add($results[0]->customerNumber, $subuser->id, false, null); } catch (Exception $e) { $response->error('Failed to add subuser grant', 500); } - $response->success(['cvr' => $cvr, 'customer_number' => $results[0]->customerNumber, 'invite' => $invite]); + $customerNotification = $this->notifyCustomerOfGrantRequest( + $grant, + $subuser, + (int)$results[0]->customerNumber + ); + $response->success([ + 'cvr' => $cvr, + 'customer_number' => $results[0]->customerNumber, + 'invite' => $invite, + 'customer_notification' => $customerNotification, + ]); }); } } diff --git a/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php b/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php index 70b6a1c2..657f7b4b 100644 --- a/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php +++ b/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php @@ -902,6 +902,23 @@ CREATE TABLE IF NOT EXISTS `subuser_grants` ( KEY `idx_subuser_grants_assigned_vehicle_id` (`assigned_vehicle_id`), KEY `idx_subuser_grants_billing_customer_number` (`billing_customer_number`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'subuser_action_tokens' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `subuser_action_tokens` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `token_hash` CHAR(64) NOT NULL, + `purpose` VARCHAR(32) NOT NULL, + `subuser_id` INT UNSIGNED NOT NULL, + `grant_id` INT UNSIGNED NULL, + `customer_number` INT NULL, + `expires_at` DATETIME NOT NULL, + `used_at` DATETIME NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_subuser_action_token_hash` (`token_hash`), + KEY `idx_subuser_action_token_subject` (`subuser_id`, `purpose`, `used_at`), + KEY `idx_subuser_action_token_expiry` (`expires_at`, `used_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci SQL, 'tokens' => <<<'SQL' CREATE TABLE IF NOT EXISTS `tokens` ( diff --git a/services/nginx/app/tests/Unit/Subusers/SubuserActionTokenServiceContractTest.php b/services/nginx/app/tests/Unit/Subusers/SubuserActionTokenServiceContractTest.php new file mode 100644 index 00000000..fc8e2aa4 --- /dev/null +++ b/services/nginx/app/tests/Unit/Subusers/SubuserActionTokenServiceContractTest.php @@ -0,0 +1,37 @@ +toContain("PURPOSE_GRANT_APPROVE = 'grant_approve'") + ->toContain("PURPOSE_GRANT_DENY = 'grant_deny'") + ->toContain("PURPOSE_PASSWORD_RESET = 'password_reset'") + ->toContain('TOKEN_BYTES = 32') + ->toContain('PASSWORD_RESET_TTL_SECONDS = 60 * 60') + ->toContain('GRANT_DECISION_TTL_SECONDS = 24 * 60 * 60'); +}); + +it('stores token digests and consumes each token with a conditional one-time update', function (): void { + $code = (string)file_get_contents(app_path('classes/subuser_action_token_service.php')); + $normalized = preg_replace('/\s+/', ' ', $code); + + expect($normalized)->toContain("\$tokenHash = hash('sha256', \$token)") + ->toContain('WHERE id = ? AND used_at IS NULL AND expires_at > UTC_TIMESTAMP()') + ->toContain('$statement->affected_rows === 1') + ->toContain('revokeGrantDecisions') + ->toContain('purpose IN (?, ?)') + ->toContain('consumeGrantDecision') + ->toContain('billing_customer_number = ? AND deleted_at IS NULL') + ->toContain('UPDATE subuser_grants SET enabled = ? WHERE id = ?') + ->toContain('$db->conn->begin_transaction()') + ->not->toContain('(token, purpose, subuser_id'); +}); + +it('creates the action-token table with hash uniqueness and subject and expiry indexes', function (): void { + $schema = (string)file_get_contents(app_path('classes/subusers_schema_bootstrap.php')); + + expect($schema)->toContain('CREATE TABLE IF NOT EXISTS `subuser_action_tokens`') + ->toContain('UNIQUE KEY `uniq_subuser_action_token_hash` (`token_hash`)') + ->toContain('KEY `idx_subuser_action_token_subject` (`subuser_id`, `purpose`, `used_at`)') + ->toContain('KEY `idx_subuser_action_token_expiry` (`expires_at`, `used_at`)'); +}); diff --git a/services/nginx/app/tests/Unit/Subusers/SubusersRouteManagementContractTest.php b/services/nginx/app/tests/Unit/Subusers/SubusersRouteManagementContractTest.php index fd4801ca..d2658c0d 100644 --- a/services/nginx/app/tests/Unit/Subusers/SubusersRouteManagementContractTest.php +++ b/services/nginx/app/tests/Unit/Subusers/SubusersRouteManagementContractTest.php @@ -22,6 +22,34 @@ it('exposes chauffeur management endpoints on the subusers route', function (): expect($normalized)->toContain("\$this->post('/superuser/subusers/{subuser_id}/password-guide/{channel}/send', function () {"); expect($normalized)->toContain("\$this->post('/superuser/subusers/{subuser_id}/login-link/{channel}/send', function () {"); expect($normalized)->toContain("\$this->post('/subusers/{subuser_id}/verification/{channel}/send', function () {"); + expect($normalized)->toContain("\$this->post('/subusers/password-reset/request', function () {"); + expect($normalized)->toContain("\$this->get('/subusers/password-reset/validate', function () {"); + expect($normalized)->toContain("\$this->post('/subusers/password-reset/set', function () {"); + expect($normalized)->toContain("\$this->post('/subusers/me/password', function () {"); + expect($normalized)->toContain("\$this->get('/subusers/access-decision', function () {"); + expect($normalized)->toContain("\$this->post('/subusers/access-decision', function () {"); +}); + +it('delivers purpose-bound SMS notifications for grant requests and decisions', function (): void { + $code = (string)file_get_contents(app_path('routes/subusersRoute.php')); + $normalized = preg_replace('/\s+/', ' ', $code); + + expect($normalized)->toContain('notifyCustomerOfGrantRequest('); + expect($normalized)->toContain('PURPOSE_GRANT_APPROVE'); + expect($normalized)->toContain('PURPOSE_GRANT_DENY'); + expect($normalized)->toContain('notifySubuserGrantDecision('); + expect($normalized)->toContain("'decision_notification' => \$delivery"); + expect($normalized)->toContain("'customer_notification' => \$customerNotification"); +}); + +it('keeps forgot-password responses generic and protects reset tokens by purpose', function (): void { + $code = (string)file_get_contents(app_path('routes/subusersRoute.php')); + $normalized = preg_replace('/\s+/', ' ', $code); + + expect($normalized)->toContain('Hvis chaufførkontoen findes, er et nulstillingslink sendt.'); + expect($normalized)->toContain('PURPOSE_PASSWORD_RESET'); + expect($normalized)->toContain("account_deletion_service::principalIsBlocked('subuser'"); + expect($normalized)->toContain('invalidateCurrentSetupToken()'); }); it('includes grant management fields in the subusers payload builder', function (): void {