From a3f92cd322e90a3d5bb78bc0932a63282fe07eb2 Mon Sep 17 00:00:00 2001
From: taekyoungc
Date: Thu, 26 Mar 2026 15:29:55 +0900
Subject: [PATCH] =?UTF-8?q?feat:=20TOTP=202=EC=B0=A8=20=EC=9D=B8=EC=A6=9D,?=
=?UTF-8?q?=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=EB=A9=94=EB=89=B4/=EB=8C=80?=
=?UTF-8?q?=EC=8B=9C=EB=B3=B4=EB=93=9C=20=EB=B0=8F=20=EC=9D=98=EC=A1=B4?=
=?UTF-8?q?=EC=84=B1=20=EB=B0=98=EC=98=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- robthree/twofactorauth, Auth 설정·TotpService·2FA 뷰·라우트
- member TOTP 컬럼 DDL(login_tables, member_add_totp.sql)
- 관리자 메뉴·레이아웃·필터·대시보드 등 연관 변경
- env 샘플에 auth.requireTotp 주석
Made-with: Cursor
---
app/Config/Auth.php | 52 ++
app/Config/Roles.php | 21 +
app/Config/Routes.php | 5 +
app/Controllers/Admin/Access.php | 4 +-
app/Controllers/Admin/DesignatedShop.php | 2 +-
app/Controllers/Admin/LocalGovernment.php | 8 +-
app/Controllers/Admin/Menu.php | 4 +-
.../Admin/SelectLocalGovernment.php | 10 +-
app/Controllers/Admin/User.php | 7 +-
app/Controllers/Auth.php | 319 +++++++-
app/Controllers/Home.php | 10 +
app/Filters/AdminAuthFilter.php | 9 +-
app/Helpers/admin_helper.php | 6 +-
app/Libraries/TotpService.php | 49 ++
app/Models/MemberModel.php | 2 +
app/Models/MenuModel.php | 4 +-
app/Views/admin/layout.php | 2 +-
app/Views/admin/menu/index.php | 5 +-
app/Views/auth/login_two_factor.php | 61 ++
app/Views/auth/register.php | 2 +-
app/Views/auth/totp_setup.php | 69 ++
app/Views/bag/daily_inventory.php | 2 +-
app/Views/bag/lg_dashboard.php | 7 +-
app/Views/bag/lg_dashboard_blend.php | 700 ++++++++++++++++++
app/Views/bag/lg_dashboard_charts.php | 4 +
app/Views/bag/lg_dashboard_dense.php | 4 +
app/Views/bag/lg_dashboard_modern.php | 4 +
composer.json | 3 +-
composer.lock | 83 ++-
env | 9 +
writable/database/login_tables.sql | 6 +-
writable/database/member_add_totp.sql | 9 +
32 files changed, 1416 insertions(+), 66 deletions(-)
create mode 100644 app/Config/Auth.php
create mode 100644 app/Libraries/TotpService.php
create mode 100644 app/Views/auth/login_two_factor.php
create mode 100644 app/Views/auth/totp_setup.php
create mode 100644 app/Views/bag/lg_dashboard_blend.php
create mode 100644 writable/database/member_add_totp.sql
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
+
+
+ = date('Y-m-d (D) H:i') ?>
+ |
+ 기준지자체 = esc($lgLabel) ?>
+
+
+
+
+
+ getFlashdata('success')): ?>
+ = esc(session()->getFlashdata('success')) ?>
+
+
+
+
+
+
+ = esc($n) ?>
+
+
+
+
+
+
+
+
+
+
+
+
= esc($k['v']) ?>
+
= esc($k['l']) ?>
+
= esc($k['sub']) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 품목 |
+ 재고(장) |
+ 상태 |
+ 소진 |
+
+
+
+
+
+ | = esc($r[0]) ?> |
+ = esc($r[1]) ?> |
+
+ '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',
+ };
+ ?>
+ = esc($r[2]) ?>
+ |
+ = esc($r[3]) ?> |
+
+
+
+
+
+
+
+
+
+
발주 / 구매신청 진행
+ 최근 5건
+
+
+
+
+
+ | 문서 |
+ 상대 |
+ 내용 |
+ 단계 |
+ 시각 |
+
+
+
+
+
+ | = esc($r[0]) ?> |
+ = esc($r[1]) ?> |
+ = esc($r[2]) ?> |
+ = esc($r[3]) ?> |
+ = esc($r[4]) ?> |
+
+
+
+
+
+
+
+
+
+
최근 이벤트 로그
+
+
+
+ -
+ = esc($L[0]) ?>
+ = esc($L[1]) ?>
+ = $L[2] ?>
+
+
+
+
+
주간 봉투 출고(천 장, 목업)
+
+
+
+
+
+
+ 월화수목금토일
+
+
+
+
+
+
+
+
+
+ 월별 출고 vs 구매신청 건수 (최근 12개월)
+
+
+
+
+
+
+
+
+
지정판매소 요약
+ 상위 5곳
+
+
+
+
+ | 판매소명 |
+ 월 봉투(백장) |
+ 상태 |
+ 최종거래 |
+
+
+
+
+
+ | = esc($s[0]) ?> |
+ = esc($s[1]) ?> |
+
+
+ = esc($s[2]) ?>
+
+ = esc($s[2]) ?>
+
+ |
+ = esc($s[3]) ?> |
+
+
+
+
+
+
+
+
+
회원·판매소 승인 대기
+
+
+
+
+
+ | 신청자 |
+ 유형 |
+ 접수일 |
+ 메모 |
+
+
+
+
+
+ | = esc($a[0]) ?> |
+ = esc($a[1]) ?> |
+ = esc($a[2]) ?> |
+ = esc($a[3]) ?> |
+
+
+
+
+
+
+
+
+
+
+
운영 브리핑 · 추가 그래프
+
+
+ - 다음 주 예상 구매신청 약 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`;