diff --git a/app/Config/Auth.php b/app/Config/Auth.php new file mode 100644 index 0000000..99c8f94 --- /dev/null +++ b/app/Config/Auth.php @@ -0,0 +1,52 @@ +requireTotp = filter_var($require, FILTER_VALIDATE_BOOLEAN); + } + + $issuer = env('auth.totpIssuer'); + if (is_string($issuer) && $issuer !== '') { + $this->totpIssuer = $issuer; + } + + $max = env('auth.totpMaxAttempts'); + if ($max !== null && $max !== '' && is_numeric($max)) { + $this->totpMaxAttempts = max(1, (int) $max); + } + + $ttl = env('auth.pending2faTtlSeconds'); + if ($ttl !== null && $ttl !== '' && is_numeric($ttl)) { + $this->pending2faTtlSeconds = max(60, (int) $ttl); + } + } +} diff --git a/app/Config/Roles.php b/app/Config/Roles.php index c5ffdf6..f0c1c14 100644 --- a/app/Config/Roles.php +++ b/app/Config/Roles.php @@ -15,6 +15,8 @@ class Roles extends BaseConfig * mb_level 상수 (member.mb_level) */ public const LEVEL_SUPER_ADMIN = 4; + /** 본부 관리자 — 현재는 super admin과 동일한 관리자 권한(지자체 선택 후 작업). 추후 super 전용 기능 분리 시 여기만 조정 */ + public const LEVEL_HEADQUARTERS_ADMIN = 5; public const LEVEL_LOCAL_ADMIN = 3; // 지자체관리자 public const LEVEL_SHOP = 2; // 지정판매소 public const LEVEL_CITIZEN = 1; // 일반 사용자(시민) @@ -29,8 +31,27 @@ class Roles extends BaseConfig self::LEVEL_SHOP => '지정판매소', self::LEVEL_LOCAL_ADMIN => '지자체관리자', self::LEVEL_SUPER_ADMIN => 'super admin', + self::LEVEL_HEADQUARTERS_ADMIN => '본부 관리자', ]; + /** + * super admin(4) 또는 본부 관리자(5) — 동일 관리자 UX(지자체 선택 등)에 사용 + */ + public static function isSuperAdminEquivalent(int $level): bool + { + return $level === self::LEVEL_SUPER_ADMIN || $level === self::LEVEL_HEADQUARTERS_ADMIN; + } + + /** + * TOTP 2차 인증 적용 대상 (지자체·super·본부 관리자) + */ + public static function requiresTotp(int $level): bool + { + return $level === self::LEVEL_LOCAL_ADMIN + || $level === self::LEVEL_SUPER_ADMIN + || $level === self::LEVEL_HEADQUARTERS_ADMIN; + } + /** * 자체 회원가입 시 기본 역할 (mb_level) */ diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 27ddbfc..a39fb77 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -11,6 +11,7 @@ $routes->get('dashboard/classic-mock', 'Home::dashboardClassicMock'); $routes->get('dashboard/modern', 'Home::dashboardModern'); $routes->get('dashboard/dense', 'Home::dashboardDense'); $routes->get('dashboard/charts', 'Home::dashboardCharts'); +$routes->get('dashboard/blend', 'Home::dashboardBlend'); $routes->get('bag/inventory-inquiry', 'Home::inventoryInquiry'); $routes->get('bag/waste-suibal-enterprise', 'Home::wasteSuibalEnterprise'); @@ -29,6 +30,10 @@ $routes->get('bag/help', 'Bag::help'); // Auth $routes->get('login', 'Auth::showLoginForm'); $routes->post('login', 'Auth::login'); +$routes->get('login/two-factor', 'Auth::showTwoFactor'); +$routes->post('login/two-factor', 'Auth::verifyTwoFactor'); +$routes->get('login/totp-setup', 'Auth::showTotpSetup'); +$routes->post('login/totp-setup', 'Auth::completeTotpSetup'); $routes->get('logout', 'Auth::logout'); $routes->get('register', 'Auth::showRegisterForm'); $routes->post('register', 'Auth::register'); diff --git a/app/Controllers/Admin/Access.php b/app/Controllers/Admin/Access.php index 7b58ed3..6b489ec 100644 --- a/app/Controllers/Admin/Access.php +++ b/app/Controllers/Admin/Access.php @@ -90,8 +90,8 @@ class Access extends BaseController } $requestedLevel = (int) $requestRow->mar_requested_level; - if ($requestedLevel === Roles::LEVEL_SUPER_ADMIN) { - return redirect()->to(site_url('admin/access/approvals'))->with('error', 'super admin 역할 요청은 승인할 수 없습니다.'); + if ($requestedLevel === Roles::LEVEL_SUPER_ADMIN || $requestedLevel === Roles::LEVEL_HEADQUARTERS_ADMIN) { + return redirect()->to(site_url('admin/access/approvals'))->with('error', '상위 관리자 역할 요청은 승인할 수 없습니다.'); } $db = db_connect(); diff --git a/app/Controllers/Admin/DesignatedShop.php b/app/Controllers/Admin/DesignatedShop.php index 3f9c16e..2656736 100644 --- a/app/Controllers/Admin/DesignatedShop.php +++ b/app/Controllers/Admin/DesignatedShop.php @@ -22,7 +22,7 @@ class DesignatedShop extends BaseController private function isSuperAdmin(): bool { - return (int) session()->get('mb_level') === Roles::LEVEL_SUPER_ADMIN; + return Roles::isSuperAdminEquivalent((int) session()->get('mb_level')); } private function isLocalAdmin(): bool diff --git a/app/Controllers/Admin/LocalGovernment.php b/app/Controllers/Admin/LocalGovernment.php index 469bd7d..e134721 100644 --- a/app/Controllers/Admin/LocalGovernment.php +++ b/app/Controllers/Admin/LocalGovernment.php @@ -19,7 +19,7 @@ class LocalGovernment extends BaseController private function isSuperAdmin(): bool { - return (int) session()->get('mb_level') === Roles::LEVEL_SUPER_ADMIN; + return Roles::isSuperAdminEquivalent((int) session()->get('mb_level')); } /** @@ -29,7 +29,7 @@ class LocalGovernment extends BaseController { if (! $this->isSuperAdmin()) { return redirect()->to(site_url('admin')) - ->with('error', '지자체 관리는 super admin만 접근할 수 있습니다.'); + ->with('error', '지자체 관리는 상위 관리자만 접근할 수 있습니다.'); } $list = $this->lgModel->orderBy('lg_idx', 'DESC')->findAll(); @@ -47,7 +47,7 @@ class LocalGovernment extends BaseController { if (! $this->isSuperAdmin()) { return redirect()->to(site_url('admin/local-governments')) - ->with('error', '지자체 등록은 super admin만 가능합니다.'); + ->with('error', '지자체 등록은 상위 관리자만 가능합니다.'); } return view('admin/layout', [ @@ -63,7 +63,7 @@ class LocalGovernment extends BaseController { if (! $this->isSuperAdmin()) { return redirect()->to(site_url('admin/local-governments')) - ->with('error', '지자체 등록은 super admin만 가능합니다.'); + ->with('error', '지자체 등록은 상위 관리자만 가능합니다.'); } $rules = [ diff --git a/app/Controllers/Admin/Menu.php b/app/Controllers/Admin/Menu.php index 2acc04e..01d7886 100644 --- a/app/Controllers/Admin/Menu.php +++ b/app/Controllers/Admin/Menu.php @@ -205,8 +205,8 @@ class Menu extends BaseController return ''; } $levels = array_map('intval', $levels); - // super admin(4)은 DB 저장 대상 아님. 1,2,3은 그대로 저장 - $levels = array_filter($levels, static fn ($v) => $v > 0 && $v !== \Config\Roles::LEVEL_SUPER_ADMIN); + // super/본부(4·5)는 mm_level 저장 대상 아님. 1,2,3은 그대로 저장 + $levels = array_filter($levels, static fn ($v) => $v > 0 && ! \Config\Roles::isSuperAdminEquivalent($v)); return implode(',', array_values($levels)); } diff --git a/app/Controllers/Admin/SelectLocalGovernment.php b/app/Controllers/Admin/SelectLocalGovernment.php index 4538ca8..5713c97 100644 --- a/app/Controllers/Admin/SelectLocalGovernment.php +++ b/app/Controllers/Admin/SelectLocalGovernment.php @@ -9,12 +9,12 @@ use Config\Roles; class SelectLocalGovernment extends BaseController { /** - * 지자체 선택 화면 (super admin 전용) + * 지자체 선택 화면 (super·본부 관리자) */ public function index() { - if ((int) session()->get('mb_level') !== Roles::LEVEL_SUPER_ADMIN) { - return redirect()->to(site_url('admin'))->with('error', '지자체 선택은 super admin만 사용할 수 있습니다.'); + if (! Roles::isSuperAdminEquivalent((int) session()->get('mb_level'))) { + return redirect()->to(site_url('admin'))->with('error', '지자체 선택은 상위 관리자만 사용할 수 있습니다.'); } $list = model(LocalGovernmentModel::class) @@ -35,8 +35,8 @@ class SelectLocalGovernment extends BaseController */ public function store() { - if ((int) session()->get('mb_level') !== Roles::LEVEL_SUPER_ADMIN) { - return redirect()->to(site_url('admin'))->with('error', '지자체 선택은 super admin만 사용할 수 있습니다.'); + if (! Roles::isSuperAdminEquivalent((int) session()->get('mb_level'))) { + return redirect()->to(site_url('admin'))->with('error', '지자체 선택은 상위 관리자만 사용할 수 있습니다.'); } $lgIdx = (int) $this->request->getPost('lg_idx'); diff --git a/app/Controllers/Admin/User.php b/app/Controllers/Admin/User.php index ce0baa9..6db9e25 100644 --- a/app/Controllers/Admin/User.php +++ b/app/Controllers/Admin/User.php @@ -177,7 +177,7 @@ class User extends BaseController /** * 현재 로그인한 관리자가 부여 가능한 역할 목록. - * super admin만 super admin(4) 부여 가능, 그 외는 1~3만 허용. + * super/본부만 4·5 부여 가능, 지자체 관리자는 1~3만. * * @return array */ @@ -185,10 +185,11 @@ class User extends BaseController { $levelNames = $this->roles->levelNames; $myLevel = (int) session()->get('mb_level'); - if ($myLevel === Roles::LEVEL_SUPER_ADMIN) { + if (Roles::isSuperAdminEquivalent($myLevel)) { return $levelNames; } - unset($levelNames[Roles::LEVEL_SUPER_ADMIN]); + unset($levelNames[Roles::LEVEL_SUPER_ADMIN], $levelNames[Roles::LEVEL_HEADQUARTERS_ADMIN]); + return $levelNames; } diff --git a/app/Controllers/Auth.php b/app/Controllers/Auth.php index ba3bec2..554989a 100644 --- a/app/Controllers/Auth.php +++ b/app/Controllers/Auth.php @@ -2,10 +2,12 @@ namespace App\Controllers; +use App\Libraries\TotpService; use App\Models\LocalGovernmentModel; use App\Models\MemberApprovalRequestModel; use App\Models\MemberLogModel; use App\Models\MemberModel; +use CodeIgniter\HTTP\RedirectResponse; class Auth extends BaseController { @@ -50,8 +52,8 @@ class Auth extends BaseController $loginId = trim($this->request->getPost('login_id')); $password = $this->request->getPost('password'); - $memberModel = model(MemberModel::class); - $member = $memberModel->findByLoginId($loginId); + $memberModel = model(MemberModel::class); + $member = $memberModel->findByLoginId($loginId); $approvalModel = model(MemberApprovalRequestModel::class); $logData = $this->buildLogData($loginId, $member?->mb_idx); @@ -123,35 +125,177 @@ class Auth extends BaseController } } - // 로그인 성공 - $sessionData = [ - 'mb_idx' => $member->mb_idx, - 'mb_id' => $member->mb_id, - 'mb_name' => $member->mb_name, - 'mb_level' => $member->mb_level, - 'mb_lg_idx' => $member->mb_lg_idx ?? null, - 'logged_in' => true, - ]; - session()->set($sessionData); + if ($this->needsTotpStep($member)) { + $this->beginPending2faSession((int) $member->mb_idx); + $enabled = (int) ($member->mb_totp_enabled ?? 0) === 1; + if ($enabled) { + return redirect()->to(site_url('login/two-factor')); + } + session()->set('pending_totp_setup', true); - $memberModel->update($member->mb_idx, [ - 'mb_latestdate' => date('Y-m-d H:i:s'), - 'mb_login_fail_count' => 0, - 'mb_locked_until' => null, + return redirect()->to(site_url('login/totp-setup')); + } + + return $this->completeLogin($member, $logData); + } + + public function showTwoFactor() + { + if (session()->get('logged_in')) { + return redirect()->to('/'); + } + $member = $this->ensurePending2faContext(); + if ($member === null) { + return redirect()->to(site_url('login'))->with('error', '로그인 세션이 만료되었습니다. 다시 로그인해 주세요.'); + } + if (session()->get('pending_totp_setup')) { + return redirect()->to(site_url('login/totp-setup')); + } + if ((int) ($member->mb_totp_enabled ?? 0) !== 1) { + return redirect()->to(site_url('login/totp-setup')); + } + + return view('auth/login_two_factor', [ + 'memberId' => $member->mb_id, ]); + } - $this->insertMemberLog($logData, true, '로그인 성공', $member->mb_idx); - - // 지자체 관리자 → 관리자 대시보드로 이동 - if ((int) $member->mb_level === \Config\Roles::LEVEL_LOCAL_ADMIN) { - return redirect()->to(site_url('admin'))->with('success', '로그인되었습니다.'); + public function verifyTwoFactor() + { + if (session()->get('logged_in')) { + return redirect()->to('/'); } - // super admin → 지자체 선택 페이지로 이동 (선택 후 관리자 페이지 사용) - if ((int) $member->mb_level === \Config\Roles::LEVEL_SUPER_ADMIN) { - return redirect()->to(site_url('admin/select-local-government'))->with('success', '로그인되었습니다.'); + $member = $this->ensurePending2faContext(); + if ($member === null) { + return redirect()->to(site_url('login'))->with('error', '로그인 세션이 만료되었습니다. 다시 로그인해 주세요.'); + } + if (session()->get('pending_totp_setup') || (int) ($member->mb_totp_enabled ?? 0) !== 1) { + return redirect()->to(site_url('login/totp-setup')); } - return redirect()->to(site_url('/'))->with('success', '로그인되었습니다.'); + $rules = [ + 'totp_code' => 'required|exact_length[6]|numeric', + ]; + $messages = [ + 'totp_code' => [ + 'required' => '인증 코드 6자리를 입력해 주세요.', + 'exact_length' => '인증 코드는 6자리 숫자입니다.', + 'numeric' => '인증 코드는 숫자만 입력해 주세요.', + ], + ]; + if (! $this->validate($rules, $messages)) { + return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); + } + + $code = (string) $this->request->getPost('totp_code'); + helper('pii_encryption'); + $secret = pii_decrypt((string) ($member->mb_totp_secret ?? '')); + if ($secret === '') { + $this->clearPending2faSession(); + + return redirect()->to(site_url('login'))->with('error', '2차 인증 설정이 올바르지 않습니다. 관리자에게 문의해 주세요.'); + } + + $totp = new TotpService(); + if (! $totp->verify($secret, $code)) { + return $this->handleTotpFailure($member, $this->buildLogData($member->mb_id, (int) $member->mb_idx)); + } + + return $this->completeLogin($member, $this->buildLogData($member->mb_id, (int) $member->mb_idx)); + } + + public function showTotpSetup() + { + if (session()->get('logged_in')) { + return redirect()->to('/'); + } + $member = $this->ensurePending2faContext(); + if ($member === null) { + return redirect()->to(site_url('login'))->with('error', '로그인 세션이 만료되었습니다. 다시 로그인해 주세요.'); + } + if (! session()->get('pending_totp_setup')) { + if ((int) ($member->mb_totp_enabled ?? 0) === 1) { + return redirect()->to(site_url('login/two-factor')); + } + + return redirect()->to(site_url('login')); + } + + $totp = new TotpService(); + $secret = session()->get('pending_totp_secret'); + if (! is_string($secret) || $secret === '') { + $secret = $totp->createSecret(); + session()->set('pending_totp_secret', $secret); + } + + $qrDataUri = null; + try { + $qrDataUri = $totp->getQrDataUri((string) $member->mb_id, $secret); + } catch (\Throwable) { + $qrDataUri = null; + } + + return view('auth/totp_setup', [ + 'memberId' => $member->mb_id, + 'qrDataUri' => $qrDataUri, + 'secret' => $secret, + ]); + } + + public function completeTotpSetup() + { + if (session()->get('logged_in')) { + return redirect()->to('/'); + } + $member = $this->ensurePending2faContext(); + if ($member === null) { + return redirect()->to(site_url('login'))->with('error', '로그인 세션이 만료되었습니다. 다시 로그인해 주세요.'); + } + if (! session()->get('pending_totp_setup')) { + return redirect()->to(site_url('login/two-factor')); + } + + $rules = [ + 'totp_code' => 'required|exact_length[6]|numeric', + ]; + $messages = [ + 'totp_code' => [ + 'required' => '인증 코드 6자리를 입력해 주세요.', + 'exact_length' => '인증 코드는 6자리 숫자입니다.', + 'numeric' => '인증 코드는 숫자만 입력해 주세요.', + ], + ]; + if (! $this->validate($rules, $messages)) { + return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); + } + + $secret = session()->get('pending_totp_secret'); + if (! is_string($secret) || $secret === '') { + return redirect()->to(site_url('login/totp-setup'))->with('error', '설정 정보가 없습니다. 페이지를 새로고침해 주세요.'); + } + + $code = (string) $this->request->getPost('totp_code'); + $totp = new TotpService(); + if (! $totp->verify($secret, $code)) { + return $this->handleTotpFailure($member, $this->buildLogData($member->mb_id, (int) $member->mb_idx)); + } + + helper('pii_encryption'); + model(MemberModel::class)->update((int) $member->mb_idx, [ + 'mb_totp_secret' => pii_encrypt($secret), + 'mb_totp_enabled' => 1, + ]); + session()->remove('pending_totp_setup'); + session()->remove('pending_totp_secret'); + + $fresh = model(MemberModel::class)->find((int) $member->mb_idx); + if ($fresh === null) { + $this->clearPending2faSession(); + + return redirect()->to(site_url('login'))->with('error', '회원 정보를 다시 확인할 수 없습니다.'); + } + + return $this->completeLogin($fresh, $this->buildLogData($fresh->mb_id, (int) $fresh->mb_idx)); } public function logout() @@ -182,6 +326,7 @@ class Auth extends BaseController } } + $this->clearPending2faSession(); session()->destroy(); return redirect()->to('login')->with('success', '로그아웃되었습니다.'); @@ -298,6 +443,130 @@ class Auth extends BaseController return redirect()->to('login')->with('success', '회원가입이 완료되었습니다. 관리자 승인 후 로그인 가능합니다.'); } + private function needsTotpStep(object $member): bool + { + if (! config('Auth')->requireTotp) { + return false; + } + + return \Config\Roles::requiresTotp((int) $member->mb_level); + } + + private function beginPending2faSession(int $mbIdx): void + { + session()->set([ + 'pending_2fa' => true, + 'pending_mb_idx' => $mbIdx, + 'pending_2fa_started' => time(), + 'totp_attempts' => 0, + ]); + session()->remove('pending_totp_setup'); + session()->remove('pending_totp_secret'); + } + + private function clearPending2faSession(): void + { + session()->remove([ + 'pending_2fa', + 'pending_mb_idx', + 'pending_2fa_started', + 'pending_totp_setup', + 'pending_totp_secret', + 'totp_attempts', + ]); + } + + private function pending2faExpired(): bool + { + $started = (int) session()->get('pending_2fa_started'); + if ($started <= 0) { + return true; + } + $ttl = config('Auth')->pending2faTtlSeconds; + + return (time() - $started) > $ttl; + } + + private function ensurePending2faContext(): ?object + { + if (! session()->get('pending_2fa')) { + return null; + } + if ($this->pending2faExpired()) { + $this->clearPending2faSession(); + + return null; + } + $mbIdx = (int) session()->get('pending_mb_idx'); + if ($mbIdx <= 0) { + $this->clearPending2faSession(); + + return null; + } + $member = model(MemberModel::class)->find($mbIdx); + if ($member === null) { + $this->clearPending2faSession(); + + return null; + } + + return $member; + } + + /** + * @param array $logData + */ + private function handleTotpFailure(object $member, array $logData): RedirectResponse + { + $this->insertMemberLog($logData, false, '2차 인증 실패', (int) $member->mb_idx); + $attempts = (int) session()->get('totp_attempts') + 1; + session()->set('totp_attempts', $attempts); + $max = config('Auth')->totpMaxAttempts; + if ($attempts >= $max) { + $this->clearPending2faSession(); + + return redirect()->to(site_url('login'))->with('error', "인증 코드가 {$max}회 틀려 세션이 종료되었습니다. 처음부터 로그인해 주세요."); + } + + return redirect()->back() + ->withInput() + ->with('error', '인증 코드가 올바르지 않습니다.'); + } + + /** + * @param array $logData + */ + private function completeLogin(object $member, array $logData): RedirectResponse + { + $this->clearPending2faSession(); + $sessionData = [ + 'mb_idx' => $member->mb_idx, + 'mb_id' => $member->mb_id, + 'mb_name' => $member->mb_name, + 'mb_level' => $member->mb_level, + 'mb_lg_idx' => $member->mb_lg_idx ?? null, + 'logged_in' => true, + ]; + session()->set($sessionData); + + model(MemberModel::class)->update($member->mb_idx, [ + 'mb_latestdate' => date('Y-m-d H:i:s'), + 'mb_login_fail_count' => 0, + 'mb_locked_until' => null, + ]); + + $this->insertMemberLog($logData, true, '로그인 성공', (int) $member->mb_idx); + + if ((int) $member->mb_level === \Config\Roles::LEVEL_LOCAL_ADMIN) { + return redirect()->to(site_url('admin'))->with('success', '로그인되었습니다.'); + } + if (\Config\Roles::isSuperAdminEquivalent((int) $member->mb_level)) { + return redirect()->to(site_url('admin/select-local-government'))->with('success', '로그인되었습니다.'); + } + + return redirect()->to(site_url('/'))->with('success', '로그인되었습니다.'); + } + private function buildLogData(string $mbId, ?int $mbIdx): array { return [ diff --git a/app/Controllers/Home.php b/app/Controllers/Home.php index def40c5..e120e06 100644 --- a/app/Controllers/Home.php +++ b/app/Controllers/Home.php @@ -61,6 +61,16 @@ class Home extends BaseController ]); } + /** + * dense(표·KPI) + charts(Chart.js) 혼합. URL: /dashboard/blend + */ + public function dashboardBlend() + { + return view('bag/lg_dashboard_blend', [ + 'lgLabel' => $this->resolveLgLabel(), + ]); + } + /** * 재고 조회(수불) 화면 (목업) */ diff --git a/app/Filters/AdminAuthFilter.php b/app/Filters/AdminAuthFilter.php index 2726c50..650407d 100644 --- a/app/Filters/AdminAuthFilter.php +++ b/app/Filters/AdminAuthFilter.php @@ -11,7 +11,7 @@ use Config\Roles; /** * 관리자 전용 접근 필터. - * logged_in 이고 mb_level 이 SUPER_ADMIN(4) 또는 LOCAL_ADMIN(3) 일 때만 통과. + * logged_in 이고 mb_level 이 SUPER_ADMIN(4)·HEADQUARTERS_ADMIN(5)·LOCAL_ADMIN(3) 일 때만 통과. */ class AdminAuthFilter implements FilterInterface { @@ -22,15 +22,16 @@ class AdminAuthFilter implements FilterInterface } $level = (int) session()->get('mb_level'); - if ($level !== Roles::LEVEL_SUPER_ADMIN && $level !== Roles::LEVEL_LOCAL_ADMIN) { + $isAdminLevel = Roles::isSuperAdminEquivalent($level) || $level === Roles::LEVEL_LOCAL_ADMIN; + if (! $isAdminLevel) { return redirect()->to(site_url('/'))->with('error', '관리자만 접근할 수 있습니다.'); } - // Super admin: 지자체 미선택 시 지자체 선택 페이지로 유도 (지자체 선택·지자체 CRUD는 미선택도 허용) + // Super/본부: 지자체 미선택 시 지자체 선택 페이지로 유도 (지자체 선택·지자체 CRUD는 미선택도 허용) $uri = $request->getUri(); $seg2 = $uri->getSegment(2); $allowedWithoutSelection = ['select-local-government', 'local-governments']; - if ($level === Roles::LEVEL_SUPER_ADMIN && ! in_array($seg2, $allowedWithoutSelection, true)) { + if (Roles::isSuperAdminEquivalent($level) && ! in_array($seg2, $allowedWithoutSelection, true)) { $selected = session()->get('admin_selected_lg_idx'); if ($selected === null || $selected === '') { return redirect()->to(site_url('admin/select-local-government'))->with('error', '작업할 지자체를 먼저 선택해 주세요.'); diff --git a/app/Helpers/admin_helper.php b/app/Helpers/admin_helper.php index 143b504..ea11dd1 100644 --- a/app/Helpers/admin_helper.php +++ b/app/Helpers/admin_helper.php @@ -7,12 +7,12 @@ use Config\Roles; if (! function_exists('admin_effective_lg_idx')) { /** * 현재 로그인한 관리자가 작업 대상으로 사용하는 지자체 PK. - * Super admin → admin_selected_lg_idx, 지자체 관리자 → mb_lg_idx, 그 외 null. + * Super/본부 관리자 → admin_selected_lg_idx, 지자체 관리자 → mb_lg_idx, 그 외 null. */ function admin_effective_lg_idx(): ?int { $level = (int) session()->get('mb_level'); - if ($level === Roles::LEVEL_SUPER_ADMIN) { + if (Roles::isSuperAdminEquivalent($level)) { $idx = session()->get('admin_selected_lg_idx'); return $idx !== null && $idx !== '' ? (int) $idx : null; } @@ -27,7 +27,7 @@ if (! function_exists('admin_effective_lg_idx')) { if (! function_exists('get_admin_nav_items')) { /** * 관리자 상단 메뉴 항목 (DB menu 테이블, admin 타입, 현재 지자체·mb_level 기준, 평면 배열). - * 지자체 미선택(super admin)이면 빈 배열. 테이블/조회 실패 시에도 빈 배열. + * 지자체 미선택(super/본부)이면 빈 배열. 테이블/조회 실패 시에도 빈 배열. * * 하위 메뉴 포함 트리 구조가 필요하면 get_admin_nav_tree() 사용. */ diff --git a/app/Libraries/TotpService.php b/app/Libraries/TotpService.php new file mode 100644 index 0000000..d3587f2 --- /dev/null +++ b/app/Libraries/TotpService.php @@ -0,0 +1,49 @@ +tfa = new TwoFactorAuth( + new QRServerProvider(), + $authConfig->totpIssuer, + ); + } + + public function createSecret(): string + { + return $this->tfa->createSecret(); + } + + public function verify(string $plainSecret, string $code): bool + { + $code = preg_replace('/\s+/', '', $code) ?? ''; + if (strlen($code) !== 6 || ! ctype_digit($code)) { + return false; + } + + return $this->tfa->verifyCode($plainSecret, $code); + } + + /** + * PNG data URI (외부 QR API 호출 — 네트워크 필요) + */ + public function getQrDataUri(string $accountLabel, string $secret): string + { + return $this->tfa->getQRCodeImageAsDataUri($accountLabel, $secret); + } +} diff --git a/app/Models/MemberModel.php b/app/Models/MemberModel.php index 9c6900f..b6222da 100644 --- a/app/Models/MemberModel.php +++ b/app/Models/MemberModel.php @@ -13,6 +13,8 @@ class MemberModel extends Model protected $allowedFields = [ 'mb_id', 'mb_passwd', + 'mb_totp_secret', + 'mb_totp_enabled', 'mb_name', 'mb_email', 'mb_phone', diff --git a/app/Models/MenuModel.php b/app/Models/MenuModel.php index c93e643..862cf7a 100644 --- a/app/Models/MenuModel.php +++ b/app/Models/MenuModel.php @@ -28,12 +28,12 @@ class MenuModel extends Model /** * 특정 mb_level에 노출할 메뉴만 필터링 (mm_is_view=Y, mm_level에 해당 레벨 포함 또는 빈값). - * lg_idx 기준 해당 지자체 메뉴만 대상. super admin(4)은 mm_level 무관하게 해당 지자체 메뉴 전체 노출. + * lg_idx 기준 해당 지자체 메뉴만 대상. super/본부(4·5)는 mm_level 무관하게 해당 지자체 메뉴 전체 노출. */ public function getVisibleByLevel(int $mtIdx, int $mbLevel, int $lgIdx): array { $all = $this->getAllByType($mtIdx, $lgIdx); - if ($mbLevel === \Config\Roles::LEVEL_SUPER_ADMIN) { + if (\Config\Roles::isSuperAdminEquivalent($mbLevel)) { return array_values(array_filter($all, static fn ($row) => (string) $row->mm_is_view === 'Y')); } $levelStr = (string) $mbLevel; diff --git a/app/Views/admin/layout.php b/app/Views/admin/layout.php index eaade17..2c653ee 100644 --- a/app/Views/admin/layout.php +++ b/app/Views/admin/layout.php @@ -5,7 +5,7 @@ $n = $uriObj->getTotalSegments(); $uri = $n >= 2 ? $uriObj->getSegment(2) : ''; $seg3 = $n >= 3 ? $uriObj->getSegment(3) : ''; $mbLevel = (int) session()->get('mb_level'); -$isSuperAdmin = ($mbLevel === \Config\Roles::LEVEL_SUPER_ADMIN); +$isSuperAdmin = \Config\Roles::isSuperAdminEquivalent($mbLevel); $effectiveLgIdx = admin_effective_lg_idx(); $effectiveLgName = null; if ($effectiveLgIdx) { diff --git a/app/Views/admin/menu/index.php b/app/Views/admin/menu/index.php index 19827fd..f0941fc 100644 --- a/app/Views/admin/menu/index.php +++ b/app/Views/admin/menu/index.php @@ -4,7 +4,6 @@ $list = $list ?? []; $mtIdx = (int) ($mtIdx ?? 0); $mtCode = (string) ($mtCode ?? ''); $levelNames = $levelNames ?? []; -$superAdminLevel = \Config\Roles::LEVEL_SUPER_ADMIN; ?>
@@ -76,7 +75,7 @@ $superAdminLevel = \Config\Roles::LEVEL_SUPER_ADMIN; if ((string) $row->mm_level === '') { echo '전체'; } else { - $levels = array_filter(explode(',', $row->mm_level), fn ($lv) => (int) trim($lv) !== $superAdminLevel); + $levels = array_filter(explode(',', $row->mm_level), fn ($lv) => ! \Config\Roles::isSuperAdminEquivalent((int) trim($lv))); $labels = array_map(fn ($lv) => $levelNames[trim($lv)] ?? trim($lv), $levels); echo esc(implode(', ', $labels) ?: '전체'); } @@ -146,7 +145,7 @@ $superAdminLevel = \Config\Roles::LEVEL_SUPER_ADMIN; 전체 $name): ?> - +
-

