100 lines
3.4 KiB
PHP
100 lines
3.4 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Admin;
|
|
|
|
use Illuminate\Support\Facades\Redis;
|
|
use Illuminate\Support\Str;
|
|
|
|
class AdminOtpService
|
|
{
|
|
public function startChallenge(int $adminUserId, string $phoneE164, string $ip, string $ua): array
|
|
{
|
|
$challengeId = Str::random(40);
|
|
$otp = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
|
|
|
|
$prefix = (string) config('admin.redis_prefix', 'admin:2fa:');
|
|
$key = $prefix . 'challenge:' . $challengeId;
|
|
|
|
$otpHashKey = (string) config('admin.otp_hash_key', '');
|
|
$otpHash = hash_hmac('sha256', $otp, $otpHashKey);
|
|
|
|
$ttl = (int) config('admin.otp_ttl', 300);
|
|
$cooldown = (int) config('admin.otp_resend_cooldown', 30);
|
|
|
|
Redis::hmset($key, [
|
|
'admin_user_id' => (string) $adminUserId,
|
|
'otp_hash' => $otpHash,
|
|
'attempts' => '0',
|
|
'resend_count' => '0',
|
|
'resend_after' => (string) (time() + $cooldown),
|
|
'ip' => $ip,
|
|
'ua' => mb_substr($ua, 0, 250),
|
|
]);
|
|
Redis::expire($key, $ttl);
|
|
|
|
return [
|
|
'challenge_id' => $challengeId,
|
|
'otp' => $otp, // DB 저장 금지, “발송에만” 사용
|
|
];
|
|
}
|
|
|
|
public function canResend(string $challengeId): array
|
|
{
|
|
$key = (string) config('admin.redis_prefix', 'admin:2fa:') . 'challenge:' . $challengeId;
|
|
$resendAfter = (int) (Redis::hget($key, 'resend_after') ?: 0);
|
|
|
|
if ($resendAfter > time()) {
|
|
return ['ok' => false, 'wait' => $resendAfter - time()];
|
|
}
|
|
return ['ok' => true, 'wait' => 0];
|
|
}
|
|
|
|
public function resend(string $challengeId): ?string
|
|
{
|
|
$key = (string) config('admin.redis_prefix', 'admin:2fa:') . 'challenge:' . $challengeId;
|
|
|
|
if (!Redis::exists($key)) return null;
|
|
|
|
$cooldown = (int) config('admin.otp_resend_cooldown', 30);
|
|
$otpHashKey = (string) config('admin.otp_hash_key', '');
|
|
|
|
$otp = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
|
|
$otpHash = hash_hmac('sha256', $otp, $otpHashKey);
|
|
|
|
Redis::hset($key, 'otp_hash', $otpHash);
|
|
Redis::hincrby($key, 'resend_count', 1);
|
|
Redis::hset($key, 'resend_after', (string) (time() + $cooldown));
|
|
|
|
return $otp;
|
|
}
|
|
|
|
public function verify(string $challengeId, string $otpInput): array
|
|
{
|
|
$key = (string) config('admin.redis_prefix', 'admin:2fa:') . 'challenge:' . $challengeId;
|
|
if (!Redis::exists($key)) {
|
|
return ['ok' => false, 'reason' => 'expired'];
|
|
}
|
|
|
|
$maxAttempts = (int) config('admin.otp_max_attempts', 5);
|
|
$attempts = (int) (Redis::hget($key, 'attempts') ?: 0);
|
|
if ($attempts >= $maxAttempts) {
|
|
return ['ok' => false, 'reason' => 'locked'];
|
|
}
|
|
|
|
$otpHashKey = (string) config('admin.otp_hash_key', '');
|
|
$expected = (string) Redis::hget($key, 'otp_hash');
|
|
$given = hash_hmac('sha256', trim($otpInput), $otpHashKey);
|
|
|
|
if (!hash_equals($expected, $given)) {
|
|
Redis::hincrby($key, 'attempts', 1);
|
|
$left = max(0, $maxAttempts - ($attempts + 1));
|
|
return ['ok' => false, 'reason' => 'mismatch', 'left' => $left];
|
|
}
|
|
|
|
$adminUserId = (int) (Redis::hget($key, 'admin_user_id') ?: 0);
|
|
Redis::del($key);
|
|
|
|
return ['ok' => true, 'admin_user_id' => $adminUserId];
|
|
}
|
|
}
|