Closes Module 1: 9 auth endpoints under /api/v1/auth, OTP via SMS (Semaphore + log + fake drivers), role middleware, role + admin seeders, 27 feature tests passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
107 lines
3.2 KiB
PHP
107 lines
3.2 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Otp;
|
|
|
|
use App\Models\OtpCode;
|
|
use App\Models\User;
|
|
use App\Services\Sms\SmsService;
|
|
use Illuminate\Support\Facades\Hash;
|
|
|
|
class OtpService
|
|
{
|
|
public function __construct(private readonly SmsService $sms) {}
|
|
|
|
public function issue(
|
|
string $destination,
|
|
string $purpose,
|
|
?User $user = null,
|
|
string $channel = OtpCode::CHANNEL_SMS,
|
|
): OtpIssueResult {
|
|
$cooldown = (int) config('services.otp.resend_cooldown_seconds', 60);
|
|
$latest = OtpCode::query()
|
|
->where('destination', $destination)
|
|
->where('purpose', $purpose)
|
|
->latest('id')
|
|
->first();
|
|
|
|
if ($latest && $latest->created_at->diffInSeconds(now()) < $cooldown && ! $latest->isConsumed()) {
|
|
$retryAfter = $cooldown - (int) $latest->created_at->diffInSeconds(now());
|
|
|
|
return OtpIssueResult::throttled(max(1, $retryAfter));
|
|
}
|
|
|
|
$code = $this->generateCode();
|
|
$ttl = (int) config('services.otp.ttl_minutes', 10);
|
|
|
|
$otp = OtpCode::create([
|
|
'user_id' => $user?->id,
|
|
'channel' => $channel,
|
|
'destination' => $destination,
|
|
'purpose' => $purpose,
|
|
'code_hash' => Hash::make($code),
|
|
'attempts' => 0,
|
|
'expires_at' => now()->addMinutes($ttl),
|
|
]);
|
|
|
|
$message = $this->renderMessage($purpose, $code, $ttl);
|
|
$sendResult = $channel === OtpCode::CHANNEL_SMS
|
|
? $this->sms->send($destination, $message)
|
|
: null;
|
|
|
|
return OtpIssueResult::issued($otp, $code, $sendResult);
|
|
}
|
|
|
|
public function verify(string $destination, string $purpose, string $code): OtpVerifyResult
|
|
{
|
|
$otp = OtpCode::query()
|
|
->where('destination', $destination)
|
|
->where('purpose', $purpose)
|
|
->whereNull('consumed_at')
|
|
->latest('id')
|
|
->first();
|
|
|
|
if (! $otp) {
|
|
return OtpVerifyResult::notFound();
|
|
}
|
|
|
|
if ($otp->isExpired()) {
|
|
return OtpVerifyResult::expired();
|
|
}
|
|
|
|
if ($otp->isExhausted()) {
|
|
return OtpVerifyResult::exhausted();
|
|
}
|
|
|
|
$otp->increment('attempts');
|
|
|
|
if (! Hash::check($code, $otp->code_hash)) {
|
|
return OtpVerifyResult::invalid($otp->fresh());
|
|
}
|
|
|
|
$otp->forceFill(['consumed_at' => now()])->save();
|
|
|
|
return OtpVerifyResult::ok($otp);
|
|
}
|
|
|
|
private function generateCode(): string
|
|
{
|
|
$length = max(4, (int) config('services.otp.length', 6));
|
|
$max = (10 ** $length) - 1;
|
|
|
|
return str_pad((string) random_int(0, $max), $length, '0', STR_PAD_LEFT);
|
|
}
|
|
|
|
private function renderMessage(string $purpose, string $code, int $ttlMinutes): string
|
|
{
|
|
$reason = match ($purpose) {
|
|
OtpCode::PURPOSE_REGISTER => 'verify your Verde registration',
|
|
OtpCode::PURPOSE_LOGIN => 'log in to Verde',
|
|
OtpCode::PURPOSE_PASSWORD_RESET => 'reset your Verde password',
|
|
OtpCode::PURPOSE_PHONE_VERIFY => 'verify your Verde phone number',
|
|
default => 'continue with Verde',
|
|
};
|
|
|
|
return "Your Verde code is {$code} (valid {$ttlMinutes} min). Use to {$reason}. Do not share.";
|
|
}
|
|
}
|