- 차장님 요청 반영: 봉투별 재고·구매신청 리스트·그래프 / - 추가 시안: 발주·입고, 승인 대기, 수불 추이. - 레이아웃은 수불 엔터프라이즈 화면과 동일한 상단 메뉴·제목바 스타일입니다. -

diff --git a/app/Views/bag/lg_dashboard_blend.php b/app/Views/bag/lg_dashboard_blend.php new file mode 100644 index 0000000..303265e --- /dev/null +++ b/app/Views/bag/lg_dashboard_blend.php @@ -0,0 +1,700 @@ +get('mb_name') ?? '담당자'; +$dashClassic = base_url('dashboard/classic-mock'); +$dashModern = base_url('dashboard/modern'); +$dashDense = base_url('dashboard/dense'); +$dashCharts = base_url('dashboard/charts'); +$dashBlend = base_url('dashboard/blend'); + +$kpiTop = [ + ['icon' => 'fa-triangle-exclamation', 'c' => 'text-amber-700', 'bg' => 'bg-amber-50', 'v' => '3', 'l' => '재고부족', 'sub' => '품목'], + ['icon' => 'fa-cart-shopping', 'c' => 'text-sky-700', 'bg' => 'bg-sky-50', 'v' => '12', 'l' => '구매신청', 'sub' => '미처리'], + ['icon' => 'fa-truck', 'c' => 'text-emerald-700', 'bg' => 'bg-emerald-50', 'v' => '8', 'l' => '발주·입고', 'sub' => '금주'], + ['icon' => 'fa-user-clock', 'c' => 'text-violet-700', 'bg' => 'bg-violet-50', 'v' => '4', 'l' => '회원승인', 'sub' => '대기'], + ['icon' => 'fa-store', 'c' => 'text-rose-700', 'bg' => 'bg-rose-50', 'v' => '127', 'l' => '지정판매소', 'sub' => '등록'], + ['icon' => 'fa-boxes-stacked', 'c' => 'text-slate-700', 'bg' => 'bg-slate-100', 'v' => '48.2k', 'l' => '봉투재고', 'sub' => '장 합계'], + ['icon' => 'fa-file-invoice', 'c' => 'text-orange-700', 'bg' => 'bg-orange-50', 'v' => '6', 'l' => '세금계산서', 'sub' => '발행대기'], + ['icon' => 'fa-headset', 'c' => 'text-cyan-700', 'bg' => 'bg-cyan-50', 'v' => '2', 'l' => '민원·문의', 'sub' => '오늘'], +]; + +$stockRows = [ + ['일반 5L', '12,400', '안전', '3.2주'], + ['일반 10L', '8,200', '주의', '1.8주'], + ['일반 20L', '2,100', '부족', '0.6주'], + ['음식물 스티커', '15,000', '안전', '5.1주'], + ['재사용봉투', '4,300', '안전', '2.4주'], + ['특수규격 A', '890', '부족', '0.3주'], +]; + +$orderRows = [ + ['PO-2025-0218', '○○상사', '일반 5L×2박스', '발주확인', '02-26 10:20'], + ['PO-2025-0217', '△△유통', '스티커 500매', '납품중', '02-26 09:05'], + ['PO-2025-0216', '□□종량제', '20L 혼합', '입고완료', '02-25 16:40'], + ['REQ-8841', '행복마트 북구점', '5L 2,000장', '접수', '02-26 09:12'], + ['REQ-8839', '○○슈퍼', '스티커 500', '처리중', '02-26 08:45'], +]; + +$logRows = [ + ['10:42', 'system', '일일 재고 스냅샷 생성 완료'], + ['10:18', 'user', esc($mbName) . ' 로그인 (IP 마스킹)'], + ['09:55', 'batch', '구매신청 자동 분배 3건'], + ['09:30', 'admin', '판매소 코드 2건 갱신'], + ['08:12', 'api', '세금계산서 연동 응답 정상'], +]; + +$storeSummary = [ + ['행복마트 북구점', '42', '정상', '02-26'], + ['○○슈퍼', '38', '정상', '02-25'], + ['△△상회', '15', '연체1건', '02-20'], + ['□□마트', '29', '정상', '02-26'], + ['◇◇할인점', '51', '정상', '02-26'], +]; + +$approvals = [ + ['김○○', '판매소', '02-26', '서류검토'], + ['이○○', '일반', '02-25', '본인확인'], + ['박○○', '판매소', '02-25', '주소불일치'], +]; + +$notices = [ + '2월 말 정기 재고 실사 안내 — 2/28 17:00 마감', + '봉투 단가 조정 예고 — 3/1 적용 예정 (안내문 배포 완료)', +]; +?> + + + + + + 종량제 시스템 — 종합·그래프 혼합 + + + + + + +
+ +
+ +
+ + 종합·그래프 혼합 현황 + · dense 표/KPI + Chart.js + +
+ + | + 기준지자체 + +
+
+ +
+ getFlashdata('success')): ?> +
getFlashdata('success')) ?>
+ + +
+ +
+ + +
+ +
+ +
+ +
+
+ +
+
+
+
+
+
+
+ +
+ + +
+
+
+

