Files
Verde-Web/app/Services/Otp/OtpService.php
admin dc297d4cd7 feat(backend): complete Module 1 auth (register/login/OTP/reset)
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>
2026-04-29 21:46:53 +08:00

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.";
}
}