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 + +
+ + + @error('code') +
{{ $message }}
+ @enderror +
+ + + +
+ + 앱 시간이 틀어지면 코드가 맞아도 실패할 수 있습니다. (휴대폰 시간 자동 설정 권장) + +
+ +@endsection diff --git a/resources/views/admin/layouts/auth.blade.php b/resources/views/admin/layouts/auth.blade.php index c1f5496..132e90b 100644 --- a/resources/views/admin/layouts/auth.blade.php +++ b/resources/views/admin/layouts/auth.blade.php @@ -30,7 +30,7 @@

@yield('subheading', '승인된 관리자만 접근할 수 있습니다.')

- @yield('subheading', '모든 로그인·조회·변경 시도는 기록되며, 무단 접근 및 오남용은 정책 및 관련 법령에 따라 조치될 수 있습니다.') + @yield('mess', '모든 로그인·조회·변경 시도는 기록되며, 무단 접근 및 오남용은 정책 및 관련 법령에 따라 조치될 수 있습니다.')

diff --git a/resources/views/admin/me/show.blade.php b/resources/views/admin/me/show.blade.php index 3205fee..330cd00 100644 --- a/resources/views/admin/me/show.blade.php +++ b/resources/views/admin/me/show.blade.php @@ -122,18 +122,18 @@ -
-
내 권한
-
-
- @forelse(($perms ?? []) as $p) - {{ $p['code'] }} - @empty - 권한 정보가 없습니다. - @endforelse -
-
-
+{{--
--}} +{{--
내 권한
--}} +{{--
--}} +{{--
--}} +{{-- @forelse(($perms ?? []) as $p)--}} +{{-- {{ $p['code'] }}--}} +{{-- @empty--}} +{{-- 권한 정보가 없습니다.--}} +{{-- @endforelse--}} +{{--
--}} +{{--
--}} +{{--
--}}
최근 로그인
diff --git a/resources/views/admin/partials/sidebar.blade.php b/resources/views/admin/partials/sidebar.blade.php index 632f8f0..b7772f6 100644 --- a/resources/views/admin/partials/sidebar.blade.php +++ b/resources/views/admin/partials/sidebar.blade.php @@ -1,4 +1,5 @@ @php + $roles = session('admin_ctx.role_names', []); $menu = [ [ 'title' => '대시보드', @@ -9,103 +10,134 @@ [ 'title' => '콘솔 관리', 'items' => [ - ['label' => '내 정보', 'route' => 'admin.me'], // 추후 라우트 - ['label' => '관리자 계정 관리', 'route' => 'admin.admins.index'], - ['label' => '권한/역할 관리', 'route' => 'admin.roles.index'], - ['label' => '접근 IP 허용목록', 'route' => 'admin.allowip.index'], + ['label' => '내 정보', 'route' => 'admin.me'], + ['label' => '관리자 계정 관리', 'route' => 'admin.admins.index' ,'roles' => ['super_admin']], ], ], [ 'title' => '알림/메시지', 'items' => [ - ['label' => '관리자 SMS 발송', 'route' => 'admin.sms.send'], - ['label' => 'SMS 발송 이력', 'route' => 'admin.sms.logs'], - ['label' => '알림 템플릿', 'route' => 'admin.templates.index'], + ['label' => '관리자 SMS 발송', 'route' => 'admin.sms.send','roles' => ['super_admin','finance','product','support']], + ['label' => 'SMS 발송 이력', 'route' => 'admin.sms.logs','roles' => ['super_admin','finance','product','support']], + ['label' => '알림 템플릿', 'route' => 'admin.templates.index','roles' => ['super_admin','finance','product','support']], ], ], [ 'title' => '고객지원', 'items' => [ - ['label' => '공지사항', 'route' => 'admin.notice.index'], - ['label' => '1:1 문의', 'route' => 'admin.inquiry.index'], - ['label' => 'FAQ 코드 관리', 'route' => 'admin.faqcodes.index'], - ['label' => 'QnA 코드 관리', 'route' => 'admin.qnacodes.index'], + ['label' => '공지사항', 'route' => 'admin.notice.index','roles' => ['super_admin','support']], + ['label' => '1:1 문의', 'route' => 'admin.inquiry.index','roles' => ['super_admin','support']], + ['label' => 'FAQ 코드 관리', 'route' => 'admin.faqcodes.index','roles' => ['super_admin','support']], + ['label' => 'QnA 코드 관리', 'route' => 'admin.qnacodes.index','roles' => ['super_admin','support']], ], ], [ 'title' => '상품권 관리', 'items' => [ - ['label' => '상품 리스트', 'route' => 'admin.products.index'], - ['label' => '상품 등록', 'route' => 'admin.products.create'], - ['label' => '판매 코드 관리', 'route' => 'admin.sale-codes.index'], - ['label' => '핀 번호 관리', 'route' => 'admin.pins.index'], - ['label' => '메인 노출 관리', 'route' => 'admin.exposure.index'], - ['label' => '결제 수수료/정책', 'route' => 'admin.fees.index'], + ['label' => '상품 리스트', 'route' => 'admin.products.index','roles' => ['super_admin','product']], + ['label' => '상품 등록', 'route' => 'admin.products.create','roles' => ['super_admin','product']], + ['label' => '판매 코드 관리', 'route' => 'admin.sale-codes.index','roles' => ['super_admin','product']], + ['label' => '핀 번호 관리', 'route' => 'admin.pins.index','roles' => ['super_admin','product']], + ['label' => '메인 노출 관리', 'route' => 'admin.exposure.index','roles' => ['super_admin','product']], + ['label' => '결제 수수료/정책', 'route' => 'admin.fees.index','roles' => ['super_admin','product']], ], ], [ 'title' => '매입/정산', 'items' => [ - ['label' => '핀 매입 현황(출금)', 'route' => 'admin.buyback.index'], - ['label' => '출금 요청 관리', 'route' => 'admin.withdraw.index'], - ['label' => '정산 리포트', 'route' => 'admin.settlement.index'], + ['label' => '핀 매입 현황(출금)', 'route' => 'admin.buyback.index','roles' => ['super_admin','finance']], + ['label' => '출금 요청 관리', 'route' => 'admin.withdraw.index','roles' => ['super_admin','finance']], + ['label' => '정산 리포트', 'route' => 'admin.settlement.index','roles' => ['super_admin','finance']], ], ], [ 'title' => '거래/매출', 'items' => [ - ['label' => '상품권 거래 장부', 'route' => 'admin.ledger.index'], - ['label' => '매출 리포트', 'route' => 'admin.sales.index'], - ['label' => '환불/취소 내역', 'route' => 'admin.refunds.index'], + ['label' => '상품권 거래 장부', 'route' => 'admin.ledger.index','roles' => ['super_admin','finance']], + ['label' => '매출 리포트', 'route' => 'admin.sales.index','roles' => ['super_admin','finance']], + ['label' => '환불/취소 내역', 'route' => 'admin.refunds.index','roles' => ['super_admin','finance']], ], ], [ 'title' => '회원/정책', 'items' => [ - ['label' => '회원 관리', 'route' => 'admin.members.index'], - ['label' => '회원가입 필터 설정', 'route' => 'admin.signup-filter.index'], - ['label' => '블랙리스트/제재', 'route' => 'admin.sanctions.index'], - ['label' => '마케팅 수신동의', 'route' => 'admin.marketing.index'], + ['label' => '회원 관리', 'route' => 'admin.members.index','roles' => ['super_admin','support']], + ['label' => '회원가입 필터 설정', 'route' => 'admin.signup-filter.index','roles' => ['super_admin','support']], + ['label' => '블랙리스트/제재', 'route' => 'admin.sanctions.index','roles' => ['super_admin','support']], + ['label' => '마케팅 수신동의', 'route' => 'admin.marketing.index','roles' => ['super_admin','support']], ], ], [ 'title' => '시스템 로그', 'items' => [ - ['label' => '로그인 로그', 'route' => 'admin.logs.login'], - ['label' => '다날 인증 로그', 'route' => 'admin.logs.danal'], - ['label' => '결제 로그', 'route' => 'admin.logs.pay'], - ['label' => '기타 로그', 'route' => 'admin.logs.misc'], - ['label' => '관리자 활동 로그', 'route' => 'admin.logs.audit'], + ['label' => '로그인 로그', 'route' => 'admin.logs.login','roles' => ['super_admin','finance','product','support']], + ['label' => '다날 인증 로그', 'route' => 'admin.logs.danal','roles' => ['super_admin','finance','product','support']], + ['label' => '결제 로그', 'route' => 'admin.logs.pay','roles' => ['super_admin','finance','product','support']], + ['label' => '기타 로그', 'route' => 'admin.logs.misc','roles' => ['super_admin','finance','product','support']], + ['label' => '관리자 활동 로그', 'route' => 'admin.logs.audit','roles' => ['super_admin','finance','product','support']], ], ], ]; + + $roleNames = (array) data_get(session('admin_ctx', []), 'role_names', []); + $isSuper = in_array('super_admin', $roleNames, true); @endphp + diff --git a/routes/admin.php b/routes/admin.php index 83a32e4..76b713a 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -24,24 +24,37 @@ Route::middleware(['web'])->group(function () { ->middleware('throttle:admin-login') ->name('admin.password.reset.store'); + // OTP(sms) 2차 인증 Route::get('/otp', [AdminAuthController::class, 'showOtp']) ->name('admin.otp.form'); Route::post('/otp', [AdminAuthController::class, 'verifyOtp']) ->middleware('throttle:admin-otp') ->name('admin.otp.store'); + + // TOTP(구글 OTP) 2차 인증 + Route::get('/totp', [AdminAuthController::class, 'showTotp']) + ->name('admin.totp.form'); + + Route::post('/totp', [AdminAuthController::class, 'verifyTotp']) + ->middleware('throttle:admin-otp') // 필요하면 throttle:admin-totp 로 분리 가능 + ->name('admin.totp.store'); }); // 로그인 이후 Route::middleware(['auth:admin', \App\Http\Middleware\NoStore::class])->group(function () { + + // ✅ 대시보드: 전체 허용 Route::get('/', fn() => view('admin.home'))->name('admin.home'); + // ✅ 내 정보: 전체 허용 Route::get('/me', [MeController::class, 'show'])->name('admin.me'); Route::post('/me', [MeController::class, 'update'])->name('admin.me.update'); Route::get('/me/password', [MeController::class, 'showPassword'])->name('admin.me.password.form'); Route::post('/me/password', [MeController::class, 'updatePassword'])->name('admin.me.password.update'); + // ✅ 보안/OTP 등록(자기계정 설정): 전체 허용 Route::get('/security', [AdminAuthController::class, 'security'])->name('admin.security'); Route::post('/totp/start', [AdminAuthController::class, 'totpStart'])->name('admin.totp.start'); @@ -52,22 +65,45 @@ Route::middleware(['web'])->group(function () { Route::post('/logout', [AdminAuthController::class, 'logout'])->name('admin.logout'); - Route::prefix('/admins')->name('admin.admins.')->group(function () { - Route::get('/', [AdminAdminsController::class, 'index'])->name('index'); - Route::get('/create', [AdminAdminsController::class, 'create'])->name('create'); - Route::post('/', [AdminAdminsController::class, 'store'])->name('store'); - Route::get('/{id}', [AdminAdminsController::class, 'edit'])->name('edit'); - Route::post('/{id}', [AdminAdminsController::class, 'update'])->name('update'); - Route::post('/{id}/reset-password', [AdminAdminsController::class, 'resetPassword'])->name('reset_password'); - Route::post('/{id}/unlock', [AdminAdminsController::class, 'unlock'])->name('unlock'); - }); + // ✅ 관리자 계정 관리: super_admin 전용 + Route::prefix('/admins') + ->name('admin.admins.') + ->middleware('admin.role:super_admin') + ->group(function () { + + Route::get('/', [AdminAdminsController::class, 'index'])->name('index'); + Route::get('/create', [AdminAdminsController::class, 'create'])->name('create'); + Route::post('/', [AdminAdminsController::class, 'store'])->name('store'); + Route::get('/{id}', [AdminAdminsController::class, 'edit'])->name('edit'); + Route::post('/{id}', [AdminAdminsController::class, 'update'])->name('update'); + Route::post('/{id}/reset-password', [AdminAdminsController::class, 'resetPassword'])->name('reset_password'); + Route::post('/{id}/unlock', [AdminAdminsController::class, 'unlock'])->name('unlock'); + }); + + /** + * 아래는 메뉴는 있지만 실제 라우트/컨트롤러가 아직 없으니, + * 구현 시점에만 같은 패턴으로 그룹에 admin.role 을 붙이면 됨. + * + * 예) + * - support 전용: + * Route::prefix('/inquiry')->name('admin.inquiry.') + * ->middleware('admin.role:support') + * ->group(...) + * + * - finance 전용: + * Route::prefix('/settlement')->name('admin.settlement.') + * ->middleware('admin.role:finance') + * ->group(...) + * + * - product 전용: + * Route::prefix('/products')->name('admin.products.') + * ->middleware('admin.role:product') + * ->group(...) + */ }); - - }); - /* 개발용 페이지 세션 보기 */ if (config('app.debug') || app()->environment('local')) { require __DIR__.'/dev_admin.php';