diff --git a/app/Http/Controllers/Admin/AdminAdminsController.php b/app/Http/Controllers/Admin/AdminAdminsController.php index 7ada6f6..58697e1 100644 --- a/app/Http/Controllers/Admin/AdminAdminsController.php +++ b/app/Http/Controllers/Admin/AdminAdminsController.php @@ -29,7 +29,15 @@ final class AdminAdminsController public function update(Request $request, int $id) { - $res = $this->service->update($id, $request->all(), (int)auth('admin')->id()); + $res = $this->service->update( + $id, + $request->all(), + (int)auth('admin')->id(), + $request->ip(), + (string)($request->userAgent() ?? '') + ); + + if (!($res['ok'] ?? false)) { return redirect()->back()->withInput() @@ -50,9 +58,14 @@ final class AdminAdminsController ]); } - public function resetPassword(int $id) + public function resetPassword(Request $request, int $id) { - $res = $this->service->resetPasswordToEmail($id, (int)auth('admin')->id()); + $res = $this->service->resetPasswordToEmail( + $id, + (int)auth('admin')->id(), + $request->ip(), + (string)($request->userAgent() ?? '') + ); if (!($res['ok'] ?? false)) { return redirect()->back()->withInput() @@ -76,7 +89,13 @@ final class AdminAdminsController public function unlock(int $id) { - $res = $this->service->unlock($id, (int)auth('admin')->id()); + $res = $this->service->unlock( + $id, + (int)auth('admin')->id(), + $request->ip(), + (string)($request->userAgent() ?? '') + ); + if (!($res['ok'] ?? false)) { return redirect()->back()->withInput() ->with('toast', [ @@ -131,7 +150,11 @@ final class AdminAdminsController 'nickname' => $data['nickname'], 'phone_digits' => $phoneDigits, 'role' => $data['role'], - ], $actorId); + ], + $actorId, + $request->ip(), + (string)($request->userAgent() ?? ''), + ); if (!($res['ok'] ?? false)) { return back()->withErrors(['email' => (string)($res['message'] ?? '등록에 실패했습니다.')])->withInput(); diff --git a/app/Http/Controllers/Admin/Auth/AdminAuthController.php b/app/Http/Controllers/Admin/Auth/AdminAuthController.php index d8f2356..50d5b5f 100644 --- a/app/Http/Controllers/Admin/Auth/AdminAuthController.php +++ b/app/Http/Controllers/Admin/Auth/AdminAuthController.php @@ -126,6 +126,36 @@ final class AdminAuthController extends Controller ]); } + if ($state === 'totp_required') { + $adminId = (int) ($res['admin_id'] ?? 0); + if ($adminId <= 0) { + return back() + ->withInput() + ->withErrors(['login_id' => '로그인 처리 중 오류가 발생했습니다. 다시 시도해 주세요.']); + } + + // (권장) 세션 고정 공격 방지 + $request->session()->regenerate(); + + $ttl = (int) config('admin.totp_ttl', 300); + $request->session()->put('admin_totp', [ + 'admin_id' => $adminId, + 'remember' => $remember, + 'ip' => $ip, + 'attempts' => 0, + 'max_attempts' => (int) config('admin.totp_max_attempts', 5), + 'expires_at' => time() + $ttl, + ]); + + return redirect() + ->route('admin.totp.form') + ->with('toast', [ + 'type' => 'info', + 'title' => 'Google OTP 인증', + 'message' => 'Google Authenticator 앱의 6자리 코드를 입력해 주세요.', + ]); + } + // ✅ 6) OTP 발송 성공 if ($state === 'otp_sent') { $request->session()->put('admin_2fa', [ @@ -134,6 +164,9 @@ final class AdminAuthController extends Controller 'expires_at' => time() + (int) config('admin.sms_ttl', 180), ]); + // (권장) 세션 고정 공격 방지 + $request->session()->regenerate(); + return redirect() ->route('admin.otp.form') ->with('toast', [ @@ -195,10 +228,12 @@ final class AdminAuthController extends Controller ]); $ip = (string) $request->ip(); + $ua = (string) $request->userAgent(); $res = $this->authService->resetPassword( adminId: (int) $pending['admin_id'], newPassword: (string) $data['password'], - ip: $ip + ip: $ip, + ua: $ua, ); if (!($res['ok'] ?? false)) { @@ -276,6 +311,10 @@ final class AdminAuthController extends Controller Auth::guard('admin')->login($admin, (bool)($res['remember'] ?? false)); + // 권한/관리자 정보 세션 적재 + $ctx = $this->authService->buildAdminSessionContext((int)$admin->id); + if (!empty($ctx)) $request->session()->put('admin_ctx', $ctx); + $request->session()->forget('admin_2fa'); return redirect()->route('admin.home'); @@ -285,6 +324,8 @@ final class AdminAuthController extends Controller { Auth::guard('admin')->logout(); + $request->session()->forget('admin_ctx'); + $request->session()->invalidate(); $request->session()->regenerateToken(); @@ -303,7 +344,12 @@ final class AdminAuthController extends Controller public function totpStart(Request $request) { $adminId = (int) auth()->guard('admin')->id(); - $res = $this->authService->totpStart($adminId, false); + $res = $this->authService->totpStart( + $adminId, + false, + (string)$request->ip(), + (string)($request->userAgent() ?? '') + ); return back()->with($res['ok'] ? 'status' : 'error', $res['message']); } @@ -315,7 +361,12 @@ final class AdminAuthController extends Controller 'code' => ['required', 'string', 'max:10'], ]); - $res = $this->authService->totpConfirm($adminId, (string) $data['code']); + $res = $this->authService->totpConfirm( + $adminId, + (string) $data['code'], + (string)$request->ip(), + (string)($request->userAgent() ?? '') + ); return redirect()->route('admin.security') ->with($res['ok'] ? 'status' : 'error', $res['message']); @@ -324,7 +375,11 @@ final class AdminAuthController extends Controller public function totpDisable(Request $request) { $adminId = (int) auth()->guard('admin')->id(); - $res = $this->authService->totpDisable($adminId); + $res = $this->authService->totpDisable( + $adminId, + (string)$request->ip(), + (string)($request->userAgent() ?? '') + ); return redirect()->route('admin.security') ->with($res['ok'] ? 'status' : 'error', $res['message']); @@ -346,9 +401,108 @@ final class AdminAuthController extends Controller 'totp_enabled' => ['required', 'in:0,1'], ]); - $res = $this->authService->totpMode($adminId, (int) $data['totp_enabled']); + $res = $this->authService->totpMode( + $adminId, + (int) $data['totp_enabled'], + (string)$request->ip(), + (string)($request->userAgent() ?? '') + ); return redirect()->route('admin.security') ->with($res['ok'] ? 'status' : 'error', $res['message']); } + + public function showTotp(Request $request) + { + $pending = (array) $request->session()->get('admin_totp', []); + + if (empty($pending['admin_id'])) { + return redirect()->route('admin.login.form') + ->withErrors(['login_id' => '인증 정보가 없습니다. 다시 로그인해 주세요.']); + } + + if ((int)($pending['expires_at'] ?? 0) < time()) { + $request->session()->forget('admin_totp'); + return redirect()->route('admin.login.form') + ->withErrors(['login_id' => '인증이 만료되었습니다. 다시 로그인해 주세요.']); + } + + return view('admin.auth.totp'); + } + + public function verifyTotp(Request $request) + { + $data = $request->validate([ + 'code' => ['required', 'digits:6'], + ]); + + $pending = (array) $request->session()->get('admin_totp', []); + $adminId = (int)($pending['admin_id'] ?? 0); + + if ($adminId <= 0) { + return redirect()->route('admin.login.form') + ->withErrors(['login_id' => '인증 정보가 없습니다. 다시 로그인해 주세요.']); + } + + if ((int)($pending['expires_at'] ?? 0) < time()) { + $request->session()->forget('admin_totp'); + return redirect()->route('admin.login.form') + ->withErrors(['login_id' => '인증이 만료되었습니다. 다시 로그인해 주세요.']); + } + + // IP 고정 (SMS와 동일 정책) + $ip = (string) $request->ip(); + if (!hash_equals((string)($pending['ip'] ?? ''), $ip)) { + $request->session()->forget('admin_totp'); + return redirect()->route('admin.login.form') + ->withErrors(['login_id' => '접속 정보가 변경되었습니다. 다시 로그인해 주세요.']); + } + + // 시도 횟수 제한 + $attempts = (int)($pending['attempts'] ?? 0); + $max = (int)($pending['max_attempts'] ?? 5); + if ($attempts >= $max) { + $request->session()->forget('admin_totp'); + return redirect()->route('admin.login.form') + ->withErrors(['login_id' => '시도 횟수를 초과했습니다. 다시 로그인해 주세요.']); + } + + $pending['attempts'] = $attempts + 1; + $request->session()->put('admin_totp', $pending); + + $res = $this->authService->verifyTotp( + adminId: $adminId, + code: (string) $data['code'], + ip: $ip + ); + + if (!($res['ok'] ?? false)) { + $reason = (string)($res['reason'] ?? ''); + $msg = match ($reason) { + 'blocked' => '로그인 할 수 없는 계정입니다.', + 'not_registered' => 'OTP 등록 상태가 올바르지 않습니다. 최고관리자에게 문의해 주세요.', + default => '인증코드가 올바르지 않습니다.', + }; + + // invalid는 페이지에서 재입력, 나머지는 로그인으로 + if ($reason !== 'invalid') { + $request->session()->forget('admin_totp'); + return redirect()->route('admin.login.form')->withErrors(['login_id' => $msg]); + } + + return back()->withErrors(['code' => $msg]); + } + + /** @var \App\Models\AdminUser $admin */ + $admin = $res['admin']; + + Auth::guard('admin')->login($admin, (bool)($pending['remember'] ?? false)); + + $request->session()->forget('admin_totp'); + + $ctx = $this->authService->buildAdminSessionContext((int)$admin->id); + if (!empty($ctx)) $request->session()->put('admin_ctx', $ctx); + + return redirect()->route('admin.home'); + } } diff --git a/app/Http/Middleware/AdminRole.php b/app/Http/Middleware/AdminRole.php new file mode 100644 index 0000000..820a004 --- /dev/null +++ b/app/Http/Middleware/AdminRole.php @@ -0,0 +1,32 @@ +app->singleton(CiSeedCrypto::class, function () { $key = (string) config('legacy.seed_user_key_default', ''); $iv = (string) config('legacy.iv', ''); diff --git a/app/Services/Admin/AdminAdminsService.php b/app/Services/Admin/AdminAdminsService.php index 6f40c08..fc2e0a8 100644 --- a/app/Services/Admin/AdminAdminsService.php +++ b/app/Services/Admin/AdminAdminsService.php @@ -3,18 +3,66 @@ namespace App\Services\Admin; use App\Repositories\Admin\AdminUserRepository; +use App\Services\Admin\AdminAuditService; use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; + final class AdminAdminsService { public function __construct( private readonly AdminUserRepository $repo, + private readonly AdminAuditService $audit, ) {} + private function snapAdmin(?object $admin): ?array + { + if (!$admin) return null; + + return [ + 'id' => (int)($admin->id ?? 0), + 'email' => (string)($admin->email ?? ''), + 'name' => (string)($admin->name ?? ''), + 'nickname' => (string)($admin->nickname ?? ''), + 'status' => (string)($admin->status ?? ''), + 'totp_enabled' => (int)($admin->totp_enabled ?? 0), + 'totp_verified_at' => $admin->totp_verified_at ? (string)$admin->totp_verified_at : null, + // ❗시크릿은 절대 로그 저장 금지 + 'totp_secret_set' => !empty($admin->totp_secret_enc) ? 1 : 0, + 'locked_until' => $admin->locked_until ? (string)$admin->locked_until : null, + 'failed_login_count' => (int)($admin->failed_login_count ?? 0), + 'must_reset_password' => (int)($admin->must_reset_password ?? 0), + ]; + } + + private function packRoles(array $roles): array + { + // repo->getRolesForUser()가 반환하는 형태(id, code, name)를 + // 네가 원하는 id/name/label로 정리 + return array_map(function ($r) { + $r = (array)$r; + return [ + 'id' => (int)($r['id'] ?? 0), + 'name' => (string)($r['code'] ?? ''), // ex) super_admin + 'label' => (string)($r['name'] ?? ''), // ex) 최고관리자 + ]; + }, $roles); + } + + private function snapRolesForUser(int $adminId): array + { + $roles = $this->packRoles($this->repo->getRolesForUser($adminId)); + + return [ + 'roles' => $roles, + 'role_ids' => array_values(array_map(fn($x)=> (int)$x['id'], $roles)), + 'role_names' => array_values(array_map(fn($x)=> (string)$x['name'], $roles)), + ]; + } + public function list(array $filters): array { $page = $this->repo->paginateUsers($filters, 20); @@ -57,20 +105,28 @@ final class AdminAdminsService ]; } - public function update(int $id, array $input, int $actorId): array + public function update(int $id, array $input, int $actorId, string $ip = '', string $ua = ''): array { $admin = $this->repo->find($id); if (!$admin) return $this->fail('관리자를 찾을 수 없습니다.'); - // 본인 계정 비활성화 방지(사고 방지) + // before snapshot (admin + roles) + $before = array_merge( + ['admin' => $this->snapAdmin($admin)], + $this->snapRolesForUser($id), + ); + + // 본인 계정 비활성화 방지 if ((int)$id === (int)$actorId && isset($input['status']) && $input['status'] !== 'active') { return $this->fail('본인 계정은 비활성화할 수 없습니다.'); } - $wantTotp = (int)($data['totp_enabled'] ?? 0) === 1; + // 버그 수정: $data가 아니라 $input 기준 + $wantTotp = (int)($input['totp_enabled'] ?? 0) === 1; - if ($wantTotp && empty($admin->totp_secret_enc)) { - return $this->fail('Google OTP가 미등록 상태라 선택할 수 없습니다.'); + // TOTP를 켜려면 "등록 완료" 상태여야 함 (secret + verified_at) + if ($wantTotp && (empty($admin->totp_secret_enc) || empty($admin->totp_verified_at))) { + return $this->fail('Google OTP가 등록 완료 상태가 아니라 선택할 수 없습니다.'); } $data = [ @@ -91,6 +147,8 @@ final class AdminAdminsService if ($hashKey === '') return $this->fail('ADMIN_PHONE_HASH_KEY 가 설정되지 않았습니다.'); $e164 = $this->toE164Kr($phone); + + // update는 e164로 해시하니, 여기서도 e164 기준 유지 $hash = hash_hmac('sha256', $e164, $hashKey); if (method_exists($this->repo, 'existsPhoneHash') && $this->repo->existsPhoneHash($hash, $id)) { @@ -105,10 +163,14 @@ final class AdminAdminsService } } - // null은 업데이트 제외(컬럼 유무 필터는 repo에서 함) + // null만 제외 (0은 유지) $data = array_filter($data, fn($v)=>$v !== null); $ok = $this->repo->updateById($id, $data); + + // ⚠️ updateById가 "변경사항 없음"이면 0 리턴할 수도 있음 + // 지금은 0이면 실패 처리인데, 원하면 아래처럼 완화 가능: + // if (!$ok && empty($data)) { $ok = true; } if (!$ok) return $this->fail('저장에 실패했습니다. 잠시 후 다시 시도해 주세요.'); // roles sync @@ -118,37 +180,75 @@ final class AdminAdminsService $this->repo->syncRoles($id, $roleIds, $actorId); } + // after snapshot + $afterAdmin = $this->repo->find($id); + $after = array_merge( + ['admin' => $this->snapAdmin($afterAdmin)], + $this->snapRolesForUser($id), + ); + + // Audit log (actor=수정한 관리자, target=수정당한 관리자) + $this->audit->log( + actorAdminId: $actorId, + action: 'admin_user_update', + targetType: 'admin_users', + targetId: $id, + before: $before, + after: $after, + ip: $ip, + ua: $ua, + ); + return $this->ok('변경되었습니다.'); } - public function resetPasswordToEmail(int $id, int $actorId): array + + public function resetPasswordToEmail(int $id, int $actorId, string $ip = '', string $ua = ''): array { $admin = $this->repo->find($id); if (!$admin) return $this->fail('관리자를 찾을 수 없습니다.'); + $before = ['admin' => $this->snapAdmin($admin)]; + $email = (string)($admin->email ?? ''); if ($email === '') return $this->fail('이메일이 비어 있어 초기화할 수 없습니다.'); $payload = [ - 'password' => Hash::make($email), + 'password' => Hash::make($email), // ❗비번 원문/해시는 로그에 남기지 않음 'must_reset_password' => 1, 'password_changed_at' => now(), 'updated_by' => $actorId ?: null, 'updated_at' => now(), ]; - // 임시 비밀번호 = 이메일 $ok = $this->repo->updateById($id, $payload); - if (!$ok) return $this->fail('비밀번호 초기화에 실패했습니다.'); + + $afterAdmin = $this->repo->find($id); + $after = ['admin' => $this->snapAdmin($afterAdmin)]; + + $this->audit->log( + actorAdminId: $actorId, + action: 'admin_user_reset_password_to_email', + targetType: 'admin_users', + targetId: $id, + before: $before, + after: $after, + ip: $ip, + ua: $ua, + ); + return $this->ok('임시 비밀번호가 이메일로 초기화되었습니다. (로그인 후 OTP 진행)'); } - public function unlock(int $id, int $actorId): array + + public function unlock(int $id, int $actorId, string $ip = '', string $ua = ''): array { $admin = $this->repo->find($id); if (!$admin) return $this->fail('관리자를 찾을 수 없습니다.'); + $before = ['admin' => $this->snapAdmin($admin)]; + $ok = $this->repo->updateById($id, [ 'locked_until' => null, 'failed_login_count' => 0, @@ -156,9 +256,25 @@ final class AdminAdminsService ]); if (!$ok) return $this->fail('잠금 해제에 실패했습니다.'); + + $afterAdmin = $this->repo->find($id); + $after = ['admin' => $this->snapAdmin($afterAdmin)]; + + $this->audit->log( + actorAdminId: $actorId, + action: 'admin_user_unlock', + targetType: 'admin_users', + targetId: $id, + before: $before, + after: $after, + ip: $ip, + ua: $ua, + ); + return $this->ok('잠금이 해제되었습니다.'); } + private function ok(string $msg): array { return ['ok'=>true,'message'=>$msg]; } private function fail(string $msg): array { return ['ok'=>false,'message'=>$msg]; } @@ -210,7 +326,7 @@ final class AdminAdminsService ]; } - public function createAdmin(array $payload, int $actorId): array + public function createAdmin(array $payload, int $actorId, string $ip = '', string $ua = ''): array { $email = (string)($payload['email'] ?? ''); $name = (string)($payload['name'] ?? ''); @@ -247,7 +363,7 @@ final class AdminAdminsService $email, $name, $nick, $phoneEnc, $phoneHash, $phoneLast4, $pwHash, $tempPassword, - $roleId, $actorId + $roleId, $actorId, $ip, $ua ) { // 중복 방어(이메일/휴대폰) if ($this->repo->existsByEmail($email)) { @@ -257,7 +373,7 @@ final class AdminAdminsService return $this->fail('이미 등록된 휴대폰 번호입니다.'); } - // ✅ admin_users insert + // admin_users insert $adminId = $this->repo->insertAdminUser([ 'email' => $email, 'password' => $pwHash, @@ -292,6 +408,23 @@ final class AdminAdminsService $this->repo->deleteRoleMappingsExcept($adminId, $roleId); $this->repo->upsertRoleMapping($adminId, $roleId); + $afterAdmin = $this->repo->find($adminId); + $after = array_merge( + ['admin' => $this->snapAdmin($afterAdmin)], + $this->snapRolesForUser($adminId), + ); + + $this->audit->log( + actorAdminId: $actorId, + action: 'admin_user_create', + targetType: 'admin_users', + targetId: $adminId, + before: null, + after: $after, + ip: $ip, + ua: $ua, + ); + return [ 'ok' => true, 'admin_id' => $adminId, diff --git a/app/Services/Admin/AdminAuthService.php b/app/Services/Admin/AdminAuthService.php index a6c7471..dca0dfe 100644 --- a/app/Services/Admin/AdminAuthService.php +++ b/app/Services/Admin/AdminAuthService.php @@ -15,6 +15,7 @@ use BaconQrCode\Renderer\ImageRenderer; use BaconQrCode\Renderer\RendererStyle\RendererStyle; use BaconQrCode\Writer; use Laravel\Fortify\Contracts\TwoFactorAuthenticationProvider; +use App\Services\Admin\AdminAuditService; final class AdminAuthService { @@ -22,6 +23,7 @@ final class AdminAuthService private readonly AdminUserRepository $users, private readonly SmsService $sms, private readonly TwoFactorAuthenticationProvider $totpProvider, + private readonly AdminAuditService $audit, ) {} /** @@ -74,6 +76,29 @@ final class AdminAuthService return ['state' => 'must_reset', 'admin_id' => (int)$admin->id]; } + // ✅ TOTP 모드면 SMS 발송 없이 "TOTP 입력"으로 보냄 + $totpEnabled = (int)($admin->totp_enabled ?? 0) === 1; + $totpReady = $totpEnabled + && !empty($admin->totp_secret_enc) + && !empty($admin->totp_verified_at); + + if ($totpReady) { + return [ + 'state' => 'totp_required', + 'admin_id' => (int)$admin->id, + ]; + } + + // totp_enabled=1인데 등록이 깨진 상태면 로그 남기고 SMS로 fallback (락아웃 방지) + if ($totpEnabled && !$totpReady) { + Log::warning('[admin-auth] totp_enabled=1 but not registered/verified', [ + 'admin_id' => (int)$admin->id, + 'has_secret' => !empty($admin->totp_secret_enc), + 'has_verified_at' => !empty($admin->totp_verified_at), + ]); + } + + // phone_enc 복호화(E164 or digits) $phoneDigits = $this->decryptPhoneToDigits($admin); if ($phoneDigits === '') { @@ -106,8 +131,8 @@ final class AdminAuthService $smsPayload = [ 'from_number' => config('services.sms.from', '1833-4856'), - //'to_number' => $phoneDigits, - 'to_number' => '01036828958', + 'to_number' => $phoneDigits, + //'to_number' => '01036828958', 'message' => "[PIN FOR YOU] 인증번호 {$code} 를 입력해 주세요. ({$ttl}초 이내)", 'sms_type' => 'sms', ]; @@ -202,20 +227,81 @@ final class AdminAuthService ]; } + /** + * return: + * - ['ok'=>false,'reason'=>'expired|invalid|attempts|ip|blocked|not_registered'] + * - ['ok'=>true,'admin'=>AdminUser] + */ + public function verifyTotp(int $adminId, string $code, string $ip): array + { + $admin = $this->users->findActiveById($adminId); + if (!$admin) { + return ['ok' => false, 'reason' => 'blocked']; + } + + if (empty($admin->totp_secret_enc) || empty($admin->totp_verified_at)) { + return ['ok' => false, 'reason' => 'not_registered']; + } + + try { + $secret = Crypt::decryptString((string) $admin->totp_secret_enc); + } catch (\Throwable $e) { + return ['ok' => false, 'reason' => 'not_registered']; + } + + $code = preg_replace('/\D+/', '', $code); + if (strlen($code) !== 6) { + return ['ok' => false, 'reason' => 'invalid']; + } + + $valid = $this->totpProvider->verify($secret, $code); + if (!$valid) { + return ['ok' => false, 'reason' => 'invalid']; + } + + // 로그인 메타 업데이트 + $this->users->touchLogin($admin, $ip); + + return [ + 'ok' => true, + 'admin' => $admin, + ]; + } + + /** * 비밀번호 초기화 처리 * return: ['ok'=>true] | ['ok'=>false] */ - public function resetPassword(int $adminId, string $newPassword, string $ip): array + public function resetPassword(int $adminId, string $newPassword, string $ip, string $ua = ''): array { + $beforeAdmin = $this->users->find($adminId); + $before = $this->snapPwPolicy($beforeAdmin); + $admin = $this->users->findActiveById($adminId); if (!$admin) return ['ok' => false]; $this->users->setPassword($admin, $newPassword); + + $afterAdmin = $this->users->find($adminId); + $after = $this->snapPwPolicy($afterAdmin); + + $this->audit->log( + actorAdminId: $adminId, + action: 'password_force_reset_done', + targetType: 'admin_users', + targetId: $adminId, + before: $before, + after: $after, + ip: $ip, + ua: $ua, + ); + return ['ok' => true]; } + private function otpKey(string $challengeId): string { $prefix = (string)config('admin.redis_prefix', 'admin:2fa:'); @@ -326,28 +412,48 @@ final class AdminAuthService } /** 등록 시작(시크릿 생성) */ - public function totpStart(int $adminId, bool $forceReset = false): array + public function totpStart(int $adminId, bool $forceReset = false, string $ip = '', string $ua = ''): array { - $admin = $this->users->find($adminId); + $beforeAdmin = $this->users->find($adminId); + $before = $this->snapAdmin2fa($beforeAdmin); + + $admin = $beforeAdmin; if (!$admin) return ['ok' => false, 'message' => '관리자 정보를 찾을 수 없습니다.']; if (!$forceReset && !empty($admin->totp_verified_at) && !empty($admin->totp_secret_enc)) { return ['ok' => false, 'message' => '이미 Google OTP가 등록되어 있습니다.']; } - // 새 시크릿 생성(기본 16) - $secret = $this->totpProvider->generateSecretKey(16); // :contentReference[oaicite:2]{index=2} + $secret = $this->totpProvider->generateSecretKey(16); $secretEnc = Crypt::encryptString($secret); $ok = $this->users->updateTotpStart($adminId, $secretEnc); if (!$ok) return ['ok' => false, 'message' => 'OTP 등록 시작에 실패했습니다.']; + $afterAdmin = $this->users->find($adminId); + $after = $this->snapAdmin2fa($afterAdmin); + + // Audit + $this->audit->log( + actorAdminId: $adminId, + action: $forceReset ? 'totp_reset' : 'totp_start', + targetType: 'admin_users', + targetId: $adminId, + before: $before, + after: $after, + ip: $ip, + ua: $ua, + ); + return ['ok' => true, 'message' => 'Google OTP 등록을 시작합니다. 앱에서 QR을 스캔한 뒤 인증코드를 입력해 주세요.']; } /** 등록 확인(코드 검증) */ - public function totpConfirm(int $adminId, string $code): array + public function totpConfirm(int $adminId, string $code, string $ip = '', string $ua = ''): array { + $beforeAdmin = $this->users->find($adminId); + $before = $this->snapAdmin2fa($beforeAdmin); + $vm = $this->totpViewModel($adminId); if (!($vm['ok'] ?? false)) return ['ok' => false, 'message' => $vm['message'] ?? '오류']; @@ -368,21 +474,53 @@ final class AdminAuthService $ok = $this->users->confirmTotp($adminId); if (!$ok) return ['ok' => false, 'message' => 'OTP 등록 완료 처리에 실패했습니다.']; + $afterAdmin = $this->users->find($adminId); + $after = $this->snapAdmin2fa($afterAdmin); + + $this->audit->log( + actorAdminId: $adminId, + action: 'totp_confirm', + targetType: 'admin_users', + targetId: $adminId, + before: $before, + after: $after, + ip: $ip, + ua: $ua, + ); + return ['ok' => true, 'message' => 'Google OTP 등록이 완료되었습니다.']; } /** 삭제(해제) */ - public function totpDisable(int $adminId): array + public function totpDisable(int $adminId, string $ip = '', string $ua = ''): array { - $admin = $this->users->find($adminId); + $beforeAdmin = $this->users->find($adminId); + $before = $this->snapAdmin2fa($beforeAdmin); + + $admin = $beforeAdmin; if (!$admin) return ['ok' => false, 'message' => '관리자 정보를 찾을 수 없습니다.']; $ok = $this->users->disableTotp($adminId); if (!$ok) return ['ok' => false, 'message' => 'Google OTP 삭제에 실패했습니다.']; + $afterAdmin = $this->users->find($adminId); + $after = $this->snapAdmin2fa($afterAdmin); + + $this->audit->log( + actorAdminId: $adminId, + action: 'totp_disable', + targetType: 'admin_users', + targetId: $adminId, + before: $before, + after: $after, + ip: $ip, + ua: $ua, + ); + return ['ok' => true, 'message' => 'Google OTP가 삭제되었습니다. 이제 SMS 인증을 사용합니다.']; } + /** 재등록(새 시크릿 발급) */ public function totpReset(int $adminId): array { @@ -391,9 +529,12 @@ final class AdminAuthService } /** sms/otp 모드 저장(선택) */ - public function totpMode(int $adminId, int $enabled): array + public function totpMode(int $adminId, int $enabled, string $ip = '', string $ua = ''): array { - $admin = $this->users->find($adminId); + $beforeAdmin = $this->users->find($adminId); + $before = $this->snapAdmin2fa($beforeAdmin); + + $admin = $beforeAdmin; if (!$admin) return ['ok' => false, 'message' => '관리자 정보를 찾을 수 없습니다.']; if ($enabled === 1) { @@ -405,6 +546,74 @@ final class AdminAuthService $ok = $this->users->updateTotpMode($adminId, $enabled ? 1 : 0); if (!$ok) return ['ok' => false, 'message' => '2차 인증방법 저장에 실패했습니다.']; + $afterAdmin = $this->users->find($adminId); + $after = $this->snapAdmin2fa($afterAdmin); + + $this->audit->log( + actorAdminId: $adminId, + action: 'totp_mode', + targetType: 'admin_users', + targetId: $adminId, + before: $before, + after: $after, + ip: $ip, + ua: $ua, + ); + return ['ok' => true, 'message' => '2차 인증방법이 저장되었습니다.']; } + + + public function buildAdminSessionContext(int $adminId): array + { + $admin = $this->users->findActiveById($adminId); + if (!$admin) return []; + + // repo가 반환: ['id'=>?, 'code'=>?, 'name'=>?] (code=권한코드, name=표시명) + $rows = $this->users->getRolesForUser($adminId); + + $roles = array_map(fn($r) => [ + 'id' => (int)($r['id'] ?? 0), + 'name' => (string)($r['code'] ?? ''), // 체크용 + 'label' => (string)($r['name'] ?? ''), // 표시용 + ], $rows); + + $roleIds = array_values(array_filter(array_map(fn($x) => (int)$x['id'], $roles))); + $roleNames = array_values(array_filter(array_map(fn($x) => (string)$x['name'], $roles))); + + return [ + 'id' => (int)$admin->id, + 'email' => (string)($admin->email ?? ''), + 'roles' => $roles, + 'role_ids' => $roleIds, + 'role_names' => $roleNames, + 'at' => time(), + ]; + } + + private function snapAdmin2fa(?object $admin): ?array + { + if (!$admin) return null; + + return [ + 'id' => (int)($admin->id ?? 0), + 'email' => (string)($admin->email ?? ''), + 'totp_enabled' => (int)($admin->totp_enabled ?? 0), + 'totp_verified_at' => $admin->totp_verified_at ? (string)$admin->totp_verified_at : null, + // 시크릿 원문/암호문 저장 금지 + 'totp_secret_set' => !empty($admin->totp_secret_enc) ? 1 : 0, + ]; + } + + private function snapPwPolicy(?object $admin): ?array + { + if (!$admin) return null; + + return [ + 'id' => (int)($admin->id ?? 0), + 'email' => (string)($admin->email ?? ''), + 'must_reset_password' => (int)($admin->must_reset_password ?? 0), + 'password_changed_at' => $admin->password_changed_at ? (string)$admin->password_changed_at : null, + ]; + } } diff --git a/bootstrap/app.php b/bootstrap/app.php index e6edc72..75e950b 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -36,6 +36,7 @@ return Application::configure(basePath: dirname(__DIR__)) 'legacy.auth' => \App\Http\Middleware\LegacyAuth::class, 'legacy.guest' => \App\Http\Middleware\LegacyGuest::class, 'admin.ip' => \App\Http\Middleware\AdminIpAllowlist::class, + 'admin.role' => \App\Http\Middleware\AdminRole::class, ]); // ✅ guest redirect (auth 미들웨어가 login 라우트 찾다 터지는거 방지) diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 0ad9c57..4043dc5 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -1,6 +1,6 @@ + @csrf + +
@yield('subheading', '승인된 관리자만 접근할 수 있습니다.')
- @yield('subheading', '모든 로그인·조회·변경 시도는 기록되며, 무단 접근 및 오남용은 정책 및 관련 법령에 따라 조치될 수 있습니다.')
+ @yield('mess', '모든 로그인·조회·변경 시도는 기록되며, 무단 접근 및 오남용은 정책 및 관련 법령에 따라 조치될 수 있습니다.')