품목별 재고·소진예상

+ 상세 +
+
+ + + + + + + + + + + + + + + + + + + +
품목재고(장)상태소진
+ 'bg-emerald-100 text-emerald-800', + '주의' => 'bg-amber-100 text-amber-800', + '부족' => 'bg-red-100 text-red-800', + default => 'bg-gray-100 text-gray-700', + }; + ?> + +
+
+
+ +
+
+

발주 / 구매신청 진행

+ 최근 5건 +
+
+ + + + + + + + + + + + + + + + + + + + + +
문서상대내용단계시각
+
+
+ +
+
+

최근 이벤트 로그

+
+
    + +
  • + + + +
  • + +
+
+
주간 봉투 출고(천 장, 목업)
+
+ + + +
+
+ +
+
+
+
+ + +
+
+

규격 출고 비중

+
+
+
+

구매신청 처리 단계

+
+
+
+

금주 일별 출고(천장)

+
+
+
+

운영 지표 (목업)

+
+
+
+ +
+

월별 출고 vs 구매신청 건수 (최근 12개월)

+
+
+ +
+
+

품목별 재고 (천 장)

+
+
+
+

판매소별 월 출고 TOP

+
+
+
+ +
+
+
+

지정판매소 요약

+ 상위 5곳 +
+ + + + + + + + + + + + + + + + + + + +
판매소명월 봉투(백장)상태최종거래
+ + + + + +
+
+ +
+
+

