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:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user