diff --git a/app/Http/Controllers/Api/Client/ClientController.php b/app/Http/Controllers/Api/Client/ClientController.php index 6f40260..bda0cdb 100644 --- a/app/Http/Controllers/Api/Client/ClientController.php +++ b/app/Http/Controllers/Api/Client/ClientController.php @@ -8,6 +8,7 @@ use App\Http\Requests\Api\Client\ClientRegisterRequest; use App\Http\Requests\Api\Client\ClientUpdateRequest; use App\Http\Resources\Api\Client\ClientResource; use App\Services\Api\Client\ClientAuthService; +use Illuminate\Http\Request; use Tymon\JWTAuth\Facades\JWTAuth; use Tymon\JWTAuth\Exceptions\JWTException; @@ -97,7 +98,7 @@ class ClientController extends Controller * operationId="clientLogin", * tags={"Client Authentication"}, * summary="Client login", - * description="Authenticate a client and return access token", + * description="Authenticate a client and return access token + refresh token", * @OA\RequestBody( * required=true, * @OA\JsonContent( @@ -113,7 +114,9 @@ class ClientController extends Controller * @OA\Property(property="error", type="boolean", example=false), * @OA\Property(property="data", type="object", * @OA\Property(property="client", type="object"), - * @OA\Property(property="token", type="string", example="1|laravel_sanctum_token...") + * @OA\Property(property="token", type="string", example="eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..."), + * @OA\Property(property="refresh_token", type="string", example="random64charstring..."), + * @OA\Property(property="expires_in", type="integer", example=900) * ) * ) * ), @@ -130,8 +133,10 @@ class ClientController extends Controller return response()->json([ 'error' => false, 'data' => [ - 'client' => new ClientResource($result['client']), - 'token' => $result['token'], + 'client' => new ClientResource($result['client']), + 'token' => $result['token'], + 'refresh_token' => $result['refresh_token'], + 'expires_in' => config('jwt.ttl', 60) * 60, // seconds ], ]); } @@ -161,7 +166,9 @@ class ClientController extends Controller * @OA\Property(property="error", type="boolean", example=false), * @OA\Property(property="data", type="object", * @OA\Property(property="client", type="object"), - * @OA\Property(property="token", type="string", example="1|laravel_sanctum_token...") + * @OA\Property(property="token", type="string", example="eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..."), + * @OA\Property(property="refresh_token", type="string", example="random64charstring..."), + * @OA\Property(property="expires_in", type="integer", example=900) * ) * ) * ), @@ -178,8 +185,10 @@ class ClientController extends Controller return response()->json([ 'error' => false, 'data' => [ - 'client' => new ClientResource($result['client']), - 'token' => $result['token'], + 'client' => new ClientResource($result['client']), + 'token' => $result['token'], + 'refresh_token' => $result['refresh_token'], + 'expires_in' => config('jwt.ttl', 60) * 60, ], ]); } @@ -190,8 +199,13 @@ class ClientController extends Controller * operationId="clientLogout", * tags={"Client Authentication"}, * summary="Client logout", - * description="Logout the currently authenticated client", + * description="Logout the currently authenticated client and revoke refresh token", * security={{"bearerAuth":{}}}, + * @OA\RequestBody( + * @OA\JsonContent( + * @OA\Property(property="refresh_token", type="string", example="random64charstring...") + * ) + * ), * @OA\Response( * response=200, * description="Logout successful", @@ -206,50 +220,92 @@ class ClientController extends Controller * ) * ) */ - public function logout() + public function logout(Request $request) { + if ($rawToken = $request->input('refresh_token')) { + $this->authService->revokeRefreshToken($rawToken); + } + auth('client')->logout(); return response()->json([ - 'error' => false, + 'error' => false, 'message' => 'Đã đăng xuất thành công.', ]); } + /** + * @OA\Post( + * path="/client/refresh", + * operationId="clientRefresh", + * tags={"Client Authentication"}, + * summary="Refresh access token", + * description="Exchange a valid refresh token for a new access token + rotated refresh token", + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"refresh_token"}, + * @OA\Property(property="refresh_token", type="string", example="random64charstring...") + * ) + * ), + * @OA\Response( + * response=200, + * description="Token refreshed", + * @OA\JsonContent( + * @OA\Property(property="error", type="boolean", example=false), + * @OA\Property(property="data", type="object", + * @OA\Property(property="token", type="string"), + * @OA\Property(property="refresh_token", type="string"), + * @OA\Property(property="expires_in", type="integer", example=900), + * @OA\Property(property="client", type="object") + * ) + * ) + * ), + * @OA\Response( + * response=401, + * description="Invalid or expired refresh token" + * ) + * ) + */ + public function refresh(Request $request) + { + $request->validate([ + 'refresh_token' => ['required', 'string'], + ]); + + try { + $result = $this->authService->rotateRefreshToken($request->input('refresh_token')); + + return response()->json([ + 'error' => false, + 'data' => [ + 'token' => $result['token'], + 'refresh_token' => $result['refresh_token'], + 'expires_in' => config('jwt.ttl', 60) * 60, + 'client' => new ClientResource($result['client']), + ], + ]); + } catch (\Illuminate\Validation\ValidationException $e) { + return response()->json([ + 'error' => true, + 'message' => collect($e->errors())->flatten()->first(), + ], 401); + } + } + public function update(ClientUpdateRequest $request) { $user = auth('client')->user(); - + $user->fill($request->validated()); $user->save(); return response()->json([ - 'error' => false, + 'error' => false, 'message' => 'Cập nhật thông tin thành công', - 'data' => [ + 'data' => [ 'client' => new ClientResource($user), ], ]); } - - public function refresh() - { - try { - $newToken = JWTAuth::parseToken()->refresh(); - $user = JWTAuth::setToken($newToken)->toUser(); - - return response()->json([ - 'error' => false, - 'data' => [ - 'token' => $newToken, - 'client' => new ClientResource($user), - ], - ]); - } catch (JWTException $e) { - return response()->json([ - 'error' => true, - 'message' => 'Không thể làm mới phiên đăng nhập. Vui lòng đăng nhập lại.', - ], 401); - } - } } diff --git a/app/Models/RefreshToken.php b/app/Models/RefreshToken.php new file mode 100644 index 0000000..616eae3 --- /dev/null +++ b/app/Models/RefreshToken.php @@ -0,0 +1,28 @@ + 'datetime', + ]; + + public function customer() + { + return $this->belongsTo(Customer::class); + } + + public function isExpired(): bool + { + return $this->expires_at->isPast(); + } +} diff --git a/app/Services/Api/Client/ClientAuthService.php b/app/Services/Api/Client/ClientAuthService.php index 50de2de..72145ab 100644 --- a/app/Services/Api/Client/ClientAuthService.php +++ b/app/Services/Api/Client/ClientAuthService.php @@ -2,13 +2,18 @@ namespace App\Services\Api\Client; +use App\Models\RefreshToken; use App\Repositories\Api\Client\ClientRepositoryInterface; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; use Tymon\JWTAuth\Facades\JWTAuth; class ClientAuthService { + // Refresh token lifetime in days + const REFRESH_TOKEN_TTL_DAYS = 30; + protected $clientRepository; public function __construct(ClientRepositoryInterface $clientRepository) @@ -17,7 +22,7 @@ class ClientAuthService } /** - * Xác thực khách hàng và tạo token + * Xác thực khách hàng và tạo access + refresh token */ public function authenticateClient(array $credentials): array { @@ -36,20 +41,23 @@ class ClientAuthService 3 => 'bị khoá', default => 'vô hiệu hoá', }; - + throw ValidationException::withMessages([ 'email' => ["Tài khoản của bạn đã {$statusLabel}, vui lòng liên hệ hotline."], ]); } + $refreshToken = $this->createRefreshToken($client->id); + return [ - 'client' => $client, - 'token' => $token, + 'client' => $client, + 'token' => $token, + 'refresh_token' => $refreshToken, ]; } /** - * Đăng ký khách hàng mới và tạo token + * Đăng ký khách hàng mới và tạo access + refresh token */ public function registerClient(array $data): array { @@ -61,10 +69,82 @@ class ClientAuthService ]); $token = JWTAuth::fromUser($client); + $refreshToken = $this->createRefreshToken($client->id); return [ - 'client' => $client, - 'token' => $token, + 'client' => $client, + 'token' => $token, + 'refresh_token' => $refreshToken, ]; } + + /** + * Tạo refresh token mới, lưu hash vào DB, trả về raw token. + */ + public function createRefreshToken(int $customerId): string + { + $raw = Str::random(64); + $hash = hash('sha256', $raw); + + RefreshToken::create([ + 'customer_id' => $customerId, + 'token' => $hash, + 'expires_at' => now()->addDays(self::REFRESH_TOKEN_TTL_DAYS), + ]); + + return $raw; + } + + /** + * Xác minh refresh token, xoay vòng (rotation), trả về access token mới + refresh token mới. + * Ném ValidationException nếu token không hợp lệ hoặc đã hết hạn. + */ + public function rotateRefreshToken(string $rawToken): array + { + $hash = hash('sha256', $rawToken); + + $record = RefreshToken::where('token', $hash)->first(); + + if (!$record || $record->isExpired()) { + // Xoá token cũ nếu tồn tại nhưng hết hạn + $record?->delete(); + + throw ValidationException::withMessages([ + 'refresh_token' => ['Refresh token không hợp lệ hoặc đã hết hạn.'], + ]); + } + + $customer = $record->customer; + + if (!$customer || (int)$customer->status !== 1) { + $record->delete(); + throw ValidationException::withMessages([ + 'refresh_token' => ['Tài khoản không hợp lệ.'], + ]); + } + + // Rotation: xoá token cũ + $record->delete(); + + // Tạo access token mới + $newAccessToken = JWTAuth::fromUser($customer); + + // Tạo refresh token mới + $newRefreshToken = $this->createRefreshToken($customer->id); + + return [ + 'client' => $customer, + 'token' => $newAccessToken, + 'refresh_token' => $newRefreshToken, + ]; + } + + /** + * Huỷ refresh token khi đăng xuất. + */ + public function revokeRefreshToken(string $rawToken): void + { + $hash = hash('sha256', $rawToken); + RefreshToken::where('token', $hash)->delete(); + } } diff --git a/database/migrations/2026_03_16_091003_create_refresh_tokens_table.php b/database/migrations/2026_03_16_091003_create_refresh_tokens_table.php new file mode 100644 index 0000000..0dfc347 --- /dev/null +++ b/database/migrations/2026_03_16_091003_create_refresh_tokens_table.php @@ -0,0 +1,27 @@ +id(); + $table->unsignedBigInteger('customer_id'); + $table->string('token', 64)->unique(); // sha256 hex hash + $table->timestamp('expires_at'); + $table->timestamps(); + + $table->foreign('customer_id')->references('id')->on('customers')->onDelete('cascade'); + $table->index('customer_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('refresh_tokens'); + } +};