first init
This commit is contained in:
@@ -0,0 +1,378 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\ActivityLogger;
|
||||
use App\Traits\NotifiesAdmins;
|
||||
use Exception;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
abstract class BaseCrudController extends Controller
|
||||
{
|
||||
use AuthorizesRequests, NotifiesAdmins;
|
||||
|
||||
/**
|
||||
* The repository interface for this controller
|
||||
*/
|
||||
protected $repository;
|
||||
|
||||
/**
|
||||
* The model class for authorization
|
||||
*/
|
||||
protected $modelClass;
|
||||
|
||||
/**
|
||||
* The resource class for API responses
|
||||
*/
|
||||
protected $resourceClass;
|
||||
|
||||
/**
|
||||
* The request class for validation
|
||||
*/
|
||||
protected $requestClass;
|
||||
|
||||
/**
|
||||
* The name of the resource for logging (e.g., 'category', 'model')
|
||||
*/
|
||||
protected $resourceName;
|
||||
|
||||
/**
|
||||
* The plural name of the resource for messages
|
||||
*/
|
||||
protected $resourceNamePlural;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
public function __construct($repository)
|
||||
{
|
||||
$this->repository = $repository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('viewAny', $this->modelClass);
|
||||
|
||||
try {
|
||||
$perPage = $request->get('per_page', 10) ?? 10;
|
||||
$perPage = min($perPage, 1000); // Limit max per page to 1000
|
||||
$search = (string) ($request->get('search', '') ?? '');
|
||||
$sortBy = (string) ($request->get('sort_by', 'id') ?? 'id');
|
||||
$sortOrder = (string) ($request->get('sort_order', 'asc') ?? 'asc');
|
||||
|
||||
$items = $this->getIndexData($request, $perPage, $search, $sortBy, $sortOrder);
|
||||
|
||||
// Check if the result is paginated
|
||||
if ($items instanceof LengthAwarePaginator) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $this->resourceClass::collection($items->items()),
|
||||
'pagination' => [
|
||||
'current_page' => $items->currentPage(),
|
||||
'per_page' => $items->perPage(),
|
||||
'total' => $items->total(),
|
||||
'last_page' => $items->lastPage(),
|
||||
'from' => $items->firstItem(),
|
||||
'to' => $items->lastItem(),
|
||||
'has_more_pages' => $items->hasMorePages(),
|
||||
],
|
||||
'message' => $this->getSuccessMessage('index'),
|
||||
]);
|
||||
}
|
||||
|
||||
// Fallback for non-paginated results
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $this->resourceClass::collection($items),
|
||||
'message' => $this->getSuccessMessage('index'),
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
Log::error("Error fetching {$this->resourceNamePlural}: ".$e->getMessage());
|
||||
|
||||
return $this->errorResponse($this->getErrorMessage('index'), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', $this->modelClass);
|
||||
|
||||
try {
|
||||
$validated = $this->validateRequest($request);
|
||||
$data = $this->prepareStoreData($validated);
|
||||
|
||||
$item = $this->repository->create($data);
|
||||
|
||||
ActivityLogger::log("Created {$this->resourceName}: {$this->getItemName($item)}", $item);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => new $this->resourceClass($item),
|
||||
'message' => $this->getSuccessMessage('store'),
|
||||
], 201);
|
||||
|
||||
} catch (Exception $e) {
|
||||
Log::error("Error creating {$this->resourceName}: ".$e->getMessage());
|
||||
|
||||
return $this->errorResponse($this->getErrorMessage('store').': '.$e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified resource.
|
||||
*/
|
||||
public function show(string $id): JsonResponse
|
||||
{
|
||||
$this->authorize('view', $this->modelClass);
|
||||
|
||||
try {
|
||||
$item = $this->repository->findById($id);
|
||||
|
||||
if (! $item) {
|
||||
return $this->errorResponse($this->getNotFoundMessage(), 404);
|
||||
}
|
||||
|
||||
$item = $this->loadShowRelations($item);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => new $this->resourceClass($item),
|
||||
'message' => $this->getSuccessMessage('show'),
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
Log::error("Error fetching {$this->resourceName}: ".$e->getMessage());
|
||||
|
||||
return $this->errorResponse($this->getErrorMessage('show'), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*/
|
||||
public function update(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $this->modelClass);
|
||||
|
||||
try {
|
||||
$item = $this->repository->findById($id);
|
||||
|
||||
if (! $item) {
|
||||
return $this->errorResponse($this->getNotFoundMessage(), 404);
|
||||
}
|
||||
|
||||
$validated = $this->validateRequest($request);
|
||||
$data = $this->prepareUpdateData($validated, $item);
|
||||
|
||||
$item->update($data);
|
||||
|
||||
ActivityLogger::log("Updated {$this->resourceName}: {$this->getItemName($item)}", $item);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => new $this->resourceClass($item),
|
||||
'message' => $this->getSuccessMessage('update'),
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
Log::error("Error updating {$this->resourceName}: ".$e->getMessage());
|
||||
|
||||
return $this->errorResponse($this->getErrorMessage('update').': '.$e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Partially update the specified resource in storage (PATCH).
|
||||
*/
|
||||
public function patch(Request $request, string $id): JsonResponse
|
||||
{
|
||||
// PATCH uses the same logic as update
|
||||
return $this->update($request, $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*/
|
||||
public function destroy(string $id): JsonResponse
|
||||
{
|
||||
$this->authorize('deleteAny', $this->modelClass);
|
||||
|
||||
try {
|
||||
$item = $this->repository->findById($id);
|
||||
|
||||
if (! $item) {
|
||||
return $this->errorResponse($this->getNotFoundMessage(), 404);
|
||||
}
|
||||
|
||||
// Check for dependencies before deletion
|
||||
$dependencyCheck = $this->checkDependencies($item);
|
||||
if ($dependencyCheck !== null) {
|
||||
return $dependencyCheck;
|
||||
}
|
||||
|
||||
$this->repository->delete($id);
|
||||
|
||||
ActivityLogger::log("Deleted {$this->resourceName}: {$this->getItemName($item)}", $item);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $this->getSuccessMessage('destroy'),
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
Log::error("Error deleting {$this->resourceName}: ".$e->getMessage());
|
||||
|
||||
return $this->errorResponse($this->getErrorMessage('destroy').': '.$e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get data for index method - can be overridden by child classes
|
||||
*/
|
||||
protected function getIndexData(Request $request, int $perPage = 10, string $search = '', string $sortBy = 'id', string $sortOrder = 'asc')
|
||||
{
|
||||
// Always try paginated methods first when perPage is specified
|
||||
if ($perPage > 0) {
|
||||
if (method_exists($this->repository, 'getAllWithRelationsPaginated')) {
|
||||
return $this->repository->getAllWithRelationsPaginated($perPage, $search, $sortBy, $sortOrder);
|
||||
}
|
||||
|
||||
if (method_exists($this->repository, 'getAllPaginated')) {
|
||||
return $this->repository->getAllPaginated($perPage, $search, $sortBy, $sortOrder);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to non-paginated methods
|
||||
if (method_exists($this->repository, 'getAllWithRelations')) {
|
||||
return $this->repository->getAllWithRelations($search, $sortBy, $sortOrder);
|
||||
}
|
||||
|
||||
return $this->repository->all($search, $sortBy, $sortOrder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate request data - can be overridden by child classes
|
||||
*/
|
||||
protected function validateRequest(Request $request): array
|
||||
{
|
||||
if ($this->requestClass) {
|
||||
// For multipart/form-data, use direct validation to avoid FormRequest parsing issues
|
||||
if (str_contains($request->header('Content-Type', ''), 'multipart/form-data')) {
|
||||
return $request->validate(app($this->requestClass)->rules());
|
||||
}
|
||||
|
||||
// Create FormRequest instance with the current request
|
||||
$formRequest = $this->requestClass::createFrom($request);
|
||||
$formRequest->setContainer(app());
|
||||
$formRequest->setRedirector(app('redirect'));
|
||||
|
||||
// Validate the request
|
||||
$formRequest->validateResolved();
|
||||
|
||||
return $formRequest->validated();
|
||||
}
|
||||
|
||||
return $request->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare data for store method - can be overridden by child classes
|
||||
*/
|
||||
protected function prepareStoreData(array $validated): array
|
||||
{
|
||||
return $validated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare data for update method - can be overridden by child classes
|
||||
*/
|
||||
protected function prepareUpdateData(array $validated, $item): array
|
||||
{
|
||||
return $validated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load relations for show method - can be overridden by child classes
|
||||
*/
|
||||
protected function loadShowRelations($item)
|
||||
{
|
||||
return $item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check dependencies before deletion - can be overridden by child classes
|
||||
* Return null if deletion is allowed, or a JsonResponse if not
|
||||
*/
|
||||
protected function checkDependencies($item): ?JsonResponse
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the name of the item for logging - can be overridden by child classes
|
||||
*/
|
||||
protected function getItemName($item): string
|
||||
{
|
||||
return $item->name ?? $item->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get success message for different operations
|
||||
*/
|
||||
protected function getSuccessMessage(string $operation): string
|
||||
{
|
||||
$messages = [
|
||||
'index' => ucfirst($this->resourceNamePlural).' berjaya dimuatkan',
|
||||
'store' => ucfirst($this->resourceName).' berjaya ditambah',
|
||||
'show' => ucfirst($this->resourceName).' berjaya dimuatkan',
|
||||
'update' => ucfirst($this->resourceName).' berjaya dikemaskini',
|
||||
'destroy' => ucfirst($this->resourceName).' berjaya dipadam',
|
||||
];
|
||||
|
||||
return $messages[$operation] ?? 'Operasi berjaya';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error message for different operations
|
||||
*/
|
||||
protected function getErrorMessage(string $operation): string
|
||||
{
|
||||
$messages = [
|
||||
'index' => 'Gagal memuatkan '.$this->resourceNamePlural,
|
||||
'store' => 'Gagal menambah '.$this->resourceName,
|
||||
'show' => 'Gagal memuatkan '.$this->resourceName,
|
||||
'update' => 'Gagal mengemaskini '.$this->resourceName,
|
||||
'destroy' => 'Gagal memadam '.$this->resourceName,
|
||||
];
|
||||
|
||||
return $messages[$operation] ?? 'Operasi gagal';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get not found message
|
||||
*/
|
||||
protected function getNotFoundMessage(): string
|
||||
{
|
||||
return ucfirst($this->resourceName).' tidak dijumpai.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a standardized error response
|
||||
*/
|
||||
protected function errorResponse(string $message, int $statusCode = 400): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $message,
|
||||
], $statusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Services\ContactService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ContactController extends Controller
|
||||
{
|
||||
protected $contactService;
|
||||
|
||||
public function __construct(ContactService $contactService)
|
||||
{
|
||||
$this->contactService = $contactService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active contact persons
|
||||
*/
|
||||
public function getContacts(): JsonResponse
|
||||
{
|
||||
try {
|
||||
$contacts = $this->contactService->getActiveContacts();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $contacts,
|
||||
'message' => 'Contact persons retrieved successfully'
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to retrieve contact persons',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get contact settings for admin (including inactive)
|
||||
*/
|
||||
public function getContactSettings(): JsonResponse
|
||||
{
|
||||
try {
|
||||
$settings = $this->contactService->getAllContactSettings();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $settings,
|
||||
'message' => 'Contact settings retrieved successfully'
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to retrieve contact settings',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update contact settings (Admin only)
|
||||
*/
|
||||
public function updateContacts(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'contacts' => 'required|array',
|
||||
'contacts.*.name' => 'required|string|max:100',
|
||||
'contacts.*.position' => 'nullable|string|max:100',
|
||||
'contacts.*.email' => 'nullable|email|max:255',
|
||||
'contacts.*.phone' => 'nullable|string|max:50',
|
||||
'contacts.*.department' => 'nullable|string|max:100',
|
||||
'contacts.*.is_active' => 'boolean',
|
||||
'contacts.*.sort_order' => 'integer|min:0'
|
||||
]);
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
$result = $this->contactService->updateContactSettings($request->contacts);
|
||||
|
||||
DB::commit();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
'message' => 'Contact settings updated successfully'
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to update contact settings',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new contact person (Admin only)
|
||||
*/
|
||||
public function addContact(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'position' => 'nullable|string|max:100',
|
||||
'email' => 'nullable|email|max:255',
|
||||
'phone' => 'nullable|string|max:50',
|
||||
'department' => 'nullable|string|max:100',
|
||||
'is_active' => 'boolean',
|
||||
'sort_order' => 'integer|min:0'
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = $this->contactService->addContactPerson($request->all());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
'message' => 'Contact person added successfully'
|
||||
], 201);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to add contact person',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a specific contact person (Admin only)
|
||||
*/
|
||||
public function updateContact(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'position' => 'nullable|string|max:100',
|
||||
'email' => 'nullable|email|max:255',
|
||||
'phone' => 'nullable|string|max:50',
|
||||
'department' => 'nullable|string|max:100',
|
||||
'is_active' => 'boolean',
|
||||
'sort_order' => 'integer|min:0'
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = $this->contactService->updateContactPerson($id, $request->all());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
'message' => 'Contact person updated successfully'
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to update contact person',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a contact person (Admin only)
|
||||
*/
|
||||
public function deleteContact(int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$result = $this->contactService->deleteContactPerson($id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
'message' => 'Contact person deleted successfully'
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to delete contact person',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle active status of a contact person (Admin only)
|
||||
*/
|
||||
public function toggleContact(int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$result = $this->contactService->toggleContactPerson($id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
'message' => 'Contact person status toggled successfully'
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to toggle contact person status',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Country;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class CountryController extends Controller
|
||||
{
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$countries = Country::all();
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $countries,
|
||||
'message' => 'Countries fetched successfully',
|
||||
]);
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $countries,
|
||||
'message' => 'Countries fetched successfully',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\PersonalAccessToken;
|
||||
use App\Services\ActiveRoleService;
|
||||
use App\Support\AuthCookie;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Auth\Entities\User;
|
||||
use Modules\Auth\Transformers\UserResource;
|
||||
|
||||
class ImpersonateController extends Controller
|
||||
{
|
||||
private const IMPERSONATION_TOKEN = 'impersonation-token';
|
||||
|
||||
private const AUTH_TOKEN_NAMES = ['authToken', 'auth-token'];
|
||||
|
||||
public function take(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$target = User::findOrFail($id);
|
||||
$admin = $request->user();
|
||||
|
||||
if (! $admin->can('impersonate.user', $target)) {
|
||||
return $this->error('Anda tidak mempunyai keizinan untuk menyamar pengguna.', 403);
|
||||
}
|
||||
|
||||
if ($request->hasCookie(AuthCookie::originalUserCookieName())) {
|
||||
return $this->error('Anda sudah menyamar sebagai pengguna.', 400);
|
||||
}
|
||||
|
||||
$issued = $this->issueToken($target, self::IMPERSONATION_TOKEN);
|
||||
|
||||
return AuthCookie::attachAuthToken(
|
||||
response()->json([
|
||||
'success' => true,
|
||||
'message' => "Anda sekarang menyamar sebagai {$target->name}",
|
||||
'impersonated_user' => $this->userSummary($target),
|
||||
...$this->sessionPayload($target, $issued['accessToken']),
|
||||
]),
|
||||
$issued['plainTextToken']
|
||||
)->withCookie(AuthCookie::makeOriginalUserId($admin->id));
|
||||
}
|
||||
|
||||
public function leave(Request $request): JsonResponse
|
||||
{
|
||||
$originalUserId = $request->cookie(AuthCookie::originalUserCookieName());
|
||||
|
||||
if (! $originalUserId) {
|
||||
return $this->error('Maklumat pengguna asal tidak ditemui.', 400);
|
||||
}
|
||||
|
||||
$original = User::findOrFail($originalUserId);
|
||||
|
||||
$request->user()?->tokens()->where('name', self::IMPERSONATION_TOKEN)->delete();
|
||||
$original->tokens()->whereIn('name', [self::IMPERSONATION_TOKEN, ...self::AUTH_TOKEN_NAMES])->delete();
|
||||
|
||||
$issued = $this->issueToken($original, 'auth-token', expires: true);
|
||||
|
||||
$response = response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Anda telah kembali ke akaun anda',
|
||||
'original_user' => $this->userSummary($original),
|
||||
...$this->sessionPayload($original, $issued['accessToken']),
|
||||
]);
|
||||
|
||||
if (AuthCookie::shouldExposeTokenInResponse()) {
|
||||
$response->setData(array_merge($response->getData(true), [
|
||||
'original_token' => $issued['plainTextToken'],
|
||||
]));
|
||||
}
|
||||
|
||||
return AuthCookie::attachAuthToken($response, $issued['plainTextToken'])
|
||||
->withCookie(AuthCookie::forgetOriginalUserId());
|
||||
}
|
||||
|
||||
public function status(Request $request): JsonResponse
|
||||
{
|
||||
if (! $request->hasCookie(AuthCookie::originalUserCookieName())) {
|
||||
return response()->json(['is_impersonating' => false]);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
return response()->json([
|
||||
'is_impersonating' => true,
|
||||
'impersonated_user' => $user ? $this->userSummary($user) : null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{plainTextToken: string, accessToken: PersonalAccessToken}
|
||||
*/
|
||||
private function issueToken(User $user, string $name, bool $expires = false): array
|
||||
{
|
||||
$user->loadMissing(['roles.permissions']);
|
||||
|
||||
$result = $user->createToken(
|
||||
$name,
|
||||
['*'],
|
||||
$expires ? now()->addMinutes((int) config('auth_cookie.lifetime_minutes', 720)) : null
|
||||
);
|
||||
|
||||
ActiveRoleService::assignDefaultToToken($user, $result->accessToken);
|
||||
|
||||
return [
|
||||
'plainTextToken' => $result->plainTextToken,
|
||||
'accessToken' => $result->accessToken,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function sessionPayload(User $user, PersonalAccessToken $accessToken): array
|
||||
{
|
||||
$user->loadMissing(['roles.permissions']);
|
||||
|
||||
$activeRole = $user->roles->firstWhere('id', $accessToken->active_role_id)
|
||||
?? ActiveRoleService::resolveDefaultRole($user);
|
||||
|
||||
return [
|
||||
'data' => new UserResource($user),
|
||||
'active_role' => ActiveRoleService::formatRole($activeRole),
|
||||
'can_switch_role' => $user->roles->count() > 1,
|
||||
'redirect_path' => $activeRole
|
||||
? ActiveRoleService::redirectPathForRole($activeRole)
|
||||
: config('active_role.member_redirect', '/profile'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id: mixed, name: string, email: string}
|
||||
*/
|
||||
private function userSummary(User $user): array
|
||||
{
|
||||
return [
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
];
|
||||
}
|
||||
|
||||
private function error(string $message, int $status): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $message,
|
||||
], $status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\OnlineUsersService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class OnlineUsersController extends Controller
|
||||
{
|
||||
protected OnlineUsersService $onlineUsersService;
|
||||
|
||||
public function __construct(OnlineUsersService $onlineUsersService)
|
||||
{
|
||||
$this->onlineUsersService = $onlineUsersService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of online users
|
||||
*
|
||||
* @param Request $request
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$timeoutMinutes = 5; // Fixed to 5 minutes
|
||||
|
||||
$onlineUsers = $this->onlineUsersService->getOnlineUsers($timeoutMinutes);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $onlineUsers,
|
||||
'meta' => [
|
||||
'timeout_minutes' => $timeoutMinutes,
|
||||
'total_online' => $onlineUsers->count(),
|
||||
'timestamp' => now()->toISOString()
|
||||
],
|
||||
'message' => 'Online users retrieved successfully.'
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error fetching online users: ' . $e->getMessage());
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to retrieve online users.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : 'Internal server error'
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get online users count
|
||||
*
|
||||
* @param Request $request
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function count(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$timeoutMinutes = 5; // Fixed to 5 minutes
|
||||
|
||||
$count = $this->onlineUsersService->getOnlineUsersCount($timeoutMinutes);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'count' => $count,
|
||||
'timeout_minutes' => $timeoutMinutes
|
||||
],
|
||||
'message' => 'Online users count retrieved successfully.'
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error fetching online users count: ' . $e->getMessage());
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to retrieve online users count.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : 'Internal server error'
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get online users statistics
|
||||
*
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function stats(): JsonResponse
|
||||
{
|
||||
try {
|
||||
$stats = $this->onlineUsersService->getOnlineUsersStats();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $stats,
|
||||
'message' => 'Online users statistics retrieved successfully.'
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error fetching online users stats: ' . $e->getMessage());
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to retrieve online users statistics.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : 'Internal server error'
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user's session information
|
||||
*
|
||||
* @param Request $request
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function mySession(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$user = $request->user();
|
||||
|
||||
if (!$user) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'User not authenticated.'
|
||||
], 401);
|
||||
}
|
||||
|
||||
$sessionInfo = $this->onlineUsersService->getUserSessionInfo($user->id);
|
||||
|
||||
if (!$sessionInfo) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'No active session found.'
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $sessionInfo,
|
||||
'message' => 'Session information retrieved successfully.'
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error fetching user session info: ' . $e->getMessage());
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to retrieve session information.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : 'Internal server error'
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear online users cache (admin only)
|
||||
*
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function clearCache(): JsonResponse
|
||||
{
|
||||
try {
|
||||
$this->onlineUsersService->clearCache();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Online users cache cleared successfully.'
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error clearing online users cache: ' . $e->getMessage());
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to clear cache.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : 'Internal server error'
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Services\SocialMediaService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class SocialMediaController extends Controller
|
||||
{
|
||||
protected $socialMediaService;
|
||||
|
||||
public function __construct(SocialMediaService $socialMediaService)
|
||||
{
|
||||
$this->socialMediaService = $socialMediaService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active social media platforms
|
||||
*/
|
||||
public function getSocialMedia(): JsonResponse
|
||||
{
|
||||
try {
|
||||
$socialMedia = $this->socialMediaService->getActiveSocialMedia();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $socialMedia,
|
||||
'message' => 'Social media platforms retrieved successfully'
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to retrieve social media platforms',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update social media settings (Admin only)
|
||||
*/
|
||||
public function updateSocialMedia(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'social_media' => 'required|array',
|
||||
'social_media.*.platform' => 'required|string|max:50',
|
||||
'social_media.*.name' => 'required|string|max:100',
|
||||
'social_media.*.url' => 'required|url|max:255',
|
||||
'social_media.*.icon' => 'nullable|string|max:100',
|
||||
'social_media.*.is_active' => 'boolean',
|
||||
'social_media.*.sort_order' => 'integer|min:0'
|
||||
]);
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
$result = $this->socialMediaService->updateSocialMediaSettings($request->social_media);
|
||||
|
||||
DB::commit();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
'message' => 'Social media settings updated successfully'
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to update social media settings',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get social media settings for admin (including inactive)
|
||||
*/
|
||||
public function getSocialMediaSettings(): JsonResponse
|
||||
{
|
||||
try {
|
||||
$settings = $this->socialMediaService->getAllSocialMediaSettings();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $settings,
|
||||
'message' => 'Social media settings retrieved successfully'
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to retrieve social media settings',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new social media platform (Admin only)
|
||||
*/
|
||||
public function addSocialMedia(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'platform' => 'required|string|max:50',
|
||||
'name' => 'required|string|max:100',
|
||||
'url' => 'required|url|max:255',
|
||||
'icon' => 'nullable|string|max:100',
|
||||
'is_active' => 'boolean',
|
||||
'sort_order' => 'integer|min:0'
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = $this->socialMediaService->addSocialMediaPlatform($request->all());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
'message' => 'Social media platform added successfully'
|
||||
], 201);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to add social media platform',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a specific social media platform (Admin only)
|
||||
*/
|
||||
public function updateSocialMediaPlatform(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'platform' => 'required|string|max:50',
|
||||
'name' => 'required|string|max:100',
|
||||
'url' => 'required|url|max:255',
|
||||
'icon' => 'nullable|string|max:100',
|
||||
'is_active' => 'boolean',
|
||||
'sort_order' => 'integer|min:0'
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = $this->socialMediaService->updateSocialMediaPlatform($id, $request->all());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
'message' => 'Social media platform updated successfully'
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to update social media platform',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a social media platform (Admin only)
|
||||
*/
|
||||
public function deleteSocialMediaPlatform(int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$result = $this->socialMediaService->deleteSocialMediaPlatform($id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
'message' => 'Social media platform deleted successfully'
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to delete social media platform',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle active status of a social media platform (Admin only)
|
||||
*/
|
||||
public function toggleSocialMediaPlatform(int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$result = $this->socialMediaService->toggleSocialMediaPlatform($id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
'message' => 'Social media platform status toggled successfully'
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to toggle social media platform status',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ApiKeyAuthenticationMiddleware
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
// Check if API key authentication is enabled
|
||||
if (!config('api_security.enable_api_key_auth', true)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Get API key from request
|
||||
$apiKey = $this->extractApiKey($request);
|
||||
|
||||
if (!$apiKey) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'API key is required for external system access.',
|
||||
'error' => 'API_KEY_REQUIRED'
|
||||
], 401);
|
||||
}
|
||||
|
||||
// Validate API key
|
||||
if (!$this->isValidApiKey($apiKey)) {
|
||||
// Log invalid API key attempt
|
||||
if (config('api_security.log_api_key_usage', true)) {
|
||||
Log::warning('Invalid API key attempt', [
|
||||
'ip' => $request->ip(),
|
||||
'user_agent' => $request->header('User-Agent'),
|
||||
'endpoint' => $request->fullUrl(),
|
||||
'method' => $request->method(),
|
||||
'api_key_prefix' => substr($apiKey, 0, 8) . '...',
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Invalid API key provided.',
|
||||
'error' => 'API_KEY_INVALID'
|
||||
], 401);
|
||||
}
|
||||
|
||||
// Log successful API key usage
|
||||
if (config('api_security.log_api_key_usage', true)) {
|
||||
Log::info('API key authenticated', [
|
||||
'ip' => $request->ip(),
|
||||
'user_agent' => $request->header('User-Agent'),
|
||||
'endpoint' => $request->fullUrl(),
|
||||
'method' => $request->method(),
|
||||
'api_key_prefix' => substr($apiKey, 0, 8) . '...',
|
||||
]);
|
||||
}
|
||||
|
||||
// Add API key info to request for downstream use
|
||||
$request->merge(['_api_key' => $apiKey]);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract API key from request headers
|
||||
*/
|
||||
private function extractApiKey(Request $request): ?string
|
||||
{
|
||||
// Check multiple header formats
|
||||
$apiKeyHeaders = config('api_security.api_key_headers', [
|
||||
'X-API-Key',
|
||||
'API-Key',
|
||||
'Authorization'
|
||||
]);
|
||||
|
||||
foreach ($apiKeyHeaders as $header) {
|
||||
$value = $request->header($header);
|
||||
|
||||
if ($value) {
|
||||
// Handle Bearer token format
|
||||
if ($header === 'Authorization' && preg_match('/Bearer\s+(.*)$/i', $value, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
// Direct API key
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate API key against configured valid keys
|
||||
*/
|
||||
private function isValidApiKey(string $apiKey): bool
|
||||
{
|
||||
$validApiKeys = config('api_security.valid_api_keys', []);
|
||||
|
||||
if (empty($validApiKeys)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($apiKey, $validApiKeys);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Support\AuthCookie;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AuthenticateFromCookie
|
||||
{
|
||||
/**
|
||||
* Promote HttpOnly auth cookie to Authorization header for Sanctum.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! $request->bearerToken()) {
|
||||
$token = $request->cookie(AuthCookie::name());
|
||||
|
||||
if (is_string($token) && $token !== '') {
|
||||
$request->headers->set('Authorization', 'Bearer '.$token);
|
||||
}
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class BlockApiToolsMiddleware
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
// Check if API tool blocking is enabled
|
||||
if (!config('api_security.block_api_tools_in_production')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Skip blocking for external API routes - they use API key authentication
|
||||
if ($request->is('api/external*')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Only block in production environment
|
||||
if (app()->environment('production')) {
|
||||
$userAgent = strtolower($request->header('User-Agent', ''));
|
||||
$clientIp = $request->ip();
|
||||
|
||||
// Check if request has valid API key - bypass blocking for external systems
|
||||
if ($this->hasValidApiKey($request)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Check if IP is in allowed list
|
||||
$allowedIps = config('api_security.allowed_ips', []);
|
||||
if (!empty($allowedIps) && in_array($clientIp, $allowedIps)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Check if User-Agent is in allowed list
|
||||
$allowedUserAgents = config('api_security.allowed_user_agents', []);
|
||||
foreach ($allowedUserAgents as $allowedAgent) {
|
||||
if (str_contains($userAgent, strtolower($allowedAgent))) {
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the User-Agent matches any blocked patterns
|
||||
$blockedUserAgents = config('api_security.blocked_user_agents', []);
|
||||
foreach ($blockedUserAgents as $blockedAgent) {
|
||||
if (str_contains($userAgent, $blockedAgent)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => config('api_security.blocked_message', 'API access is restricted in production environment. Please use the web interface.'),
|
||||
'error' => 'API_TOOLS_BLOCKED'
|
||||
], 403);
|
||||
}
|
||||
}
|
||||
|
||||
// Additional check for requests without proper browser User-Agent
|
||||
// This catches tools that might not be in our list but don't look like browsers
|
||||
if ($this->isSuspiciousUserAgent($userAgent)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => config('api_security.blocked_message', 'API access is restricted in production environment. Please use the web interface.'),
|
||||
'error' => 'API_TOOLS_BLOCKED'
|
||||
], 403);
|
||||
}
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the User-Agent looks suspicious (not a real browser)
|
||||
*/
|
||||
private function isSuspiciousUserAgent(string $userAgent): bool
|
||||
{
|
||||
$minLength = config('api_security.min_user_agent_length', 10);
|
||||
|
||||
// If User-Agent is empty or very short, it's suspicious
|
||||
if (empty($userAgent) || strlen($userAgent) < $minLength) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for common browser patterns
|
||||
$browserPatterns = [
|
||||
'mozilla',
|
||||
'chrome',
|
||||
'safari',
|
||||
'firefox',
|
||||
'edge',
|
||||
'opera',
|
||||
'webkit',
|
||||
'gecko',
|
||||
'trident',
|
||||
'msie',
|
||||
];
|
||||
|
||||
$hasBrowserPattern = false;
|
||||
foreach ($browserPatterns as $pattern) {
|
||||
if (str_contains($userAgent, $pattern)) {
|
||||
$hasBrowserPattern = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If no browser pattern is found, it's likely an API tool
|
||||
return !$hasBrowserPattern;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the request has a valid API key for external system access
|
||||
*/
|
||||
private function hasValidApiKey(Request $request): bool
|
||||
{
|
||||
// Check for API key in headers (X-API-Key, Authorization Bearer, or API-Key)
|
||||
$apiKey = $request->header('X-API-Key')
|
||||
?? $request->header('API-Key')
|
||||
?? $this->extractBearerToken($request->header('Authorization'));
|
||||
|
||||
if (!$apiKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get valid API keys from configuration
|
||||
$validApiKeys = config('api_security.valid_api_keys', []);
|
||||
|
||||
// If no API keys configured, return false
|
||||
if (empty($validApiKeys)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the provided API key is valid
|
||||
return in_array($apiKey, $validApiKeys);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract Bearer token from Authorization header
|
||||
*/
|
||||
private function extractBearerToken(?string $authorization): ?string
|
||||
{
|
||||
if (!$authorization) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/Bearer\s+(.*)$/i', $authorization, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Support\AuthCookie;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Laravel\Sanctum\PersonalAccessToken;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Modules\Auth\Entities\User;
|
||||
|
||||
class SingleSessionMiddleware
|
||||
{
|
||||
/**
|
||||
* Cache TTL for token processing lock (seconds)
|
||||
*/
|
||||
private const PROCESSING_LOCK_TTL = 5;
|
||||
|
||||
/**
|
||||
* Cache TTL for token name cache (seconds)
|
||||
*/
|
||||
private const TOKEN_NAME_CACHE_TTL = 60;
|
||||
|
||||
/**
|
||||
* Fresh token age threshold (seconds)
|
||||
*/
|
||||
private const FRESH_TOKEN_AGE = 30;
|
||||
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($user) {
|
||||
try {
|
||||
$this->enforceSingleSession($user, $request);
|
||||
} catch (\Throwable $e) {
|
||||
// Log error but don't block the request
|
||||
Log::error('SingleSessionMiddleware error', [
|
||||
'user_id' => $user->id,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce single session per user
|
||||
*/
|
||||
private function enforceSingleSession(User $user, Request $request): void
|
||||
{
|
||||
if (app()->environment('local')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip single session enforcement during impersonation
|
||||
if ($this->isImpersonationRequest($request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip single session enforcement for post-impersonation requests
|
||||
if ($this->isPostImpersonationRequest($request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current token from request (Bearer header or HttpOnly cookie)
|
||||
$currentToken = $this->resolveToken($request);
|
||||
if (!$currentToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse the token to get the token ID and hash
|
||||
$tokenParts = explode('|', $currentToken);
|
||||
if (count($tokenParts) !== 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tokenId = $tokenParts[0];
|
||||
$tokenHash = $tokenParts[1];
|
||||
|
||||
// Find current token record with caching
|
||||
$currentTokenRecord = $this->getTokenRecord($tokenId, $tokenHash);
|
||||
|
||||
if (!$currentTokenRecord) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is an impersonation token - skip enforcement
|
||||
if ($currentTokenRecord->name === 'impersonation-token') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip enforcement for fresh login tokens (SSO login or fresh auth-token)
|
||||
if ($this->isFreshLoginToken($currentTokenRecord, $user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use atomic cache lock per user to prevent race conditions
|
||||
$lockKey = "single_session_user_{$user->id}";
|
||||
$lock = Cache::lock($lockKey, self::PROCESSING_LOCK_TTL);
|
||||
|
||||
if (!$lock->get()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get all active tokens for this user (excluding impersonation), ordered by newest first
|
||||
$activeTokens = PersonalAccessToken::where('tokenable_type', get_class($user))
|
||||
->where('tokenable_id', $user->id)
|
||||
->where('name', '!=', 'impersonation-token')
|
||||
->where(function ($query) {
|
||||
$query->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', now());
|
||||
})
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('id')
|
||||
->get();
|
||||
|
||||
// Keep only the most recently created token (latest login); revoke all others
|
||||
if ($activeTokens->count() > 1) {
|
||||
$latestToken = $activeTokens->first();
|
||||
$idsToDelete = $activeTokens->where('id', '!=', $latestToken->id)->pluck('id');
|
||||
|
||||
PersonalAccessToken::whereIn('id', $idsToDelete)->delete();
|
||||
|
||||
$this->notifyConcurrentSession($user, $request);
|
||||
}
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token record with caching
|
||||
*/
|
||||
private function getTokenRecord(string $tokenId, string $tokenHash): ?PersonalAccessToken
|
||||
{
|
||||
$cacheKey = "token_record_{$tokenId}";
|
||||
|
||||
return Cache::remember($cacheKey, self::TOKEN_NAME_CACHE_TTL, function () use ($tokenId, $tokenHash) {
|
||||
return PersonalAccessToken::where('id', $tokenId)
|
||||
->where('token', hash('sha256', $tokenHash))
|
||||
->first();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is an impersonation-related request
|
||||
*/
|
||||
private function isImpersonationRequest(Request $request): bool
|
||||
{
|
||||
$path = $request->path();
|
||||
|
||||
// Check if the request is to impersonation endpoints
|
||||
if (str_contains($path, 'impersonate')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if there's an impersonation header
|
||||
if ($request->hasHeader('X-Original-User-Id')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if the request has impersonation token
|
||||
$currentToken = $this->resolveToken($request);
|
||||
if ($currentToken) {
|
||||
$tokenParts = explode('|', $currentToken);
|
||||
if (count($tokenParts) === 2) {
|
||||
$tokenId = $tokenParts[0];
|
||||
$tokenHash = $tokenParts[1];
|
||||
|
||||
// Use cached token record to avoid duplicate queries
|
||||
$tokenRecord = $this->getTokenRecord($tokenId, $tokenHash);
|
||||
|
||||
if ($tokenRecord && $tokenRecord->name === 'impersonation-token') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a post-impersonation request (after leaving impersonation)
|
||||
*/
|
||||
private function isPostImpersonationRequest(Request $request): bool
|
||||
{
|
||||
$path = $request->path();
|
||||
|
||||
// Check if this is a request after leaving impersonation
|
||||
// Look for requests that have X-Original-User-Id header but are not impersonation endpoints
|
||||
if ($request->hasHeader('X-Original-User-Id') && !str_contains($path, 'impersonate')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current token is a fresh login token (SSO login or post-impersonation)
|
||||
* This prevents false positives when a user legitimately logs in
|
||||
*/
|
||||
private function isFreshLoginToken(PersonalAccessToken $currentTokenRecord, User $user): bool
|
||||
{
|
||||
// Check if token was created recently (within threshold)
|
||||
$tokenAge = $currentTokenRecord->created_at->diffInSeconds(now());
|
||||
|
||||
if ($tokenAge > self::FRESH_TOKEN_AGE) {
|
||||
return false; // Token is not fresh
|
||||
}
|
||||
|
||||
// For SSO tokens, if they're fresh, skip enforcement (legitimate login)
|
||||
if ($currentTokenRecord->name === 'sso-token') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for both 'authToken' (camelCase - regular login) and 'auth-token' (kebab-case - post-impersonation)
|
||||
$isAuthToken = in_array($currentTokenRecord->name, ['authToken', 'auth-token'], true);
|
||||
|
||||
if ($isAuthToken) {
|
||||
// Check if there are any other auth tokens for this user that were created before this one
|
||||
// When logging out properly, all tokens should be deleted, so if there are no older
|
||||
// auth tokens, this is likely a fresh login or post-impersonation
|
||||
$olderAuthTokens = PersonalAccessToken::where('tokenable_type', get_class($user))
|
||||
->where('tokenable_id', $user->id)
|
||||
->where('id', '!=', $currentTokenRecord->id)
|
||||
->whereIn('name', ['authToken', 'auth-token']) // Check for both naming conventions
|
||||
->where('created_at', '<', $currentTokenRecord->created_at)
|
||||
->where(function ($query) {
|
||||
$query->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', now());
|
||||
})
|
||||
->exists(); // Use exists() instead of count() for better performance
|
||||
|
||||
// If there are no older auth tokens, this is a fresh login or post-impersonation
|
||||
if (!$olderAuthTokens) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify user about concurrent session
|
||||
*/
|
||||
private function notifyConcurrentSession(User $user, Request $request): void
|
||||
{
|
||||
// Notification creation removed per user request
|
||||
// Concurrent session detection still works, but no notification is created
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve Sanctum plain-text token from Authorization header or auth cookie.
|
||||
*/
|
||||
private function resolveToken(Request $request): ?string
|
||||
{
|
||||
$token = $request->bearerToken();
|
||||
|
||||
if (is_string($token) && $token !== '') {
|
||||
return $token;
|
||||
}
|
||||
|
||||
$cookieToken = $request->cookie(AuthCookie::name());
|
||||
|
||||
return is_string($cookieToken) && $cookieToken !== '' ? $cookieToken : null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user