10
Portal Eksternal Klien
Portal eksternal klien adalah dua komponen Livewire, ExternalLogin dan ExternalDashboard, yang dipasang di luar panel Filament pada prefix rute external. Portal ini tidak memakai guard Laravel dan tidak terhubung ke akun User: satu baris tabel external_access menentukan proyek mana yang boleh dilihat, dan access_token di URL menjadi identitas utamanya. routes/web.php:17-201
Rute portal
| Method | URI | Nama rute | Komponen |
|---|---|---|---|
| GET | /external/{token} | external.login | App\Livewire\ExternalLogin |
| GET | /external/{token}/dashboard | external.dashboard | App\Livewire\ExternalDashboard |
Kedua rute hanya GET dan tidak diberi middleware. Seluruh pemeriksaan akses terjadi di dalam mount() masing-masing komponen, bukan di lapisan router. routes/web.php:17-201
Otorisasi portal sengaja dipisahkan dari login admin. GoogleController hanya dipakai oleh rute auth/google untuk memasukkan staf ke /admin lewat Socialite, dan tidak menyentuh ExternalAccess. Perbandingan keduanya ada di Kontrol Akses dan Peran. app/Http/Controllers/Auth/GoogleController.php:13-292
Kredensial di tabel external_access
| Kolom | Definisi | Peran di portal |
|---|---|---|
project_id | foreignId constrained, onDelete('cascade') | proyek yang dibuka portal |
access_token | string unique | segmen {token} pada URL |
password | string | password yang diketik klien |
is_active | boolean default true | gerbang utama; false membuat token berakhir 404 |
last_accessed_at | timestamp nullable | diisi setiap kali kredensial dipakai |
timestamps | created_at, updated_at | jejak audit baris |
database/migrations/2025_08_05_030102_create_external_access_table.php:11-193
Model ExternalAccess memetakan kolom itu lewat $fillable, lalu menambahkan cast 'password' => 'encrypted' dan 'last_accessed_at' => 'datetime'. Artinya password disimpan dalam bentuk terenkripsi yang bisa didekripsi, bukan hash satu arah. app/Models/ExternalAccess.php:16-234 app/Models/ExternalAccess.php:25-295
Pembuatan kredensial dilakukan ExternalAccess::generateForProject: Str::random(32) untuk access_token, Str::random(8) untuk password, dan is_active langsung true. app/Models/ExternalAccess.php:36-476 Project::generateExternalAccess menghapus baris lama sebelum membuat yang baru, sehingga tautan yang pernah dibagikan langsung mati saat kredensial dibuat ulang. app/Models/Project.php:117-1227 Pembuatan tautan dari panel Filament dibahas di Manajemen Proyek dan Anggota.
Skema tabel ini berasal dari halaman Model Data dan Migration. Yang khusus untuk portal adalah migration lanjutan yang menambah kolom sementara migration_generated untuk menandai baris hasil migrasi, dan mengisi kredensial bagi proyek yang belum punya akses. database/migrations/2025_08_05_031001_generate_client_access_for_existing_projects_safe.php:19-218
⚠️ Migration
2025_08_05_031001menulis seluruh token dan password yang dihasilkannya ke berkasstorage/logs/external_credentials_<tanggal>.jsondi samping\Log::info. Berkas itu berisi password polos dan tidak pernah dibersihkan oleh kode. database/migrations/2025_08_05_031001_generate_client_access_for_existing_projects_safe.php:51-599
Karena cast encrypted baru ditambahkan setelah tabel berisi data, migration 2026_09_17_050054 menyisir baris lama: nilai yang gagal didekripsi dienkripsi dengan Crypt::encryptString, sedangkan nilai yang sudah terbaca sebagai ciphertext dilewati. database/migrations/2026_09_17_050054_encrypt_external_access_passwords.php:15-3310 Method down() mengembalikan semuanya menjadi plaintext. database/migrations/2026_09_17_050054_encrypt_external_access_passwords.php:41-5611
⚠️ Nilai
passwordterikat padaAPP_KEY. MenggantiAPP_KEYtanpa menjalankan ulang enkripsi membuat seluruh password lama gagal didekripsi, dan klien tidak bisa masuk meskipun token masih aktif.
Alur token dan password
ExternalLogin::mount menerima $token, lalu memeriksa Session external_authenticated_<token>. Bila flag itu ada, komponen langsung melempar redirect ke external.dashboard. Bila belum ada, komponen mencari ExternalAccess dengan access_token dan is_active = true; hasil kosong memicu abort(404, 'External access not found'). app/Livewire/ExternalLogin.php:22-3712
Password dikirim lewat form wire:submit.prevent="authenticate" dengan field password, yang diteruskan authenticate() ke login(). resources/views/livewire/external-login.blade.php:11-4313 app/Livewire/ExternalLogin.php:39-4214
sequenceDiagram
autonumber
actor Klien
participant L as ExternalLogin
participant M as ExternalAccess
participant R as RateLimiter
participant D as ExternalDashboard
Klien->>L: GET /external/{token}
L->>L: mount(token)
Note over L: flag Session external_authenticated per token sudah ada berarti redirect ke external.dashboard
L->>M: where access_token, is_active true
alt token kosong atau nonaktif
L-->>Klien: abort 404 "External access not found"
else token valid
Klien->>L: authenticate() dengan password
L->>R: tooManyAttempts(key, 5)
alt batas percobaan terlampaui
R-->>L: availableIn(key)
L-->>Klien: "Too many attempts. Please try again in N seconds."
else masih diizinkan
L->>M: where access_token, is_active true
L->>L: hash_equals(password baris, input)
alt password tidak cocok
L->>R: hit(key, 300)
L-->>Klien: "Invalid password"
else password cocok
L->>R: clear(key)
L->>M: updateLastAccessed()
L-->>Klien: redirect external.dashboard
end
end
end
Klien->>D: GET /external/{token}/dashboard
D->>M: where access_token, is_active true
D-->>Klien: projectStats, tickets, recent activity
%% fig: Siklus login portal eksternal
%% desc: Token di URL divalidasi dua kali, password diperiksa dengan pembatas laju per token dan per IP, lalu Session mengantar klien ke ExternalDashboard.Rate limit login
Pembatas laju memakai kunci 'external-login:'.$this->token.'|'.request()->ip(), sehingga satu token dan satu IP berbagi kuota yang sama. Dua konstanta privat menentukan ambangnya, dan pesan galatnya dikutip apa adanya dari kode. app/Livewire/ExternalLogin.php:12-1415 app/Livewire/ExternalLogin.php:44-6616
| Aspek | Nilai |
|---|---|
MAX_ATTEMPTS | 5 percobaan |
DECAY_SECONDS | 300 detik |
| Kunci throttle | `external-login:{token} |
| Pesan saat terkunci | Too many attempts. Please try again in {seconds} seconds. |
| Pesan saat password salah | Invalid password |
| Reset penghitung | RateLimiter::clear() hanya setelah password cocok |
Perlu dicatat bahwa RateLimiter::hit dipanggil pada setiap kegagalan, termasuk ketika token tidak lagi aktif karena query diulang di dalam login(). app/Livewire/ExternalLogin.php:57-6617
Sesi terautentikasi
Setelah password cocok, ExternalLogin memanggil updateLastAccessed() lalu menulis empat kunci Session, dan mengakhiri dengan redirect ke rute external.dashboard. app/Livewire/ExternalLogin.php:68-8118 app/Models/ExternalAccess.php:49-5219
| Kunci Session | Isi | Dibaca oleh |
|---|---|---|
external_authenticated_{token} | true | penjaga ExternalDashboard::mount |
external_authenticated_at_{token} | now()->timestamp | pengecekan masa berlaku 720 menit |
external_project_id | project_id baris akses | tidak dibaca di mana pun |
external_authenticated | true | tidak dibaca di mana pun |
ExternalDashboard memakai SESSION_LIFETIME_MINUTES = 720. Bila flag token tidak ada, komponen redirect ke external.login. Bila external_authenticated_at_<token> hilang atau selisih waktu melebihi 720 * 60 detik, kedua kunci itu dihapus lewat Session::forget dan klien dikembalikan ke halaman login. app/Livewire/ExternalDashboard.php:2220 app/Livewire/ExternalDashboard.php:88-10121
Karena proyek selalu diambil ulang dari access_token yang aktif, external_project_id hanya menjadi sisa penulisan: menonaktifkan baris akses langsung memutus sesi lama dengan 404, bukan membuka proyek lain. app/Livewire/ExternalDashboard.php:103-11122
stateDiagram-v2
[*] --> TokenDibuka
TokenDibuka --> TidakDitemukan: baris kosong atau is_active false
TokenDibuka --> FormPassword: baris aktif ditemukan
FormPassword --> Terkunci: 5 kegagalan, hit selama 300 detik
Terkunci --> FormPassword: availableIn habis
FormPassword --> FormPassword: hash_equals gagal, hit(key, 300)
FormPassword --> Terautentikasi: password cocok, Session ditulis
Terautentikasi --> Kedaluwarsa: selisih waktu melebihi 720 menit
Kedaluwarsa --> FormPassword: kedua kunci Session dihapus
Terautentikasi --> Keluar: logout()
Keluar --> FormPassword: Session dihapus, redirect external.login
TidakDitemukan --> [*]
classDef gagal fill:#dc2626,color:#fff
classDef sukses fill:#2d7d46,color:#fff
classDef kunci fill:#f59e0b,color:#000
class TidakDitemukan gagal
class Kedaluwarsa gagal
class Keluar gagal
class Terautentikasi sukses
class Terkunci kunci
%% fig: Keadaan akses portal eksternal
%% desc: Satu baris external_access bergerak dari token dibuka, form password, terkunci sementara oleh RateLimiter, terautentikasi, lalu kedaluwarsa atau keluar.Isi dashboard eksternal
Setelah lolos penjagaan, mount() menyimpan project, memuat TicketStatus milik proyek itu (urut sort_order lalu name), memuat seluruh TicketPriority urut name, memperbarui last_accessed_at, lalu memanggil loadWidgetData(). app/Livewire/ExternalDashboard.php:103-12323
graph TD
TOKEN["access_token pada URL"] --> ACC["ExternalAccess is_active"]
ACC -->|project_id| PROJ["Project"]
PROJ --> STAT["projectStats"]
PROJ --> TIX["tickets paginate 10"]
PROJ --> HIST["TicketHistory paginate 10"]
TIX --> TS["TicketStatus per project"]
TIX --> TP["TicketPriority"]
style TOKEN fill:#2d7d46,color:#fff
style ACC fill:#336791,color:#fff
style PROJ fill:#336791,color:#fff
style STAT fill:#8b5cf6,color:#fff
style TIX fill:#8b5cf6,color:#fff
style HIST fill:#8b5cf6,color:#fff
style TS fill:#f59e0b,color:#000
style TP fill:#f59e0b,color:#000
%% fig: Cakupan data dashboard eksternal
%% desc: Satu baris external_access menentukan satu proyek, dan seluruh widget hanya membaca ticket, status, prioritas, serta riwayat milik proyek itu.| Bagian | Sumber data | Batas |
|---|---|---|
projectStats | users()->count(), tickets()->count(), end_date, progress_percentage | remaining_days dihitung Carbon::diffInDays(..., false), bisa negatif |
| Tabel ticket | $project->tickets() dengan filter status, prioritas, dan pencarian pada name, description, uuid | paginate(10, ['*'], 'tickets') urut id naik |
| Recent activity | TicketHistory milik ticket proyek yang sama | paginate(10, ['*'], 'activities') urut created_at turun |
app/Livewire/ExternalDashboard.php:221-23424 app/Livewire/ExternalDashboard.php:125-14525 app/Livewire/ExternalDashboard.php:147-15526
Tampilan hanya menyediakan dua tab, tasks dan activity, dengan tombol Project Tasks dan Recent Activity. Tab activity menampilkan judul Recent Activity beserta kalimat Latest updates and changes in the project. resources/views/livewire/external-dashboard.blade.php:147-17327 resources/views/livewire/external-dashboard.blade.php:433-44028
Tabel tugas menampilkan empat kolom: Code (isi uuid), Task Name, Due Date, dan Status. Tidak ada tombol ubah atau hapus di portal, sehingga klien hanya membaca. resources/views/livewire/external-dashboard.blade.php:287-30229
Tombol Refresh memanggil refreshData(), yang memuat ulang widget, me-reset halaman paginasi tickets dan activities, lalu menulis flash Data refreshed successfully! atau Failed to refresh data. Please try again. saat terjadi Exception. app/Livewire/ExternalDashboard.php:68-8230
Detail status, prioritas, dan riwayat yang ditampilkan di sini dijelaskan pada Status, Prioritas, dan Epic, Manajemen Ticket, dan Komentar dan Riwayat Ticket. Perhitungan agregat di panel internal ada di Dashboard, Analitik, dan Leaderboard.
Batas keamanan portal
⚠️ Password portal hanya 8 karakter hasil
Str::random(8)dan disimpan dengan castencrypted, bukanHash::make. Siapa pun yang memegang dump database besertaAPP_KEYbisa membaca password klien apa adanya. app/Models/ExternalAccess.php:36-476 app/Models/ExternalAccess.php:25-295
⚠️
hash_equalsdilogin()membandingkan string polos hasil dekripsi cast dengan input pengguna. Perbandingan ini waktu-konstan, tetapi tidak menambah entropi password 8 karakter di atas sehingga kuota 5 percobaan per 300 detik adalah satu-satunya penahan tebak-tebakan. app/Livewire/ExternalLogin.php:44-6616
⚠️
logout()hanya menghapus empat kunci Session lalu redirect keexternal.login. Token dan password tetap berlaku, jadi satu-satunya cara mencabut akses adalah membuat kredensial baru lewat panel proyek, sebagaimana dijelaskan di Manajemen Proyek dan Anggota. app/Livewire/ExternalDashboard.php:332-34231
⚠️
exportGanttData()membaca$this->ganttData, padahal properti itu tidak dideklarasikan diExternalDashboard. Method ini juga tidak dipanggil dari blade dashboard eksternal, sehingga kegagalannya hanya terlihat bila method dipanggil manual. app/Livewire/ExternalDashboard.php:301-32532
Pencarian status dan prioritas memakai like pada name, description, dan uuid tanpa escape wildcard, sehingga karakter % dan _ pada searchTerm ikut menjadi pola pencarian. app/Livewire/ExternalDashboard.php:135-14133