Files
api/services/nginx/app/classes/authentication.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

310 lines
10 KiB
PHP

<?php
namespace classes;
require_once WD . '/classes/account_deletion_service.php';
use classes\totp;
use Exception;
use interfaces\authentication_i;
use objects\plate_scanners_o;
use objects\subuser_grants_o;
use objects\tokens_o;
use objects\users_o;
use objects\subusers_o;
class authentication implements authentication_i
{
private function touchResolvedUserSession(users_o $user, string $token): void
{
if (trim($token) === '') {
return;
}
try {
(new system_session_activity_tracker())->touchUser($user, $token);
} catch (\Throwable) {
// Session tracking must never block authentication resolution.
}
}
private function touchResolvedSubuserSession(subusers_o $subuser, string $token, int|null $customerNumberContext = null): void
{
if (trim($token) === '') {
return;
}
try {
(new system_session_activity_tracker())->touchSubuser($subuser, $token, $customerNumberContext);
} catch (\Throwable) {
// Session tracking must never block authentication resolution.
}
}
/**
* @throws Exception
*/
public function authenticate(int $customer_number, string $password): bool
{
// Get the customer from the database
$customer = (new users_o())->getUserByCustomerNumber($customer_number);
// Check if the customer exists
if (!$customer->exists()) {
return false;
}
// Check if the customer has a password
if (!$customer->hasPassword()) {
// If the customer doesn't have a password, we can't authenticate them, so we return false
return false;
}
// Check if the password is correct
if (!$this->match_passwords($password, $customer->getPassword())) {
return false;
}
return true;
}
public function is_2fa_enabled(users_o|subusers_o $user): bool
{
return $user->isTwoFactorEnabled();
}
public function create_2fa_token(int $id, string $type): string
{
$principalType = $type === '2FA_VERIFICATION_SUBUSER' ? 'subuser' : 'customer';
if (account_deletion_service::principalIsBlocked($principalType, $id)) {
throw new Exception('Account unavailable');
}
// Create a temporary 2FA token
$token = bin2hex(random_bytes(32));
(new tokens_o())->create($id, $token, $type);
return $token;
}
public function verify_2fa_code(users_o|subusers_o $user, string $code): bool
{
$secret = $user->getTwoFactorSecret();
if (!$secret) {
return false;
}
return (new totp())->verifyCode($secret, $code);
}
public function match_passwords($password, $hash): bool
{
// Compare the password with the hash
return password_verify($password, $hash);
}
public function create_token(int $customer_number): string
{
// Create a token
$token = bin2hex(random_bytes(32));
// Resolve the user by customer number and ensure it exists to avoid accessing an uninitialized typed property
$user = (new users_o())->getUserByCustomerNumber($customer_number);
if (!$user->exists()) {
throw new \Exception('User not found for customer number: ' . $customer_number);
}
$user_id = $user->id;
if (account_deletion_service::principalIsBlocked('customer', (int)$user_id)) {
throw new Exception('Account unavailable');
}
// Save the token in the database
(new tokens_o())->create($user_id, $token, 'AUTH_TOKEN');
return $token;
}
public function create_token_by_user_id(int $user_id): string
{
if (account_deletion_service::principalIsBlocked('customer', $user_id)) {
throw new Exception('Account unavailable');
}
// Create a token
$token = bin2hex(random_bytes(32));
// Save the token in the database
(new tokens_o())->create($user_id, $token, 'AUTH_TOKEN');
return $token;
}
public function create_employee_token(int $employee_id): string
{
if (account_deletion_service::principalIsBlocked('customer', $employee_id)) {
throw new Exception('Account unavailable');
}
// Create a token
$token = bin2hex(random_bytes(32));
// Save the token in the database
(new tokens_o())->create($employee_id, $token, 'AUTH_TOKEN');
return $token;
}
public function create_impersonation_token(int $target_user_id, int $actor_user_id): string
{
if ($actor_user_id <= 0 || account_deletion_service::principalIsBlocked('customer', $target_user_id)) {
throw new Exception('Account unavailable');
}
$token = bin2hex(random_bytes(32));
(new tokens_o())->create($target_user_id, $token, 'AUTH_TOKEN_IMPERSONATION:' . $actor_user_id);
return $token;
}
public function validate_token(string $token): bool
{
// First: try validating as a classic user auth token
try {
$dbToken = (new tokens_o())->getToken($token);
if ($dbToken && $dbToken->id && $this->isClassicAuthTokenType((string)$dbToken->type->value())) {
return !account_deletion_service::principalIsBlocked(
'customer',
(int)$dbToken->user_id->value()
);
}
} catch (Exception) {
// Ignore and continue to subuser session validation
}
// Fallback: try validating as a subuser session token
$subuser = (new subusers_o())->getSubuserBySessionToken($token);
if ($subuser !== null) {
return !account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id);
}
return false;
}
/**
* @throws Exception
*/
public function get_user(): users_o|false
{
/**
* Get the user from the token
*/
// Get the token from the headers
$headers = getallheaders();
if (!isset($headers['Authorization'])) {
return false;
}
$rawToken = $headers['Authorization'];
// Strip the Bearer prefix
$rawToken = str_replace('Bearer ', '', $rawToken);
// Get the token from the database
try {
$token = (new tokens_o())->getToken($rawToken);
} catch (Exception) {
return false;
}
// Check if the token exists
if (!$token->id) {
return false;
}
if (!$this->isClassicAuthTokenType((string)$token->type->value())) {
return false;
}
if (account_deletion_service::principalIsBlocked('customer', (int)$token->user_id->value())) {
return false;
}
// Get the user from the database
$user = (new users_o())->getUserById($token->user_id->value());
$this->touchResolvedUserSession($user, $rawToken);
return $user;
}
private function isClassicAuthTokenType(string $type): bool
{
return $type === 'AUTH_TOKEN' || str_starts_with($type, 'AUTH_TOKEN_IMPERSONATION:');
}
public function get_plate_scanner(): plate_scanners_o|false
{
// Get the token from the headers
$headers = getallheaders();
$tmp = json_decode(file_get_contents('php://input'), true);
if (!is_array($tmp)) {
$tmp = [];
}
if (!isset($headers['Authorization']) && !isset($_GET['token']) && !isset($_POST['token']) && !isset($tmp['token'])) {
return false;
}
$token = $_GET['token'] ?? $headers['Authorization'] ?? $tmp['token'] ?? $_POST['token'];
// Strip the Bearer prefix (If the token is from the headers)
if (isset($headers['Authorization'])) {
$token = str_replace('Bearer ', '', $token);
}
// Get the token from the database
$token = (new plate_scanners_o())->getPlateScannerByApiKey($token);
// Check if the token exists
if (!isset($token->id)) {
return false;
}
// Get the plate scanner from the database
return $token;
}
/**
* @throws Exception
*/
public function get_subuser(): subusers_o|false
{
// Try to resolve a subuser from an incoming bearer token or explicit token parameter
$headers = getallheaders();
$tmp = json_decode(file_get_contents('php://input'), true);
if (!is_array($tmp)) {
$tmp = [];
}
if (!isset($headers['Authorization']) && !isset($_GET['token']) && !isset($_POST['token']) && !isset($tmp['token'])) {
return false;
}
$token = $_GET['token'] ?? $headers['Authorization'] ?? $tmp['token'] ?? $_POST['token'];
// Strip the Bearer prefix (If the token is from the headers)
if (isset($headers['Authorization'])) {
$token = str_replace('Bearer ', '', $token);
}
// Resolve subuser session from cache
$subuser = (new subusers_o())->getSubuserBySessionToken($token);
if ($subuser === null) {
return false;
}
if (account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id)) {
return false;
}
$customerNumberContext = null;
if (isset($headers['X-Customer-Number'])) {
$customerNumberContext = (int)$headers['X-Customer-Number'];
}
$this->touchResolvedSubuserSession($subuser, $token, $customerNumberContext);
return $subuser;
}
public function hash_password($password): string
{
// Hash the password
return password_hash($password, PASSWORD_DEFAULT);
}
public function authenticateEmployee(int $user_id, string $password): bool
{
// Get the employee from the database
$employee = (new users_o())->getUserById($user_id);
// Check if the employee exists
if (!$employee->exists()) {
return false;
}
// Check if the password is correct
if (!$this->match_passwords($password, $employee->getPassword())) {
return false;
}
return true;
}
public function get_subuser_customer_number_target(): int|false
{
/**
* Decode the headers
*/
$headers = getallheaders();
if (!isset($headers['X-Customer-Number'])) {
return false;
}
return (int)$headers['X-Customer-Number'];
}
}