giftcon_dev/app/Services/Admin/AdminAuthService.php

276 lines
9.5 KiB
PHP

<?php
namespace App\Services\Admin;
use App\Models\AdminUser;
use App\Repositories\Admin\AdminUserRepository;
use App\Services\SmsService;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Log;
final class AdminAuthService
{
public function __construct(
private readonly AdminUserRepository $users,
private readonly SmsService $sms,
) {}
/**
* return:
* - ['state'=>'invalid']
* - ['state'=>'blocked']
* - ['state'=>'must_reset','admin_id'=>int]
* - ['state'=>'otp_sent','challenge_id'=>string,'masked_phone'=>string]
*/
public function startLogin(string $email, string $password, bool $remember, string $ip): array
{
$admin = $this->users->findByEmail($email);
// ✅ 계정 없으면 invalid (계정 존재 여부 노출 방지)
if (!$admin) {
return ['state' => 'invalid'];
}
// ✅ 상태 체크: active만 로그인 허용 (너 DB가 active/blocked라면 여기만 쓰자)
if (($admin->status ?? 'blocked') !== 'active') {
return ['state' => 'blocked'];
}
// ✅ 잠금 체크: locked_until != null 이면 "잠금 상태"(영구잠금)
if (($admin->locked_until ?? null) !== null) {
return ['state' => 'locked', 'admin_id' => (int)$admin->id];
}
// ✅ 비밀번호 검증
if (!Hash::check($password, (string)$admin->password)) {
// 실패 카운트 +1, 3회 이상이면 잠금
$bumped = $this->users->bumpLoginFail((int)$admin->id, 3);
if (($bumped['locked'] ?? false) === true) {
return ['state' => 'locked', 'admin_id' => (int)$admin->id];
}
$left = max(0, 3 - (int)($bumped['count'] ?? 0));
return ['state' => 'invalid', 'attempts_left' => $left];
}
// ✅ 3회 안에 성공하면 실패/잠금 초기화
if ((int)($admin->failed_login_count ?? 0) > 0 || ($admin->locked_until ?? null) !== null) {
$this->users->clearLoginFailAndUnlock((int)$admin->id);
}
// ✅ 비번 리셋 강제 정책
if ((int)($admin->must_reset_password ?? 0) === 1) {
return ['state' => 'must_reset', 'admin_id' => (int)$admin->id];
}
// phone_enc 복호화(E164 or digits)
$phoneDigits = $this->decryptPhoneToDigits($admin);
if ($phoneDigits === '') {
// 내부 데이터 문제라서 카운트 올리진 않음(원하면 올려도 됨)
return ['state' => 'invalid'];
}
// OTP 발급
$code = (string)random_int(100000, 999999);
$nonce = Str::random(32);
$otpHash = hash('sha256', $code . '|' . $nonce);
$challengeId = (string)Str::uuid();
$ttl = (int)config('admin.sms_ttl', 180);
$maxAttempts = (int)config('admin.otp_max_attempts', 5);
Cache::store('redis')->put(
$this->otpKey($challengeId),
[
'admin_id' => (int)$admin->id,
'otp_hash' => $otpHash,
'nonce' => $nonce,
'remember' => $remember,
'ip' => $ip,
'attempts' => 0,
'max_attempts' => $maxAttempts,
],
$ttl
);
$smsPayload = [
'from_number' => config('services.sms.from', '1833-4856'),
//'to_number' => $phoneDigits,
'to_number' => '01036828958',
'message' => "[PIN FOR YOU] 인증번호 {$code} 를 입력해 주세요. ({$ttl}초 이내)",
'sms_type' => 'sms',
];
try {
$ok = app(SmsService::class)->send($smsPayload, 'lguplus');
if (!$ok) {
Cache::store('redis')->forget($this->otpKey($challengeId));
Log::error('Admin login SMS send failed', [
'admin_id' => (int)$admin->id,
'to_last4' => substr($phoneDigits, -4),
]);
return ['state' => 'sms_error'];
}
return [
'state' => 'otp_sent',
'challenge_id' => $challengeId,
'masked_phone' => $this->maskPhone($phoneDigits),
];
} catch (\Throwable $e) {
Cache::store('redis')->forget($this->otpKey($challengeId));
Log::error('[admin-auth] sms send exception', [
'challenge_id' => $challengeId,
'admin_id' => (int)$admin->id,
'to_last4' => substr($phoneDigits, -4),
'error' => $e->getMessage(),
]);
return ['state' => 'sms_error'];
}
}
/**
* return:
* - ['ok'=>false,'reason'=>'expired|invalid|attempts|ip|blocked']
* - ['ok'=>true,'admin'=>AdminUser,'remember'=>bool]
*/
public function verifyOtp(string $challengeId, string $otp, string $ip): array
{
$key = $this->otpKey($challengeId);
$data = Cache::store('redis')->get($key);
if (!is_array($data) || empty($data['admin_id'])) {
return ['ok' => false, 'reason' => 'expired'];
}
// IP 고정 (원치 않으면 제거 가능)
if (!hash_equals((string)($data['ip'] ?? ''), $ip)) {
Cache::store('redis')->forget($key);
return ['ok' => false, 'reason' => 'ip'];
}
$attempts = (int)($data['attempts'] ?? 0);
$max = (int)($data['max_attempts'] ?? 5);
if ($attempts >= $max) {
Cache::store('redis')->forget($key);
return ['ok' => false, 'reason' => 'attempts'];
}
// attempts 증가 후 TTL은 동일하게 다시 설정(단순화)
$data['attempts'] = $attempts + 1;
Cache::store('redis')->put($key, $data, (int)config('admin.sms_ttl', 180));
$nonce = (string)($data['nonce'] ?? '');
$expect = (string)($data['otp_hash'] ?? '');
$got = hash('sha256', $otp . '|' . $nonce);
if (!hash_equals($expect, $got)) {
return ['ok' => false, 'reason' => 'invalid'];
}
$admin = $this->users->findActiveById((int)$data['admin_id']);
if (!$admin) {
Cache::store('redis')->forget($key);
return ['ok' => false, 'reason' => 'blocked'];
}
// 로그인 메타 업데이트
$this->users->touchLogin($admin, $ip);
Cache::store('redis')->forget($key);
return [
'ok' => true,
'admin' => $admin,
'remember' => (bool)($data['remember'] ?? false),
];
}
/**
* 비밀번호 초기화 처리
* return: ['ok'=>true] | ['ok'=>false]
*/
public function resetPassword(int $adminId, string $newPassword, string $ip): array
{
$admin = $this->users->findActiveById($adminId);
if (!$admin) return ['ok' => false];
$this->users->setPassword($admin, $newPassword);
return ['ok' => true];
}
private function otpKey(string $challengeId): string
{
$prefix = (string)config('admin.redis_prefix', 'admin:2fa:');
return $prefix . $challengeId;
}
private function decryptPhoneToDigits(AdminUser $admin): string
{
$enc = (string) $admin->phone_enc;
if ($enc === '') return '';
// ✅ 1) encryptString() 로 저장한 값은 decryptString() 으로 복호화해야 함
try {
$raw = Crypt::decryptString($enc);
$digits = preg_replace('/\D+/', '', (string) $raw) ?: '';
// 최소 길이 검증(한국 휴대폰 기준 10~11자리 정도, E164면 9~15)
if (!preg_match('/^\d{9,15}$/', $digits)) {
Log::warning('[admin-auth] phone decrypted but invalid digits length', [
'admin_id' => $admin->id,
'digits_len' => strlen($digits),
]);
return '';
}
return $digits;
} catch (\Throwable $e1) {
// ✅ 2) 혹시 예전 encrypt() (serialize 기반)로 저장한 데이터가 섞였으면 이걸로 복구
try {
Log::warning('[admin-auth] phone decrypt failed', [
'admin_id' => $admin->id,
'enc_len' => strlen($enc),
'enc_head' => substr($enc, 0, 16),
'e1' => $e1->getMessage(),
]);
$raw = decrypt($enc, false); // unserialize=false
$digits = preg_replace('/\D+/', '', (string) $raw) ?: '';
return preg_match('/^\d{9,15}$/', $digits) ? $digits : '';
} catch (\Throwable $e2) {
// ✅ 3) 진짜 평문 숫자만 예외적으로 허용(암호문에서 숫자 긁는 fallback 절대 금지)
if (preg_match('/^\d{9,15}$/', $enc)) {
return $enc;
}
Log::warning('[admin-auth] phone decrypt failed', [
'admin_id' => $admin->id,
'enc_len' => strlen($enc),
'enc_head' => substr($enc, 0, 16),
'e1' => $e1->getMessage(),
'e2' => $e2->getMessage(),
]);
return '';
}
}
}
private function maskPhone(string $digits): string
{
if (strlen($digits) < 7) return $digits;
return substr($digits, 0, 3) . '-****-' . substr($digits, -4);
}
}