redis = $redis ?? (defined('redis') ? constant('redis') : null); $this->codeGenerator = $codeGenerator; $this->timeProvider = $timeProvider; $this->smsSender = $smsSender; $this->emailSender = $emailSender; } /** * @return array{state:string,email:array,phone:array} */ public function status(subusers_o $subuser): array { $email = $this->emailDestination($subuser); $phone = $this->phoneDestination($subuser); $emailVerifiedAt = $this->verifiedAtValue($subuser, self::CHANNEL_EMAIL); $phoneVerifiedAt = $this->verifiedAtValue($subuser, self::CHANNEL_PHONE); $emailStatus = [ 'channel' => self::CHANNEL_EMAIL, 'value' => $email, 'masked_value' => $email !== null ? $this->maskEmail($email) : null, 'available' => $email !== null, 'verified' => $email !== null && $emailVerifiedAt !== null, 'verified_at' => $emailVerifiedAt, ]; $phoneStatus = [ 'channel' => self::CHANNEL_PHONE, 'country_code' => $subuser->phone_country_code->value() !== null ? (int)$subuser->phone_country_code->value() : null, 'phone' => $subuser->phone->value() !== null ? (int)$subuser->phone->value() : null, 'value' => $phone, 'masked_value' => $phone !== null ? $this->maskPhone($phone) : null, 'available' => $phone !== null, 'verified' => $phone !== null && $phoneVerifiedAt !== null, 'verified_at' => $phoneVerifiedAt, ]; return [ 'state' => $this->verificationState($emailStatus, $phoneStatus), 'email' => $emailStatus, 'phone' => $phoneStatus, ]; } /** * @return array */ public function sendCode(subusers_o $subuser, string $channel, array $actor = []): array { $channel = $this->normalizeChannel($channel); $destination = $this->destinationFor($subuser, $channel); if ($destination === null) { return $this->delivery($channel, 'missing_destination', 'No contact value is available for verification.'); } if ($this->redis === null) { return $this->delivery($channel, 'unavailable', 'Verification delivery is not available.'); } $now = $this->now(); $existing = $this->readChallenge($subuser, $channel); if ($existing !== null) { $sentAt = (int)($existing['sent_at'] ?? 0); $retryAfter = self::RESEND_COOLDOWN_SECONDS - ($now - $sentAt); if ($retryAfter > 0) { return $this->delivery($channel, 'throttled', 'Please wait before requesting another code.', [ 'retry_after' => $retryAfter, ]); } } $code = $this->generateCode(); $destinationKey = $this->destinationKey($subuser, $channel); $nonce = bin2hex(random_bytes(16)); $payload = [ 'hash' => $this->hashCode($code, $destinationKey, $nonce), 'nonce' => $nonce, 'destination' => $destinationKey, 'attempts' => 0, 'sent_at' => $now, 'expires_at' => $now + self::CODE_TTL_SECONDS, ]; try { $this->deliverCode($subuser, $channel, $destination, $code); $this->writeChallenge($subuser, $channel, $payload); $this->logEvent('SUBUSER_CONTACT_VERIFICATION_SENT', $subuser, $channel, $actor); } catch (Exception $exception) { $this->logEvent('SUBUSER_CONTACT_VERIFICATION_FAILED', $subuser, $channel, $actor); return $this->delivery($channel, 'failed', 'Verification delivery failed.'); } return $this->delivery($channel, 'sent', 'Verification code sent.', [ 'masked_destination' => $channel === self::CHANNEL_EMAIL ? $this->maskEmail($destination) : $this->maskPhone($destination), 'expires_in' => self::CODE_TTL_SECONDS, ]); } /** * @return array */ public function verifyCode(subusers_o $subuser, string $channel, string $code, array $actor = []): array { $channel = $this->normalizeChannel($channel); $code = trim($code); if (!preg_match('/^[0-9]{6}$/', $code)) { return $this->verificationResult($channel, 'invalid_code', 'Invalid verification code.'); } $challenge = $this->readChallenge($subuser, $channel); if ($challenge === null || (int)($challenge['expires_at'] ?? 0) < $this->now()) { $this->deleteChallenge($subuser, $channel); return $this->verificationResult($channel, 'expired', 'Verification code expired.'); } if (($challenge['destination'] ?? null) !== $this->destinationKey($subuser, $channel)) { $this->deleteChallenge($subuser, $channel); return $this->verificationResult($channel, 'destination_changed', 'Contact value changed. Request a new code.'); } $attempts = (int)($challenge['attempts'] ?? 0); if ($attempts >= self::MAX_ATTEMPTS) { $this->deleteChallenge($subuser, $channel); return $this->verificationResult($channel, 'too_many_attempts', 'Too many verification attempts.'); } $expectedHash = (string)($challenge['hash'] ?? ''); $nonce = (string)($challenge['nonce'] ?? ''); if (!hash_equals($expectedHash, $this->hashCode($code, (string)$challenge['destination'], $nonce))) { $nextAttempts = $attempts + 1; if ($nextAttempts >= self::MAX_ATTEMPTS) { $this->deleteChallenge($subuser, $channel); return $this->verificationResult($channel, 'too_many_attempts', 'Too many verification attempts.'); } $challenge['attempts'] = $nextAttempts; $remainingTtl = max(1, (int)($challenge['expires_at'] ?? $this->now()) - $this->now()); $this->writeChallenge($subuser, $channel, $challenge, $remainingTtl); return $this->verificationResult($channel, 'invalid_code', 'Invalid verification code.'); } $this->markVerified($subuser, $channel); $this->deleteChallenge($subuser, $channel); $this->logEvent('SUBUSER_CONTACT_VERIFICATION_VERIFIED', $subuser, $channel, $actor); return $this->verificationResult($channel, 'verified', 'Contact value verified.', [ 'verified_at' => $this->verifiedAtValue($subuser, $channel), ]); } /** * @return array */ public function setVerificationState(subusers_o $subuser, string $channel, bool $verified, array $actor = []): array { $channel = $this->normalizeChannel($channel); if ($verified && $this->destinationFor($subuser, $channel) === null) { return $this->verificationResult($channel, 'missing_destination', 'No contact value is available for verification.'); } if ($verified) { $this->markVerified($subuser, $channel); } else { $this->markUnverified($subuser, $channel); } $this->deleteChallenge($subuser, $channel); $this->logEvent( $verified ? 'SUBUSER_CONTACT_VERIFICATION_MARKED_VERIFIED' : 'SUBUSER_CONTACT_VERIFICATION_MARKED_UNVERIFIED', $subuser, $channel, $actor ); return $this->verificationResult($channel, $verified ? 'verified' : 'unverified', 'Contact verification state updated.', [ 'verified_at' => $this->verifiedAtValue($subuser, $channel), ]); } /** * @param array $updates * @return array */ public function clearVerificationForChangedContacts(subusers_o $subuser, array $updates): array { if (array_key_exists('email', $updates)) { $current = $this->normalizeNullableString($subuser->email->value()); $next = $this->normalizeNullableString($updates['email']); if ($current !== $next) { $updates['email_verified_at'] = null; $this->deleteChallenge($subuser, self::CHANNEL_EMAIL); } } $phoneChanged = false; if (array_key_exists('phone_country_code', $updates)) { $phoneChanged = (int)$subuser->phone_country_code->value() !== (int)$updates['phone_country_code']; } if (array_key_exists('phone', $updates)) { $phoneChanged = $phoneChanged || (int)$subuser->phone->value() !== (int)$updates['phone']; } if ($phoneChanged) { $updates['phone_verified_at'] = null; $this->deleteChallenge($subuser, self::CHANNEL_PHONE); } return $updates; } public function normalizeChannel(string $channel): string { $channel = strtolower(trim($channel)); if (!in_array($channel, [self::CHANNEL_EMAIL, self::CHANNEL_PHONE], true)) { throw new Exception('Invalid verification channel'); } return $channel; } private function now(): int { if ($this->timeProvider !== null) { return (int)call_user_func($this->timeProvider); } return time(); } private function generateCode(): string { if ($this->codeGenerator !== null) { $code = (string)call_user_func($this->codeGenerator); if (preg_match('/^[0-9]{6}$/', $code)) { return $code; } } return (string)random_int(100000, 999999); } private function hashCode(string $code, string $destination, string $nonce): string { $secret = (string)(getenv('APP_KEY') ?: getenv('JWT_SECRET') ?: __FILE__); return hash('sha256', $secret . ':' . $nonce . ':' . $destination . ':' . $code); } private function destinationFor(subusers_o $subuser, string $channel): ?string { return $channel === self::CHANNEL_EMAIL ? $this->emailDestination($subuser) : $this->phoneDestination($subuser); } private function emailDestination(subusers_o $subuser): ?string { $email = $this->normalizeNullableString($subuser->email->value()); return $email !== null && filter_var($email, FILTER_VALIDATE_EMAIL) ? $email : null; } private function phoneDestination(subusers_o $subuser): ?string { $countryCode = $subuser->phone_country_code->value(); $phone = $subuser->phone->value(); if ($countryCode === null || $phone === null) { return null; } $normalized = preg_replace('/[^0-9]/', '', (string)$countryCode . (string)$phone); return $normalized !== '' ? $normalized : null; } private function destinationKey(subusers_o $subuser, string $channel): string { return $channel . ':' . (string)($this->destinationFor($subuser, $channel) ?? ''); } private function key(subusers_o $subuser, string $channel): string { return 'subuser_contact_verification:' . (int)$subuser->id . ':' . $channel; } private function readChallenge(subusers_o $subuser, string $channel): ?array { if ($this->redis === null) { return null; } $raw = $this->redis->get($this->key($subuser, $channel)); if (!is_string($raw) || $raw === '') { return null; } $decoded = json_decode($raw, true); return is_array($decoded) ? $decoded : null; } private function writeChallenge(subusers_o $subuser, string $channel, array $payload, ?int $ttl = null): void { if ($this->redis === null) { throw new Exception('Verification cache is unavailable'); } $encoded = json_encode($payload, JSON_UNESCAPED_SLASHES); if ($encoded === false) { throw new Exception('Failed to encode verification challenge'); } $this->redis->setEx($this->key($subuser, $channel), $encoded, $ttl ?? self::CODE_TTL_SECONDS); } private function deleteChallenge(subusers_o $subuser, string $channel): void { if ($this->redis !== null) { $this->redis->delete($this->key($subuser, $channel)); } } private function deliverCode(subusers_o $subuser, string $channel, string $destination, string $code): void { if ($channel === self::CHANNEL_PHONE) { if ($this->smsSender !== null) { call_user_func($this->smsSender, $destination, $code, $subuser); return; } $gateway = new gatewayapi(); if (!$gateway->isEnabled()) { throw new Exception('SMS delivery is not configured.'); } $gateway->send([$destination], 'Truck Wash verifikationskode: ' . $code . '. Den udløber om 10 minutter.'); return; } if ($this->emailSender !== null) { call_user_func($this->emailSender, $destination, $code, $subuser); return; } $recipientName = $this->normalizeNullableString($subuser->name->value()) ?? 'Chauffør'; $message = '

Din verifikationskode til Truck Wash er ' . htmlspecialchars($code, ENT_QUOTES, 'UTF-8') . '.

' . '

Koden udløber om 10 minutter.

'; (new email())->sendEmail($destination, $recipientName, 'Truck Wash verifikationskode', $message); } private function markVerified(subusers_o $subuser, string $channel): void { $timestamp = date('Y-m-d H:i:s', $this->now()); if ($channel === self::CHANNEL_EMAIL) { $subuser->email_verified_at->set($timestamp); return; } $subuser->phone_verified_at->set($timestamp); } private function markUnverified(subusers_o $subuser, string $channel): void { if ($channel === self::CHANNEL_EMAIL) { $subuser->email_verified_at->set(null); return; } $subuser->phone_verified_at->set(null); } private function verifiedAtValue(subusers_o $subuser, string $channel): ?string { $value = $channel === self::CHANNEL_EMAIL ? $subuser->email_verified_at->value() : $subuser->phone_verified_at->value(); $value = $this->normalizeNullableString($value); return $value; } private function verificationState(array $emailStatus, array $phoneStatus): string { if (!$phoneStatus['available'] && !$emailStatus['available']) { return 'missing_contacts'; } if (!$phoneStatus['available']) { return 'missing_phone'; } if (!$emailStatus['available']) { return 'missing_email'; } if ($phoneStatus['verified'] && $emailStatus['verified']) { return 'verified'; } if ($phoneStatus['verified'] || $emailStatus['verified']) { return 'partial'; } return 'unverified'; } private function normalizeNullableString(mixed $value): ?string { if ($value === null) { return null; } $normalized = trim((string)$value); return $normalized === '' ? null : $normalized; } private function maskEmail(string $email): string { [$local, $domain] = array_pad(explode('@', $email, 2), 2, ''); $prefix = substr($local, 0, 2); return $prefix . str_repeat('*', max(2, strlen($local) - 2)) . '@' . $domain; } private function maskPhone(string $phone): string { $suffix = substr($phone, -4); return str_repeat('*', max(0, strlen($phone) - 4)) . $suffix; } private function delivery(string $channel, string $status, string $message, array $extra = []): array { return [ 'channel' => $channel, 'status' => $status, 'message' => $message, ...$extra, ]; } private function verificationResult(string $channel, string $status, string $message, array $extra = []): array { return [ 'channel' => $channel, 'status' => $status, 'message' => $message, ...$extra, ]; } private function logEvent(string $action, subusers_o $subuser, string $channel, array $actor): void { try { if (!defined('redis')) { return; } $actorId = isset($actor['id']) ? (int)$actor['id'] : 0; (new logs_o())->add( 'subusers', 'global', 1, $actorId, $action, 'Chauffeur contact verification ' . $channel . ': ' . (int)$subuser->id ); } catch (Exception) { } } }