(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]; } }