회원·판매소 승인 대기

+
+
+
+
4
+
전체 대기
+
+
+
2
+
오늘 접수
+
+
+
1.2일
+
평균 처리
+
+
+ + + + + + + + + + + + + + + + + + + +
신청자유형접수일메모
+
+
+ +
+
+

분기별 입고 / 출고 / 조정

+
+
+
+

요일·시간대 신청 분포

+
+
+
+ +
+

운영 브리핑 · 추가 그래프

+
+
    +
  • 다음 주 예상 구매신청 약 28건 (전주 대비 +12%)
  • +
  • 일반 20L·특수규격 A 발주 권고
  • +
  • 세금계산서 6건 미발행 — 회계 알림 발송됨
  • +
+
+

누적 출고 추이 (올해)

+
+
+
+
+ +

+ /dashboard/blend (표 + 차트 혼합) + · /dashboard/dense + · /dashboard/charts + · 클래식 +

+
+ + + + diff --git a/app/Views/bag/lg_dashboard_charts.php b/app/Views/bag/lg_dashboard_charts.php index 9f4e578..f15ac24 100644 --- a/app/Views/bag/lg_dashboard_charts.php +++ b/app/Views/bag/lg_dashboard_charts.php @@ -11,6 +11,7 @@ $dashClassic = base_url('dashboard/classic-mock'); $dashModern = base_url('dashboard/modern'); $dashDense = base_url('dashboard/dense'); $dashCharts = base_url('dashboard/charts'); +$dashBlend = base_url('dashboard/blend'); ?> @@ -85,6 +86,8 @@ $dashCharts = base_url('dashboard/charts'); + + @@ -165,6 +168,7 @@ $dashCharts = base_url('dashboard/charts'); /dashboard · /dashboard/modern · /dashboard/dense + · /dashboard/blend · /dashboard/charts

