276 lines
9.5 KiB
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);
|
|
}
|
|
}
|