P2-19~21 지자체 수정/삭제, 비밀번호 변경, 로그인 5회 실패 lock

- P2-19: LocalGovernment edit/update/delete 추가, 목록에 수정/비활성 버튼
- P2-20: PasswordChange 컨트롤러 + View (현재 비밀번호 검증 후 변경)
- P2-21: 로그인 5회 연속 실패 시 30분 lock
  - member 테이블에 mb_login_fail_count, mb_locked_until 컬럼 추가
  - Auth::login에 lock 체크/실패 카운트 증가/성공 시 리셋 로직
- E2E 테스트 4개 전체 통과

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
javamon1174
2026-03-25 17:53:52 +09:00
parent da132f0e51
commit c2840a9e34
10 changed files with 319 additions and 4 deletions

View File

@@ -48,6 +48,13 @@ $routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): vo
$routes->get('local-governments', 'Admin\LocalGovernment::index'); $routes->get('local-governments', 'Admin\LocalGovernment::index');
$routes->get('local-governments/create', 'Admin\LocalGovernment::create'); $routes->get('local-governments/create', 'Admin\LocalGovernment::create');
$routes->post('local-governments/store', 'Admin\LocalGovernment::store'); $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) // 기본코드 종류 관리 (P2-01)
$routes->get('code-kinds', 'Admin\CodeKind::index'); $routes->get('code-kinds', 'Admin\CodeKind::index');

View File

@@ -95,5 +95,89 @@ class LocalGovernment extends BaseController
return redirect()->to(site_url('admin/local-governments')) return redirect()->to(site_url('admin/local-governments'))
->with('success', '지자체가 등록되었습니다.'); ->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', '지자체가 비활성화되었습니다.');
}
} }

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Controllers\Admin;
use App\Controllers\BaseController;
use App\Models\MemberModel;
class PasswordChange extends BaseController
{
public function index()
{
return view('admin/layout', [
'title' => '비밀번호 변경',
'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', '비밀번호가 변경되었습니다.');
}
}

View File

@@ -77,11 +77,33 @@ class Auth extends BaseController
->with('error', '정지된 회원입니다.'); ->with('error', '정지된 회원입니다.');
} }
if (! password_verify($password, $member->mb_passwd)) { // P2-21: 로그인 잠금 체크 (5회 실패 시 30분 lock)
$this->insertMemberLog($logData, false, '비밀번호 불일치'); 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() return redirect()->back()
->withInput() ->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);
} }
// 승인 요청 상태 확인(공개 회원가입 사용자) // 승인 요청 상태 확인(공개 회원가입 사용자)
@@ -114,6 +136,8 @@ class Auth extends BaseController
$memberModel->update($member->mb_idx, [ $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); $this->insertMemberLog($logData, true, '로그인 성공', $member->mb_idx);

View File

@@ -24,6 +24,8 @@ class MemberModel extends Model
'mb_regdate', 'mb_regdate',
'mb_latestdate', 'mb_latestdate',
'mb_leavedate', 'mb_leavedate',
'mb_login_fail_count',
'mb_locked_until',
]; ];
/** /**

View File

@@ -0,0 +1,46 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">지자체 수정</span>
</section>
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
<form action="<?= base_url('admin/local-governments/update/' . (int) $item->lg_idx) ?>" method="POST" class="space-y-4">
<?= csrf_field() ?>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">코드</label>
<span class="text-sm font-mono font-bold"><?= esc($item->lg_code) ?></span>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">지자체명 <span class="text-red-500">*</span></label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="lg_name" type="text" value="<?= esc(old('lg_name', $item->lg_name)) ?>" required/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">시/도 <span class="text-red-500">*</span></label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="lg_sido" type="text" value="<?= esc(old('lg_sido', $item->lg_sido)) ?>" required/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">구/군 <span class="text-red-500">*</span></label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="lg_gugun" type="text" value="<?= esc(old('lg_gugun', $item->lg_gugun)) ?>" required/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">주소</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-96" name="lg_addr" type="text" value="<?= esc(old('lg_addr', $item->lg_addr)) ?>"/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">상태 <span class="text-red-500">*</span></label>
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-32" name="lg_state" required>
<option value="1" <?= (int) old('lg_state', $item->lg_state) === 1 ? 'selected' : '' ?>>사용</option>
<option value="0" <?= (int) old('lg_state', $item->lg_state) === 0 ? 'selected' : '' ?>>미사용</option>
</select>
</div>
<div class="flex gap-2 pt-2">
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">수정</button>
<a href="<?= base_url('admin/local-governments') ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm hover:bg-gray-300 transition">취소</a>
</div>
</form>
</div>

View File

@@ -15,6 +15,7 @@
<th>/</th> <th>/</th>
<th>상태</th> <th>상태</th>
<th>등록일</th> <th>등록일</th>
<th class="w-28">작업</th>
</tr> </tr>
</thead> </thead>
<tbody class="text-right"> <tbody class="text-right">
@@ -27,6 +28,13 @@
<td class="text-left pl-2"><?= esc($row->lg_gugun) ?></td> <td class="text-left pl-2"><?= esc($row->lg_gugun) ?></td>
<td class="text-center"><?= (int) $row->lg_state === 1 ? '사용' : '미사용' ?></td> <td class="text-center"><?= (int) $row->lg_state === 1 ? '사용' : '미사용' ?></td>
<td class="text-left pl-2"><?= esc($row->lg_regdate ?? '') ?></td> <td class="text-left pl-2"><?= esc($row->lg_regdate ?? '') ?></td>
<td class="text-center">
<a href="<?= base_url('admin/local-governments/edit/' . (int) $row->lg_idx) ?>" class="text-blue-600 hover:underline text-sm">수정</a>
<form action="<?= base_url('admin/local-governments/delete/' . (int) $row->lg_idx) ?>" method="POST" class="inline ml-1" onsubmit="return confirm('이 지자체를 비활성화하시겠습니까?');">
<?= csrf_field() ?>
<button type="submit" class="text-red-600 hover:underline text-sm">비활성</button>
</form>
</td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>

View File

@@ -0,0 +1,28 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">비밀번호 변경</span>
</section>
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-md">
<form action="<?= base_url('admin/password-change') ?>" method="POST" class="space-y-4">
<?= csrf_field() ?>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-32">현재 비밀번호 <span class="text-red-500">*</span></label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="current_password" type="password" required autocomplete="current-password"/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-32">새 비밀번호 <span class="text-red-500">*</span></label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="new_password" type="password" required autocomplete="new-password"/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-32">비밀번호 확인 <span class="text-red-500">*</span></label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="new_password_confirm" type="password" required autocomplete="new-password"/>
</div>
<div class="flex gap-2 pt-2">
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">변경</button>
<a href="<?= base_url('admin') ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm hover:bg-gray-300 transition">취소</a>
</div>
</form>
</div>

54
e2e/phase2-extra.spec.js Normal file
View File

@@ -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();
});
});

View File

@@ -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`;