diff --git a/app/Views/bag/lg_dashboard_dense.php b/app/Views/bag/lg_dashboard_dense.php index 85179c5..90fdbd0 100644 --- a/app/Views/bag/lg_dashboard_dense.php +++ b/app/Views/bag/lg_dashboard_dense.php @@ -11,6 +11,7 @@ $dashClassic = base_url('dashboard/classic-mock'); $dashModern = base_url('dashboard/modern'); $dashDense = base_url('dashboard/dense'); $dashCharts = base_url('dashboard/charts'); +$dashBlend = base_url('dashboard/blend'); $kpiTop = [ ['icon' => 'fa-triangle-exclamation', 'c' => 'text-amber-700', 'bg' => 'bg-amber-50', 'v' => '3', 'l' => '재고부족', 'sub' => '품목'], @@ -126,6 +127,8 @@ $notices = [ + + @@ -384,6 +387,7 @@ $notices = [ 레이아웃: /dashboard · /dashboard/modern · /dashboard/dense (이 화면) + · /dashboard/blend · /dashboard/charts

diff --git a/app/Views/bag/lg_dashboard_modern.php b/app/Views/bag/lg_dashboard_modern.php index f0e7836..4ecc27a 100644 --- a/app/Views/bag/lg_dashboard_modern.php +++ b/app/Views/bag/lg_dashboard_modern.php @@ -11,6 +11,7 @@ $dashClassic = base_url('dashboard/classic-mock'); $dashModern = base_url('dashboard/modern'); $dashDense = base_url('dashboard/dense'); $dashCharts = base_url('dashboard/charts'); +$dashBlend = base_url('dashboard/blend'); ?> @@ -70,6 +71,8 @@ $dashCharts = base_url('dashboard/charts'); + + @@ -207,6 +210,7 @@ $dashCharts = base_url('dashboard/charts');

URL 비교 — 클래식 레이아웃 /dashboard · 모던 콘텐츠(이 화면) /dashboard/modern + · /dashboard/blend · /dashboard/charts · 상단 메뉴는 동일

diff --git a/composer.json b/composer.json index d47149e..f9f870c 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,8 @@ }, "require": { "php": "^8.2", - "codeigniter4/framework": "^4.7" + "codeigniter4/framework": "^4.7", + "robthree/twofactorauth": "^3.0" }, "require-dev": { "fakerphp/faker": "^1.9", diff --git a/composer.lock b/composer.lock index 9c4ddb2..baeed40 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f5cce40800fa5dae1504b9364f585e6a", + "content-hash": "62775ba19440bda6e4bb1fbe91908932", "packages": [ { "name": "codeigniter4/framework", @@ -193,6 +193,87 @@ "source": "https://github.com/php-fig/log/tree/3.0.2" }, "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "robthree/twofactorauth", + "version": "v3.0.3", + "source": { + "type": "git", + "url": "https://github.com/RobThree/TwoFactorAuth.git", + "reference": "85408c4e775dba7c0802f2d928efd921d530bc5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/RobThree/TwoFactorAuth/zipball/85408c4e775dba7c0802f2d928efd921d530bc5b", + "reference": "85408c4e775dba7c0802f2d928efd921d530bc5b", + "shasum": "" + }, + "require": { + "php": ">=8.2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.13", + "phpstan/phpstan": "^1.9", + "phpunit/phpunit": "^9" + }, + "suggest": { + "bacon/bacon-qr-code": "Needed for BaconQrCodeProvider provider", + "endroid/qr-code": "Needed for EndroidQrCodeProvider" + }, + "type": "library", + "autoload": { + "psr-4": { + "RobThree\\Auth\\": "lib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Rob Janssen", + "homepage": "http://robiii.me", + "role": "Developer" + }, + { + "name": "Nicolas CARPi", + "homepage": "https://github.com/NicolasCARPi", + "role": "Developer" + }, + { + "name": "Will Power", + "homepage": "https://github.com/willpower232", + "role": "Developer" + } + ], + "description": "Two Factor Authentication", + "homepage": "https://github.com/RobThree/TwoFactorAuth", + "keywords": [ + "Authentication", + "MFA", + "Multi Factor Authentication", + "Two Factor Authentication", + "authenticator", + "authy", + "php", + "tfa" + ], + "support": { + "issues": "https://github.com/RobThree/TwoFactorAuth/issues", + "source": "https://github.com/RobThree/TwoFactorAuth" + }, + "funding": [ + { + "url": "https://paypal.me/robiii", + "type": "custom" + }, + { + "url": "https://github.com/RobThree", + "type": "github" + } + ], + "time": "2026-01-05T13:17:41+00:00" } ], "packages-dev": [ diff --git a/env b/env index f359ec2..864b455 100644 --- a/env +++ b/env @@ -55,6 +55,15 @@ # encryption.key = +#-------------------------------------------------------------------- +# AUTH (TOTP 2차 인증) — 관리자(mb_level 3·4·5)만 적용, 로컬은 false 권장 +#-------------------------------------------------------------------- + +# auth.requireTotp = true +# auth.totpIssuer = "쓰레기봉투 물류시스템" +# auth.totpMaxAttempts = 5 +# auth.pending2faTtlSeconds = 600 + #-------------------------------------------------------------------- # SESSION #-------------------------------------------------------------------- diff --git a/writable/database/login_tables.sql b/writable/database/login_tables.sql index 6af4aa2..0c6219d 100644 --- a/writable/database/login_tables.sql +++ b/writable/database/login_tables.sql @@ -8,17 +8,19 @@ SET FOREIGN_KEY_CHECKS = 0; -- member: 로그인·권한용 회원 테이블 -- mb_state: 1=정상, 2=정지, 0=탈퇴 -- mb_level: app/Config/Roles.php 참고 --- 1=일반 사용자, 2=지정판매소, 3=지자체관리자, 4=super admin +-- 1=일반 사용자, 2=지정판매소, 3=지자체관리자, 4=super admin, 5=본부 관리자 -- --------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS `member` ( `mb_idx` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '회원 PK', `mb_id` VARCHAR(50) NOT NULL COMMENT '로그인 아이디', `mb_passwd` VARCHAR(255) NOT NULL COMMENT '비밀번호 해시(password_hash)', + `mb_totp_secret` TEXT NULL DEFAULT NULL COMMENT 'TOTP 시크릿(암호화 저장, pii_encrypt)', + `mb_totp_enabled` TINYINT(1) UNSIGNED NOT NULL DEFAULT 0 COMMENT '1=등록 완료·검증 대상', `mb_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '이름', `mb_email` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '이메일', `mb_phone` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '연락처', `mb_lang` VARCHAR(10) NOT NULL DEFAULT 'ko' COMMENT '언어', - `mb_level` TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '1=일반, 2=지정판매소, 3=지자체관리자, 4=super admin (Roles.php)', + `mb_level` TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '1=일반, 2=지정판매소, 3=지자체관리자, 4=super admin, 5=본부 관리자 (Roles.php)', `mb_group` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '권한 그룹 코드(Phase 2)', `mb_lg_idx` INT UNSIGNED NULL DEFAULT NULL COMMENT '소속 지자체 PK(지자체관리자만 사용)', `mb_state` TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '1=정상, 2=정지, 0=탈퇴', diff --git a/writable/database/member_add_totp.sql b/writable/database/member_add_totp.sql new file mode 100644 index 0000000..4a01345 --- /dev/null +++ b/writable/database/member_add_totp.sql @@ -0,0 +1,9 @@ +-- TOTP 2차 인증 컬럼 추가 (기존 DB 마이그레이션용) +-- docs/2차인증-TOTP-개발계획.md +-- 실행 예: mysql ... < writable/database/member_add_totp.sql + +SET NAMES utf8mb4; + +ALTER TABLE `member` + ADD COLUMN `mb_totp_secret` TEXT NULL DEFAULT NULL COMMENT 'TOTP 시크릿(Base32), 저장 시 pii_encrypt 권장' AFTER `mb_passwd`, + ADD COLUMN `mb_totp_enabled` TINYINT(1) UNSIGNED NOT NULL DEFAULT 0 COMMENT '1=등록·로그인 시 TOTP 검증' AFTER `mb_totp_secret`;