feat: harden admin auth and ticket workflows

- add rotating admin refresh tokens and password gates\n- expand ticket galleries, exports, and client resources\n- improve IP allocation auditing and dashboard data\n- enforce encrypted client access tokens on public routes
This commit is contained in:
baodev0
2026-10-16 17:00:55 +07:00
parent 0cdf224309
commit 0b08689ea4
52 changed files with 1421 additions and 118 deletions
@@ -6,27 +6,36 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\UpdateProfileRequest;
use App\Http\Requests\UpdateAdminPasswordRequest;
use App\Http\Resources\Api\Admin\AdminUserResource;
use App\Services\Api\Admin\AdminRefreshTokenService;
use App\Services\ActivityLogger;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\Cookie;
use Tymon\JWTAuth\Facades\JWTAuth;
use Tymon\JWTAuth\Exceptions\JWTException;
class AuthController extends Controller
{
private const LOGIN_MAX_ATTEMPTS = 5;
private const LOGIN_DECAY_SECONDS = 60;
private const REMEMBER_TTL_SECONDS = 2592000;
private const REFRESH_COOKIE = 'admin_refresh_token';
private const REFRESH_COOKIE_PATH = '/api/v1/administrator';
public function __construct(private readonly ActivityLogger $activityLogger) {}
public function __construct(
private readonly ActivityLogger $activityLogger,
private readonly AdminRefreshTokenService $refreshTokens,
) {}
public function login(Request $request): JsonResponse
{
$request->validate([
'username' => 'required|string',
'password' => 'required|string',
'remember' => 'sometimes|boolean',
]);
$throttleKey = 'admin-login:' . strtolower($request->string('username')->toString()) . '|' . $request->ip();
@@ -38,7 +47,10 @@ class AuthController extends Controller
], 429);
}
$token = Auth::guard('admin')->attempt($request->only('username', 'password'));
$guard = Auth::guard('admin');
$remember = $request->boolean('remember');
$credentials = $request->only('username', 'password');
$token = $guard->attempt($credentials);
if (!$token) {
RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS);
@@ -61,15 +73,26 @@ class AuthController extends Controller
$this->activityLogger->logAuth('login', 'admin', $user->id, $user->username ?? $user->email);
return response()->json([
$data = [
'token' => $token,
'token_type' => 'bearer',
'expires_in' => config('jwt.ttl') * 60,
'user' => new AdminUserResource($user),
];
if ($remember) {
$refreshToken = $this->refreshTokens->create($user->id, now()->addDays(30));
$data['refresh_expires_in'] = self::REMEMBER_TTL_SECONDS;
}
$response = response()->json([
'error' => false,
'data' => [
'token' => $token,
'token_type' => 'bearer',
'expires_in' => config('jwt.ttl') * 60,
'user' => new AdminUserResource($user),
],
'data' => $data,
]);
return $remember
? $response->withCookie($this->refreshCookie($refreshToken, self::REMEMBER_TTL_SECONDS))
: $response->withCookie($this->forgetRefreshCookie());
}
public function me(): JsonResponse
@@ -116,31 +139,55 @@ class AuthController extends Controller
]);
}
public function refresh(): JsonResponse
public function refresh(Request $request): JsonResponse
{
try {
$newToken = JWTAuth::parseToken()->refresh();
$user = JWTAuth::setToken($newToken)->toUser();
return response()->json([
'error' => false,
'data' => [
'token' => $newToken,
'token_type' => 'bearer',
'expires_in' => config('jwt.ttl') * 60,
'user' => new AdminUserResource($user),
],
]);
} catch (JWTException) {
if (! $request->ajax()) {
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);
'message' => 'Yêu cầu làm mới phiên không hợp lệ.',
], 403);
}
$request->validate([
'refresh_token' => ['prohibited'],
]);
$refreshToken = $request->cookie(self::REFRESH_COOKIE);
if (! is_string($refreshToken) || $refreshToken === '') {
return response()->json([
'error' => true,
'message' => 'Phiên ghi nhớ đăng nhập không tồn tại.',
], 401)->withCookie($this->forgetRefreshCookie());
}
try {
$result = $this->refreshTokens->rotate($refreshToken);
} catch (ValidationException) {
return response()->json([
'error' => true,
'message' => 'Refresh token không hợp lệ hoặc đã hết hạn.',
], 401)->withCookie($this->forgetRefreshCookie());
}
return response()->json([
'error' => false,
'data' => [
'token' => $result['token'],
'token_type' => 'bearer',
'expires_in' => config('jwt.ttl') * 60,
'refresh_expires_in' => $result['refresh_expires_in'],
'user' => new AdminUserResource($result['user']),
],
])->withCookie($this->refreshCookie($result['refresh_token'], $result['refresh_expires_in']));
}
public function logout(): JsonResponse
{
$refreshToken = request()->cookie(self::REFRESH_COOKIE);
if (is_string($refreshToken) && $refreshToken !== '') {
$this->refreshTokens->revoke($refreshToken);
}
$user = Auth::guard('admin')->user();
if ($user) {
$this->activityLogger->logAuth('logout', 'admin', $user->id, $user->username ?? $user->email);
@@ -151,6 +198,26 @@ class AuthController extends Controller
return response()->json([
'error' => false,
'message' => 'Đã đăng xuất thành công.',
]);
])->withCookie($this->forgetRefreshCookie());
}
private function refreshCookie(string $token, int $maxAgeSeconds): Cookie
{
return cookie(
self::REFRESH_COOKIE,
$token,
(int) ceil($maxAgeSeconds / 60),
self::REFRESH_COOKIE_PATH,
null,
true,
true,
false,
app()->isLocal() ? 'none' : 'strict',
);
}
private function forgetRefreshCookie(): Cookie
{
return cookie()->forget(self::REFRESH_COOKIE, self::REFRESH_COOKIE_PATH);
}
}
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Admin\IpPool\Concerns;
use App\Models\Customer;
use App\Models\IpPool;
use App\Models\IpPoolLog;
use App\Services\IpPool\IpAllocationService;
use App\Support\MailAttachments;
use Illuminate\Http\JsonResponse;
@@ -55,6 +56,45 @@ trait ManagesIpPoolAllocation
]);
}
public function allLogs(Request $request): JsonResponse
{
$data = $request->validate([
'filter' => 'nullable|string|max:255',
'action' => 'nullable|array',
'action.*' => 'in:' . implode(',', [IpPoolLog::ALLOCATED, IpPoolLog::REVOKED, IpPoolLog::VM_ASSIGNED, IpPoolLog::VM_RELEASED]),
'date_from' => 'nullable|date',
'date_to' => 'nullable|date',
'per_page' => 'nullable|integer|min:1|max:100',
]);
$paginator = IpPoolLog::with('user:id,username,fullname')
->when($data['filter'] ?? null, function ($query, string $filter) {
$like = '%' . addcslashes($filter, '%_\\') . '%';
$query->where(fn ($q) => $q->where('ip_address', 'like', $like)
->orWhere('customer_name', 'like', $like)
->orWhere('service_name', 'like', $like)
->orWhere('vm_name', 'like', $like)
->orWhere('email_to', 'like', $like)
->orWhere('email_cc', 'like', $like)
->orWhere('email_bcc', 'like', $like));
})
->when($data['action'] ?? null, fn ($query, array $actions) => $query->whereIn('action', $actions))
->when($data['date_from'] ?? null, fn ($query, string $from) => $query->whereDate('created_at', '>=', $from))
->when($data['date_to'] ?? null, fn ($query, string $to) => $query->whereDate('created_at', '<=', $to))
->latest('id')
->paginate($data['per_page'] ?? 20);
return response()->json([
'error' => false,
'data' => [
'items' => $paginator->items(),
'total' => $paginator->total(),
'per_page' => $paginator->perPage(),
'current_page' => $paginator->currentPage(),
],
]);
}
private function validateAllocation(Request $request): array
{
return $request->validate([
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Admin\IpPool\Concerns;
use App\Models\IpPool;
use App\Models\ModuleNetwork;
use App\Services\IpPool\IpPoolDashboard;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
@@ -33,6 +34,11 @@ trait ReadsIpPoolUsage
return response()->json(['error' => false, 'data' => $used]);
}
public function dashboard(IpPoolDashboard $dashboard): JsonResponse
{
return response()->json(['error' => false, 'data' => $dashboard->build()]);
}
public function networks(): JsonResponse
{
$networks = ModuleNetwork::select(['id', 'name', 'type', 'network', 'module_host_id'])
@@ -15,12 +15,36 @@ use App\Support\Turnstile;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
class SettingController extends Controller
{
public function verifyPassword(Request $request): JsonResponse
{
$data = $request->validate([
'password' => ['required', 'string'],
], [
'password.required' => 'Vui lòng nhập mật khẩu hiện tại.',
]);
$user = $request->user('admin');
if (!$user || !Hash::check($data['password'], $user->password)) {
return response()->json([
'error' => true,
'message' => 'Mật khẩu hiện tại không chính xác.',
], 422);
}
return response()->json([
'error' => false,
'message' => 'Xác thực thành công.',
]);
}
/**
* GET /administrator/settings
* Returns all setting keys: setting, smtp.
@@ -2,6 +2,8 @@
namespace App\Http\Controllers\Api\Admin\Ticket;
use App\Exports\BrandedReportWriter;
use App\Exports\TicketsExport;
use App\Http\Controllers\Controller;
use App\Events\TicketReplyCreated;
use App\Http\Resources\Api\Ticket\TicketResource;
@@ -9,7 +11,10 @@ use App\Http\Resources\Api\Ticket\TicketReplyResource;
use App\Models\Notification;
use App\Models\Ticket;
use App\Repositories\TicketReply\TicketReplyRepositoryInterface;
use App\Support\TicketGallery;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class TicketController extends Controller
{
@@ -17,29 +22,19 @@ class TicketController extends Controller
protected TicketReplyRepositoryInterface $ticketReplyRepository,
) {}
private const FILTER_RULES = [
'status' => ['nullable', 'array'],
'status.*' => ['string', 'in:open,pending,resolved,closed'],
'room' => ['nullable', 'string', 'max:50'],
'customer_id' => ['nullable', 'integer'],
'date_from' => ['nullable', 'date'],
'date_to' => ['nullable', 'date'],
'search' => ['nullable', 'string', 'max:255'],
];
public function index(Request $request)
{
$filters = $request->validate([
'status' => ['nullable', 'array'],
'status.*' => ['string', 'in:open,pending,resolved,closed'],
'room' => ['nullable', 'string', 'max:50'],
'customer_id' => ['nullable', 'integer'],
'date_from' => ['nullable', 'date'],
'date_to' => ['nullable', 'date'],
'search' => ['nullable', 'string', 'max:255'],
]);
$query = Ticket::query()
->with(['customer'])
->latest('id')
->when($filters['status'] ?? null, fn ($q, $status) => $q->whereIn('status', $status))
->when($filters['room'] ?? null, fn ($q, $room) => $q->where('room', $room))
->when($filters['customer_id'] ?? null, fn ($q, $id) => $q->where('customer_id', $id))
->when($filters['date_from'] ?? null, fn ($q, $from) => $q->whereDate('created_at', '>=', $from))
->when($filters['date_to'] ?? null, fn ($q, $to) => $q->whereDate('created_at', '<=', $to))
->when($filters['search'] ?? null, fn ($q, $search) => $q->where(
fn ($q) => $q->where('subject', 'like', "%{$search}%")->orWhere('ticket_num', 'like', "%{$search}%")
));
$query = $this->filteredQuery($request->validate(self::FILTER_RULES))->with(['customer']);
$paginated = $query->paginate($request->input('per_page', 15));
@@ -58,6 +53,39 @@ class TicketController extends Controller
/**
* Show a ticket with its replies and customer info.
*/
public function export(Request $request, BrandedReportWriter $writer): BinaryFileResponse
{
$filters = $request->validate(self::FILTER_RULES + ['filter' => ['nullable', 'string', 'max:255']]);
$filters['search'] ??= $filters['filter'] ?? null;
$query = $this->filteredQuery($filters)->with(['customer'])->withCount('reply');
return $writer->download(new TicketsExport($query), 'ticket-' . now()->format('Y-m-d-His') . '.xlsx');
}
private function filteredQuery(array $filters): Builder
{
return Ticket::query()
->latest('id')
->when($filters['status'] ?? null, fn ($q, $status) => $q->whereIn('status', $status))
->when($filters['room'] ?? null, fn ($q, $room) => $q->where('room', $room))
->when($filters['customer_id'] ?? null, fn ($q, $id) => $q->where('customer_id', $id))
->when($filters['date_from'] ?? null, fn ($q, $from) => $q->whereDate('created_at', '>=', $from))
->when($filters['date_to'] ?? null, fn ($q, $to) => $q->whereDate('created_at', '<=', $to))
->when($filters['search'] ?? null, function ($q, $search) {
$keyword = '%' . addcslashes($search, '%_\\') . '%';
$q->where(fn ($q) => $q->where('subject', 'like', $keyword)->orWhere('ticket_num', 'like', $keyword));
});
}
public function pendingCount()
{
return response()->json([
'error' => false,
'data' => ['count' => Ticket::whereIn('status', Ticket::UNRESOLVED_STATUSES)->count()],
]);
}
public function show(string $ticket_num)
{
$ticket = Ticket::query()
@@ -74,7 +102,7 @@ class TicketController extends Controller
return response()->json([
'error' => false,
'data' => new TicketResource($ticket),
'data' => [...(new TicketResource($ticket))->resolve(), 'status_history' => $ticket->statusHistory()],
]);
}
@@ -162,4 +190,9 @@ class TicketController extends Controller
'data' => new TicketReplyResource($reply),
], 201);
}
public function image(string $ticket, int $index)
{
return TicketGallery::response(Ticket::where('ticket_num', $ticket)->firstOrFail(), $index);
}
}
@@ -3,8 +3,8 @@
namespace App\Http\Controllers\Api\Client\Ticket;
use App\Http\Controllers\Controller;
use App\Http\Resources\Api\Ticket\TicketResource;
use App\Http\Resources\Api\Ticket\TicketReplyResource;
use App\Http\Resources\Api\Client\Ticket\ClientTicketReplyResource;
use App\Http\Resources\Api\Client\Ticket\ClientTicketResource;
use App\Jobs\Ticket\TicketJobs;
use App\Mail\TicketCreated;
use App\Mail\Admin\TicketAdminNotification;
@@ -14,9 +14,9 @@ use App\Models\Notification;
use App\Events\TicketReplyCreated;
use App\Repositories\Ticket\TicketRepositoryInterface;
use App\Repositories\TicketReply\TicketReplyRepositoryInterface;
use App\Support\TicketGallery;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class TicketController extends Controller
@@ -26,27 +26,38 @@ class TicketController extends Controller
protected TicketReplyRepositoryInterface $ticketReplyRepository,
) {}
/**
* List tickets for the authenticated customer (paginated, filterable by status).
*/
public function index(Request $request)
{
$filters = $request->validate([
'status' => ['nullable', 'string', 'max:20'],
'room' => ['nullable', 'string', 'max:50'],
'search' => ['nullable', 'string', 'max:100'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:50'],
]);
$customer = auth('client')->user();
$query = Ticket::query()
->where('customer_id', $customer->id)
->latest('id');
if ($request->filled('status')) {
$query->where('status', $request->input('status'));
foreach (['status', 'room'] as $column) {
if (filled($filters[$column] ?? null)) {
$query->where($column, $filters[$column]);
}
}
$paginated = $query->paginate(15);
if (filled($filters['search'] ?? null)) {
$keyword = '%' . addcslashes($filters['search'], '%_\\') . '%';
$query->where(fn ($q) => $q->where('subject', 'like', $keyword)->orWhere('ticket_num', 'like', $keyword));
}
$paginated = $query->paginate($filters['per_page'] ?? 15);
return response()->json([
'error' => false,
'data' => [
'items' => TicketResource::collection($paginated->items()),
'items' => ClientTicketResource::collection($paginated->items()),
'total' => $paginated->total(),
'per_page' => $paginated->perPage(),
'current_page' => $paginated->currentPage(),
@@ -73,11 +84,7 @@ class TicketController extends Controller
$ticketNum = 'TKT-' . now()->format('Ymd') . '-' . strtoupper(Str::random(5));
$gallery = [];
foreach ($request->file('images', []) as $image) {
$path = $image->store('tickets', 'public');
$gallery[] = Storage::disk('public')->url($path);
}
$gallery = TicketGallery::store($request->file('images', []), $customer->id);
$ticket = $this->ticketRepository->create([
'uuid' => (string) Str::uuid(),
@@ -103,7 +110,7 @@ class TicketController extends Controller
return response()->json([
'error' => false,
'message' => 'Ticket đã được khởi tạo.',
'data' => new TicketResource($ticket),
'data' => new ClientTicketResource($ticket),
], 201);
}
@@ -129,7 +136,7 @@ class TicketController extends Controller
return response()->json([
'error' => false,
'data' => new TicketResource($ticket),
'data' => new ClientTicketResource($ticket),
]);
}
@@ -193,7 +200,7 @@ class TicketController extends Controller
return response()->json([
'error' => false,
'message' => 'Phản hồi đã được gửi.',
'data' => new TicketReplyResource($reply),
'data' => new ClientTicketReplyResource($reply),
], 201);
}
@@ -228,7 +235,17 @@ class TicketController extends Controller
return response()->json([
'error' => false,
'message' => 'Ticket đã được đóng.',
'data' => new TicketResource($ticket->fresh()),
'data' => new ClientTicketResource($ticket->fresh()),
]);
}
public function image(string $ticket_num, int $index)
{
$ticket = Ticket::query()
->where('ticket_num', $ticket_num)
->where('customer_id', auth('client')->id())
->firstOrFail();
return TicketGallery::response($ticket, $index);
}
}
@@ -314,7 +314,7 @@ class ManualDepositController extends Controller
Mail::to($adminEmail)->send(new ManualDepositPendingNotification([
'company_name' => $settingValue['company'] ?? config('app.name', 'Portal Admin'),
'customer_name' => $customer->fullname ?: trim(($customer->firstname ?? '') . ' ' . ($customer->lastname ?? '')),
'customer_name' => (string) $customer->fullname,
'customer_email' => $customer->email,
'customer_phone' => $customer->phone ?? null,
'client_ip' => $request->ip(),