diff --git a/.gitignore b/.gitignore index 3494aa5..d93d566 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ yarn-error.log /storage/app/cccd/ /storage/app/deposit-proofs/ +/storage/app/public/popup-banners/ diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 905bef8..45dee43 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -85,6 +85,10 @@ class Handler extends ExceptionHandler ], 500); } + if ($e instanceof \Illuminate\Database\Eloquent\ModelNotFoundException) { + $e = new \Symfony\Component\HttpKernel\Exception\NotFoundHttpException($e->getMessage(), $e); + } + if ($e instanceof HttpException) { $status = $e->getStatusCode(); $messages = [ diff --git a/app/Http/Controllers/Api/Admin/Setting/PopupBannerController.php b/app/Http/Controllers/Api/Admin/Setting/PopupBannerController.php new file mode 100644 index 0000000..c431409 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/Setting/PopupBannerController.php @@ -0,0 +1,91 @@ +json([ + 'error' => false, + 'data' => PopupBanner::orderBy('sort_order')->orderBy('id')->get(), + 'types' => PopupBanner::TYPES, + ]); + } + + public function store(Request $request): JsonResponse + { + $data = $this->validated($request, true); + $data['image_path'] = $request->file('image')->store('popup-banners', PopupBanner::DISK); + + return response()->json([ + 'error' => false, + 'message' => 'Đã thêm popup.', + 'data' => PopupBanner::create($data), + ], 201); + } + + public function update(Request $request, int $id): JsonResponse + { + $popup = PopupBanner::findOrFail($id); + $data = $this->validated($request, false); + $oldImage = null; + + if ($request->hasFile('image')) { + $oldImage = $popup->image_path; + $data['image_path'] = $request->file('image')->store('popup-banners', PopupBanner::DISK); + } + + $popup->update($data); + + if ($oldImage) { + Storage::disk(PopupBanner::DISK)->delete($oldImage); + } + + return response()->json([ + 'error' => false, + 'message' => 'Đã cập nhật popup.', + 'data' => $popup->fresh(), + ]); + } + + public function destroy(int $id): JsonResponse + { + $popup = PopupBanner::findOrFail($id); + $popup->delete(); + Storage::disk(PopupBanner::DISK)->delete($popup->image_path); + + return response()->json(['error' => false, 'message' => 'Đã xoá popup.']); + } + + /** + * @return array + */ + private function validated(Request $request, bool $creating): array + { + $data = $request->validate([ + 'title' => 'required|string|max:150', + 'type' => ['required', Rule::in(array_keys(PopupBanner::TYPES))], + 'image' => [$creating ? 'required' : 'nullable', 'file', 'mimes:jpg,jpeg,png,webp,gif', 'max:5120'], + 'link_url' => ['nullable', 'string', 'max:255', 'regex:' . ClientMenu::HREF_PATTERN], + 'open_in_new_tab' => 'boolean', + 'is_active' => 'boolean', + 'starts_at' => 'nullable|date', + 'ends_at' => 'nullable|date|after_or_equal:starts_at', + 'sort_order' => 'integer|min:0|max:65535', + ]); + unset($data['image']); + + return $data; + } +} diff --git a/app/Http/Controllers/Api/Client/ClientController.php b/app/Http/Controllers/Api/Client/ClientController.php index 72dce79..bbb2f07 100644 --- a/app/Http/Controllers/Api/Client/ClientController.php +++ b/app/Http/Controllers/Api/Client/ClientController.php @@ -9,6 +9,7 @@ use App\Services\Api\Client\ClientAuthService; use App\Http\Controllers\Api\Client\Concerns\HandlesClientAuthentication; use App\Http\Controllers\Api\Client\Concerns\HandlesClientPasswordReset; +use App\Http\Controllers\Api\Client\Concerns\HandlesClientTwoFactor; use App\Http\Controllers\Api\Client\Concerns\ManagesClientAccountSettings; use App\Http\Controllers\Api\Client\Concerns\ManagesClientProfile; /** @@ -21,6 +22,7 @@ class ClientController extends Controller { use HandlesClientAuthentication; use HandlesClientPasswordReset; + use HandlesClientTwoFactor; use ManagesClientAccountSettings; use ManagesClientProfile; diff --git a/app/Http/Controllers/Api/Client/Concerns/HandlesClientAuthentication.php b/app/Http/Controllers/Api/Client/Concerns/HandlesClientAuthentication.php index 255a6fe..708f953 100644 --- a/app/Http/Controllers/Api/Client/Concerns/HandlesClientAuthentication.php +++ b/app/Http/Controllers/Api/Client/Concerns/HandlesClientAuthentication.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\Client\Concerns; use App\Http\Requests\Api\Client\ClientAuthRequest; use App\Http\Requests\Api\Client\ClientRegisterRequest; use App\Http\Resources\Api\Client\ClientResource; +use App\Services\Api\Client\ClientTwoFactorService; use Illuminate\Http\Request; trait HandlesClientAuthentication @@ -43,10 +44,26 @@ trait HandlesClientAuthentication * ) * ) */ - public function login(ClientAuthRequest $request) + public function login(ClientAuthRequest $request, ClientTwoFactorService $twoFactor) { - $result = $this->authService->authenticateClient($request->validated()); + $client = $this->authService->verifyCredentials($request->safe()->only(['email', 'password'])); + if ($twoFactor->requiresChallenge($client, $request->input('device_token'))) { + return response()->json([ + 'error' => false, + 'data' => ['two_factor' => $twoFactor->startLoginChallenge($client)], + ]); + } + + return $this->loginResponse($this->authService->issueTokens($client)); + } + + /** + * @param array{client: \App\Models\Customer, token: string, refresh_token: string} $result + * @param array $extra + */ + protected function loginResponse(array $result, array $extra = []) + { $client = $result['client']; $this->activityLogger->logAuth('login', 'customer', $client->id, $client->email); @@ -57,6 +74,7 @@ trait HandlesClientAuthentication 'token' => $result['token'], 'refresh_token' => $result['refresh_token'], 'expires_in' => config('jwt.ttl', 60) * 60, // seconds + ...$extra, ], ]); } diff --git a/app/Http/Controllers/Api/Client/Concerns/HandlesClientPasswordReset.php b/app/Http/Controllers/Api/Client/Concerns/HandlesClientPasswordReset.php index 10ab29b..f7e1acb 100644 --- a/app/Http/Controllers/Api/Client/Concerns/HandlesClientPasswordReset.php +++ b/app/Http/Controllers/Api/Client/Concerns/HandlesClientPasswordReset.php @@ -3,7 +3,7 @@ namespace App\Http\Controllers\Api\Client\Concerns; use App\Http\Requests\Api\Client\ClientChangePasswordRequest; -use App\Models\Customer; +use App\Support\PasswordResetToken; use Illuminate\Http\Request; use Illuminate\Validation\Rules\Password; @@ -33,15 +33,28 @@ trait HandlesClientPasswordReset ]); } + public function showResetToken(string $token) + { + $customer = PasswordResetToken::findCustomer($token); + + if (!$customer) { + return response()->json([ + 'error' => true, + 'message' => 'Link đặt lại mật khẩu không hợp lệ hoặc đã hết hạn.', + ], 404); + } + + return response()->json(['error' => false, 'data' => ['email' => $customer->email]]); + } + public function resetPassword(Request $request) { $data = $request->validate([ - 'email' => ['required', 'email', 'max:255'], - 'token' => ['required', 'string', 'max:255'], + 'token' => ['required', 'string', 'size:' . PasswordResetToken::LENGTH], 'password' => ['required', Password::min(10), 'confirmed'], ]); - $customer = $this->authService->resetPassword($data); + $customer = $this->authService->resetPassword($data['token'], $data['password']); $this->activityLogger->log( event: 'password_reset', diff --git a/app/Http/Controllers/Api/Client/Concerns/HandlesClientTwoFactor.php b/app/Http/Controllers/Api/Client/Concerns/HandlesClientTwoFactor.php new file mode 100644 index 0000000..c2c8b7d --- /dev/null +++ b/app/Http/Controllers/Api/Client/Concerns/HandlesClientTwoFactor.php @@ -0,0 +1,109 @@ +validate([ + 'challenge' => ['required', 'string', 'size:64'], + 'code' => ['required', 'digits:6'], + 'remember_device' => ['sometimes', 'boolean'], + ]); + + $customer = $twoFactor->verifyLoginChallenge($data['challenge'], $data['code']); + + $extra = []; + if ($request->boolean('remember_device')) { + $extra = [ + 'device_token' => $twoFactor->rememberDevice($customer, $request->userAgent(), $request->ip()), + 'device_expires_in' => ClientTwoFactorService::TRUSTED_DEVICE_DAYS * 86400, + ]; + } + + return $this->loginResponse($this->authService->issueTokens($customer), $extra); + } + + public function resendLoginTwoFactor(Request $request, ClientTwoFactorService $twoFactor) + { + $data = $request->validate(['challenge' => ['required', 'string', 'size:64']]); + + $twoFactor->resendLoginChallenge($data['challenge']); + + return response()->json(['error' => false, 'message' => 'Đã gửi lại mã xác thực.']); + } + + public function twoFactorStatus(ClientTwoFactorService $twoFactor) + { + $customer = auth('client')->user(); + + return response()->json([ + 'error' => false, + 'data' => [ + 'enabled' => (bool) $customer->two_factor_enabled, + 'devices' => $twoFactor->devices($customer)->map(fn (CustomerTrustedDevice $device) => [ + 'id' => $device->id, + 'user_agent' => $device->user_agent, + 'ip_address' => $device->ip_address, + 'last_used_at' => $device->last_used_at?->toIso8601String(), + 'expires_at' => $device->expires_at->toIso8601String(), + ]), + ], + ]); + } + + public function sendTwoFactorSetupCode(ClientTwoFactorService $twoFactor) + { + $twoFactor->sendSetupCode(auth('client')->user()); + + return response()->json([ + 'error' => false, + 'message' => 'Đã gửi mã xác thực tới email của bạn.', + 'data' => ['resend_in' => ClientTwoFactorService::RESEND_COOLDOWN_SECONDS], + ]); + } + + public function enableTwoFactor(Request $request, ClientTwoFactorService $twoFactor) + { + $data = $request->validate(['code' => ['required', 'digits:6']]); + $customer = auth('client')->user(); + + $twoFactor->enable($customer, $data['code']); + $this->activityLogger->log( + event: 'two_factor_enabled', + logName: 'Customer', + description: "Customer {$customer->email} bật xác thực 2 lớp", + subject: $customer, + ); + + return response()->json(['error' => false, 'message' => 'Đã bật xác thực 2 lớp.']); + } + + public function disableTwoFactor(Request $request, ClientTwoFactorService $twoFactor) + { + $data = $request->validate(['password' => ['required', 'string']]); + $customer = auth('client')->user(); + + $twoFactor->disable($customer, $data['password']); + $this->activityLogger->log( + event: 'two_factor_disabled', + logName: 'Customer', + description: "Customer {$customer->email} tắt xác thực 2 lớp", + subject: $customer, + ); + + return response()->json(['error' => false, 'message' => 'Đã tắt xác thực 2 lớp.']); + } + + public function revokeTrustedDevices(Request $request, ClientTwoFactorService $twoFactor) + { + $twoFactor->revokeDevices(auth('client')->user(), $request->integer('id') ?: null); + + return response()->json(['error' => false, 'message' => 'Đã xoá thiết bị đã ghi nhớ.']); + } +} diff --git a/app/Http/Controllers/Api/Setting/SettingController.php b/app/Http/Controllers/Api/Setting/SettingController.php index 9c88892..5123ebe 100644 --- a/app/Http/Controllers/Api/Setting/SettingController.php +++ b/app/Http/Controllers/Api/Setting/SettingController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api\Setting; use App\Http\Controllers\Controller; +use App\Models\PopupBanner; use App\Models\Setting; use App\Support\ClientMenu; use App\Support\ClientQuickLinks; @@ -80,4 +81,9 @@ class SettingController extends Controller 'data' => ClientQuickLinks::items(), ]); } + + public function getPopups(): \Illuminate\Http\JsonResponse + { + return response()->json(['error' => false, 'data' => PopupBanner::publicList()]); + } } diff --git a/app/Http/Middleware/ApiAuthCheck.php b/app/Http/Middleware/ApiAuthCheck.php index 0788b7d..def89c8 100644 --- a/app/Http/Middleware/ApiAuthCheck.php +++ b/app/Http/Middleware/ApiAuthCheck.php @@ -29,6 +29,14 @@ class ApiAuthCheck // Token cấp trước lần đổi mật khẩu gần nhất → vô hiệu. // ponytail: token cũ chưa có claim `pv` vẫn được nhận cho tới khi hết JWT_TTL (chỉ lúc mới deploy) + if ($checkGuard === 'client' && (int) Auth::guard('client')->user()->status !== 1) { + return response()->json([ + 'error' => true, + 'message' => 'Tài khoản không còn hoạt động, vui lòng liên hệ hỗ trợ.', + 'code' => 401 + ], 401); + } + if ($checkGuard === 'client' && Auth::guard('client')->getToken()) { $pv = Auth::guard('client')->payload()->get('pv'); if ($pv !== null && !hash_equals(Auth::guard('client')->user()->passwordVersion(), (string) $pv)) { diff --git a/app/Http/Requests/Api/Client/ClientAuthRequest.php b/app/Http/Requests/Api/Client/ClientAuthRequest.php index f27143e..4dcf823 100644 --- a/app/Http/Requests/Api/Client/ClientAuthRequest.php +++ b/app/Http/Requests/Api/Client/ClientAuthRequest.php @@ -31,6 +31,8 @@ class ClientAuthRequest extends FormRequest 'string', 'min:6' ], + + 'device_token' => ['nullable', 'string', 'max:128'], ]; } diff --git a/app/Http/Resources/Api/Client/ClientResource.php b/app/Http/Resources/Api/Client/ClientResource.php index 746f16d..2371ae4 100644 --- a/app/Http/Resources/Api/Client/ClientResource.php +++ b/app/Http/Resources/Api/Client/ClientResource.php @@ -38,7 +38,7 @@ class ClientResource extends JsonResource 'cmnd_after' => $this->cccdUrl('back', $this->back_identity_number), 'cmnd' => $this->identity_number ? Str::mask($this->identity_number, '*', 0, -4) : null, 'fb_link' => null, - 'active_2fa' => 0, + 'active_2fa' => (int) $this->two_factor_enabled, 'off_mail_marketing' => $this->off_mail_marketing, 'email_marketing' => (bool) $this->email_marketing, 'receive_deposit' => (bool) $this->receive_deposit, diff --git a/app/Mail/Customer/TwoFactorCode.php b/app/Mail/Customer/TwoFactorCode.php new file mode 100644 index 0000000..e576d04 --- /dev/null +++ b/app/Mail/Customer/TwoFactorCode.php @@ -0,0 +1,34 @@ + $this->data['fullname'] ?? 'Quý khách', + 'verify_code' => $this->data['code'], + 'otp_purpose' => $this->data['purpose'], + 'expire_minutes' => (string) $this->data['expire_minutes'], + ]; + } +} diff --git a/app/Models/Customer.php b/app/Models/Customer.php index 94f2bba..219b5ea 100644 --- a/app/Models/Customer.php +++ b/app/Models/Customer.php @@ -91,6 +91,7 @@ class Customer extends Authenticatable implements JWTSubject protected $casts = [ 'gender' => \App\Enums\Client\ClientGender::class, 'email_verified_at' => 'datetime', + 'two_factor_enabled' => 'boolean', ]; /** diff --git a/app/Models/CustomerTrustedDevice.php b/app/Models/CustomerTrustedDevice.php new file mode 100644 index 0000000..531afce --- /dev/null +++ b/app/Models/CustomerTrustedDevice.php @@ -0,0 +1,25 @@ + 'datetime', + 'expires_at' => 'datetime', + ]; + + public function scopeActive(Builder $query): Builder + { + return $query->where('expires_at', '>', now()); + } +} diff --git a/app/Models/PopupBanner.php b/app/Models/PopupBanner.php new file mode 100644 index 0000000..932dcb4 --- /dev/null +++ b/app/Models/PopupBanner.php @@ -0,0 +1,70 @@ + 'Quảng cáo', + 'promotion' => 'Chương trình khuyến mãi', + ]; + + public const PUBLIC_FIELDS = ['id', 'title', 'type', 'image_url', 'link_url', 'open_in_new_tab']; + + protected $fillable = [ + 'title', + 'type', + 'image_path', + 'link_url', + 'open_in_new_tab', + 'is_active', + 'starts_at', + 'ends_at', + 'sort_order', + ]; + + protected $casts = [ + 'open_in_new_tab' => 'boolean', + 'is_active' => 'boolean', + 'starts_at' => 'datetime', + 'ends_at' => 'datetime', + 'sort_order' => 'integer', + ]; + + protected $appends = ['image_url']; + + public function getImageUrlAttribute(): string + { + return Storage::disk(self::DISK)->url($this->image_path); + } + + public function scopeVisible(Builder $query): Builder + { + return $query->where('is_active', true) + ->where(fn (Builder $q) => $q->whereNull('starts_at')->orWhere('starts_at', '<=', now())) + ->where(fn (Builder $q) => $q->whereNull('ends_at')->orWhere('ends_at', '>=', now())) + ->orderBy('sort_order') + ->orderBy('id'); + } + + /** + * @return list> + */ + public static function publicList(): array + { + return self::visible()->get() + ->map(fn (self $banner) => $banner->only(self::PUBLIC_FIELDS)) + ->all(); + } +} diff --git a/app/Services/Api/Client/ClientAuthService.php b/app/Services/Api/Client/ClientAuthService.php index f1d0948..31354f9 100644 --- a/app/Services/Api/Client/ClientAuthService.php +++ b/app/Services/Api/Client/ClientAuthService.php @@ -8,14 +8,16 @@ use App\Mail\Customer\PasswordResetLink; use App\Mail\Customer\VerifyAccount as VerifyAccountMail; use App\Mail\Customer\Welcome as WelcomeMail; use App\Models\Customer; +use App\Models\CustomerTrustedDevice; use App\Models\RefreshToken; use App\Models\Setting; use App\Repositories\Api\Client\ClientRepositoryInterface; +use App\Support\PasswordResetToken; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Mail; -use Illuminate\Support\Facades\Password; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; @@ -39,15 +41,17 @@ class ClientAuthService /** * Xác thực khách hàng và tạo access + refresh token */ - public function authenticateClient(array $credentials): array + public function verifyCredentials(array $credentials): Customer { - if (!$token = Auth::guard('client')->attempt($credentials)) { + $guard = Auth::guard('client'); + + if (!$guard->validate($credentials)) { throw ValidationException::withMessages([ 'email' => ['Thông tin đăng nhập không chính xác.'], ]); } - $client = Auth::guard('client')->user(); + $client = $guard->getLastAttempted(); if ((int)$client->status !== 1) { $statusLabel = match ((int)$client->status) { @@ -68,12 +72,18 @@ class ClientAuthService ]); } - $refreshToken = $this->createRefreshToken($client->id); + return $client; + } + /** + * @return array{client: Customer, token: string, refresh_token: string} + */ + public function issueTokens(Customer $client): array + { return [ 'client' => $client, - 'token' => $token, - 'refresh_token' => $refreshToken, + 'token' => JWTAuth::fromUser($client), + 'refresh_token' => $this->createRefreshToken($client->id), ]; } @@ -357,21 +367,14 @@ class ClientAuthService return null; } - // Broker tự throttle theo email (auth.passwords.users.throttle). - Password::broker('users')->sendResetLink( - ['email' => $customer->email], - function (Customer $customer, string $token) { - $url = rtrim(config('app.frontend_url'), '/') . '/dat-lai-mat-khau?' . http_build_query([ - 'token' => $token, - 'email' => $customer->email, - ]); + $token = PasswordResetToken::issue($customer); - Mail::to($customer->email)->queue(new PasswordResetLink([ - 'fullname' => $customer->fullname, - 'reset_url' => $url, - ])); - }, - ); + if ($token !== null) { + Mail::to($customer->email)->queue(new PasswordResetLink([ + 'fullname' => $customer->fullname, + 'reset_url' => rtrim(config('app.frontend_url'), '/') . '/dat-lai-mat-khau?t=' . $token, + ])); + } return $customer; } @@ -380,24 +383,25 @@ class ClientAuthService * Đặt lại mật khẩu bằng token trong email. Thành công thì thu hồi toàn bộ refresh token * để các phiên cũ (có thể của kẻ gian) phải đăng nhập lại. */ - public function resetPassword(array $data): ?Customer + public function resetPassword(string $token, string $password): Customer { - $resetCustomer = null; + $customer = PasswordResetToken::findCustomer($token); - $status = Password::broker('users')->reset($data, function (Customer $customer, string $password) use (&$resetCustomer) { - $customer->password = Hash::make($password); - $customer->save(); - RefreshToken::where('customer_id', $customer->id)->delete(); - $resetCustomer = $customer; - }); - - if ($status !== Password::PASSWORD_RESET) { + if (!$customer) { throw ValidationException::withMessages([ 'token' => ['Link đặt lại mật khẩu không hợp lệ hoặc đã hết hạn.'], ]); } - return $resetCustomer; + DB::transaction(function () use ($customer, $password) { + $customer->password = Hash::make($password); + $customer->save(); + PasswordResetToken::revokeFor($customer); + RefreshToken::where('customer_id', $customer->id)->delete(); + CustomerTrustedDevice::where('customer_id', $customer->id)->delete(); + }); + + return $customer; } /** @@ -446,5 +450,6 @@ class ClientAuthService // Access token cũ tự vô hiệu nhờ claim `pv`; thu hồi luôn refresh token để mọi phiên phải đăng nhập lại. RefreshToken::where('customer_id', $client->id)->delete(); + CustomerTrustedDevice::where('customer_id', $client->id)->delete(); } } diff --git a/app/Services/Api/Client/ClientTwoFactorService.php b/app/Services/Api/Client/ClientTwoFactorService.php new file mode 100644 index 0000000..abe8db9 --- /dev/null +++ b/app/Services/Api/Client/ClientTwoFactorService.php @@ -0,0 +1,258 @@ +two_factor_enabled && !$this->touchTrustedDevice($customer, $deviceToken); + } + + /** + * @return array{challenge: string, email: string, expires_in: int, resend_in: int} + */ + public function startLoginChallenge(Customer $customer): array + { + $challenge = Str::random(64); + $this->issueCode($this->challengeKey($challenge), $customer, self::PURPOSE_LOGIN); + + return [ + 'challenge' => $challenge, + 'email' => $this->maskEmail($customer->email), + 'expires_in' => self::CODE_TTL_MINUTES * 60, + 'resend_in' => self::RESEND_COOLDOWN_SECONDS, + ]; + } + + public function resendLoginChallenge(string $challenge): void + { + $key = $this->challengeKey($challenge); + $entry = Cache::get($key); + $customer = $entry ? Customer::find($entry['customer_id']) : null; + + if (!$customer) { + throw $this->invalidChallenge(); + } + + $this->ensureCooldownPassed($entry); + $this->issueCode($key, $customer, self::PURPOSE_LOGIN); + } + + public function verifyLoginChallenge(string $challenge, string $code): Customer + { + $key = $this->challengeKey($challenge); + $entry = Cache::get($key); + + if (!$entry) { + throw $this->invalidChallenge(); + } + + $this->checkCode($key, $entry, $code); + + $customer = Customer::find($entry['customer_id']); + if (!$customer || (int) $customer->status !== 1) { + throw $this->invalidChallenge(); + } + + return $customer; + } + + public function sendSetupCode(Customer $customer): void + { + if ($customer->two_factor_enabled) { + throw ValidationException::withMessages(['code' => ['Xác thực 2 lớp đang được bật.']]); + } + + $key = $this->setupKey($customer); + if ($entry = Cache::get($key)) { + $this->ensureCooldownPassed($entry); + } + + $this->issueCode($key, $customer, self::PURPOSE_SETUP); + } + + public function enable(Customer $customer, string $code): void + { + $key = $this->setupKey($customer); + $entry = Cache::get($key); + + if (!$entry) { + throw ValidationException::withMessages(['code' => ['Mã xác thực đã hết hạn, vui lòng gửi lại mã.']]); + } + + $this->checkCode($key, $entry, $code); + + $customer->two_factor_enabled = true; + $customer->save(); + } + + public function disable(Customer $customer, string $password): void + { + if (!Hash::check($password, $customer->password)) { + throw ValidationException::withMessages(['password' => ['Mật khẩu không chính xác.']]); + } + + $customer->two_factor_enabled = false; + $customer->save(); + $this->revokeDevices($customer); + } + + public function rememberDevice(Customer $customer, ?string $userAgent, ?string $ip): string + { + $token = Str::random(64); + + CustomerTrustedDevice::create([ + 'customer_id' => $customer->id, + 'token_hash' => hash('sha256', $token), + 'user_agent' => $userAgent ? Str::limit($userAgent, 250, '') : null, + 'ip_address' => $ip, + 'last_used_at' => now(), + 'expires_at' => now()->addDays(self::TRUSTED_DEVICE_DAYS), + ]); + + return $token; + } + + /** + * @return Collection + */ + public function devices(Customer $customer): Collection + { + return CustomerTrustedDevice::where('customer_id', $customer->id)->active()->latest('last_used_at')->get(); + } + + public function revokeDevices(Customer $customer, ?int $deviceId = null): void + { + CustomerTrustedDevice::where('customer_id', $customer->id) + ->when($deviceId, fn ($query) => $query->whereKey($deviceId)) + ->delete(); + } + + private function touchTrustedDevice(Customer $customer, ?string $deviceToken): bool + { + if (!$deviceToken) { + return false; + } + + $device = CustomerTrustedDevice::where('customer_id', $customer->id) + ->where('token_hash', hash('sha256', $deviceToken)) + ->active() + ->first(); + + $device?->update(['last_used_at' => now()]); + + return $device !== null; + } + + private function issueCode(string $key, Customer $customer, string $purpose): void + { + if (!AutoEmailGate::mailEnabled()) { + throw ValidationException::withMessages([ + 'code' => ['Hệ thống tạm thời không gửi được email xác thực, vui lòng liên hệ hỗ trợ.'], + ]); + } + + $code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT); + + Cache::put($key, [ + 'customer_id' => $customer->id, + 'code_hash' => hash('sha256', $key . $code), + 'attempts' => 0, + 'sent_at' => now()->getTimestamp(), + 'expires_at' => now()->addMinutes(self::CODE_TTL_MINUTES)->getTimestamp(), + ], now()->addMinutes(self::CODE_TTL_MINUTES)); + + AutoEmailGate::markManual(); + Mail::to($customer->email)->queue(new TwoFactorCode([ + 'fullname' => $customer->fullname, + 'code' => $code, + 'purpose' => $purpose, + 'expire_minutes' => self::CODE_TTL_MINUTES, + ])); + } + + private function checkCode(string $key, array $entry, string $code): void + { + if (hash_equals($entry['code_hash'], hash('sha256', $key . $code))) { + Cache::forget($key); + + return; + } + + $entry['attempts']++; + if ($entry['attempts'] >= self::MAX_ATTEMPTS) { + Cache::forget($key); + + throw ValidationException::withMessages([ + 'code' => ['Nhập sai quá nhiều lần, vui lòng gửi lại mã xác thực.'], + ]); + } + + Cache::put($key, $entry, max($entry['expires_at'] - now()->getTimestamp(), 1)); + + throw ValidationException::withMessages([ + 'code' => ['Mã xác thực không chính xác.'], + ]); + } + + private function ensureCooldownPassed(array $entry): void + { + $wait = $entry['sent_at'] + self::RESEND_COOLDOWN_SECONDS - now()->getTimestamp(); + + if ($wait > 0) { + throw ValidationException::withMessages([ + 'code' => ["Vui lòng đợi {$wait} giây trước khi gửi lại mã."], + ]); + } + } + + private function invalidChallenge(): ValidationException + { + return ValidationException::withMessages([ + 'code' => ['Phiên xác thực đã hết hạn, vui lòng đăng nhập lại.'], + ]); + } + + private function maskEmail(string $email): string + { + [$local, $domain] = explode('@', $email, 2) + [1 => '']; + + return Str::mask($local, '*', min(2, max(strlen($local) - 1, 0))) . '@' . $domain; + } + + private function challengeKey(string $challenge): string + { + return 'client-2fa:login:' . hash('sha256', $challenge); + } + + private function setupKey(Customer $customer): string + { + return 'client-2fa:setup:' . $customer->id; + } +} diff --git a/app/Support/EmailScenarioRegistry.php b/app/Support/EmailScenarioRegistry.php index 0c85fb9..30a377c 100644 --- a/app/Support/EmailScenarioRegistry.php +++ b/app/Support/EmailScenarioRegistry.php @@ -48,6 +48,10 @@ final class EmailScenarioRegistry 'label' => 'Link đặt lại mật khẩu khách hàng', 'category' => self::TRANSACTIONAL, 'group' => 'AUTH', 'required_variables' => ['reset_url'], ], + 'customer_two_factor_code' => [ + 'label' => 'Mã xác thực 2 lớp (2FA)', 'category' => self::TRANSACTIONAL, 'group' => 'SECURITY', + 'required_variables' => ['verify_code'], + ], 'password_reset_admin' => [ 'label' => 'Admin cấp lại mật khẩu', 'category' => self::TRANSACTIONAL, 'group' => 'SECURITY', 'required_variables' => ['password'], diff --git a/app/Support/PasswordResetToken.php b/app/Support/PasswordResetToken.php new file mode 100644 index 0000000..a2b6b34 --- /dev/null +++ b/app/Support/PasswordResetToken.php @@ -0,0 +1,113 @@ +email); + $lastIssuedAt = DB::table(self::TABLE)->where('email', $email)->max('created_at'); + + if ($lastIssuedAt && now()->subSeconds(self::throttleSeconds())->lt($lastIssuedAt)) { + return null; + } + + $body = ''; + for ($i = 0; $i < self::LENGTH - 1; $i++) { + $body .= self::ALPHABET[random_int(0, strlen(self::ALPHABET) - 1)]; + } + $token = $body . self::checkCharacter($body); + + DB::transaction(function () use ($email, $token) { + DB::table(self::TABLE)->where('email', $email)->delete(); + DB::table(self::TABLE)->insert([ + 'email' => $email, + 'token' => self::hash($token), + 'created_at' => now(), + ]); + }); + + return $token; + } + + public static function isWellFormed(string $token): bool + { + if (strlen($token) !== self::LENGTH || strspn($token, self::ALPHABET) !== self::LENGTH) { + return false; + } + + return self::checkCharacter(substr($token, 0, -1)) === $token[self::LENGTH - 1]; + } + + public static function findCustomer(string $token): ?Customer + { + if (!self::isWellFormed($token)) { + return null; + } + + $row = DB::table(self::TABLE) + ->where('token', self::hash($token)) + ->where('created_at', '>=', now()->subMinutes(self::expireMinutes())) + ->first(); + + if (!$row) { + return null; + } + + return Customer::whereRaw('LOWER(email) = ?', [$row->email])->first(); + } + + public static function revokeFor(Customer $customer): void + { + DB::table(self::TABLE)->where('email', strtolower($customer->email))->delete(); + } + + private static function checkCharacter(string $body): string + { + $base = strlen(self::ALPHABET); + $sum = 0; + $double = true; + + for ($i = strlen($body) - 1; $i >= 0; $i--) { + $value = strpos(self::ALPHABET, $body[$i]); + if ($double) { + $value *= 2; + $value = intdiv($value, $base) + $value % $base; + } + $sum += $value; + $double = !$double; + } + + return self::ALPHABET[($base - $sum % $base) % $base]; + } + + private static function hash(string $token): string + { + return hash('sha256', $token); + } + + private static function expireMinutes(): int + { + return (int) config('auth.passwords.users.expire', 60); + } + + private static function throttleSeconds(): int + { + return (int) config('auth.passwords.users.throttle', 60); + } +} diff --git a/database/factories/CustomerFactory.php b/database/factories/CustomerFactory.php index 2392aa8..6243b1d 100644 --- a/database/factories/CustomerFactory.php +++ b/database/factories/CustomerFactory.php @@ -22,6 +22,7 @@ class CustomerFactory extends Factory 'email' => $this->faker->unique()->safeEmail(), 'email_verify' => now(), 'password' => '$2y$10$2Tcx805kaC.Ug/IUD34pW.R.D0l0Gpy.IEeXkrjhBr1uu048pEFp.', + 'status' => 1, ]; } } diff --git a/database/migrations/2026_10_13_000001_create_popup_banners_table.php b/database/migrations/2026_10_13_000001_create_popup_banners_table.php new file mode 100644 index 0000000..5bcd100 --- /dev/null +++ b/database/migrations/2026_10_13_000001_create_popup_banners_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('title', 150); + $table->string('type', 30)->index(); + $table->string('image_path'); + $table->string('link_url')->nullable(); + $table->boolean('open_in_new_tab')->default(false); + $table->boolean('is_active')->default(true); + $table->timestamp('starts_at')->nullable(); + $table->timestamp('ends_at')->nullable(); + $table->unsignedSmallInteger('sort_order')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('popup_banners'); + } +}; diff --git a/database/migrations/2026_10_13_000002_index_token_on_password_resets_table.php b/database/migrations/2026_10_13_000002_index_token_on_password_resets_table.php new file mode 100644 index 0000000..91ca188 --- /dev/null +++ b/database/migrations/2026_10_13_000002_index_token_on_password_resets_table.php @@ -0,0 +1,24 @@ +delete(); + + Schema::table('password_resets', function (Blueprint $table) { + $table->index('token'); + }); + } + + public function down(): void + { + Schema::table('password_resets', function (Blueprint $table) { + $table->dropIndex(['token']); + }); + } +}; diff --git a/database/migrations/2026_10_13_000003_add_two_factor_to_customers.php b/database/migrations/2026_10_13_000003_add_two_factor_to_customers.php new file mode 100644 index 0000000..cccdb11 --- /dev/null +++ b/database/migrations/2026_10_13_000003_add_two_factor_to_customers.php @@ -0,0 +1,34 @@ +boolean('two_factor_enabled')->default(false); + }); + + Schema::create('customer_trusted_devices', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('customer_id')->index(); + $table->char('token_hash', 64)->unique(); + $table->string('user_agent', 255)->nullable(); + $table->string('ip_address', 45)->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->index(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('customer_trusted_devices'); + + Schema::table('customers', function (Blueprint $table) { + $table->dropColumn('two_factor_enabled'); + }); + } +}; diff --git a/database/migrations/2026_10_13_000004_seed_customer_two_factor_code_template.php b/database/migrations/2026_10_13_000004_seed_customer_two_factor_code_template.php new file mode 100644 index 0000000..92fe316 --- /dev/null +++ b/database/migrations/2026_10_13_000004_seed_customer_two_factor_code_template.php @@ -0,0 +1,17 @@ +seedScenarioIfMissing('customer_two_factor_code'); + } + + public function down(): void + { + DB::table('template')->where('scenario', 'customer_two_factor_code')->delete(); + } +}; diff --git a/database/seeders/EmailTemplates/AccountTemplates.php b/database/seeders/EmailTemplates/AccountTemplates.php index a29cbcd..723242a 100644 --- a/database/seeders/EmailTemplates/AccountTemplates.php +++ b/database/seeders/EmailTemplates/AccountTemplates.php @@ -34,6 +34,17 @@ final class AccountTemplates . T::paragraph('Nếu Quý khách không yêu cầu, vui lòng bỏ qua email này — mật khẩu hiện tại vẫn giữ nguyên. Mọi thắc mắc xin liên hệ support@tgs.com.vn hoặc (028) 7309. 7379.') . T::note('Không chia sẻ link này cho bất kỳ ai, kể cả nhân viên Thế Giới Số.'), ], + [ + 'scenario' => 'customer_two_factor_code', + 'name' => 'Mã xác thực 2 lớp (2FA)', + 'subject' => '[{{app_name}}] Mã xác thực {{otp_purpose}}', + 'body' => T::title('Mã xác thực 2 lớp') + . T::greeting('{{full_name}}') + . T::paragraph('Quý khách đang thực hiện {{otp_purpose}}. Vui lòng nhập mã dưới đây để tiếp tục:') + . T::highlight('{{verify_code}}') + . T::paragraph('Mã có hiệu lực trong {{expire_minutes}} phút và chỉ dùng được một lần. Nếu Quý khách không thực hiện thao tác này, vui lòng đổi mật khẩu ngay và liên hệ support@tgs.com.vn.') + . T::note('Không chia sẻ mã này cho bất kỳ ai, kể cả nhân viên Thế Giới Số.'), + ], [ 'scenario' => 'password_reset_admin', 'name' => 'Admin cấp lại mật khẩu', diff --git a/database/seeders/EmailTemplates/TemplateVariables.php b/database/seeders/EmailTemplates/TemplateVariables.php index 4a262a8..b158407 100644 --- a/database/seeders/EmailTemplates/TemplateVariables.php +++ b/database/seeders/EmailTemplates/TemplateVariables.php @@ -20,8 +20,9 @@ final class TemplateVariables 'email' => ['Email đăng nhập', 'string', 'khachhang@example.com'], 'verify_code' => ['Mã xác thực', 'string', '482913'], - 'reset_url' => ['Link đặt lại mật khẩu', 'url', 'https://tgs.com.vn/dat-lai-mat-khau?token=abc'], + 'reset_url' => ['Link đặt lại mật khẩu', 'url', 'https://tgs.com.vn/dat-lai-mat-khau?t=abc'], 'expire_minutes' => ['Số phút hiệu lực', 'string', '60'], + 'otp_purpose' => ['Mục đích mã xác thực', 'string', 'đăng nhập tài khoản'], 'password' => ['Mật khẩu mới', 'string', 'Xk9#pL2m'], 'product_name' => ['Tên dịch vụ', 'string', 'Cloud VPS Pro'], diff --git a/database/seeders/TemplateSeeder.php b/database/seeders/TemplateSeeder.php index faec28d..a80a612 100644 --- a/database/seeders/TemplateSeeder.php +++ b/database/seeders/TemplateSeeder.php @@ -27,6 +27,19 @@ class TemplateSeeder extends Seeder } } + public function seedScenarioIfMissing(string $scenario): void + { + if (Template::query()->where('key', $scenario)->orWhere('scenario', $scenario)->exists()) { + return; + } + + foreach (self::templates() as $item) { + if ($item['scenario'] === $scenario) { + $this->upsert($item); + } + } + } + private function createMarketingIfMissing(array $item): void { $template = Template::firstOrNew(['name' => $item['name']]); diff --git a/routes/api/admin/system.php b/routes/api/admin/system.php index 80fbb6c..80936c8 100644 --- a/routes/api/admin/system.php +++ b/routes/api/admin/system.php @@ -5,6 +5,7 @@ use App\Http\Controllers\Api\Admin\EmailLog\EmailLogController; use App\Http\Controllers\Api\Admin\EmailTemplate\EmailTemplateController; use App\Http\Controllers\Api\Admin\MailCampaign\MailCampaignController; use App\Http\Controllers\Api\Admin\Setting\ClientMenuController; +use App\Http\Controllers\Api\Admin\Setting\PopupBannerController; use App\Http\Controllers\Api\Admin\Setting\SettingController as AdminSettingController; use App\Http\Controllers\Api\Admin\Staff\StaffOnlineController; use App\Http\Controllers\Api\Admin\Ticket\TicketController as AdminTicketController; @@ -32,6 +33,16 @@ Route::prefix('client-menu') Route::put('quick-links', 'updateQuickLinks'); }); +Route::prefix('popup-banners') + ->controller(PopupBannerController::class) + ->middleware('perm:setting.manager') + ->group(function () { + Route::get('/', 'index'); + Route::post('/', 'store'); + Route::post('{id}', 'update')->whereNumber('id'); + Route::delete('{id}', 'destroy')->whereNumber('id'); + }); + // VAT settings per service type (module: settings) Route::prefix('vat-settings') ->controller(AdminSettingController::class) diff --git a/routes/client.php b/routes/client.php index 310d61e..cd04178 100644 --- a/routes/client.php +++ b/routes/client.php @@ -38,6 +38,7 @@ Route::prefix('v1') Route::post('refresh', 'refresh')->name('refresh'); Route::post('forgot-password', 'forgotPassword')->middleware(['throttle:client-auth', 'turnstile'])->name('forgot-password'); Route::post('reset-password', 'resetPassword')->middleware(['throttle:client-auth', 'turnstile'])->name('reset-password'); + Route::get('reset-password/{token}', 'showResetToken')->middleware('throttle:client-auth')->name('reset-password.show'); // Ảnh CCCD nằm trên disk private, chỉ truy cập qua URL ký (ClientResource cấp, hết hạn 60 phút) Route::get('cccd/{customer}/{side}', 'cccdImage') ->whereNumber('customer') @@ -45,6 +46,8 @@ Route::prefix('v1') ->middleware('signed:relative') ->name('cccd'); Route::post('impersonate/exchange', 'impersonateExchange')->name('impersonate.exchange'); + Route::post('login/two-factor', 'verifyLoginTwoFactor')->middleware('throttle:client-auth')->name('login.two-factor'); + Route::post('login/two-factor/resend', 'resendLoginTwoFactor')->middleware('throttle:client-auth')->name('login.two-factor.resend'); Route::group(['middleware' => ['api.auth']], function () { Route::get('me', 'me')->name('me'); @@ -54,6 +57,11 @@ Route::prefix('v1') Route::put('preferences', 'updatePreferences')->name('preferences.update'); Route::post('update-cccd', 'updateCccd')->name('update-cccd'); Route::put('change-password', 'changePassword')->name('change-password'); + Route::get('two-factor', 'twoFactorStatus')->name('two-factor.status'); + Route::post('two-factor/setup-code', 'sendTwoFactorSetupCode')->middleware('throttle:client-auth')->name('two-factor.setup-code'); + Route::post('two-factor/enable', 'enableTwoFactor')->middleware('throttle:client-auth')->name('two-factor.enable'); + Route::post('two-factor/disable', 'disableTwoFactor')->middleware('throttle:client-auth')->name('two-factor.disable'); + Route::delete('two-factor/devices', 'revokeTrustedDevices')->name('two-factor.devices.revoke'); }); }); @@ -200,6 +208,7 @@ Route::prefix('v1') Route::get('turnstile', 'getTurnstileSetting')->name('turnstile'); Route::get('menu', 'getMenu')->name('menu'); Route::get('quick-links', 'getQuickLinks')->name('quick-links'); + Route::get('popups', 'getPopups')->name('popups'); }); }); diff --git a/tests/Feature/Api/Admin/PopupBannerTest.php b/tests/Feature/Api/Admin/PopupBannerTest.php new file mode 100644 index 0000000..a2bec43 --- /dev/null +++ b/tests/Feature/Api/Admin/PopupBannerTest.php @@ -0,0 +1,101 @@ + 'role-' . uniqid(), 'guard_name' => 'admin']); + $role->givePermissionTo(Permission::firstOrCreate(['name' => 'setting.manager', 'guard_name' => 'admin'])); + $admin = User::factory()->create(); + $admin->assignRole($role); + + return $admin; + } + + public function test_admin_creates_replaces_image_and_deletes_popup(): void + { + Storage::fake(PopupBanner::DISK); + $admin = $this->admin(); + + $id = $this->actingAs($admin, 'admin') + ->post('/api/v1/administrator/popup-banners', [ + 'title' => 'Sale 11.11', + 'type' => 'promotion', + 'image' => UploadedFile::fake()->image('a.png'), + 'link_url' => 'https://tgs.com.vn/khuyen-mai', + 'open_in_new_tab' => '1', + ], ['Accept' => 'application/json']) + ->assertCreated() + ->json('data.id'); + + $first = PopupBanner::findOrFail($id)->image_path; + Storage::disk(PopupBanner::DISK)->assertExists($first); + + $this->actingAs($admin, 'admin') + ->post("/api/v1/administrator/popup-banners/{$id}", [ + 'title' => 'Sale 12.12', + 'type' => 'advertisement', + 'image' => UploadedFile::fake()->image('b.png'), + ], ['Accept' => 'application/json']) + ->assertOk() + ->assertJsonPath('data.type', 'advertisement') + ->assertJsonPath('data.link_url', 'https://tgs.com.vn/khuyen-mai'); + + Storage::disk(PopupBanner::DISK)->assertMissing($first); + $second = PopupBanner::findOrFail($id)->image_path; + + $this->actingAs($admin, 'admin') + ->deleteJson("/api/v1/administrator/popup-banners/{$id}") + ->assertOk(); + + Storage::disk(PopupBanner::DISK)->assertMissing($second); + $this->assertDatabaseMissing('popup_banners', ['id' => $id]); + } + + public function test_rejects_unsafe_link_and_unknown_type(): void + { + Storage::fake(PopupBanner::DISK); + + $this->actingAs($this->admin(), 'admin') + ->post('/api/v1/administrator/popup-banners', [ + 'title' => 'X', + 'type' => 'spam', + 'image' => UploadedFile::fake()->image('a.png'), + 'link_url' => 'javascript:alert(1)', + ], ['Accept' => 'application/json']) + ->assertStatus(422) + ->assertJsonValidationErrors(['type', 'link_url']); + } + + public function test_client_only_sees_active_popups_in_schedule(): void + { + $base = ['type' => 'advertisement', 'image_path' => 'popup-banners/x.png']; + PopupBanner::create($base + ['title' => 'Visible', 'link_url' => '/nap-tien']); + PopupBanner::create($base + ['title' => 'Off', 'is_active' => false]); + PopupBanner::create($base + ['title' => 'Future', 'starts_at' => now()->addDay()]); + PopupBanner::create($base + ['title' => 'Past', 'ends_at' => now()->subDay()]); + + $this->withHeaders(['x-access-token' => $this->buildClientAccessToken()]) + ->getJson('/v1/setting/popups') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.title', 'Visible') + ->assertJsonPath('data.0.link_url', '/nap-tien') + ->assertJsonMissingPath('data.0.image_path'); + } +} diff --git a/tests/Feature/Api/Client/ClientAuthSecurityTest.php b/tests/Feature/Api/Client/ClientAuthSecurityTest.php index b477257..08737c5 100644 --- a/tests/Feature/Api/Client/ClientAuthSecurityTest.php +++ b/tests/Feature/Api/Client/ClientAuthSecurityTest.php @@ -8,10 +8,10 @@ use App\Mail\Customer\PasswordResetLink; use App\Models\Customer; use App\Models\RefreshToken; use App\Services\Api\Client\ClientAuthService; +use App\Support\PasswordResetToken; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Mail; -use Illuminate\Support\Facades\Password; use Illuminate\Support\Facades\Storage; use Tests\TestCase; @@ -66,27 +66,80 @@ class ClientAuthSecurityTest extends TestCase $this->api()->postJson('/v1/client/forgot-password', ['email' => strtoupper($customer->email)])->assertOk(); $this->assertSame($oldHash, $customer->fresh()->password); - Mail::assertQueued(PasswordResetLink::class, function (PasswordResetLink $mail) use ($customer) { - // Template seed bằng migration phải render được và chứa link reset, không chứa mật khẩu. + $token = null; + Mail::assertQueued(PasswordResetLink::class, function (PasswordResetLink $mail) use ($customer, &$token) { $html = $mail->render(); + preg_match('#/dat-lai-mat-khau\?t=([0-9a-z]+)#', $html, $match); + $token = $match[1] ?? null; return $mail->hasTo($customer->email) - && str_contains($html, '/dat-lai-mat-khau?token=') + && !str_contains($html, 'email=') && str_contains($html, '60 phút'); }); - $token = Password::broker('users')->createToken($customer); + $this->assertNotNull($token); + $this->assertSame(PasswordResetToken::LENGTH, strlen($token)); + $this->assertTrue(PasswordResetToken::isWellFormed($token)); + + $this->api()->getJson('/v1/client/reset-password/' . $token) + ->assertOk() + ->assertJsonPath('data.email', $customer->email); $this->api()->postJson('/v1/client/reset-password', [ - 'email' => $customer->email, 'token' => 'wrong', 'password' => 'NewPass123', 'password_confirmation' => 'NewPass123', - ])->assertStatus(422); - - $this->api()->postJson('/v1/client/reset-password', [ - 'email' => $customer->email, 'token' => $token, 'password' => 'NewPass123', 'password_confirmation' => 'NewPass123', + 'token' => $token, 'password' => 'NewPass1234', 'password_confirmation' => 'NewPass1234', ])->assertOk(); - $this->assertTrue(Hash::check('NewPass123', $customer->fresh()->password)); + $this->assertTrue(Hash::check('NewPass1234', $customer->fresh()->password)); $this->assertSame(0, RefreshToken::where('customer_id', $customer->id)->count()); + + $this->api()->getJson('/v1/client/reset-password/' . $token)->assertNotFound(); + $this->api()->postJson('/v1/client/reset-password', [ + 'token' => $token, 'password' => 'Other12345', 'password_confirmation' => 'Other12345', + ])->assertStatus(422); + } + + public function test_reset_token_rejects_tampered_unknown_expired_and_superseded_tokens(): void + { + $customer = Customer::factory()->create(['status' => 1]); + $token = PasswordResetToken::issue($customer); + + $lastChar = $token[PasswordResetToken::LENGTH - 1]; + $tampered = substr($token, 0, -1) . ($lastChar === 'a' ? 'b' : 'a'); + $this->assertFalse(PasswordResetToken::isWellFormed($tampered)); + $this->assertFalse(PasswordResetToken::isWellFormed(substr($token, 0, -1))); + $this->assertFalse(PasswordResetToken::isWellFormed(strtoupper($token))); + $this->api()->getJson('/v1/client/reset-password/' . $tampered)->assertNotFound(); + + $this->assertNull(PasswordResetToken::issue($customer), 'throttled within 60s'); + + $this->travel(61)->seconds(); + $newer = PasswordResetToken::issue($customer); + $this->assertNotNull($newer); + $this->assertNull(PasswordResetToken::findCustomer($token), 'old link revoked when a new one is issued'); + $this->assertSame($customer->id, PasswordResetToken::findCustomer($newer)?->id); + + $other = Customer::factory()->create(['status' => 1]); + $otherToken = PasswordResetToken::issue($other); + $this->assertSame($other->id, PasswordResetToken::findCustomer($otherToken)?->id); + + $this->travel(61)->minutes(); + $this->assertNull(PasswordResetToken::findCustomer($newer), 'expired after 60 minutes'); + $this->api()->postJson('/v1/client/reset-password', [ + 'token' => $newer, 'password' => 'NewPass1234', 'password_confirmation' => 'NewPass1234', + ])->assertStatus(422); + } + + public function test_locked_account_loses_api_access_even_with_unexpired_jwt(): void + { + $customer = Customer::factory()->create(['status' => 1]); + $token = \Tymon\JWTAuth\Facades\JWTAuth::fromUser($customer); + + $this->api()->withHeaders(['Authorization' => "Bearer {$token}"])->getJson('/v1/client/me')->assertOk(); + + $customer->update(['status' => 3]); + $this->app['auth']->forgetGuards(); + + $this->api()->withHeaders(['Authorization' => "Bearer {$token}"])->getJson('/v1/client/me')->assertUnauthorized(); } public function test_cccd_images_are_private_and_served_only_via_signed_url(): void diff --git a/tests/Feature/Api/Client/ClientTamperingTest.php b/tests/Feature/Api/Client/ClientTamperingTest.php new file mode 100644 index 0000000..582aada --- /dev/null +++ b/tests/Feature/Api/Client/ClientTamperingTest.php @@ -0,0 +1,206 @@ +victim = Customer::factory()->create(['status' => 1, 'total' => 500_000]); + $this->attacker = Customer::factory()->create(['status' => 1, 'total' => 10_000_000]); + + $this->product = Product::factory()->create(['product_type' => 'hosting', 'is_active' => true]); + ProductPricings::create(['product_id' => $this->product->id, 'billing_cycle' => 'monthly', 'price' => 150_000, 'is_active' => true]); + + $this->order = Order::create(['order_num' => 'ORD-VICTIM', 'customer_id' => $this->victim->id, 'total' => 150_000, 'status' => 0]); + $orderItem = OrderItem::create([ + 'order_id' => $this->order->id, 'product_id' => $this->product->id, 'billing_cycle' => 'monthly', + 'months' => 1, 'quantity' => 1, 'price' => 150_000, 'addons_meta' => [], + ]); + $this->invoice = Invoice::create([ + 'invoice_number' => 'INV-VICTIM', 'customer_id' => $this->victim->id, 'order_id' => $this->order->id, + 'invoice_type' => 1, 'sub_total' => 150_000, 'tax' => 0, 'total' => 150_000, + 'status' => InvoiceStatus::UNPAID->value, + ]); + + $this->hosting = Service::create([ + 'customer_id' => $this->victim->id, 'product_id' => $this->product->id, 'order_item_id' => $orderItem->id, + 'type' => 'hosting', 'status' => 'active', 'started_at' => now()->subMonth(), 'expires_at' => now()->addDays(5), + ]); + + $this->vm = ModuleVmware::create(['customer_id' => $this->victim->id, 'vmware_id' => 987654]); + $this->vps = Service::create([ + 'customer_id' => $this->victim->id, 'product_id' => $this->product->id, 'type' => 'vps', + 'status' => 'active', 'vm_id' => $this->vm->id, 'started_at' => now(), 'expires_at' => now()->addMonth(), + ]); + } + + private function as(Customer $customer): static + { + return $this->actingAs($customer, 'client')->withHeaders(['x-access-token' => $this->buildClientAccessToken()]); + } + + private function assertDenied($response): void + { + $this->assertContains($response->status(), [403, 404, 422], "Expected denial, got {$response->status()}: " . $response->getContent()); + } + + public function test_profile_endpoints_ignore_balance_status_and_email_fields(): void + { + $payload = [ + 'fullname' => 'Attacker', 'phone' => '0900000000', + 'credit' => 999_999_999, 'total' => 999_999_999, 'total_pending' => 0, 'point' => 999_999, + 'status' => 1, 'email' => 'victim-takeover@example.com', 'two_factor_enabled' => false, 'id' => $this->victim->id, + ]; + + $this->as($this->attacker)->putJson('/v1/client/update', $payload)->assertOk(); + $this->as($this->attacker)->putJson('/v1/client/preferences', ['email_marketing' => true, 'receive_deposit' => true] + $payload)->assertOk(); + + $attacker = $this->attacker->fresh(); + $this->assertSame(10_000_000, (int) $attacker->total); + $this->assertNotSame('victim-takeover@example.com', $attacker->email); + $this->assertSame('Attacker', $attacker->fullname); + $this->assertSame(500_000, (int) $this->victim->fresh()->total); + $this->assertNotSame('Attacker', $this->victim->fresh()->fullname); + } + + public function test_cannot_read_another_customers_orders_invoices_or_transactions(): void + { + foreach ([ + "/v1/client/orders/{$this->order->id}", + "/v1/client/orders/num/{$this->order->order_num}", + "/v1/client/invoices/{$this->invoice->id}", + "/v1/client/invoices/number/{$this->invoice->invoice_number}", + "/v1/client/invoices/order/{$this->order->order_num}", + "/v1/client/invoices/order/{$this->order->order_num}/pdf", + '/v1/client/payment-history/' . Str::uuid(), + ] as $url) { + $this->assertDenied($this->as($this->attacker)->getJson($url)); + } + } + + public function test_cannot_pay_retry_or_renew_with_another_customers_resources(): void + { + $this->assertDenied($this->as($this->attacker)->postJson("/v1/client/invoices/number/{$this->invoice->invoice_number}/pay", ['payment_method' => 'balance'])); + $this->assertDenied($this->as($this->attacker)->postJson("/v1/orders/{$this->order->order_num}/pay", ['payment_method' => 'balance'])); + $this->assertDenied($this->as($this->attacker)->postJson("/v1/client/orders/{$this->order->id}/retry")); + $this->assertDenied($this->as($this->attacker)->postJson("/v1/client/dich-vu/{$this->hosting->id}/renew", ['months' => 1])); + $this->assertDenied($this->as($this->attacker)->patchJson("/v1/client/dich-vu/{$this->hosting->id}/auto-renew", ['auto_renew' => true])); + $this->assertDenied($this->as($this->attacker)->patchJson("/v1/client/client-service/{$this->hosting->uuid}/auto-renew", ['auto_renew' => true])); + + $this->assertSame(InvoiceStatus::UNPAID->value, (int) $this->invoice->fresh()->status); + $this->assertSame(10_000_000, (int) $this->attacker->fresh()->total); + $this->assertSame(500_000, (int) $this->victim->fresh()->total); + $this->assertTrue($this->hosting->fresh()->expires_at->lt(now()->addDays(6))); + $this->assertFalse((bool) $this->hosting->fresh()->auto_renew); + } + + public function test_cannot_view_or_operate_another_customers_vm_hosting_or_service(): void + { + foreach (["/v1/client/client-service/{$this->hosting->uuid}", "/v1/client/service/{$this->hosting->uuid}", + "/v1/client/client-service/{$this->vps->uuid}/available-os", "/v1/client/client-service/{$this->vps->uuid}/console-ticket"] as $url) { + $this->assertDenied($this->as($this->attacker)->getJson($url)); + } + + $this->assertDenied($this->as($this->attacker)->postJson("/v1/client/client-service/{$this->vps->uuid}/rebuild", ['os_id' => 1, 'password' => 'Secret12345!'])); + + foreach (['vm-power-off', 'vm-power-on', 'vm-restart', 'vm-change-password', 'vm-change-port', 'vm-change-ip', 'vm-create-snapshot', 'vm-rebuild', 'vm-upgrade-addons'] as $action) { + foreach ([$this->vm->id, $this->vm->vmware_id, $this->vps->uuid] as $id) { + $this->assertDenied($this->as($this->attacker)->postJson("/v1/system/{$action}", [ + 'product_id' => $id, 'id' => $id, 'vm_id' => $id, 'password' => 'Secret12345!', 'port' => 2222, 'name' => 'x', + ])); + } + } + } + + public function test_cannot_touch_another_customers_dns_zone(): void + { + $serverId = DB::table('dns_manager_servers')->insertGetId(['ip_address' => '10.0.0.1', 'password' => 'x']); + $zone = DnsDomain::create(['domain_name' => 'victim.vn', 'dns_manager_server_id' => $serverId, 'is_created' => 1]); + $zone->customer()->attach($this->victim->id); + $record = DnsManagerNS::create(['dns_manager_domain_id' => $zone->id, 'type' => 'A', 'type_valid' => 'A', 'name' => 'victim.vn', 'value' => '1.1.1.1', 'ttl' => 3600]); + + $base = "/v1/client/client-service/domains/{$zone->id}"; + $this->assertDenied($this->as($this->attacker)->getJson($base)); + $this->assertDenied($this->as($this->attacker)->getJson("{$base}/records")); + $this->assertDenied($this->as($this->attacker)->postJson("{$base}/records", ['type' => 'A', 'name' => 'evil', 'value' => '6.6.6.6', 'ttl' => 300])); + $this->assertDenied($this->as($this->attacker)->putJson("{$base}/records/{$record->id}", ['type' => 'A', 'name' => 'victim.vn', 'value' => '6.6.6.6', 'ttl' => 300])); + $this->assertDenied($this->as($this->attacker)->deleteJson("{$base}/records/{$record->id}")); + + $this->assertSame('1.1.1.1', $record->fresh()?->value); + $this->assertSame(1, DnsManagerNS::where('dns_manager_domain_id', $zone->id)->count()); + } + + public function test_cannot_read_reply_or_close_another_customers_ticket_or_notification(): void + { + $ticket = Ticket::create(['uuid' => (string) Str::uuid(), 'ticket_num' => 'TK-VICTIM', 'subject' => 'S', 'customer_id' => $this->victim->id, 'description' => 'D', 'status' => 'open']); + $notification = Notification::create(['recipient_type' => 'customer', 'recipient_id' => $this->victim->id, 'type' => 'info', 'title' => 'T', 'body' => 'B', 'is_read' => false]); + + $this->assertDenied($this->as($this->attacker)->getJson("/v1/client/tickets/{$ticket->ticket_num}")); + $this->assertDenied($this->as($this->attacker)->postJson("/v1/client/tickets/{$ticket->ticket_num}/reply", ['message' => 'pwned', 'content' => 'pwned'])); + $this->assertDenied($this->as($this->attacker)->patchJson("/v1/client/tickets/{$ticket->ticket_num}/close")); + $this->as($this->attacker)->patchJson("/v1/client/notifications/{$notification->id}/read"); + + $this->assertSame('open', $ticket->fresh()->status); + $this->assertFalse((bool) $notification->fresh()->is_read); + } + + public function test_cannot_confirm_cancel_or_read_another_customers_deposit_code(): void + { + $uuid = $this->as($this->victim)->postJson('/v1/transaction/manual-deposit/prepare', ['amount' => 100_000, 'bank_id' => null])->json('data.uuid'); + + if (!$uuid) { + $this->markTestSkipped('prepare requires bank setup'); + } + + $this->assertDenied($this->as($this->attacker)->getJson("/v1/transaction/manual-deposit/{$uuid}")); + $this->assertDenied($this->as($this->attacker)->deleteJson("/v1/transaction/manual-deposit/{$uuid}/cancel")); + $this->assertDenied($this->as($this->attacker)->postJson('/v1/transaction/manual-deposit/confirm', ['uuid' => $uuid, 'proof_image' => 'deposit-proofs/x.png', 'amount' => 999_999_999])); + $this->assertSame(500_000, (int) $this->victim->fresh()->total); + } + + public function test_order_price_is_computed_server_side_and_ignores_client_amounts(): void + { + $response = $this->as($this->attacker)->postJson('/v1/orders', [ + 'product_id' => $this->product->uuid, 'billing_cycle' => 'monthly', 'server_name' => 'attacker-site.vn', + 'price' => 1, 'total' => 1, 'amount' => 1, 'sub_total' => 1, 'discount' => 999_999, + ]); + + $order = Order::where('customer_id', $this->attacker->id)->latest('id')->first(); + $this->assertNotNull($order, 'order not created: ' . $response->getContent()); + $this->assertSame(150_000, (int) $order->total); + } +} diff --git a/tests/Feature/Api/Client/ClientTwoFactorTest.php b/tests/Feature/Api/Client/ClientTwoFactorTest.php new file mode 100644 index 0000000..9b4e337 --- /dev/null +++ b/tests/Feature/Api/Client/ClientTwoFactorTest.php @@ -0,0 +1,174 @@ +withHeaders(['x-access-token' => $this->buildClientAccessToken()]); + } + + private function customer(bool $twoFactor = false): Customer + { + return Customer::factory()->create([ + 'status' => 1, + 'password' => Hash::make(self::PASSWORD), + 'two_factor_enabled' => $twoFactor, + ]); + } + + private function login(Customer $customer, ?string $deviceToken = null) + { + return $this->api()->postJson('/v1/client/login', array_filter([ + 'email' => $customer->email, + 'password' => self::PASSWORD, + 'device_token' => $deviceToken, + ])); + } + + private function lastCode(): string + { + $code = null; + Mail::assertQueued(TwoFactorCode::class, function (TwoFactorCode $mail) use (&$code) { + $code = $mail->data['code']; + + return true; + }); + + return $code; + } + + public function test_login_without_two_factor_returns_tokens_directly(): void + { + $this->login($this->customer()) + ->assertOk() + ->assertJsonStructure(['data' => ['token', 'refresh_token']]) + ->assertJsonMissingPath('data.two_factor'); + } + + public function test_customer_enables_two_factor_with_emailed_code(): void + { + Mail::fake(); + $customer = $this->customer(); + + $this->actingAs($customer, 'client')->api()->postJson('/v1/client/two-factor/setup-code')->assertOk(); + $code = $this->lastCode(); + + $this->actingAs($customer, 'client')->api() + ->postJson('/v1/client/two-factor/setup-code') + ->assertStatus(422); + + $this->actingAs($customer, 'client')->api() + ->postJson('/v1/client/two-factor/enable', ['code' => $code === '000000' ? '111111' : '000000']) + ->assertStatus(422); + $this->assertFalse($customer->fresh()->two_factor_enabled); + + $this->actingAs($customer, 'client')->api() + ->postJson('/v1/client/two-factor/enable', ['code' => $code]) + ->assertOk(); + $this->assertTrue($customer->fresh()->two_factor_enabled); + + $this->actingAs($customer->fresh(), 'client')->api() + ->getJson('/v1/client/me') + ->assertJsonPath('data.client.customer.active_2fa', 1); + } + + public function test_login_requires_emailed_code_and_can_remember_device(): void + { + Mail::fake(); + $customer = $this->customer(twoFactor: true); + + $challenge = $this->login($customer) + ->assertOk() + ->assertJsonMissingPath('data.token') + ->json('data.two_factor.challenge'); + $this->assertSame(64, strlen($challenge)); + $code = $this->lastCode(); + + $this->api()->postJson('/v1/client/login/two-factor', ['challenge' => $challenge, 'code' => $code === '000000' ? '111111' : '000000']) + ->assertStatus(422); + + $response = $this->api()->postJson('/v1/client/login/two-factor', [ + 'challenge' => $challenge, 'code' => $code, 'remember_device' => true, + ])->assertOk()->assertJsonStructure(['data' => ['token', 'refresh_token', 'device_token', 'device_expires_in']]); + + $this->api()->postJson('/v1/client/login/two-factor', ['challenge' => $challenge, 'code' => $code]) + ->assertStatus(422); + + $deviceToken = $response->json('data.device_token'); + $device = CustomerTrustedDevice::where('customer_id', $customer->id)->sole(); + $this->assertNotSame($deviceToken, $device->token_hash); + $this->assertTrue($device->expires_at->between(now()->addDays(89), now()->addDays(91))); + + $this->login($customer, $deviceToken)->assertOk()->assertJsonStructure(['data' => ['token']]); + $this->login($customer, str_repeat('x', 64))->assertOk()->assertJsonPath('data.two_factor.resend_in', 60); + + $this->travel(91)->days(); + $this->login($customer, $deviceToken)->assertOk()->assertJsonStructure(['data' => ['two_factor']]); + } + + public function test_challenge_is_destroyed_after_five_wrong_codes(): void + { + Mail::fake(); + $customer = $this->customer(twoFactor: true); + $challenge = $this->login($customer)->json('data.two_factor.challenge'); + $code = $this->lastCode(); + $wrong = $code === '000000' ? '111111' : '000000'; + + for ($i = 0; $i < 5; $i++) { + $this->api()->postJson('/v1/client/login/two-factor', ['challenge' => $challenge, 'code' => $wrong])->assertStatus(422); + } + + $this->api()->postJson('/v1/client/login/two-factor', ['challenge' => $challenge, 'code' => $code])->assertStatus(429); + + $this->travel(61)->seconds(); + $this->api()->postJson('/v1/client/login/two-factor', ['challenge' => $challenge, 'code' => $code]) + ->assertStatus(422) + ->assertJsonPath('errors.code.0', 'Phiên xác thực đã hết hạn, vui lòng đăng nhập lại.'); + } + + public function test_disabling_requires_password_and_revokes_devices_password_change_also_revokes(): void + { + $customer = $this->customer(twoFactor: true); + $twoFactor = app(\App\Services\Api\Client\ClientTwoFactorService::class); + $twoFactor->rememberDevice($customer, 'UA', '1.1.1.1'); + + $this->actingAs($customer, 'client')->api()->getJson('/v1/client/two-factor') + ->assertOk() + ->assertJsonPath('data.enabled', true) + ->assertJsonCount(1, 'data.devices') + ->assertJsonMissingPath('data.devices.0.token_hash'); + + $this->actingAs($customer, 'client')->api() + ->postJson('/v1/client/two-factor/disable', ['password' => 'wrong-password']) + ->assertStatus(422); + $this->assertTrue($customer->fresh()->two_factor_enabled); + + $this->actingAs($customer, 'client')->api() + ->postJson('/v1/client/two-factor/disable', ['password' => self::PASSWORD]) + ->assertOk(); + $this->assertFalse($customer->fresh()->two_factor_enabled); + $this->assertSame(0, CustomerTrustedDevice::count()); + + $twoFactor->rememberDevice($customer, 'UA', '1.1.1.1'); + $this->actingAs($customer, 'client')->api()->putJson('/v1/client/change-password', [ + 'current_password' => self::PASSWORD, 'password' => 'NewSecret123', 'password_confirmation' => 'NewSecret123', + ])->assertOk(); + $this->assertSame(0, CustomerTrustedDevice::count()); + } +}