From af529a4e816b6e65eda32434b7aac9255b701f8d Mon Sep 17 00:00:00 2001 From: antv Date: Mon, 6 Jul 2026 21:02:52 +0700 Subject: [PATCH] feature: update message for login feature --- app/Http/Controllers/AuthController.php | 45 +++- app/Services/Auth/AuthService.php | 8 +- bootstrap/app.php | 7 +- docs/notification-review/README.md | 6 +- lang/vi/auth.php | 4 +- .../views/components/error-dialog.blade.php | 4 +- .../views/components/info-dialog.blade.php | 4 +- .../views/components/warning-dialog.blade.php | 4 +- resources/views/login.blade.php | 88 ++++++- .../views/notification-showcase.blade.php | 6 +- .../views/user/change_password.blade.php | 58 +++-- tests/Feature/LoginNotificationTest.php | 244 ++++++++++++++++++ 12 files changed, 427 insertions(+), 51 deletions(-) create mode 100644 tests/Feature/LoginNotificationTest.php diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index 3c78520..9aca739 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -14,7 +14,7 @@ class AuthController extends Controller public function showLoginForm() { - if (Auth::check()) { + if (Auth::check() && !session('dialog_first_login')) { return redirect($this->authService->getRedirectRouteForUser(Auth::user())); } return view('login'); @@ -22,20 +22,51 @@ class AuthController extends Controller public function login(Request $request) { - $credentials = $request->validate([ + // Custom validation check: treat all validation errors as "Invalid email/password" + $validator = \Illuminate\Support\Facades\Validator::make($request->all(), [ 'mail' => 'required|email|max:255', 'password' => 'required|string|max:100', ]); - $result = $this->authService->attemptLogin($credentials, $request); - - if (!$result['success']) { + if ($validator->fails()) { return back()->withInput($request->only('mail'))->withErrors([ - $result['error_key'] => $result['error_msg'], + 'mail' => 'Thông tin đăng nhập không đúng. Vui lòng nhập lại', ]); } - return redirect($this->authService->getRedirectRouteForUser($result['user'])); + $credentials = $validator->validated(); + $email = \Illuminate\Support\Str::lower($credentials['mail']); + $throttleKey = 'login-attempts:' . $email; + + // Brute-force check (lock after 5 failed attempts) + if (\Illuminate\Support\Facades\RateLimiter::tooManyAttempts($throttleKey, 5)) { + return back()->withInput($request->only('mail'))->with('dialog_error_brute_force', 'Tài khoản của bạn tạm thời bị khóa do nhập sai mật khẩu quá 5 lần. Vui lòng thử lại sau 15 phút.'); + } + + $result = $this->authService->attemptLogin($credentials, $request); + + if (!$result['success']) { + if ($result['error_key'] === 'permission') { + return back()->withInput($request->only('mail'))->with('dialog_error_permission', $result['error_msg']); + } + + // For invalid password / email (counts towards brute-force attempts) + \Illuminate\Support\Facades\RateLimiter::hit($throttleKey, 900); // 15 minutes lockout + + return back()->withInput($request->only('mail'))->withErrors([ + 'mail' => $result['error_msg'], + ]); + } + + // Clear rate limiter on success + \Illuminate\Support\Facades\RateLimiter::clear($throttleKey); + + $user = $result['user']; + if ($user->first_login == \App\Models\User::FIRST_LOGIN_TRUE) { + return redirect()->route('login')->with('dialog_first_login', true); + } + + return redirect($this->authService->getRedirectRouteForUser($user)); } public function logout(Request $request) diff --git a/app/Services/Auth/AuthService.php b/app/Services/Auth/AuthService.php index c5b40de..75f9b65 100644 --- a/app/Services/Auth/AuthService.php +++ b/app/Services/Auth/AuthService.php @@ -14,7 +14,7 @@ class AuthService implements AuthServiceInterface // Manual MD5 password check $user = User::where('mail', $credentials['mail'])->first(); if (!$user || $user->pass !== md5($credentials['password'])) { - return ['success' => false, 'error_key' => 'mail', 'error_msg' => __('auth.failed')]; + return ['success' => false, 'error_key' => 'mail', 'error_msg' => 'Thông tin đăng nhập không đúng. Vui lòng nhập lại']; } // Log the user in manually @@ -22,7 +22,7 @@ class AuthService implements AuthServiceInterface $request->session()->regenerate(); if ($user->status != User::STATUS_ACTIVE) { Auth::logout(); - return ['success' => false, 'error_key' => 'mail', 'error_msg' => __('auth.inactive')]; + return ['success' => false, 'error_key' => 'permission', 'error_msg' => 'Bạn không có quyền truy cập. Vui lòng liên hệ quản trị viên']; } return ['success' => true, 'user' => $user]; @@ -41,6 +41,10 @@ class AuthService implements AuthServiceInterface return route('user.change_password'); } + if ($user->role == User::ROLE_ADMIN) { + return route('admin.dashboard'); + } + return route('user.dashboard'); } } \ No newline at end of file diff --git a/bootstrap/app.php b/bootstrap/app.php index d664775..28bf868 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -25,5 +25,10 @@ return Application::configure(basePath: dirname(__DIR__)) ]); }) ->withExceptions(function (Exceptions $exceptions): void { - // + $exceptions->render(function (\Illuminate\Auth\AuthenticationException $e, \Illuminate\Http\Request $request) { + if ($request->expectsJson()) { + return response()->json(['message' => $e->getMessage()], 401); + } + return redirect()->guest(route('login'))->with('session_expired', true); + }); })->create(); diff --git a/docs/notification-review/README.md b/docs/notification-review/README.md index 7059e25..39dd5a6 100644 --- a/docs/notification-review/README.md +++ b/docs/notification-review/README.md @@ -19,9 +19,9 @@ The following reusable/common components are created and used across all display | Case ID | Feature | Display Case | Notification Type | Message | Screenshot Path | | :--- | :--- | :--- | :--- | :--- | :--- | -| **01** | Authentication | Login failed | Toast (Error) | Đăng nhập thất bại. Vui lòng kiểm tra lại Email hoặc Mật khẩu. | [case_01_login_failed.png](case_01_login_failed.png) | -| **02** | Authorization | Access denied | Error Dialog | Tài khoản của bạn không có quyền truy cập vào trang quản trị này. Vui lòng liên hệ với quản trị viên nếu đây là một sự nhầm lẫn. | [case_02_access_denied.png](case_02_access_denied.png) | -| **03** | Authentication | First login (change password required) | Info Dialog | Chào mừng bạn đến với hệ thống Thankcard! Đây là lần đầu tiên bạn đăng nhập, vui lòng đổi mật khẩu mới để tiếp tục sử dụng hệ thống. | [case_03_first_login.png](case_03_first_login.png) | +| **01** | Authentication | Login failed | Inline (Error) | Thông tin đăng nhập không đúng. Vui lòng nhập lại | [case_01_login_failed.png](case_01_login_failed.png) | +| **02** | Authorization | Access denied / disabled | Error Dialog | Bạn không có quyền truy cập. Vui lòng liên hệ quản trị viên | [case_02_access_denied.png](case_02_access_denied.png) | +| **03** | Authentication | First login | Info Dialog | Vui lòng đổi mật khẩu trước khi tiếp tục sử dụng hệ thống. | [case_03_first_login.png](case_03_first_login.png) | | **04** | User Management | Delete user confirmation | Confirm Dialog (Danger) | Bạn có chắc chắn muốn xóa tài khoản của nhân viên Nguyễn Văn A (MSNV: NV0001)? Hành động này không thể hoàn tác và tất cả dữ liệu liên quan sẽ bị xóa. | [case_04_delete_user_confirmation.png](case_04_delete_user_confirmation.png) | | **05** | User Management | Create user success | Success Dialog | Tài khoản nhân viên mới đã được tạo thành công trong hệ thống. Email kích hoạt đã được gửi tới người dùng. | [case_05_create_user_success.png](case_05_create_user_success.png) | | **06** | User Management | Email already exists | Error Dialog | Địa chỉ email 'nguyenvana@runsystem.net' đã được đăng ký bởi một nhân viên khác. Vui lòng sử dụng địa chỉ email khác. | [case_06_email_already_exists.png](case_06_email_already_exists.png) | diff --git a/lang/vi/auth.php b/lang/vi/auth.php index a1ac389..dfd2848 100644 --- a/lang/vi/auth.php +++ b/lang/vi/auth.php @@ -9,6 +9,6 @@ return [ 'forgot_password' => 'Quên mật khẩu?', 'system_name' => 'Hệ thống Thank Card', 'developed_by' => 'Được phát triển bởi GMO-Z.com RUNSYSTEM', - 'failed' => 'Email hoặc mật khẩu không chính xác.', - 'inactive' => 'Tài khoản của bạn đã bị khóa hoặc bạn đã nghỉ việc.', + 'failed' => 'Thông tin đăng nhập không đúng. Vui lòng nhập lại', + 'inactive' => 'Bạn không có quyền truy cập. Vui lòng liên hệ quản trị viên', ]; diff --git a/resources/views/components/error-dialog.blade.php b/resources/views/components/error-dialog.blade.php index 0a5a6a7..2af1477 100644 --- a/resources/views/components/error-dialog.blade.php +++ b/resources/views/components/error-dialog.blade.php @@ -1,4 +1,4 @@ -@props(['id', 'title' => 'Đã xảy ra lỗi', 'message' => '', 'buttonText' => 'Đóng']) +@props(['id', 'title' => 'Đã xảy ra lỗi', 'message' => '', 'buttonText' => 'Đóng', 'onclick' => null])
-
diff --git a/resources/views/components/info-dialog.blade.php b/resources/views/components/info-dialog.blade.php index c3ddba0..2274eed 100644 --- a/resources/views/components/info-dialog.blade.php +++ b/resources/views/components/info-dialog.blade.php @@ -1,4 +1,4 @@ -@props(['id', 'title' => 'Thông tin', 'message' => '', 'buttonText' => 'Đóng']) +@props(['id', 'title' => 'Thông tin', 'message' => '', 'buttonText' => 'Đóng', 'onclick' => null])
-
diff --git a/resources/views/components/warning-dialog.blade.php b/resources/views/components/warning-dialog.blade.php index 7f41211..56ca39f 100644 --- a/resources/views/components/warning-dialog.blade.php +++ b/resources/views/components/warning-dialog.blade.php @@ -1,4 +1,4 @@ -@props(['id', 'title' => 'Cảnh báo', 'message' => '', 'buttonText' => 'Đã hiểu']) +@props(['id', 'title' => 'Cảnh báo', 'message' => '', 'buttonText' => 'Đã hiểu', 'onclick' => null])
-
diff --git a/resources/views/login.blade.php b/resources/views/login.blade.php index 70c8c1a..74c92e2 100644 --- a/resources/views/login.blade.php +++ b/resources/views/login.blade.php @@ -65,7 +65,7 @@
-
+ @csrf
@@ -180,5 +180,91 @@
+ + @if(session('dialog_error_permission')) + + @endif + + @if(session('session_expired')) + + @endif + + @if(session('dialog_first_login')) + + @endif + + @if(session('dialog_error_brute_force')) + + @endif + + diff --git a/resources/views/notification-showcase.blade.php b/resources/views/notification-showcase.blade.php index 77dea90..f13ad34 100644 --- a/resources/views/notification-showcase.blade.php +++ b/resources/views/notification-showcase.blade.php @@ -261,13 +261,13 @@ - + - + - + diff --git a/resources/views/user/change_password.blade.php b/resources/views/user/change_password.blade.php index b70aed1..b2c13aa 100644 --- a/resources/views/user/change_password.blade.php +++ b/resources/views/user/change_password.blade.php @@ -2,54 +2,60 @@ @section('title', 'Đổi Mật Khẩu') @section('content') -
-
-

Đổi Mật Khẩu

+
+
+
+ + + +
+

Đổi mật khẩu

+

Cập nhật mật khẩu mới để bảo vệ tài khoản

@csrf -
+
@if(Auth::user()->first_login == \App\Models\User::FIRST_LOGIN_TRUE) -
- +
+ - Đây là lần đầu tiên bạn đăng nhập vào hệ thống. Để đảm bảo an toàn, hệ thống yêu cầu bạn đổi mật khẩu trước khi sử dụng các chức năng. + Đây là lần đầu tiên bạn đăng nhập. Vui lòng đổi mật khẩu để tiếp tục sử dụng hệ thống.
@endif -
- -
- - +
+ +
+ + - +
- @error('password'){{ $message }}@enderror + @error('password'){{ $message }}@enderror
-
- -
- - +
+ +
+ + - +
-
-
diff --git a/tests/Feature/LoginNotificationTest.php b/tests/Feature/LoginNotificationTest.php new file mode 100644 index 0000000..bbd41f3 --- /dev/null +++ b/tests/Feature/LoginNotificationTest.php @@ -0,0 +1,244 @@ + 5001, + 'name' => 'Test User', + 'mail' => 'test@example.com', + 'pass' => md5('password123'), + 'departments' => 1, + 'role' => User::ROLE_MEMBER, + 'status' => User::STATUS_ACTIVE, + 'card' => 10, + 'flag_send' => User::FLAG_SEND_ENABLED, + 'first_login' => User::FIRST_LOGIN_FALSE, + ]); + + $response = $this->post('/login', [ + 'mail' => 'test@example.com', + 'password' => 'wrong_password', + ]); + + $response->assertRedirect(); + $response->assertSessionHasErrors(['mail' => 'Thông tin đăng nhập không đúng. Vui lòng nhập lại']); + + // 2. Empty fields + $response2 = $this->post('/login', [ + 'mail' => '', + 'password' => '', + ]); + $response2->assertRedirect(); + $response2->assertSessionHasErrors(['mail' => 'Thông tin đăng nhập không đúng. Vui lòng nhập lại']); + + // 3. Invalid email format + $response3 = $this->post('/login', [ + 'mail' => 'invalid-email-format', + 'password' => 'password123', + ]); + $response3->assertRedirect(); + $response3->assertSessionHasErrors(['mail' => 'Thông tin đăng nhập không đúng. Vui lòng nhập lại']); + + // 4. Email too long + $response4 = $this->post('/login', [ + 'mail' => str_repeat('a', 256) . '@example.com', + 'password' => 'password123', + ]); + $response4->assertRedirect(); + $response4->assertSessionHasErrors(['mail' => 'Thông tin đăng nhập không đúng. Vui lòng nhập lại']); + + // 5. Password too long + $response5 = $this->post('/login', [ + 'mail' => 'test@example.com', + 'password' => str_repeat('p', 101), + ]); + $response5->assertRedirect(); + $response5->assertSessionHasErrors(['mail' => 'Thông tin đăng nhập không đúng. Vui lòng nhập lại']); + } + + /** + * Test Case 2: Account disabled / no access permission + */ + public function test_inactive_user_displays_permission_error_dialog(): void + { + $user = User::create([ + 'msnv' => 5002, + 'name' => 'Inactive User', + 'mail' => 'test@example.com', + 'pass' => md5('password123'), + 'departments' => 1, + 'role' => User::ROLE_MEMBER, + 'status' => User::STATUS_INACTIVE, // 0 + 'card' => 10, + 'flag_send' => User::FLAG_SEND_ENABLED, + 'first_login' => User::FIRST_LOGIN_FALSE, + ]); + + $response = $this->post('/login', [ + 'mail' => 'test@example.com', + 'password' => 'password123', + ]); + + $response->assertRedirect(); + $response->assertSessionHas('dialog_error_permission', 'Bạn không có quyền truy cập. Vui lòng liên hệ quản trị viên'); + $this->assertFalse(\Illuminate\Support\Facades\Auth::check()); + } + + /** + * Test Case 3: Session expired + */ + public function test_session_expired_redirects_with_session_expired_flash(): void + { + $response = $this->get('/my-page'); // Requires auth + + $response->assertRedirect(route('login')); + $response->assertSessionHas('session_expired', true); + } + + /** + * Test Case 4: Login success + */ + public function test_login_success_redirects_correctly(): void + { + // 1. Member redirect to My Page + $member = User::create([ + 'msnv' => 5003, + 'name' => 'Member User', + 'mail' => 'member@example.com', + 'pass' => md5('password123'), + 'departments' => 1, + 'role' => User::ROLE_MEMBER, + 'status' => User::STATUS_ACTIVE, + 'card' => 10, + 'flag_send' => User::FLAG_SEND_ENABLED, + 'first_login' => User::FIRST_LOGIN_FALSE, + ]); + + $response = $this->post('/login', [ + 'mail' => 'member@example.com', + 'password' => 'password123', + ]); + + $response->assertRedirect(route('user.dashboard')); + $this->assertTrue(\Illuminate\Support\Facades\Auth::check()); + $this->assertEquals($member->id, \Illuminate\Support\Facades\Auth::user()->id); + + \Illuminate\Support\Facades\Auth::logout(); + + // 2. Admin redirect to Admin User List + $admin = User::create([ + 'msnv' => 5004, + 'name' => 'Admin User', + 'mail' => 'admin@example.com', + 'pass' => md5('password123'), + 'departments' => 1, + 'role' => User::ROLE_ADMIN, + 'status' => User::STATUS_ACTIVE, + 'card' => 10, + 'flag_send' => User::FLAG_SEND_ENABLED, + 'first_login' => User::FIRST_LOGIN_FALSE, + ]); + + $response2 = $this->post('/login', [ + 'mail' => 'admin@example.com', + 'password' => 'password123', + ]); + + $response2->assertRedirect(route('admin.dashboard')); + $this->assertTrue(\Illuminate\Support\Facades\Auth::check()); + } + + /** + * Test Case 5: First login + */ + public function test_first_login_redirects_to_login_page_with_first_login_dialog(): void + { + $user = User::create([ + 'msnv' => 5005, + 'name' => 'First Login User', + 'mail' => 'first@example.com', + 'pass' => md5('password123'), + 'departments' => 1, + 'role' => User::ROLE_MEMBER, + 'status' => User::STATUS_ACTIVE, + 'card' => 10, + 'flag_send' => User::FLAG_SEND_ENABLED, + 'first_login' => User::FIRST_LOGIN_TRUE, + ]); + + $response = $this->post('/login', [ + 'mail' => 'first@example.com', + 'password' => 'password123', + ]); + + $response->assertRedirect(route('login')); + $response->assertSessionHas('dialog_first_login', true); + $this->assertTrue(\Illuminate\Support\Facades\Auth::check()); + } + + /** + * Test Case 6: Brute-force protection + */ + public function test_brute_force_protection_locks_after_5_failed_attempts(): void + { + $user = User::create([ + 'msnv' => 5006, + 'name' => 'Lockout User', + 'mail' => 'test@example.com', + 'pass' => md5('password123'), + 'departments' => 1, + 'role' => User::ROLE_MEMBER, + 'status' => User::STATUS_ACTIVE, + 'card' => 10, + 'flag_send' => User::FLAG_SEND_ENABLED, + 'first_login' => User::FIRST_LOGIN_FALSE, + ]); + + // First 5 attempts fail + for ($i = 0; $i < 5; $i++) { + $response = $this->post('/login', [ + 'mail' => 'test@example.com', + 'password' => 'wrong_password', + ]); + $response->assertSessionHasErrors(['mail' => 'Thông tin đăng nhập không đúng. Vui lòng nhập lại']); + } + + // 6th attempt is blocked by brute force protection + $response6 = $this->post('/login', [ + 'mail' => 'test@example.com', + 'password' => 'wrong_password', + ]); + + $response6->assertSessionHas('dialog_error_brute_force', 'Tài khoản của bạn tạm thời bị khóa do nhập sai mật khẩu quá 5 lần. Vui lòng thử lại sau 15 phút.'); + + // 7th attempt with CORRECT password is also blocked + $response7 = $this->post('/login', [ + 'mail' => 'test@example.com', + 'password' => 'password123', + ]); + + $response7->assertSessionHas('dialog_error_brute_force', 'Tài khoản của bạn tạm thời bị khóa do nhập sai mật khẩu quá 5 lần. Vui lòng thử lại sau 15 phút.'); + $this->assertFalse(\Illuminate\Support\Facades\Auth::check()); + } +}