489 lines
17 KiB
PHP
489 lines
17 KiB
PHP
<?php
|
|
|
|
namespace classes;
|
|
|
|
use Exception;
|
|
use objects\logs_o;
|
|
use objects\subusers_o;
|
|
|
|
class subuser_contact_verification_service
|
|
{
|
|
public const CHANNEL_EMAIL = 'email';
|
|
public const CHANNEL_PHONE = 'phone';
|
|
public const CODE_TTL_SECONDS = 600;
|
|
public const RESEND_COOLDOWN_SECONDS = 60;
|
|
public const MAX_ATTEMPTS = 5;
|
|
|
|
private ?object $redis;
|
|
private $codeGenerator;
|
|
private $timeProvider;
|
|
private $smsSender;
|
|
private $emailSender;
|
|
|
|
public function __construct(
|
|
?object $redis = null,
|
|
?callable $codeGenerator = null,
|
|
?callable $timeProvider = null,
|
|
?callable $smsSender = null,
|
|
?callable $emailSender = null
|
|
) {
|
|
$this->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<string,mixed>,phone:array<string,mixed>}
|
|
*/
|
|
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<string,mixed>
|
|
*/
|
|
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<string,mixed>
|
|
*/
|
|
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<string,mixed>
|
|
*/
|
|
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<string,mixed> $updates
|
|
* @return array<string,mixed>
|
|
*/
|
|
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 = '<p>Din verifikationskode til Truck Wash er <strong>' . htmlspecialchars($code, ENT_QUOTES, 'UTF-8') . '</strong>.</p>'
|
|
. '<p>Koden udløber om 10 minutter.</p>';
|
|
(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) {
|
|
}
|
|
}
|
|
}
|