From 8e767ff234ae6ef33a30a6ef2c185d011482616e Mon Sep 17 00:00:00 2001 From: taekyoungc Date: Wed, 24 Jun 2026 19:02:52 +0900 Subject: [PATCH] =?UTF-8?q?feat(security):=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=B0=A9=EC=A7=80=20(=EB=82=98?= =?UTF-8?q?=EC=A4=91=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=9A=B0=EC=84=A0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 로그인 시 세션 토큰 발급→회원 행(mb_session_token) 저장 - SessionGuardFilter(전역 before)로 매 요청 토큰 대조, 다르면 세션 무효화 후 로그인 이동 - DB 공유로 로컬·운영 등 서버 간에도 동작. 기존 로그인 세션은 재로그인 전까지 유지 Co-Authored-By: Claude Opus 4.8 --- app/Config/Filters.php | 2 + app/Controllers/Auth.php | 4 ++ app/Filters/SessionGuardFilter.php | 62 +++++++++++++++++++ app/Models/MemberModel.php | 1 + .../database/member_session_token_add.sql | 2 + 5 files changed, 71 insertions(+) create mode 100644 app/Filters/SessionGuardFilter.php create mode 100644 writable/database/member_session_token_add.sql diff --git a/app/Config/Filters.php b/app/Config/Filters.php index 1c9e138..6bae047 100644 --- a/app/Config/Filters.php +++ b/app/Config/Filters.php @@ -28,6 +28,7 @@ class Filters extends BaseFilters 'adminAuth' => \App\Filters\AdminAuthFilter::class, 'loginAuth' => \App\Filters\LoginAuthFilter::class, 'embedRedirect' => \App\Filters\EmbedRedirectFilter::class, + 'sessionGuard' => \App\Filters\SessionGuardFilter::class, 'csrf' => CSRF::class, 'toolbar' => DebugToolbar::class, 'honeypot' => Honeypot::class, @@ -78,6 +79,7 @@ class Filters extends BaseFilters // 'honeypot', // 'csrf', // 'invalidchars', + 'sessionGuard', // 중복 로그인 방지(나중 로그인 우선) ], 'after' => [ // 'honeypot', diff --git a/app/Controllers/Auth.php b/app/Controllers/Auth.php index ff94fb9..6ca4b68 100644 --- a/app/Controllers/Auth.php +++ b/app/Controllers/Auth.php @@ -549,6 +549,8 @@ class Auth extends BaseController private function completeLogin(object $member, array $logData): RedirectResponse { $this->clearPending2faSession(); + // 중복 로그인 방지(나중 로그인 우선): 새 세션 토큰 발급 → 기존 다른 세션은 다음 요청 때 무효화됨 + $sessionToken = bin2hex(random_bytes(16)); $sessionData = [ 'mb_idx' => $member->mb_idx, 'mb_id' => $member->mb_id, @@ -556,6 +558,7 @@ class Auth extends BaseController 'mb_level' => $member->mb_level, 'mb_lg_idx' => $member->mb_lg_idx ?? null, 'logged_in' => true, + 'session_token' => $sessionToken, ]; session()->set($sessionData); @@ -563,6 +566,7 @@ class Auth extends BaseController 'mb_latestdate' => date('Y-m-d H:i:s'), 'mb_login_fail_count' => 0, 'mb_locked_until' => null, + 'mb_session_token' => $sessionToken, ]); // 로그인 알림 — 직전 성공 로그인과 IP 비교(새 IP면 경고). 현재 로그인 기록 INSERT 이전에 조회. diff --git a/app/Filters/SessionGuardFilter.php b/app/Filters/SessionGuardFilter.php new file mode 100644 index 0000000..8a71bad --- /dev/null +++ b/app/Filters/SessionGuardFilter.php @@ -0,0 +1,62 @@ +get('logged_in')) { + return null; // 비로그인 요청은 통과 + } + + $mbIdx = (int) ($session->get('mb_idx') ?? 0); + if ($mbIdx <= 0) { + return null; + } + + $member = model(MemberModel::class)->find($mbIdx); + if ($member === null) { + return null; + } + + $dbToken = (string) ($member->mb_session_token ?? ''); + $myToken = (string) ($session->get('session_token') ?? ''); + + // DB에 토큰이 있고 내 세션 토큰과 다르면 → 다른 곳에서 더 늦게 로그인한 것 + if ($dbToken !== '' && $dbToken !== $myToken) { + $session->destroy(); + + // AJAX/JSON 요청은 401, 일반 요청은 로그인으로 + if (strtolower((string) $request->getHeaderLine('X-Requested-With')) === 'xmlhttprequest' + || str_contains(strtolower((string) $request->getHeaderLine('Accept')), 'application/json')) { + return service('response')->setStatusCode(401)->setJSON([ + 'ok' => false, 'message' => '다른 곳에서 로그인되어 로그아웃되었습니다.', + ]); + } + + return redirect()->to(site_url('login'))->with('error', '다른 곳에서 로그인되어 자동 로그아웃되었습니다.'); + } + + return null; + } + + public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) + { + // no-op + } +} diff --git a/app/Models/MemberModel.php b/app/Models/MemberModel.php index b6222da..252d838 100644 --- a/app/Models/MemberModel.php +++ b/app/Models/MemberModel.php @@ -28,6 +28,7 @@ class MemberModel extends Model 'mb_leavedate', 'mb_login_fail_count', 'mb_locked_until', + 'mb_session_token', ]; /** diff --git a/writable/database/member_session_token_add.sql b/writable/database/member_session_token_add.sql new file mode 100644 index 0000000..6214f92 --- /dev/null +++ b/writable/database/member_session_token_add.sql @@ -0,0 +1,2 @@ +-- 중복 로그인 방지: 회원별 현재 세션 토큰(나중 로그인 우선) +ALTER TABLE member ADD COLUMN mb_session_token VARCHAR(64) NULL DEFAULT NULL AFTER mb_locked_until;