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
@@ -0,0 +1,68 @@
<?php
namespace App\Services\Api\Admin;
use App\Models\AdminRefreshToken;
use Carbon\CarbonInterface;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Tymon\JWTAuth\Facades\JWTAuth;
class AdminRefreshTokenService
{
public function create(int $userId, CarbonInterface $expiresAt): string
{
$rawToken = Str::random(64);
AdminRefreshToken::create([
'user_id' => $userId,
'token' => hash('sha256', $rawToken),
'expires_at' => $expiresAt,
]);
return $rawToken;
}
public function rotate(string $rawToken): array
{
return DB::transaction(function () use ($rawToken): array {
$record = AdminRefreshToken::where('token', hash('sha256', $rawToken))
->lockForUpdate()
->first();
if (! $record || $record->expires_at->isPast()) {
$record?->delete();
throw ValidationException::withMessages([
'refresh_token' => ['Refresh token không hợp lệ hoặc đã hết hạn.'],
]);
}
$user = $record->user;
if (! $user) {
$record->delete();
throw ValidationException::withMessages([
'refresh_token' => ['Tài khoản không hợp lệ.'],
]);
}
$expiresAt = $record->expires_at->copy();
$record->delete();
$newRefreshToken = $this->create($user->id, $expiresAt);
return [
'user' => $user,
'token' => JWTAuth::fromUser($user),
'refresh_token' => $newRefreshToken,
'refresh_expires_in' => (int) max(0, now()->diffInSeconds($expiresAt, false)),
];
});
}
public function revoke(string $rawToken): void
{
AdminRefreshToken::where('token', hash('sha256', $rawToken))->delete();
}
}
+1 -1
View File
@@ -154,7 +154,7 @@ class OrderService
AdminOrderNotificationJob::dispatch([
'order_num' => $order->order_num,
'customer_name' => trim($customer->firstname . ' ' . $customer->lastname),
'customer_name' => (string) $customer->fullname,
'customer_email' => $customer->email,
'client_ip' => $order->client_ip ?? request()?->ip(),
'product_name' => $product?->name ?? 'N/A',
+21 -1
View File
@@ -45,6 +45,7 @@ class IpAllocationService
return DB::transaction(function () use ($ids, $customer, $data, $admin, $files) {
$pools = $this->allocatable($this->pools($ids, lock: true));
$copies = $this->emailCopies(IpAllocationMail::ALLOCATED, $data['cc'] ?? null);
foreach ($pools as $pool) {
$from = $pool->status;
@@ -67,6 +68,7 @@ class IpAllocationService
'customer_name' => $customer->fullname,
'service_name' => $pool->service_name,
'email_to' => $data['to'],
...$copies,
'note' => $data['note'] ?? null,
]);
}
@@ -116,6 +118,7 @@ class IpAllocationService
$groups = $this->revocationGroups($this->revocable($this->pools($ids, lock: true)));
return $groups->map(function (Collection $pools, string $key) use ($data, $recipients, $admin) {
$copies = $this->emailCopies(IpAllocationMail::REVOKED, $recipients[$key]['cc'] ?? null);
$mail = $this->revocationMail($pools, $data, $recipients[$key]['subject'] ?? null);
$customerId = $pools->first()->customer_id;
foreach ($pools as $pool) {
@@ -136,6 +139,7 @@ class IpAllocationService
...$before,
'effective_date' => $data['date'],
'email_to' => $recipients[$key]['to'],
...$copies,
'note' => $data['note'] ?? null,
]);
}
@@ -255,19 +259,35 @@ class IpAllocationService
}
}
/**
* @return array{email_cc: string|null, email_bcc: string|null}
*/
private function emailCopies(string $scenario, ?string $cc): array
{
$config = $this->templates->loadTemplate($scenario)->mailConfig();
$join = fn (array $emails) => $emails ? implode(', ', array_values(array_unique($emails))) : null;
return [
'email_cc' => $join([...EmailRecipients::parse($cc), ...($config['cc'] ?? [])]),
'email_bcc' => $join($config['bcc'] ?? []),
];
}
private function send(IpAllocationMail $mail, string $to, ?string $cc, ?int $customerId, User $admin): void
{
AutoEmailGate::markManual();
$template = $this->templates->loadTemplate($mail->type);
$toList = EmailRecipients::parse($to);
$ccList = EmailRecipients::parse($cc);
$copies = $this->emailCopies($mail->type, $cc);
MailLog::create([
'template_id' => $template->id,
'subject' => $mail->envelope()->subject,
'from_address' => $template->mailConfig()['from_email'] ?? config('mail.from.address'),
'to' => implode(', ', $toList),
'cc' => $ccList ? implode(', ', $ccList) : null,
'cc' => $copies['email_cc'],
'bcc' => $copies['email_bcc'],
'customer_id' => $customerId,
'sent_by' => $admin->id,
'message' => (clone $mail)->render(),
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace App\Services\IpPool;
use App\Models\Customer;
use App\Models\IpPool;
use Illuminate\Support\Facades\DB;
class IpPoolDashboard
{
public const TOP_CUSTOMERS = 15;
private const ADDON_ROLE = 'ADDON';
public function build(): array
{
$blocks = IpPool::query()
->selectRaw("COALESCE(NULLIF(ip_block, ''), '') as ip_block")
->selectRaw('COUNT(*) as total')
->selectRaw('SUM(ip_role = ?) as addon', [self::ADDON_ROLE])
->selectRaw("SUM(status = 'assigned') as assigned")
->selectRaw("SUM(status = 'free') as free")
->selectRaw("SUM(status = 'reserved') as reserved")
->selectRaw('COALESCE(SUM(monthly_cost), 0) as cost')
->selectRaw("COALESCE(SUM(CASE WHEN status = 'assigned' THEN selling_price END), 0) as revenue")
->groupBy(DB::raw("COALESCE(NULLIF(ip_block, ''), '')"))
->orderBy('ip_block')
->get()
->map(fn ($row) => $this->withRates([
'ip_block' => $row->ip_block ?: null,
'total' => (int) $row->total,
'addon' => (int) $row->addon,
'assigned' => (int) $row->assigned,
'free' => (int) $row->free,
'reserved' => (int) $row->reserved,
'cost' => (float) $row->cost,
'revenue' => (float) $row->revenue,
]));
$summary = collect(['total', 'addon', 'assigned', 'free', 'reserved', 'cost', 'revenue'])
->mapWithKeys(fn (string $key) => [$key => $blocks->sum($key)])
->all();
$summary['primary'] = $summary['total'] - $summary['addon'];
return [
'summary' => $this->withRates($summary),
'blocks' => $blocks->values(),
'top_customers' => $this->topCustomers(),
];
}
private function withRates(array $stats): array
{
$profit = $stats['revenue'] - $stats['cost'];
return [
...$stats,
'usage_rate' => $stats['total'] ? $stats['assigned'] / $stats['total'] : 0,
'profit' => $profit,
'margin' => $stats['revenue'] ? $profit / $stats['revenue'] : null,
];
}
private function topCustomers(): array
{
$rows = IpPool::query()
->where('status', 'assigned')
->where(fn ($q) => $q->whereNotNull('customer_id')->orWhere('customer_name', '!=', ''))
->selectRaw('customer_id, MAX(customer_name) as customer_name')
->selectRaw('COUNT(*) as ips')
->selectRaw('COALESCE(SUM(selling_price), 0) as revenue')
->groupBy('customer_id', DB::raw('CASE WHEN customer_id IS NULL THEN customer_name END'))
->orderByDesc('ips')
->orderBy('customer_name')
->limit(self::TOP_CUSTOMERS)
->get();
$customers = Customer::whereIn('id', $rows->pluck('customer_id')->filter())->get(['id', 'uuid', 'fullname'])->keyBy('id');
return $rows->map(fn ($row) => [
'customer_uuid' => $customers[$row->customer_id]->uuid ?? null,
'customer_name' => $customers[$row->customer_id]->fullname ?? $row->customer_name,
'ips' => (int) $row->ips,
'revenue' => (float) $row->revenue,
])->all();
}
}
@@ -34,7 +34,7 @@ class VmNotificationService
VMCreationFailedJob::dispatch([
'customer_email' => $customer->email,
'customer_name' => trim($customer->firstname . ' ' . $customer->lastname),
'customer_name' => (string) $customer->fullname,
'product_name' => $product?->name ?? 'Unknown Product',
'order_id' => $item->order->id,
'order_num' => $item->order->order_num,
@@ -95,7 +95,7 @@ class VmNotificationService
try {
$order = $item->order;
$customer = $order->customer;
$name = trim($customer->firstname . ' ' . $customer->lastname);
$name = (string) $customer->fullname;
broadcast(new OrderProvisioned(
orderId: $order->id,