diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 4d61127..f9c9528 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -48,6 +48,13 @@ $routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): vo $routes->get('local-governments', 'Admin\LocalGovernment::index'); $routes->get('local-governments/create', 'Admin\LocalGovernment::create'); $routes->post('local-governments/store', 'Admin\LocalGovernment::store'); + $routes->get('local-governments/edit/(:num)', 'Admin\LocalGovernment::edit/$1'); + $routes->post('local-governments/update/(:num)', 'Admin\LocalGovernment::update/$1'); + $routes->post('local-governments/delete/(:num)', 'Admin\LocalGovernment::delete/$1'); + + // 비밀번호 변경 (P2-20) + $routes->get('password-change', 'Admin\PasswordChange::index'); + $routes->post('password-change', 'Admin\PasswordChange::update'); // 기본코드 종류 관리 (P2-01) $routes->get('code-kinds', 'Admin\CodeKind::index'); diff --git a/app/Controllers/Admin/LocalGovernment.php b/app/Controllers/Admin/LocalGovernment.php index f712002..469bd7d 100644 --- a/app/Controllers/Admin/LocalGovernment.php +++ b/app/Controllers/Admin/LocalGovernment.php @@ -95,5 +95,89 @@ class LocalGovernment extends BaseController return redirect()->to(site_url('admin/local-governments')) ->with('success', '지자체가 등록되었습니다.'); } + + /** + * 지자체 수정 폼 (P2-19) + */ + public function edit(int $id) + { + if (! $this->isSuperAdmin()) { + return redirect()->to(site_url('admin/local-governments')) + ->with('error', '지자체 수정은 super admin만 가능합니다.'); + } + + $item = $this->lgModel->find($id); + if ($item === null) { + return redirect()->to(site_url('admin/local-governments')) + ->with('error', '지자체를 찾을 수 없습니다.'); + } + + return view('admin/layout', [ + 'title' => '지자체 수정', + 'content' => view('admin/local_government/edit', ['item' => $item]), + ]); + } + + /** + * 지자체 수정 처리 (P2-19) + */ + public function update(int $id) + { + if (! $this->isSuperAdmin()) { + return redirect()->to(site_url('admin/local-governments')) + ->with('error', '지자체 수정은 super admin만 가능합니다.'); + } + + $item = $this->lgModel->find($id); + if ($item === null) { + return redirect()->to(site_url('admin/local-governments')) + ->with('error', '지자체를 찾을 수 없습니다.'); + } + + $rules = [ + 'lg_name' => 'required|max_length[100]', + 'lg_sido' => 'required|max_length[50]', + 'lg_gugun' => 'required|max_length[50]', + 'lg_addr' => 'permit_empty|max_length[255]', + 'lg_state' => 'required|in_list[0,1]', + ]; + + if (! $this->validate($rules)) { + return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); + } + + $this->lgModel->update($id, [ + 'lg_name' => (string) $this->request->getPost('lg_name'), + 'lg_sido' => (string) $this->request->getPost('lg_sido'), + 'lg_gugun' => (string) $this->request->getPost('lg_gugun'), + 'lg_addr' => (string) $this->request->getPost('lg_addr'), + 'lg_state' => (int) $this->request->getPost('lg_state'), + ]); + + return redirect()->to(site_url('admin/local-governments')) + ->with('success', '지자체가 수정되었습니다.'); + } + + /** + * 지자체 삭제 (P2-19) + */ + public function delete(int $id) + { + if (! $this->isSuperAdmin()) { + return redirect()->to(site_url('admin/local-governments')) + ->with('error', '지자체 삭제는 super admin만 가능합니다.'); + } + + $item = $this->lgModel->find($id); + if ($item === null) { + return redirect()->to(site_url('admin/local-governments')) + ->with('error', '지자체를 찾을 수 없습니다.'); + } + + $this->lgModel->update($id, ['lg_state' => 0]); + + return redirect()->to(site_url('admin/local-governments')) + ->with('success', '지자체가 비활성화되었습니다.'); + } } diff --git a/app/Controllers/Admin/PasswordChange.php b/app/Controllers/Admin/PasswordChange.php new file mode 100644 index 0000000..6bf6638 --- /dev/null +++ b/app/Controllers/Admin/PasswordChange.php @@ -0,0 +1,55 @@ + '비밀번호 변경', + 'content' => view('admin/password_change/index'), + ]); + } + + public function update() + { + $rules = [ + 'current_password' => 'required', + 'new_password' => 'required|min_length[4]|max_length[255]', + 'new_password_confirm' => 'required|matches[new_password]', + ]; + $messages = [ + 'current_password' => ['required' => '현재 비밀번호를 입력해 주세요.'], + 'new_password' => [ + 'required' => '새 비밀번호를 입력해 주세요.', + 'min_length' => '비밀번호는 4자 이상이어야 합니다.', + ], + 'new_password_confirm' => [ + 'required' => '비밀번호 확인을 입력해 주세요.', + 'matches' => '새 비밀번호가 일치하지 않습니다.', + ], + ]; + + if (! $this->validate($rules, $messages)) { + return redirect()->back()->with('errors', $this->validator->getErrors()); + } + + $mbIdx = session()->get('mb_idx'); + $memberModel = model(MemberModel::class); + $member = $memberModel->find($mbIdx); + + if (!$member || !password_verify($this->request->getPost('current_password'), $member->mb_passwd)) { + return redirect()->back()->with('error', '현재 비밀번호가 올바르지 않습니다.'); + } + + $memberModel->update($mbIdx, [ + 'mb_passwd' => password_hash($this->request->getPost('new_password'), PASSWORD_DEFAULT), + ]); + + return redirect()->to(site_url('admin/password-change'))->with('success', '비밀번호가 변경되었습니다.'); + } +} diff --git a/app/Controllers/Auth.php b/app/Controllers/Auth.php index d9c40f3..ba3bec2 100644 --- a/app/Controllers/Auth.php +++ b/app/Controllers/Auth.php @@ -77,11 +77,33 @@ class Auth extends BaseController ->with('error', '정지된 회원입니다.'); } - if (! password_verify($password, $member->mb_passwd)) { - $this->insertMemberLog($logData, false, '비밀번호 불일치'); + // P2-21: 로그인 잠금 체크 (5회 실패 시 30분 lock) + if (!empty($member->mb_locked_until) && strtotime($member->mb_locked_until) > time()) { + $remaining = ceil((strtotime($member->mb_locked_until) - time()) / 60); + $this->insertMemberLog($logData, false, '계정 잠금 상태'); return redirect()->back() ->withInput() - ->with('error', '아이디 또는 비밀번호가 올바르지 않습니다.'); + ->with('error', '로그인 시도 횟수 초과로 계정이 잠겼습니다. 약 ' . $remaining . '분 후 다시 시도해 주세요.'); + } + + if (! password_verify($password, $member->mb_passwd)) { + // 실패 횟수 증가 + $failCount = ((int) ($member->mb_login_fail_count ?? 0)) + 1; + $updateData = ['mb_login_fail_count' => $failCount]; + if ($failCount >= 5) { + $updateData['mb_locked_until'] = date('Y-m-d H:i:s', strtotime('+30 minutes')); + } + $memberModel->update($member->mb_idx, $updateData); + + $this->insertMemberLog($logData, false, '비밀번호 불일치 (' . $failCount . '회)'); + + $msg = '아이디 또는 비밀번호가 올바르지 않습니다.'; + if ($failCount >= 5) { + $msg .= ' 5회 연속 실패로 계정이 30분간 잠깁니다.'; + } elseif ($failCount >= 3) { + $msg .= ' (실패 ' . $failCount . '/5회)'; + } + return redirect()->back()->withInput()->with('error', $msg); } // 승인 요청 상태 확인(공개 회원가입 사용자) @@ -113,7 +135,9 @@ class Auth extends BaseController session()->set($sessionData); $memberModel->update($member->mb_idx, [ - 'mb_latestdate' => date('Y-m-d H:i:s'), + 'mb_latestdate' => date('Y-m-d H:i:s'), + 'mb_login_fail_count' => 0, + 'mb_locked_until' => null, ]); $this->insertMemberLog($logData, true, '로그인 성공', $member->mb_idx); diff --git a/app/Models/MemberModel.php b/app/Models/MemberModel.php index d17cfdb..9c6900f 100644 --- a/app/Models/MemberModel.php +++ b/app/Models/MemberModel.php @@ -24,6 +24,8 @@ class MemberModel extends Model 'mb_regdate', 'mb_latestdate', 'mb_leavedate', + 'mb_login_fail_count', + 'mb_locked_until', ]; /** diff --git a/app/Views/admin/local_government/edit.php b/app/Views/admin/local_government/edit.php new file mode 100644 index 0000000..0780480 --- /dev/null +++ b/app/Views/admin/local_government/edit.php @@ -0,0 +1,46 @@ +
+ 지자체 수정 +
+
+
+ + +
+ + lg_code) ?> +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + 취소 +
+
+
diff --git a/app/Views/admin/local_government/index.php b/app/Views/admin/local_government/index.php index bccfd47..0a5afd3 100644 --- a/app/Views/admin/local_government/index.php +++ b/app/Views/admin/local_government/index.php @@ -15,6 +15,7 @@ 구/군 상태 등록일 + 작업 @@ -27,6 +28,13 @@ lg_gugun) ?> lg_state === 1 ? '사용' : '미사용' ?> lg_regdate ?? '') ?> + + 수정 +
+ + +
+ diff --git a/app/Views/admin/password_change/index.php b/app/Views/admin/password_change/index.php new file mode 100644 index 0000000..5e90f7f --- /dev/null +++ b/app/Views/admin/password_change/index.php @@ -0,0 +1,28 @@ +
+ 비밀번호 변경 +
+
+
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + 취소 +
+
+
diff --git a/e2e/phase2-extra.spec.js b/e2e/phase2-extra.spec.js new file mode 100644 index 0000000..2f12b97 --- /dev/null +++ b/e2e/phase2-extra.spec.js @@ -0,0 +1,54 @@ +// @ts-check +const { test, expect } = require('@playwright/test'); +const { login } = require('./helpers/auth'); + +async function loginAsAdmin(page) { + await login(page, 'admin'); + await page.locator('input[name="lg_idx"]').first().check(); + await page.click('button[type="submit"]'); + await page.waitForURL(url => !url.pathname.includes('select-local-government'), { timeout: 15000 }); +} + +test.describe('P2-19: 지자체 수정/삭제', () => { + test('지자체 수정 폼 표시', async ({ page }) => { + await loginAsAdmin(page); + await page.goto('/admin/local-governments/edit/1'); + await expect(page.locator('input[name="lg_name"]')).toBeVisible(); + }); + + test('지자체 목록에 수정/비활성 버튼 존재', async ({ page }) => { + await loginAsAdmin(page); + await page.goto('/admin/local-governments'); + await expect(page.locator('a:has-text("수정")').first()).toBeVisible({ timeout: 10000 }); + }); +}); + +test.describe('P2-20: 비밀번호 변경', () => { + test('비밀번호 변경 폼 표시', async ({ page }) => { + await login(page, 'local'); + await page.goto('/admin/password-change'); + await expect(page.locator('input[name="current_password"]')).toBeVisible(); + await expect(page.locator('input[name="new_password"]')).toBeVisible(); + await expect(page.locator('input[name="new_password_confirm"]')).toBeVisible(); + }); +}); + +test.describe('P2-21: 로그인 실패 lock', () => { + test('잘못된 비밀번호 시 실패 카운트 메시지', async ({ page }) => { + test.setTimeout(60000); + // 3회 연속 실패하면 (실패 N/5회) 표시 + for (let i = 0; i < 3; i++) { + await page.goto('/login'); + await page.fill('input[name="login_id"]', 'tester_user'); + await page.fill('input[name="password"]', 'wrong_password'); + await page.click('button[type="submit"]'); + await page.waitForURL(/\/login/, { timeout: 15000 }); + await page.waitForTimeout(500); + } + // 3회 이후 실패 카운트 표시 확인 + await expect(page.locator('[role="alert"]').first()).toBeVisible({ timeout: 5000 }); + const alertText = await page.locator('[role="alert"]').first().textContent(); + // 실패 카운트 또는 잠금 메시지 확인 + expect(alertText?.includes('실패') || alertText?.includes('잠겼')).toBeTruthy(); + }); +}); diff --git a/writable/database/member_login_lock_add.sql b/writable/database/member_login_lock_add.sql new file mode 100644 index 0000000..3ec5741 --- /dev/null +++ b/writable/database/member_login_lock_add.sql @@ -0,0 +1,7 @@ +-- ============================================ +-- 로그인 실패 잠금 컬럼 추가 (P2-21) +-- ============================================ + +ALTER TABLE `member` + ADD COLUMN `mb_login_fail_count` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '연속 로그인 실패 횟수' AFTER `mb_leavedate`, + ADD COLUMN `mb_locked_until` DATETIME NULL DEFAULT NULL COMMENT '잠금 해제 시각' AFTER `mb_login_fail_count`;