away($this->sso->authorizationUrl()); } /** Pemeriksaan sesi X-Guard tanpa layar (prompt=none). Dipicu tombol "Cek sesi" atau middleware CekSesiXGuard. */ public function cekSesi(): RedirectResponse { return redirect()->away($this->sso->authorizationUrl(silent: true)); } /** X-Guard mengembalikan browser ke sini (Alamat callback) membawa kode login atau kode galat. */ public function callback(Request $request): RedirectResponse { try { $result = $this->sso->handleCallback($request->query()); } catch (SsoDenied $e) { if ($e->silent && in_array($e->error, ['login_required', 'access_denied'], true)) { // Sesi X-Guard sudah tidak ada (keluar dari aplikasi lain atau kedaluwarsa), atau akses ke aplikasi ini // dicabut admin: sesi lokal ikut diakhiri. CekSesiXGuard::akhiriSesiLokal($request); return redirect('/')->with('pesan', $e->error === 'login_required' ? 'Sesi X-Guard Anda sudah berakhir, jadi Anda juga keluar dari aplikasi ini.' : 'Akses Anda ke aplikasi ini sudah dicabut di X-Guard.'); } return $this->gagal($request, $this->pesanDitolak($e)); } catch (SsoException $e) { return $this->gagal($request, $e->getMessage()); } catch (RequestException|ConnectionException $e) { Log::warning('Penukaran kode X-Guard gagal', ['galat' => $e->getMessage()]); return $this->gagal($request, 'X-Guard menolak penukaran kode atau tidak bisa dihubungi. Periksa XGUARD_CLIENT_ID dan XGUARD_CLIENT_SECRET.'); } $claims = $result['claims']; try { $user = $this->penggunaDari($claims); } catch (SsoException $e) { return $this->gagal($request, $e->getMessage()); } // Pemeriksaan diam-diam untuk orang yang sama hanya memperbarui token; selain itu sesi lokal baru dibuat. $samaOrangnya = Auth::check() && Auth::user()->xguard_sub === $claims->sub; if (! $samaOrangnya) { Auth::login($user); $request->session()->regenerate(); } $request->session()->put('xguard', [ 'id_token' => $result['id_token'], 'sid' => $claims->sid ?? null, 'claims' => json_decode(json_encode($claims), true), 'access' => $result['access'] ? json_decode(json_encode($result['access']), true) : null, 'login_at' => $samaOrangnya ? $request->session()->get('xguard.login_at', now()->getTimestamp()) : now()->getTimestamp(), 'checked_at' => now()->getTimestamp(), ]); return redirect()->intended('/'); } /** Keluar: sesi lokal diakhiri, lalu browser dibawa ke X-Guard untuk mengakhiri sesi SSO organisasi. */ public function logout(Request $request): RedirectResponse { $idToken = $request->session()->get('xguard.id_token'); CekSesiXGuard::akhiriSesiLokal($request); return redirect()->away($this->sso->logoutUrl($idToken)) ->with('pesan', 'Anda sudah keluar dari aplikasi ini dan dari sesi X-Guard.'); } /** * Back-channel logout: X-Guard memanggil alamat ini antarserver saat sesi SSO berakhir dari aplikasi mana pun. * Daftarkan https://aplikasi-anda/auth/backchannel-logout sebagai "Alamat back-channel logout" di konsol. * Tidak bisa dipakai dari localhost, karena server X-Guard tidak bisa menjangkau laptop Anda. */ public function backchannel(Request $request): Response { try { $claims = $this->sso->verifyLogoutToken((string) $request->input('logout_token')); } catch (SsoException|RequestException|ConnectionException $e) { return response(json_encode(['error' => 'invalid_request', 'error_description' => $e->getMessage()]), 400) ->header('Content-Type', 'application/json')->header('Cache-Control', 'no-store'); } // Sesi lokal tersimpan per browser dan tidak bisa dicari dari sid, jadi sid ditandai berakhir. Middleware // CekSesiXGuard mengeluarkan pengguna bersesi itu pada permintaan berikutnya. $ttl = now()->addMinutes((int) config('session.lifetime')); if (! empty($claims->sid)) { Cache::put(CekSesiXGuard::kunciSid($claims->sid), true, $ttl); } else { Cache::put(CekSesiXGuard::kunciSub($claims->sub), now()->getTimestamp(), $ttl); } return response('', 200)->header('Cache-Control', 'no-store'); } /** * Webhook X-Guard: daftarkan https://aplikasi-anda/auth/webhook di panel Webhook rincian aplikasi, lalu isi * XGUARD_WEBHOOK_SECRET. Jawaban 2xx = diterima; selain itu X-Guard mencoba lagi sampai 24 jam, jadi event bisa * datang lebih dari sekali dan diproses berdasarkan id-nya. */ public function webhook(Request $request): Response { try { $event = XGuardSso::verifyWebhook((string) config('xguard.webhook_secret'), (string) $request->header('X-XGuard-Timestamp'), (string) $request->header('X-XGuard-Signature'), $request->getContent()); } catch (SsoException $e) { return response($e->getMessage(), 401)->header('Cache-Control', 'no-store'); } // Sudah pernah diproses (X-Guard mencoba ulang): cukup dijawab berhasil. if (! Cache::add('xguard:event:'.$event['id'], true, now()->addDay())) { return response('', 204); } $sub = $event['sub'] ?? ''; $ttl = now()->addMinutes((int) config('session.lifetime')); switch ($event['type']) { case 'user.disabled': case 'user.deleted': // Semua sesi lokal pengguna ini berakhir pada permintaan berikutnya (lihat CekSesiXGuard). Cache::put(CekSesiXGuard::kunciSub($sub), now()->getTimestamp(), $ttl); break; case 'user.apps_changed': if (($event['access'] ?? '') === 'revoked') { Cache::put(CekSesiXGuard::kunciSub($sub), now()->getTimestamp(), $ttl); } break; case 'user.updated': if (! empty($event['email'])) { User::where('xguard_sub', $sub)->update(['email' => $event['email']]); } break; } Log::info('Webhook X-Guard diterima', ['type' => $event['type'], 'id' => $event['id']]); return response('', 204); } /** * Akun lokal dikenali dari sub X-Guard. Akun lama yang belum tertaut ditautkan lewat email, hanya bila X-Guard * menyatakan email itu terverifikasi. */ private function penggunaDari(object $claims): User { $user = User::where('xguard_sub', $claims->sub)->first(); if (! $user && ! empty($claims->email) && ($claims->email_verified ?? false) === true) { $user = User::where('email', $claims->email)->whereNull('xguard_sub')->first(); } if (! $user && ! empty($claims->email) && User::where('email', $claims->email)->exists()) { throw new SsoException('Email '.$claims->email.' sudah dipakai akun lain di aplikasi ini, dan X-Guard belum menyatakan email itu terverifikasi, jadi akun tidak ditautkan otomatis.'); } $user ??= new User; $user->fill([ 'xguard_sub' => $claims->sub, 'name' => $claims->name ?? $claims->email ?? $claims->sub, 'email' => $claims->email ?? $claims->sub.'@xguard.invalid', ])->save(); return $user; } private function pesanDitolak(SsoDenied $e): string { return match ($e->error) { 'access_denied' => 'Akun Anda belum diberi akses ke aplikasi ini. Minta admin organisasi menambahkannya di menu Aplikasi konsol X-Guard.', 'login_required' => 'Anda belum masuk di X-Guard.', default => 'X-Guard menolak login: '.$e->getMessage(), }; } /** Galat login. Waktu pemeriksaan dicatat supaya middleware tidak mengulang pemeriksaan diam-diam yang gagal. */ private function gagal(Request $request, string $pesan): RedirectResponse { if ($request->session()->has('xguard')) { $request->session()->put('xguard.checked_at', now()->getTimestamp()); } return redirect('/')->with('galat', $pesan); } }