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 @@
+