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 <jb@truckwash.dk>
This commit is contained in:
Jeppe B
2026-07-27 18:40:40 +02:00
committed by GitHub
co-authored by Jeppe Bundgaard
parent 8d8f0eccce
commit d3e4798b11
7 changed files with 740 additions and 5 deletions
@@ -0,0 +1,208 @@
<?php
namespace classes;
use Exception;
/**
* Purpose-bound one-time tokens. Only SHA-256 digests are persisted.
*/
class subuser_action_token_service
{
public const PURPOSE_GRANT_APPROVE = 'grant_approve';
public const PURPOSE_GRANT_DENY = 'grant_deny';
public const PURPOSE_PASSWORD_RESET = 'password_reset';
public const TOKEN_BYTES = 32;
public const GRANT_DECISION_TTL_SECONDS = 24 * 60 * 60;
public const PASSWORD_RESET_TTL_SECONDS = 60 * 60;
public function issue(string $purpose, int $subuserId, ?int $grantId = null, ?int $customerNumber = null, ?int $ttlSeconds = null): string
{
global $db;
$this->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');
}
}
}
@@ -38,10 +38,41 @@ class subusers_schema_bootstrap
'email_verified_at', 'email_verified_at',
'DATETIME NULL AFTER `email`' '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; 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 private static function ensureColumn(string $table, string $column, string $definition): void
{ {
global $db; global $db;
+114
View File
@@ -2406,6 +2406,120 @@ paths:
'404': { $ref: '#/components/responses/NotFound' } '404': { $ref: '#/components/responses/NotFound' }
'500': { $ref: '#/components/responses/InternalServerError' } '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: /subusers/grants:
get: get:
tags: tags:
+305 -5
View File
@@ -8,6 +8,7 @@ use classes\economic;
use classes\email; use classes\email;
use classes\gatewayapi; use classes\gatewayapi;
use classes\response; use classes\response;
use classes\subuser_action_token_service;
use classes\subuser_contact_verification_service; use classes\subuser_contact_verification_service;
use classes\subusers_schema_bootstrap; use classes\subusers_schema_bootstrap;
use classes\subuser_permission_templates_service; 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 private function buildSubuserManagementPayload(subusers_o $subuser, int $customerNumber, ?string $customerName = null): array
{ {
$grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer((int)$subuser->id, $customerNumber, true); $grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer((int)$subuser->id, $customerNumber, true);
@@ -1469,6 +1540,7 @@ class subusersRoute
global $response; global $response;
$grant = $this->getGrantForScopedUserOrFail($grantId, $customerNumber); $grant = $this->getGrantForScopedUserOrFail($grantId, $customerNumber);
$wasEnabled = (bool)$grant->enabled->value();
$this->rejectBlockedSubuser((int)$grant->subuser->value()); $this->rejectBlockedSubuser((int)$grant->subuser->value());
$updates = []; $updates = [];
$templateAccess = $this->parseAccessTemplatePayload(); $templateAccess = $this->parseAccessTemplatePayload();
@@ -1518,10 +1590,17 @@ class subusersRoute
$updatedGrant->getObjectProperties(); $updatedGrant->getObjectProperties();
$subuser = (new subusers_o())->select((int)$updatedGrant->subuser->value()); $subuser = (new subusers_o())->select((int)$updatedGrant->subuser->value());
$subuser->getObjectProperties(); $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([ $response->success([
'subuser' => $this->buildSubuserManagementPayload($subuser, $customerNumber), 'subuser' => $this->buildSubuserManagementPayload($subuser, $customerNumber),
'grant' => $updatedGrant->asArray(), 'grant' => $updatedGrant->asArray(),
'decision_notification' => $delivery,
]); ]);
} }
@@ -1809,6 +1888,7 @@ class subusersRoute
if (!$grant->exists()) { if (!$grant->exists()) {
$response->error('Grant not found', 404); $response->error('Grant not found', 404);
} }
$wasEnabled = (bool)$grant->enabled->value();
$this->rejectBlockedSubuser((int)$grant->subuser->value()); $this->rejectBlockedSubuser((int)$grant->subuser->value());
$targetCustomer = (int)$grant->billing_customer_number->value(); $targetCustomer = (int)$grant->billing_customer_number->value();
@@ -1851,7 +1931,17 @@ class subusersRoute
$targetCustomer $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).', 'manage_subuser_grants' => 'Edit subuser grants for any customer (admin).',
@@ -1981,11 +2071,21 @@ class subusersRoute
// Add the grant request // Add the grant request
$subuser_grants_o = new subuser_grants_o(); $subuser_grants_o = new subuser_grants_o();
try { 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) { } catch (Exception $e) {
$response->error('Failed to add subuser grant', 500); $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 // Code for creating a new subuser would go here
}); });
$this->get('/subusers/setup', function () { $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 () { $this->post('/subusers/auth/password', function () {
global $response; 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) // Subusers - List & Get (with grant visibility)
// ============================= // =============================
@@ -2685,12 +2975,22 @@ class subusersRoute
// Create a pending grant request for the company // Create a pending grant request for the company
$subuser_grants_o = new subuser_grants_o(); $subuser_grants_o = new subuser_grants_o();
try { 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) { } catch (Exception $e) {
$response->error('Failed to add subuser grant', 500); $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,
]);
}); });
} }
} }
@@ -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_assigned_vehicle_id` (`assigned_vehicle_id`),
KEY `idx_subuser_grants_billing_customer_number` (`billing_customer_number`) KEY `idx_subuser_grants_billing_customer_number` (`billing_customer_number`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) 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, SQL,
'tokens' => <<<'SQL' 'tokens' => <<<'SQL'
CREATE TABLE IF NOT EXISTS `tokens` ( CREATE TABLE IF NOT EXISTS `tokens` (
@@ -0,0 +1,37 @@
<?php
it('defines isolated token purposes and short expiry windows', function (): void {
$code = (string)file_get_contents(app_path('classes/subuser_action_token_service.php'));
expect($code)->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`)');
});
@@ -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}/password-guide/{channel}/send', function () {");
expect($normalized)->toContain("\$this->post('/superuser/subusers/{subuser_id}/login-link/{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/{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 { it('includes grant management fields in the subusers payload builder', function (): void {