Files
api/services/nginx/app/classes/subuser_action_token_service.php
T
Jeppe BandJeppe Bundgaard d3e4798b11 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>
2026-07-27 18:40:40 +02:00

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');
}
}
}