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:
@@ -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');
|
||||||
|
|||||||
@@ -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', '지자체가 비활성화되었습니다.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
55
app/Controllers/Admin/PasswordChange.php
Normal file
55
app/Controllers/Admin/PasswordChange.php
Normal 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', '비밀번호가 변경되었습니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 승인 요청 상태 확인(공개 회원가입 사용자)
|
// 승인 요청 상태 확인(공개 회원가입 사용자)
|
||||||
@@ -113,7 +135,9 @@ class Auth extends BaseController
|
|||||||
session()->set($sessionData);
|
session()->set($sessionData);
|
||||||
|
|
||||||
$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);
|
||||||
|
|||||||
@@ -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',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
46
app/Views/admin/local_government/edit.php
Normal file
46
app/Views/admin/local_government/edit.php
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
28
app/Views/admin/password_change/index.php
Normal file
28
app/Views/admin/password_change/index.php
Normal 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
54
e2e/phase2-extra.spec.js
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
7
writable/database/member_login_lock_add.sql
Normal file
7
writable/database/member_login_lock_add.sql
Normal 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`;
|
||||||
Reference in New Issue
Block a user