Files
api/services/nginx/app/objects/passkeys_o.php
T
Jeppe B 0060fb45ca Add in-app account deletion (#319)
## Summary
- Add self-service deletion for the authenticated customer or subuser
identity only.
- Preserve shared customer grants, reset keys, bookings, order bookings,
vehicles, invoices, and legally required history.
- Require password/TOTP or a fresh deletion-specific, five-minute,
single-use WebAuthn assertion.
- Reject support impersonation and expired legacy plain-session tokens.
- Use durable database throttling, transactional request processing, a
durable outbox, and terminal `manual_review` state.
- Keep API and worker default-off behind separate
`account_deletion.api_enabled` and `account_deletion.worker_enabled`
module-config flags.

## Safe rollout
1. Keep both flags disabled.
2. Run `php scripts/account-deletion-schema.php check`.
3. If needed, run `php scripts/account-deletion-schema.php apply --yes`,
then rerun `check` until `ready:true`.
4. Deploy the frontend companion PR while the API remains disabled.
5. Enable `api_enabled` for a controlled canary; verify password and
passwordless request flows plus immediate authentication revocation.
6. Inspect queued request/outbox state, then enable `worker_enabled`.
7. Verify anonymization, preserved tenant/history data, outbox delivery,
retries, and manual-review behavior before broad rollout.

## Verification
- Account deletion unit tests: 2 passed, 43 assertions.
- PHP lint, both OpenAPI YAML parses, runtime-DDL scan,
destructive-scope scan, and `git diff --check` passed.
- Full API/unit/integration evidence is required from exact-head CI;
local Docker is unavailable and shared-vendor tests were explicitly
discarded.

## Security notes
- Schema mutation is CLI-only; web and cron paths perform read-only
readiness checks.
- Runtime behavior fails closed when schema/config/throttle/delivery
prerequisites are unavailable.
2026-07-22 19:22:17 +02:00

110 lines
5.0 KiB
PHP

<?php
namespace objects;
use classes\db;
use classes\object_property;
use Exception;
use Random\RandomException;
use traits\db_object_t;
class passkeys_o extends db
{
use db_object_t;
public object_property $credential_id;
public object_property $public_key;
public object_property $is_subuser; // If the passkey belongs to a subuser or a customer
public object_property $user_id; // The customer number or subuser id, depending on the value of is_subuser
public object_property $sign_count; // The number of times the passkey has been used to sign in, used to detect cloned passkeys
public object_property $algorithm; // The algorithm used to create the passkey, e.g. "ES256"
public object_property $transports; // The transports supported by the authenticator, e.g. ["usb", "nfc", "ble"] (JSON encoded array)
public object_property $backup_state; // JSON encoded object containing the backup state of the passkey, e.g. {"backup_id": "1234", "backup_date": "2024-01-01T00:00:00Z"}
public object_property $name; // The name of the passkey, e.g. "iPhone 12 Pro Max"
public object_property $created_at;
public object_property $updated_at;
public object_property $deleted_at;
public function structure(): void
{
$this->setTable('passkeys');
}
/**
* Finds a passkey by its credential ID, optionally constrained to a given user ID.
* Returns the selected object instance on success or null if not found.
*/
public function findByCredentialId(string $credentialId, ?int $userId = null): ?self
{
global $db;
$credentialId = $db->escape_string($credentialId);
$where = "credential_id = '" . $credentialId . "' AND deleted_at IS NULL";
if ($userId !== null) {
$where .= ' AND user_id = ' . (int)$userId;
}
$sql = "SELECT id FROM $this->table WHERE $where LIMIT 1";
$result = $db->query($sql);
$row = $db->fetch_assoc($result);
if (!$row || !isset($row['id'])) {
return null;
}
$this->select((int)$row['id']);
return $this;
}
/**
* Add a new passkey for a user
* @param int $user_id The customer number or subuser id, depending on the value of is_subuser
* @param bool $is_subuser If the passkey belongs to a subuser or a customer
* @param string $credential_id The credential ID of the passkey, base64url encoded
* @param string $public_key The public key of the passkey, base64url encoded
* @param string $algorithm The algorithm used to create the passkey, e.g. "ES256"
* @param array $transports The transports supported by the authenticator, e.g. ["usb", "nfc", "ble"]
* @param string|null $name The name of the passkey, e.g. "iPhone 12 Pro Max"
* @return void
* @throws Exception If the object was not created successfully
* @throws RandomException If the token generation fails
*/
public function add(int $user_id, bool $is_subuser, string $credential_id, string $public_key, string $algorithm, array $transports, ?string $name = null): void
{
$data = [
'user_id' => $user_id,
'is_subuser' => $is_subuser,
'credential_id' => $credential_id,
'public_key' => $public_key,
'algorithm' => $algorithm,
'transports' => json_encode($transports),
'sign_count' => 0,
'backup_state' => json_encode(new \stdClass()),
'name' => $name,
];
$tmp_id = self::add_object($data);
$this->id = $tmp_id;
self::getObjectProperties();
self::objectChanged();
}
/**
* @return void
*/
public function getObjectProperties(): void
{
$this->credential_id = new object_property($this->table, $this->id, 'credential_id', 'string', false);
$this->public_key = new object_property($this->table, $this->id, 'public_key', 'string', false);
$this->is_subuser = new object_property($this->table, $this->id, 'is_subuser', 'bool', false);
$this->user_id = new object_property($this->table, $this->id, 'user_id', 'int', false);
$this->sign_count = new object_property($this->table, $this->id, 'sign_count', 'int', false);
$this->algorithm = new object_property($this->table, $this->id, 'algorithm', 'string', false);
$this->transports = new object_property($this->table, $this->id, 'transports', 'json', false);
$this->backup_state = new object_property($this->table, $this->id, 'backup_state', 'json', false);
$this->name = new object_property($this->table, $this->id, 'name', 'string', false);
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
$this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'timestamp', false);
$this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp', false);
}
public function objectChanged(): void
{
//TODO: Add cache invalidation
}
}