Files
api/services/nginx/app/classes/subuser_contact_verification_service.php
T

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) {
}
}
}