Lompat ke konten
dokumentasi.id
Commit a2522f5
v1
Star645

Kamu membaca versi lama. Halaman ini hanya bisa dibaca.

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-20

Rute portal

MethodURINama ruteKomponen
GET/external/{token}external.loginApp\Livewire\ExternalLogin
GET/external/{token}/dashboardexternal.dashboardApp\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-20

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-29

Kredensial di tabel external_access

KolomDefinisiPeran di portal
project_idforeignId constrained, onDelete('cascade')proyek yang dibuka portal
access_tokenstring uniquesegmen {token} pada URL
passwordstringpassword yang diketik klien
is_activeboolean default truegerbang utama; false membuat token berakhir 404
last_accessed_attimestamp nullablediisi setiap kali kredensial dipakai
timestampscreated_at, updated_atjejak audit baris

database/migrations/2025_08_05_030102_create_external_access_table.php:11-19

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-23 app/Models/ExternalAccess.php:25-29

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-47 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-122 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-21

⚠️ Migration 2025_08_05_031001 menulis seluruh token dan password yang dihasilkannya ke berkas storage/logs/external_credentials_<tanggal>.json di 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-59

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-33 Method down() mengembalikan semuanya menjadi plaintext. database/migrations/2026_09_17_050054_encrypt_external_access_passwords.php:41-56

⚠️ Nilai password terikat pada APP_KEY. Mengganti APP_KEY tanpa 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-37

Password dikirim lewat form wire:submit.prevent="authenticate" dengan field password, yang diteruskan authenticate() ke login(). resources/views/livewire/external-login.blade.php:11-43 app/Livewire/ExternalLogin.php:39-42

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.
Fig. 1: Siklus login portal eksternalToken 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-14 app/Livewire/ExternalLogin.php:44-66

AspekNilai
MAX_ATTEMPTS5 percobaan
DECAY_SECONDS300 detik
Kunci throttle`external-login:{token}
Pesan saat terkunciToo many attempts. Please try again in {seconds} seconds.
Pesan saat password salahInvalid password
Reset penghitungRateLimiter::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-66

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-81 app/Models/ExternalAccess.php:49-52

Kunci SessionIsiDibaca oleh
external_authenticated_{token}truepenjaga ExternalDashboard::mount
external_authenticated_at_{token}now()->timestamppengecekan masa berlaku 720 menit
external_project_idproject_id baris aksestidak dibaca di mana pun
external_authenticatedtruetidak 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:22 app/Livewire/ExternalDashboard.php:88-101

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-111

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.
Fig. 2: Keadaan akses portal eksternalSatu 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-123

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.
Fig. 3: Cakupan data dashboard eksternalSatu baris external_access menentukan satu proyek, dan seluruh widget hanya membaca ticket, status, prioritas, serta riwayat milik proyek itu.
BagianSumber dataBatas
projectStatsusers()->count(), tickets()->count(), end_date, progress_percentageremaining_days dihitung Carbon::diffInDays(..., false), bisa negatif
Tabel ticket$project->tickets() dengan filter status, prioritas, dan pencarian pada name, description, uuidpaginate(10, ['*'], 'tickets') urut id naik
Recent activityTicketHistory milik ticket proyek yang samapaginate(10, ['*'], 'activities') urut created_at turun

app/Livewire/ExternalDashboard.php:221-234 app/Livewire/ExternalDashboard.php:125-145 app/Livewire/ExternalDashboard.php:147-155

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-173 resources/views/livewire/external-dashboard.blade.php:433-440

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-302

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-82

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 cast encrypted, bukan Hash::make. Siapa pun yang memegang dump database beserta APP_KEY bisa membaca password klien apa adanya. app/Models/ExternalAccess.php:36-47 app/Models/ExternalAccess.php:25-29

⚠️ hash_equals di login() 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-66

⚠️ logout() hanya menghapus empat kunci Session lalu redirect ke external.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-342

⚠️ exportGanttData() membaca $this->ganttData, padahal properti itu tidak dideklarasikan di ExternalDashboard. Method ini juga tidak dipanggil dari blade dashboard eksternal, sehingga kegagalannya hanya terlihat bila method dipanggil manual. app/Livewire/ExternalDashboard.php:301-325

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-141