## 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>
209 lines
7.8 KiB
PHP
209 lines
7.8 KiB
PHP
<?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');
|
|
}
|
|
}
|
|
}
|