From cfbce1f22e463a459daacc019b673edfb2f187d6 Mon Sep 17 00:00:00 2001 From: Ahmad Afandi Date: Mon, 10 Aug 2026 09:13:03 +0700 Subject: [PATCH 1/9] Feat: SSO login ke OpenSID --- .env.example | 20 ++ .gitignore | 3 + README.md | 7 + .../Commands/SsoPurgeTokensCommand.php | 40 +++ app/Enums/Modul.php | 28 +- app/Enums/SsoStatusEnum.php | 62 +++++ app/Exceptions/SsoConfigurationException.php | 10 + .../Controllers/Api/SsoVerifyController.php | 148 ++++++++++ .../Sso/DesaSsoConfigController.php | 56 ++++ .../Controllers/Sso/SsoAuditController.php | 57 ++++ .../Controllers/Sso/SsoLoginController.php | 142 ++++++++++ app/Http/Middleware/SsoCallbackAuth.php | 57 ++++ app/Http/Middleware/SsoIpWhitelist.php | 39 +++ app/Http/Requests/SsoDesaConfigRequest.php | 63 +++++ app/Http/Requests/SsoGenerateRequest.php | 29 ++ app/Http/Transformers/SsoLogTransformer.php | 30 +++ app/Models/Sso/DesaSsoConfig.php | 37 +++ app/Models/Sso/OpenKabSsoLog.php | 75 ++++++ app/Models/Sso/OpenKabSsoToken.php | 51 ++++ app/Providers/AppServiceProvider.php | 68 ++++- app/Services/OpenSidUrlResolver.php | 54 ++++ app/Services/SsoAuditLogger.php | 65 +++++ app/Services/SsoKeyManager.php | 134 ++++++++++ app/Services/SsoTokenService.php | 159 +++++++++++ bootstrap/app.php | 128 ++++++--- composer.json | 1 + composer.lock | 68 ++++- config/sso.php | 61 +++++ ...8_000001_create_openkab_sso_logs_table.php | 39 +++ ...000002_create_openkab_sso_tokens_table.php | 36 +++ ...8_000003_create_desa_sso_configs_table.php | 30 +++ database/seeders/DatabaseSeeder.php | 1 + database/seeders/SsoPermissionsSeeder.php | 26 ++ docs/sso-opensid.md | 112 ++++++++ package-lock.json | 124 ++------- package.json | 2 +- resources/views/desa/index.blade.php | 78 +++++- resources/views/sso/audit.blade.php | 159 +++++++++++ .../views/sso/desa_sso_config/form.blade.php | 54 ++++ .../views/sso/desa_sso_config/index.blade.php | 93 +++++++ resources/views/sso/partials/filter.blade.php | 60 +++++ routes/apiv1.php | 5 + routes/web.php | 233 +++++++++------- scripts/sso-keygen.sh | 26 ++ tests/Browser/SmokeSsoAccessTest.php | 64 +++++ tests/Feature/Sso/SsoAuditDashboardTest.php | 90 +++++++ tests/Feature/Sso/SsoGenerateSessionTest.php | 126 +++++++++ tests/Feature/Sso/SsoSecurityTest.php | 179 +++++++++++++ tests/Feature/Sso/SsoVerifyTokenTest.php | 253 ++++++++++++++++++ tests/fixtures/sso/private-other.pem | 28 ++ tests/fixtures/sso/private.pem | 28 ++ tests/fixtures/sso/public-other.pem | 9 + tests/fixtures/sso/public.pem | 9 + 53 files changed, 3300 insertions(+), 256 deletions(-) create mode 100644 app/Console/Commands/SsoPurgeTokensCommand.php create mode 100644 app/Enums/SsoStatusEnum.php create mode 100644 app/Exceptions/SsoConfigurationException.php create mode 100644 app/Http/Controllers/Api/SsoVerifyController.php create mode 100644 app/Http/Controllers/Sso/DesaSsoConfigController.php create mode 100644 app/Http/Controllers/Sso/SsoAuditController.php create mode 100644 app/Http/Controllers/Sso/SsoLoginController.php create mode 100644 app/Http/Middleware/SsoCallbackAuth.php create mode 100644 app/Http/Middleware/SsoIpWhitelist.php create mode 100644 app/Http/Requests/SsoDesaConfigRequest.php create mode 100644 app/Http/Requests/SsoGenerateRequest.php create mode 100644 app/Http/Transformers/SsoLogTransformer.php create mode 100644 app/Models/Sso/DesaSsoConfig.php create mode 100644 app/Models/Sso/OpenKabSsoLog.php create mode 100644 app/Models/Sso/OpenKabSsoToken.php create mode 100644 app/Services/OpenSidUrlResolver.php create mode 100644 app/Services/SsoAuditLogger.php create mode 100644 app/Services/SsoKeyManager.php create mode 100644 app/Services/SsoTokenService.php create mode 100644 config/sso.php create mode 100644 database/migrations/2026_08_08_000001_create_openkab_sso_logs_table.php create mode 100644 database/migrations/2026_08_08_000002_create_openkab_sso_tokens_table.php create mode 100644 database/migrations/2026_08_08_000003_create_desa_sso_configs_table.php create mode 100644 database/seeders/SsoPermissionsSeeder.php create mode 100644 docs/sso-opensid.md create mode 100644 resources/views/sso/audit.blade.php create mode 100644 resources/views/sso/desa_sso_config/form.blade.php create mode 100644 resources/views/sso/desa_sso_config/index.blade.php create mode 100644 resources/views/sso/partials/filter.blade.php create mode 100755 scripts/sso-keygen.sh create mode 100644 tests/Browser/SmokeSsoAccessTest.php create mode 100644 tests/Feature/Sso/SsoAuditDashboardTest.php create mode 100644 tests/Feature/Sso/SsoGenerateSessionTest.php create mode 100644 tests/Feature/Sso/SsoSecurityTest.php create mode 100644 tests/Feature/Sso/SsoVerifyTokenTest.php create mode 100644 tests/fixtures/sso/private-other.pem create mode 100644 tests/fixtures/sso/private.pem create mode 100644 tests/fixtures/sso/public-other.pem create mode 100644 tests/fixtures/sso/public.pem diff --git a/.env.example b/.env.example index b197fa6fb..6f264edc6 100644 --- a/.env.example +++ b/.env.example @@ -111,3 +111,23 @@ ACCOUNT_LOCKOUT_DECAY_MINUTES=15 # Additional delay (in seconds) added after each failed attempt PROGRESSIVE_DELAY_BASE_SECONDS=2 PROGRESSIVE_DELAY_MULTIPLIER=2 + +SCREENSHOT_ON_SUCCESS=false + +# SSO OpenSID Configuration +# Kunci RS256 (RSA-2048): private key HANYA di OpenKab; public key didistribusikan +# ke instalasi OpenSID. Nilai PEM dapat dimasukkan langsung atau via file. +# Generasi: openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out sso-private.pem +# openssl pkey -in sso-private.pem -pubout -out sso-public.pem +SSO_SIGNING_PRIVATE_KEY= +SSO_SIGNING_PRIVATE_KEY_FILE= +SSO_SIGNING_PUBLIC_KEY= +SSO_SIGNING_PUBLIC_KEY_FILE= +SSO_SIGNING_PUBLIC_KEYS_FILE= +# Secret callback minimal 32 byte. Nilai dibagikan ke instalasi OpenSID. +SSO_CALLBACK_SECRET= +SSO_TOKEN_TTL=300 +SSO_OPENSID_BASE_URL= +SSO_IP_WHITELIST= +SSO_RATE_LIMIT_MAX=5 +SSO_CLOCK_SKEW_TOLERANCE=30 diff --git a/.gitignore b/.gitignore index 466875d17..f28956df3 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ tests/Browser/screenshots/ tests/Browser/.session_state.json graphify-out docs +specs/ +.opencode/ +.specify/ diff --git a/README.md b/README.md index 09c2d4070..26375c804 100644 --- a/README.md +++ b/README.md @@ -9,3 +9,10 @@ Demo aplikasi OpenKab dapat dilihat di https://devopenkab.opendesa.id. Versi yan Modul administrasi OpenKab demo dapat diaskses pada [https://devopenkab.opendesa.id/index.php/login](https://devopenkab.opendesa.id/login). - Username = admin@gmail.com - Password = Admin100% + +### 🔐 SSO Akses Panel Admin OpenSID + +OpenKab mendukung Single Sign-On (SSO) sehingga administrator dapat masuk ke panel admin OpenSID desa tanpa login ulang (wajib sesi aktif + 2FA). Dokumen integrasi: +- Deployment/operasional OpenKab: [`docs/sso-opensid.md`](docs/sso-opensid.md) +- Kontrak API sisi OpenKab: `specs/001-opensid-sso-access/contracts/openkab-sso-api.md` +- Kontrak integrasi sisi OpenSID (repo terpisah): `specs/001-opensid-sso-access/contracts/opensid-sso-contract.md` diff --git a/app/Console/Commands/SsoPurgeTokensCommand.php b/app/Console/Commands/SsoPurgeTokensCommand.php new file mode 100644 index 000000000..18fa07e10 --- /dev/null +++ b/app/Console/Commands/SsoPurgeTokensCommand.php @@ -0,0 +1,40 @@ +where(function ($query) { + $query->where('expires_at', '<', now()) + ->orWhereNotNull('used_at'); + }) + ->delete(); + + $this->info("Pembersihan selesai. {$deleted} token SSO dihapus."); + + return 0; + } +} diff --git a/app/Enums/Modul.php b/app/Enums/Modul.php index 4eb08b48a..d91a2426d 100644 --- a/app/Enums/Modul.php +++ b/app/Enums/Modul.php @@ -113,7 +113,7 @@ final class Modul extends Enum 'text' => 'Desa Aktif', 'url' => 'laporan/desa-aktif', 'permission' => 'laporan-desa-aktif', - ], + ], ], ], [ @@ -123,8 +123,8 @@ final class Modul extends Enum 'submenu' => [ [ 'text' => 'Statistik', - 'icon' => 'fa fa-chart-pie', - 'submenu' => [ + 'icon' => 'fa fa-chart-pie', + 'submenu' => [ [ 'text' => 'Pangan', 'url' => 'data-presisi/statistik/pangan', @@ -183,8 +183,8 @@ final class Modul extends Enum 'text' => 'Adat', 'url' => 'data-presisi/statistik/adat', 'permission' => 'statistik-datapresisi-adat', - ] - ] + ], + ], ], [ 'icon' => 'far fa-fw fa-circle', @@ -227,13 +227,13 @@ final class Modul extends Enum 'text' => 'Ketenagakerjaan', 'url' => 'data-presisi/ketenagakerjaan', 'permission' => 'datapresisi-ketenagakerjaan', - ], + ], [ 'icon' => 'far fa-fw fa-circle', 'text' => 'Adat', 'url' => 'data-presisi/adat', 'permission' => 'datapresisi-adat', - ], + ], [ 'icon' => 'far fa-fw fa-circle', 'text' => 'Agama', @@ -245,7 +245,7 @@ final class Modul extends Enum 'text' => 'Seni Budaya', 'url' => 'data-presisi/seni-budaya', 'permission' => 'datapresisi-seni-budaya', - ], + ], [ 'icon' => 'far fa-fw fa-circle', 'text' => 'Pariwisata', @@ -257,8 +257,8 @@ final class Modul extends Enum 'text' => 'Data Infrastruktur', 'url' => 'data-pokok/infrastruktur', 'permission' => 'datapokok-infrastruktur', - ], - [ + ], + [ 'icon' => 'far fa-fw fa-circle', 'text' => 'Laporan Pengisian', 'url' => 'data-presisi/laporan', @@ -333,6 +333,12 @@ final class Modul extends Enum 'url' => 'master/artikel', 'permission' => 'master-data-artikel', ], + [ + 'icon' => 'far fa-fw fa-circle', + 'text' => 'Audit Akses SSO', + 'url' => 'sso/audit', + 'permission' => 'sso-audit-read', + ], ], ], [ @@ -449,7 +455,7 @@ final class Modul extends Enum 'text' => 'Aktivasi OTP dan 2FA', 'icon' => 'far fa-fw fa-circle', 'url' => 'pengaturan/otp', - ] + ], ], ], ]; diff --git a/app/Enums/SsoStatusEnum.php b/app/Enums/SsoStatusEnum.php new file mode 100644 index 000000000..40b0ad4f4 --- /dev/null +++ b/app/Enums/SsoStatusEnum.php @@ -0,0 +1,62 @@ + 'Berhasil', + self::FAILED => 'Gagal', + ]; + } +} diff --git a/app/Exceptions/SsoConfigurationException.php b/app/Exceptions/SsoConfigurationException.php new file mode 100644 index 000000000..6d26d25c9 --- /dev/null +++ b/app/Exceptions/SsoConfigurationException.php @@ -0,0 +1,10 @@ +validate([ + 'token' => ['required', 'string'], + 'callback_nonce' => ['required', 'string', 'min:8'], + ]); + + $token = (string) $request->input('token'); + + try { + $payload = $this->tokens->verify($token); + } catch (ExpiredException $e) { + return $this->error('TOKEN_EXPIRED'); + } catch (BeforeValidException $e) { + return $this->error('TOKEN_INVALID'); + } catch (UnexpectedValueException|SignatureInvalidException|\DomainException $e) { + return $this->error('TOKEN_INVALID'); + } catch (SsoConfigurationException $e) { + return $this->error('TOKEN_INVALID'); + } + + $adminId = (int) $payload['sub']; + $desaId = (string) $payload['desa_id']; + $jti = (string) $payload['jti']; + $fingerprint = $this->tokens->fingerprint($jti); + + $tokenRecord = OpenKabSsoToken::query()->where('jti', $jti)->first(); + + if (! $tokenRecord) { + return $this->error('TOKEN_INVALID', $adminId, $desaId, $fingerprint); + } + + if ($tokenRecord->used_at !== null) { + return $this->error('TOKEN_REPLAY', $adminId, $desaId, $fingerprint); + } + + if ($tokenRecord->expires_at->isPast()) { + return $this->error('TOKEN_EXPIRED', $adminId, $desaId, $fingerprint); + } + + $admin = User::query()->find($adminId); + + if (! $admin || ! $this->adminIsEligible($admin)) { + return $this->error('USER_UNAUTHORIZED', $adminId, $desaId, $fingerprint); + } + + if (! OpenKabSsoToken::consumeAtomic($jti, $request->ip())) { + return $this->error('TOKEN_REPLAY', $adminId, $desaId, $fingerprint); + } + + $this->audit->logVerification( + $adminId, + $desaId, + SsoStatusEnum::SUCCESS, + ip: $request->ip(), + userAgent: $request->userAgent(), + tokenFingerprint: $fingerprint, + ); + + return response()->json([ + 'status' => 'success', + 'data' => [ + 'desa_id' => $desaId, + 'username' => $admin->username, + 'admin_id' => $adminId, + 'expires_at' => (int) $payload['exp'], + ], + ]); + } + + /** + * Cek ulang state administrator (double-check sebelum membuat sesi di OpenSID). + */ + protected function adminIsEligible(User $admin): bool + { + if (! $admin->active) { + return false; + } + + if ($admin->isLocked()) { + return false; + } + + if (! ($admin->hasRole('administrator') || $admin->isSuperAdmin())) { + return false; + } + + if (! $admin->{'2fa_enabled'}) { + return false; + } + + return true; + } + + /** + * Respons gagal generik (HTTP 200, hasil di status) + pencatatan audit. + * Token error yang tidak diketahui identitasnya tidak dicatat ke DB audit. + */ + protected function error(string $code, ?int $adminId = null, ?string $desaId = null, ?string $fingerprint = null): JsonResponse + { + if ($adminId !== null && $desaId !== null) { + $this->audit->logVerification( + $adminId, + $desaId, + SsoStatusEnum::FAILED, + strtolower($code), + request()->ip(), + request()->userAgent(), + $fingerprint, + ); + } + + return response()->json([ + 'status' => 'error', + 'code' => $code, + 'message' => 'Token tidak valid.', + ]); + } +} diff --git a/app/Http/Controllers/Sso/DesaSsoConfigController.php b/app/Http/Controllers/Sso/DesaSsoConfigController.php new file mode 100644 index 000000000..9ae591e0a --- /dev/null +++ b/app/Http/Controllers/Sso/DesaSsoConfigController.php @@ -0,0 +1,56 @@ +orderBy('desa_id')->paginate(20); + + return view('sso.desa_sso_config.index', compact('configs')); + } + + public function create(): View + { + return view('sso.desa_sso_config.form', ['config' => null]); + } + + public function store(SsoDesaConfigRequest $request): RedirectResponse + { + DesaSsoConfig::create($request->validated()); + + session()->flash('success', 'Konfigurasi SSO desa berhasil disimpan.'); + + return redirect()->route('sso-config.index'); + } + + public function edit(DesaSsoConfig $ssoConfig): View + { + return view('sso.desa_sso_config.form', ['config' => $ssoConfig]); + } + + public function update(SsoDesaConfigRequest $request, DesaSsoConfig $ssoConfig): RedirectResponse + { + $ssoConfig->update($request->validated()); + + session()->flash('success', 'Konfigurasi SSO desa berhasil diperbarui.'); + + return redirect()->route('sso-config.index'); + } + + public function destroy(DesaSsoConfig $ssoConfig): RedirectResponse + { + $ssoConfig->delete(); + + session()->flash('success', 'Konfigurasi SSO desa berhasil dihapus.'); + + return redirect()->route('sso-config.index'); + } +} diff --git a/app/Http/Controllers/Sso/SsoAuditController.php b/app/Http/Controllers/Sso/SsoAuditController.php new file mode 100644 index 000000000..f70a835b6 --- /dev/null +++ b/app/Http/Controllers/Sso/SsoAuditController.php @@ -0,0 +1,57 @@ +ajax()) { + return $this->fractal($this->list(), new SsoLogTransformer, 'sso-logs')->respond(); + } + + $admins = ['' => 'Semua'] + User::query()->orderBy('username')->pluck('username', 'id')->toArray(); + $statuses = [ + '' => 'Semua', + 'success' => 'Berhasil', + 'failed' => 'Gagal', + ]; + + return view('sso.audit', compact('admins', 'statuses')); + } + + protected function list() + { + return QueryBuilder::for(OpenKabSsoLog::query()->with(['admin'])) + ->allowedFields('*') + ->allowedFilters([ + AllowedFilter::callback('created_at', function ($query, $value) { + return $query->whereBetween('attempt_time', $value); + }), + AllowedFilter::exact('status'), + AllowedFilter::exact('desa_id'), + AllowedFilter::exact('admin_id'), + AllowedFilter::callback('search', function ($query, $value) { + $query->where(function ($query) use ($value) { + $query->where('desa_id', 'LIKE', '%'.$value.'%') + ->orWhere('ip_address', 'LIKE', '%'.$value.'%') + ->orWhere('reason_if_failed', 'LIKE', '%'.$value.'%'); + }); + }), + ]) + ->allowedSorts(['attempt_time']) + ->defaultSort('-attempt_time') + ->jsonPaginate(); + } +} diff --git a/app/Http/Controllers/Sso/SsoLoginController.php b/app/Http/Controllers/Sso/SsoLoginController.php new file mode 100644 index 000000000..e393f3295 --- /dev/null +++ b/app/Http/Controllers/Sso/SsoLoginController.php @@ -0,0 +1,142 @@ +user(); + $desaId = $request->validated('desa_id'); + + if (! $this->isOriginAllowed($request)) { + return $this->reject($user, $desaId, SsoStatusEnum::REASON_ORIGIN_INVALID, 'ORIGIN_INVALID', 403); + } + + $failure = $this->checkEligibility($user, $desaId); + if ($failure !== null) { + return $this->reject($user, $desaId, $failure['reason'], $failure['code'], $failure['status']); + } + + try { + $redirectUrl = $this->resolver->resolveAdminLoginUrl($desaId); + $issued = $this->tokens->issue($user->id, $desaId); + } catch (SsoConfigurationException $e) { + return $this->reject($user, $desaId, SsoStatusEnum::REASON_UNKNOWN, 'CONFIGURATION_ERROR', 500); + } + + $this->audit->logAttempt( + $user->id, + $desaId, + SsoStatusEnum::SUCCESS, + ip: $request->ip(), + userAgent: $request->userAgent(), + tokenFingerprint: $this->tokens->fingerprint($issued['jti']), + ); + + return response()->json([ + 'status' => 'success', + 'redirect_url' => $redirectUrl, + 'token' => $issued['token'], + 'token_method' => 'post_param', + 'expires_at' => $issued['expires_at'], + 'retry_after' => (int) config('sso.rate_limit_max', 5), + ]); + } + + /** + * Gate kelayakan berurutan, default-to-reject (fail-safe). + * + * @return array{reason: string, code: string, status: int}|null + */ + protected function checkEligibility(User $user, string $desaId): ?array + { + if (! ($user->hasRole('administrator') || $user->isSuperAdmin())) { + return ['reason' => SsoStatusEnum::REASON_BUKAN_ADMIN, 'code' => 'AUTH_FAILED', 'status' => 403]; + } + + if (session('2fa_verified') !== true) { + return ['reason' => SsoStatusEnum::REASON_2FA_BELUM_VERIFIKASI, 'code' => 'AUTH_FAILED', 'status' => 403]; + } + + if (! $user->{'2fa_enabled'}) { + return ['reason' => SsoStatusEnum::REASON_2FA_NONAKTIF, 'code' => 'AUTH_FAILED', 'status' => 403]; + } + + if (! $user->active) { + return ['reason' => SsoStatusEnum::REASON_AKUN_NONAKTIF, 'code' => 'AUTH_FAILED', 'status' => 403]; + } + + if ($user->isLocked()) { + return ['reason' => SsoStatusEnum::REASON_AKUN_TERKUNCI, 'code' => 'AUTH_FAILED', 'status' => 403]; + } + + if ($user->kode_kabupaten && ! Str::startsWith($desaId, (string) $user->kode_kabupaten)) { + return ['reason' => SsoStatusEnum::REASON_DESA_INVALID, 'code' => 'VALIDATION_FAILED', 'status' => 422]; + } + + return null; + } + + /** + * Validasi asal permintaan (Origin/Referer) untuk mencegah CSRF/cross-site. + */ + protected function isOriginAllowed(Request $request): bool + { + if (app()->environment(['local', 'testing'])) { + return true; + } + + $origin = $request->headers->get('Origin') ?: $request->headers->get('Referer'); + + if (! $origin) { + return false; + } + + $appUrl = rtrim((string) config('app.url'), '/'); + + return Str::startsWith($origin, $appUrl); + } + + /** + * Respons gagal generik + pencatatan audit. + */ + protected function reject(User $user, string $desaId, string $reason, string $code, int $status): JsonResponse + { + $this->audit->logAttempt( + $user->id, + $desaId, + SsoStatusEnum::FAILED, + $reason, + request()->ip(), + request()->userAgent(), + ); + + return response()->json([ + 'status' => 'error', + 'message' => 'Autentikasi gagal.', + 'code' => $code, + ], $status); + } +} diff --git a/app/Http/Middleware/SsoCallbackAuth.php b/app/Http/Middleware/SsoCallbackAuth.php new file mode 100644 index 000000000..1415aa97e --- /dev/null +++ b/app/Http/Middleware/SsoCallbackAuth.php @@ -0,0 +1,57 @@ +header('X-SSO-Callback-Key', ''); + $timestamp = (int) $request->header('X-SSO-Callback-Timestamp', 0); + $signature = (string) $request->header('X-SSO-Callback-Signature', ''); + + if (! hash_equals($secret, $key)) { + return $this->unauthorized(); + } + + $tolerance = (int) config('sso.clock_skew_tolerance', 30); + if ($timestamp === 0 || abs(time() - $timestamp) > $tolerance) { + return $this->unauthorized(); + } + + $expected = hash_hmac('sha256', $request->getContent(), $secret); + if (! hash_equals($expected, $signature)) { + return $this->unauthorized(); + } + + return $next($request); + } + + protected function unauthorized(): JsonResponse + { + return response()->json([ + 'status' => 'error', + 'code' => 'CALLBACK_UNAUTHORIZED', + 'message' => 'Autentikasi gagal.', + ], 401); + } +} diff --git a/app/Http/Middleware/SsoIpWhitelist.php b/app/Http/Middleware/SsoIpWhitelist.php new file mode 100644 index 000000000..f0309c144 --- /dev/null +++ b/app/Http/Middleware/SsoIpWhitelist.php @@ -0,0 +1,39 @@ +ip(), $whitelist, true)) { + return $next($request); + } + + return $this->forbidden(); + } + + protected function forbidden(): JsonResponse + { + return response()->json([ + 'status' => 'error', + 'code' => 'CALLBACK_UNAUTHORIZED', + 'message' => 'Autentikasi gagal.', + ], 403); + } +} diff --git a/app/Http/Requests/SsoDesaConfigRequest.php b/app/Http/Requests/SsoDesaConfigRequest.php new file mode 100644 index 000000000..e3cdd44fa --- /dev/null +++ b/app/Http/Requests/SsoDesaConfigRequest.php @@ -0,0 +1,63 @@ +check(); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + $desaId = $this->route('sso_config')?->id; + + return [ + 'desa_id' => [ + 'required', + 'string', + 'regex:/^\d{10,13}$/', + 'unique:desa_sso_configs,desa_id,'.$desaId, + ], + 'opensid_url' => [ + 'required', + 'string', + 'max:255', + function ($attribute, $value, $fail) { + $scheme = parse_url($value, PHP_URL_SCHEME); + $host = parse_url($value, PHP_URL_HOST); + + if (! in_array($scheme, ['http', 'https'], true) || ! $host) { + $fail('URL OpenSID tidak valid.'); + + return; + } + + if ($scheme === 'http' && ! app()->environment(['local', 'testing'])) { + $fail('URL OpenSID wajib menggunakan HTTPS.'); + } + }, + ], + 'enabled' => ['nullable', 'boolean'], + ]; + } + + protected function prepareForValidation(): void + { + $this->merge([ + 'enabled' => $this->boolean('enabled'), + ]); + } +} diff --git a/app/Http/Requests/SsoGenerateRequest.php b/app/Http/Requests/SsoGenerateRequest.php new file mode 100644 index 000000000..9edf88380 --- /dev/null +++ b/app/Http/Requests/SsoGenerateRequest.php @@ -0,0 +1,29 @@ +check(); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'desa_id' => ['required', 'string', 'regex:/^\d{10,13}$/'], + ]; + } +} diff --git a/app/Http/Transformers/SsoLogTransformer.php b/app/Http/Transformers/SsoLogTransformer.php new file mode 100644 index 000000000..618989952 --- /dev/null +++ b/app/Http/Transformers/SsoLogTransformer.php @@ -0,0 +1,30 @@ +attempt_time + ? Carbon::parse($log->attempt_time)->setTimezone(config('app.timezone'))->format('d-m-Y H:i:s') + : null; + + return [ + 'id' => $log->id, + 'admin_name' => $log->admin?->name, + 'admin_username' => $log->admin?->username, + 'desa_id' => $log->desa_id, + 'attempt_time' => $date, + 'status' => $log->status, + 'reason_if_failed' => $log->reason_if_failed, + 'ip_address' => $log->ip_address, + 'user_agent' => $log->user_agent, + 'token_fingerprint' => $log->token_fingerprint, + ]; + } +} diff --git a/app/Models/Sso/DesaSsoConfig.php b/app/Models/Sso/DesaSsoConfig.php new file mode 100644 index 000000000..0f3ddae2b --- /dev/null +++ b/app/Models/Sso/DesaSsoConfig.php @@ -0,0 +1,37 @@ + 'boolean', + ]; + + public function scopeEnabled(Builder $query): Builder + { + return $query->where('enabled', true); + } + + /** + * Validasi dasar URL OpenSID. Pengecekan skema https dilakukan oleh + * OpenSidUrlResolver sesuai environment. + */ + public static function validateUrl(string $url): bool + { + $parsed = parse_url($url); + + if (! $parsed || empty($parsed['scheme']) || empty($parsed['host'])) { + return false; + } + + return in_array($parsed['scheme'], ['https', 'http'], true); + } +} diff --git a/app/Models/Sso/OpenKabSsoLog.php b/app/Models/Sso/OpenKabSsoLog.php new file mode 100644 index 000000000..efe3caa84 --- /dev/null +++ b/app/Models/Sso/OpenKabSsoLog.php @@ -0,0 +1,75 @@ + 'datetime', + ]; + + public function admin(): BelongsTo + { + return $this->belongsTo(User::class, 'admin_id'); + } + + protected static function booted(): void + { + static::updating(function () { + throw new LogicException('Log audit SSO bersifat append-only dan tidak dapat diubah.'); + }); + + static::deleting(function () { + if (! static::$forceDelete) { + throw new LogicException('Log audit SSO bersifat append-only dan tidak dapat dihapus.'); + } + }); + } + + /** + * Hapus baris untuk pembersihan resmi (super admin). + * Selalu menulis jejak activitylog agar tetap dapat diaudit. + */ + public function forceDeleteForAudit(): void + { + static::$forceDelete = true; + + try { + activity('sso-audit') + ->causedBy(auth()->user()) + ->performedOn($this) + ->withProperties([ + 'admin_id' => $this->admin_id, + 'desa_id' => $this->desa_id, + 'attempt_time' => $this->attempt_time?->toDateTimeString(), + ]) + ->log('Log audit SSO dihapus secara paksa'); + + $this->delete(); + } finally { + static::$forceDelete = false; + } + } + + public function scopeForAuditDashboard(Builder $query): Builder + { + return $query->latest('attempt_time'); + } +} diff --git a/app/Models/Sso/OpenKabSsoToken.php b/app/Models/Sso/OpenKabSsoToken.php new file mode 100644 index 000000000..d246f459d --- /dev/null +++ b/app/Models/Sso/OpenKabSsoToken.php @@ -0,0 +1,51 @@ + 'datetime', + 'used_at' => 'datetime', + ]; + + public function admin(): BelongsTo + { + return $this->belongsTo(User::class, 'admin_id'); + } + + /** + * Konsumsi token secara atomik (sekali pakai). + * Hanya berhasil bila jti dikenal dan belum pernah digunakan. + */ + public static function consumeAtomic(string $jti, ?string $ip = null): bool + { + return (bool) static::query() + ->where('jti', $jti) + ->whereNull('used_at') + ->update([ + 'used_at' => now(), + 'consumed_by_ip' => $ip, + ]); + } + + /** + * Ambil token yang masih aktif (belum dipakai, belum kedaluwarsa) berdasarkan fingerprint. + */ + public static function activeByFingerprint(string $fingerprint): ?self + { + return static::query() + ->where('token_fingerprint', $fingerprint) + ->whereNull('used_at') + ->where('expires_at', '>', now()) + ->first(); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f130cfbbd..8d94c0e9a 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,25 +2,28 @@ namespace App\Providers; +use App\Enums\SsoStatusEnum; use App\Http\Transformers\IdentitasTransformer; use App\Http\Transformers\SettingTransformer; use App\Models\Identitas; use App\Models\Setting; +use App\Models\User; +use App\Observers\UserObserver; +use App\Observers\VisitorObserver; +use App\Services\SsoAuditLogger; +use Exception; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\View; use Illuminate\Support\ServiceProvider; -use App\Models\User; -use App\Observers\UserObserver; -use App\Observers\VisitorObserver; -use Exception; -use Illuminate\Support\Facades\Log; +use League\Fractal\Serializer\JsonApiSerializer; use Shetabit\Visitor\Models\Visit; class AppServiceProvider extends ServiceProvider @@ -45,7 +48,7 @@ public function boot(): void $this->bootHttps(); $this->addValidation(); $this->addLogQuery(); - + try { $this->shareViewIdentitas(); } catch (Exception $e) { @@ -56,14 +59,14 @@ public function boot(): void $identitasAplikasi = fractal( Identitas::first(), IdentitasTransformer::class, - \League\Fractal\Serializer\JsonApiSerializer::class + JsonApiSerializer::class )->toArray()['data']['attributes']; $settingAplikasi = collect( fractal( Setting::all(), SettingTransformer::class, - \League\Fractal\Serializer\JsonApiSerializer::class + JsonApiSerializer::class )->toArray()['data'] )->pluck('attributes.value', 'attributes.key'); @@ -85,14 +88,14 @@ private function shareViewIdentitas(): void $identitasAplikasi = fractal( Identitas::first(), IdentitasTransformer::class, - \League\Fractal\Serializer\JsonApiSerializer::class + JsonApiSerializer::class )->toArray()['data']['attributes']; $settingAplikasi = collect( fractal( Setting::all(), SettingTransformer::class, - \League\Fractal\Serializer\JsonApiSerializer::class + JsonApiSerializer::class )->toArray()['data'] )->pluck('attributes.value', 'attributes.key'); @@ -115,6 +118,51 @@ protected function configureRateLimiting(): void RateLimiter::for('api', function (Request $request) { return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()); }); + + RateLimiter::for('sso-generate', function (Request $request) { + $key = ($request->user()?->getAuthIdentifier() ?? 'guest').':'.$request->ip(); + + return Limit::perMinute((int) config('sso.rate_limit_max', 5)) + ->by($key) + ->response(function (Request $request, array $headers) { + $availableIn = max(1, (int) ($headers['X-RateLimit-Reset'] ?? 1)); + + $this->logSsoRateLimitedAttempt($request); + + return response()->json([ + 'status' => 'error', + 'message' => 'Terlalu banyak permintaan. Coba lagi nanti.', + 'code' => 'RATE_LIMITED', + 'retry_after' => $availableIn, + ], 429, ['Retry-After' => $availableIn]); + }); + }); + } + + /** + * Catat percobaan yang diblokir rate limit ke log audit SSO. + */ + protected function logSsoRateLimitedAttempt(Request $request): void + { + $user = $request->user(); + $desaId = (string) $request->input('desa_id', ''); + + if (! $user || $desaId === '') { + return; + } + + try { + app(SsoAuditLogger::class)->logAttempt( + $user->getAuthIdentifier(), + $desaId, + SsoStatusEnum::FAILED, + SsoStatusEnum::REASON_RATE_LIMITED, + $request->ip(), + $request->userAgent(), + ); + } catch (\Throwable $e) { + Log::error('Gagal mencatat attempt SSO yang di-rate-limit', ['exception' => $e]); + } } public function bootHttps() diff --git a/app/Services/OpenSidUrlResolver.php b/app/Services/OpenSidUrlResolver.php new file mode 100644 index 000000000..8338f0496 --- /dev/null +++ b/app/Services/OpenSidUrlResolver.php @@ -0,0 +1,54 @@ +where('desa_id', $desaId) + ->enabled() + ->first(); + + $base = rtrim((string) ($config?->opensid_url ?: config('sso.opensid_base_url')), '/'); + + if ($base === '') { + throw new SsoConfigurationException('URL OpenSID untuk desa ini belum dikonfigurasi.'); + } + + $scheme = parse_url($base, PHP_URL_SCHEME); + $isLocalEnv = app()->environment(['local', 'testing']); + + if (! in_array($scheme, ['http', 'https'], true)) { + throw new SsoConfigurationException('URL OpenSID tidak valid.'); + } + + if ($scheme === 'http' && ! $isLocalEnv) { + throw new SsoConfigurationException('URL OpenSID wajib menggunakan HTTPS di luar lingkungan lokal.'); + } + + return $base; + } + + /** + * URL endpoint login SSO di OpenSID untuk desa. + */ + public function resolveAdminLoginUrl(string $desaId): string + { + $path = (string) config('sso.endpoint.sso_login', 'admin/sso-login'); + + return $this->resolveBaseUrl($desaId).'/'.ltrim($path, '/'); + } +} diff --git a/app/Services/SsoAuditLogger.php b/app/Services/SsoAuditLogger.php new file mode 100644 index 000000000..ef1f4e84c --- /dev/null +++ b/app/Services/SsoAuditLogger.php @@ -0,0 +1,65 @@ +create($adminId, $desaId, $status, $reason, $ip, $userAgent, $tokenFingerprint); + } + + /** + * Catat percobaan verifikasi token dari sisi OpenSID (callback). + */ + public function logVerification( + int $adminId, + string $desaId, + string $status, + ?string $reason = null, + ?string $ip = null, + ?string $userAgent = null, + ?string $tokenFingerprint = null, + ): OpenKabSsoLog { + return $this->create($adminId, $desaId, $status, $reason, $ip, $userAgent, $tokenFingerprint); + } + + protected function create( + int $adminId, + string $desaId, + string $status, + ?string $reason, + ?string $ip, + ?string $userAgent, + ?string $tokenFingerprint, + ): OpenKabSsoLog { + $request = request(); + + return OpenKabSsoLog::create([ + 'admin_id' => $adminId, + 'desa_id' => $desaId, + 'attempt_time' => now(), + 'status' => $status, + 'reason_if_failed' => $reason, + 'ip_address' => $ip ?? $request->ip(), + 'user_agent' => Str::limit($userAgent ?? $request->userAgent() ?? 'unknown', 255, ''), + 'token_fingerprint' => $tokenFingerprint, + ]); + } +} diff --git a/app/Services/SsoKeyManager.php b/app/Services/SsoKeyManager.php new file mode 100644 index 000000000..be8b0b92a --- /dev/null +++ b/app/Services/SsoKeyManager.php @@ -0,0 +1,134 @@ +load('sso.signing_private_key', 'sso.signing_private_key_file'); + + if ($key === '') { + throw new SsoConfigurationException('SSO_SIGNING_PRIVATE_KEY belum dikonfigurasi.'); + } + + return $this->assertPrivate($key); + } + + /** + * Public key PEM yang sedang aktif (untuk verifikasi & self-check). + */ + public function publicKey(): string + { + $key = $this->load('sso.signing_public_key', 'sso.signing_public_key_file'); + + if ($key === '') { + throw new SsoConfigurationException('SSO_SIGNING_PUBLIC_KEY belum dikonfigurasi.'); + } + + return $this->assertPublic($key); + } + + /** + * Daftar public key PEM yang diterima verifikasi (kunci aktif + kunci lama). + * + * @return array + */ + public function publicKeys(): array + { + $keys = [$this->publicKey()]; + + foreach ((array) config('sso.signing_public_keys_file', []) as $file) { + if ($file !== '') { + $keys[] = $this->assertPublic($this->readFile($file)); + } + } + + return $keys; + } + + /** + * Muat material kunci dari nilai langsung env atau file PEM. + */ + protected function load(string $valueKey, string $fileKey): string + { + $value = (string) config($valueKey, ''); + + if ($value !== '') { + return $value; + } + + $file = (string) config($fileKey, ''); + + if ($file === '') { + return ''; + } + + return $this->readFile($file); + } + + protected function readFile(string $path): string + { + $full = str_starts_with($path, '/') || preg_match('#^[A-Za-z]:[\\\\/]#', $path) + ? $path + : base_path($path); + + if (! File::exists($full)) { + throw new SsoConfigurationException(sprintf('File kunci SSO tidak ditemukan: %s', $path)); + } + + return (string) File::get($full); + } + + protected function assertPrivate(string $pem): string + { + $key = openssl_pkey_get_private($pem); + + if ($key === false) { + throw new SsoConfigurationException('Private key SSO tidak valid.'); + } + + $details = openssl_pkey_get_details($key); + + if (! $details || ($details['bits'] ?? 0) < self::MIN_BITS) { + throw new SsoConfigurationException(sprintf('Private key SSO wajib RSA minimal %d bit.', self::MIN_BITS)); + } + + return $pem; + } + + protected function assertPublic(string $pem): string + { + $key = openssl_pkey_get_public($pem); + + if ($key === false) { + throw new SsoConfigurationException('Public key SSO tidak valid.'); + } + + $details = openssl_pkey_get_details($key); + + if (! $details || ($details['bits'] ?? 0) < self::MIN_BITS) { + throw new SsoConfigurationException(sprintf('Public key SSO wajib RSA minimal %d bit.', self::MIN_BITS)); + } + + return $pem; + } +} diff --git a/app/Services/SsoTokenService.php b/app/Services/SsoTokenService.php new file mode 100644 index 000000000..5d9f52759 --- /dev/null +++ b/app/Services/SsoTokenService.php @@ -0,0 +1,159 @@ +ttl(); + $now = time(); + $jti = (string) Str::uuid(); + + $payload = [ + 'jti' => $jti, + 'iat' => $now, + 'nbf' => $now, + 'exp' => $now + $ttl, + 'sub' => (string) $adminId, + 'desa_id' => $desaId, + 'iss' => config('sso.issuer'), + 'aud' => config('sso.audience'), + ]; + + $token = JWT::encode($payload, $this->keys->privateKey(), self::ALG()); + + OpenKabSsoToken::create([ + 'jti' => $jti, + 'admin_id' => $adminId, + 'desa_id' => $desaId, + 'token_fingerprint' => $this->fingerprint($jti), + 'expires_at' => now()->addSeconds($ttl), + ]); + + return [ + 'token' => $token, + 'expires_at' => $payload['exp'], + 'jti' => $jti, + ]; + } + + /** + * Verifikasi tanda tangan RS256, masa berlaku, issuer, audience, dan desa. + * + * Melempar exception Firebase berikut bila gagal (di-handle pemanggil): + * - Firebase\JWT\ExpiredException → token kedaluwarsa + * - Firebase\JWT\BeforeValidException → token belum berlaku (iat/nbf) + * - Firebase\JWT\UnexpectedValueException → token rusak/alg tak dikenal + * - Firebase\JWT\SignatureInvalidException → tanda tangan tidak cocok + * - App\Exceptions\SsoConfigurationException → key/claim tidak valid + * + * @param string|null $expectedDesaId jika diberikan, desa target token wajib cocok + * @return array payload terverifikasi + */ + public function verify(string $token, ?string $expectedDesaId = null): array + { + $previousLeeway = JWT::$leeway; + JWT::$leeway = (int) config('sso.clock_skew_tolerance', 30); + + try { + $payload = $this->decodeWithAnyPublicKey($token); + } finally { + JWT::$leeway = $previousLeeway; + } + + $data = (array) $payload; + + if (($data['iss'] ?? null) !== config('sso.issuer')) { + throw new SsoConfigurationException('Issuer token tidak cocok.'); + } + + if (($data['aud'] ?? null) !== config('sso.audience')) { + throw new SsoConfigurationException('Audience token tidak cocok.'); + } + + if ($expectedDesaId !== null && ($data['desa_id'] ?? null) !== $expectedDesaId) { + throw new SsoConfigurationException('Desa target pada token tidak cocok.'); + } + + if (empty($data['jti']) || empty($data['sub'])) { + throw new SsoConfigurationException('Klaim token tidak lengkap.'); + } + + return $data; + } + + /** + * Coba verifikasi terhadap seluruh public key yang diterima (rotasi transisi). + * Algoritma dipaksa RS256 pada Key sehingga token dengan alg lain (mis. HS256) + * ditolak (anti downgrade). + */ + protected function decodeWithAnyPublicKey(string $token): object + { + $lastSignatureError = null; + + foreach ($this->keys->publicKeys() as $publicKey) { + try { + return JWT::decode($token, new Key($publicKey, SsoKeyManager::ALG)); + } catch (ExpiredException|BeforeValidException $e) { + // Tanda tangan terverifikasi untuk kunci ini; token valid secara + // kriptografis tetapi tidak berlaku waktu → diagnosis akurat. + throw $e; + } catch (\Throwable $e) { + $lastSignatureError = $e; + } + } + + throw $lastSignatureError instanceof \Throwable + ? $lastSignatureError + : new \UnexpectedValueException('Tidak ada public key untuk verifikasi.'); + } + + /** + * Algoritma tanda tangan (RS256). + */ + public static function ALG(): string + { + return SsoKeyManager::ALG; + } + + /** + * Masa berlaku token (detik), dibatasi oleh batas atas konfigurasi. + */ + public function ttl(): int + { + $max = (int) config('sso.token_ttl_max', 600); + $ttl = (int) config('sso.token_ttl', 300); + + return max(60, min($ttl, $max)); + } + + /** + * Sidik jari token untuk log & lookup (hash dari jti). + */ + public function fingerprint(string $jti): string + { + return hash('sha256', $jti); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 33a42e6de..9e00cf3e1 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,17 +1,55 @@ use([ ValidatePathEncoding::class, InvokeDeferredCallbacks::class, - App\Http\Middleware\TrustProxies::class, - \Illuminate\Http\Middleware\HandleCors::class, - App\Http\Middleware\PreventRequestsDuringMaintenance::class, - \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class, - App\Http\Middleware\TrimStrings::class, - \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class, - App\Http\Middleware\GlobalRateLimiter::class, + TrustProxies::class, + HandleCors::class, + PreventRequestsDuringMaintenance::class, + ValidatePostSize::class, + TrimStrings::class, + ConvertEmptyStringsToNull::class, + GlobalRateLimiter::class, ]); $middleware->group('web', [ - App\Http\Middleware\EncryptCookies::class, - \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, - \Illuminate\Session\Middleware\StartSession::class, - \Illuminate\View\Middleware\ShareErrorsFromSession::class, - App\Http\Middleware\VerifyCsrfToken::class, - \Illuminate\Routing\Middleware\SubstituteBindings::class, - App\Http\Middleware\CspExclusion::class, - \Spatie\Csp\AddCspHeaders::class, + EncryptCookies::class, + AddQueuedCookiesToResponse::class, + StartSession::class, + ShareErrorsFromSession::class, + VerifyCsrfToken::class, + SubstituteBindings::class, + CspExclusion::class, + AddCspHeaders::class, ]); $middleware->group('api', [ - \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class, + EnsureFrontendRequestsAreStateful::class, 'throttle:api', - \Illuminate\Routing\Middleware\SubstituteBindings::class, + SubstituteBindings::class, ]); $middleware->alias([ - 'auth' => App\Http\Middleware\Authenticate::class, - 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, - 'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class, - 'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class, - 'can' => \Illuminate\Auth\Middleware\Authorize::class, - 'guest' => App\Http\Middleware\RedirectIfAuthenticated::class, - 'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class, - 'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class, - 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, - 'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class, - 'abilities' => \Laravel\Sanctum\Http\Middleware\CheckAbilities::class, - 'ability' => \Laravel\Sanctum\Http\Middleware\CheckForAnyAbility::class, - 'role' => \Spatie\Permission\Middleware\RoleMiddleware::class, - 'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class, - 'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class, - 'teams_permission' => App\Http\Middleware\TeamsPermission::class, - 'password.weak' => App\Http\Middleware\WeakPassword::class, - 'password.expiry' => App\Http\Middleware\CheckPasswordExpiry::class, - 'website.enable' => App\Http\Middleware\WebsiteEnable::class, - 'log.visitor' => \Shetabit\Visitor\Middlewares\LogVisits::class, - 'easyauthorize' => App\Http\Middleware\EasyAuthorize::class, - 'check.presisi' => App\Http\Middleware\CheckPresisiStatus::class, - '2fa' => App\Http\Middleware\TwoFactorMiddleware::class, + 'auth' => Authenticate::class, + 'auth.basic' => AuthenticateWithBasicAuth::class, + 'auth.session' => AuthenticateSession::class, + 'cache.headers' => SetCacheHeaders::class, + 'can' => Authorize::class, + 'guest' => RedirectIfAuthenticated::class, + 'password.confirm' => RequirePassword::class, + 'signed' => ValidateSignature::class, + 'throttle' => ThrottleRequests::class, + 'verified' => EnsureEmailIsVerified::class, + 'abilities' => CheckAbilities::class, + 'ability' => CheckForAnyAbility::class, + 'role' => RoleMiddleware::class, + 'permission' => PermissionMiddleware::class, + 'role_or_permission' => RoleOrPermissionMiddleware::class, + 'teams_permission' => TeamsPermission::class, + 'password.weak' => WeakPassword::class, + 'password.expiry' => CheckPasswordExpiry::class, + 'website.enable' => WebsiteEnable::class, + 'log.visitor' => LogVisits::class, + 'easyauthorize' => EasyAuthorize::class, + 'check.presisi' => CheckPresisiStatus::class, + '2fa' => TwoFactorMiddleware::class, + 'sso.callback' => SsoCallbackAuth::class, + 'sso.ip-whitelist' => SsoIpWhitelist::class, ]); $middleware->statefulApi(); diff --git a/composer.json b/composer.json index 6c1cb8b49..0ea1dc40c 100644 --- a/composer.json +++ b/composer.json @@ -14,6 +14,7 @@ "bensampo/laravel-enum": "^6.3", "cviebrock/eloquent-sluggable": "^13.0", "diglactic/laravel-breadcrumbs": "^10.1", + "firebase/php-jwt": "^7.0", "guzzlehttp/guzzle": "^7.2", "intervention/image": "^3.0", "intervention/image-laravel": "^1.2", diff --git a/composer.lock b/composer.lock index 352145a04..61acede08 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "27d97b095c210931e907629054c7d0ad", + "content-hash": "3ad59d3d9268f66edc486be8f53f3447", "packages": [ { "name": "akaunting/laravel-apexcharts", @@ -1365,6 +1365,72 @@ }, "time": "2020-10-16T08:27:54+00:00" }, + { + "name": "firebase/php-jwt", + "version": "v7.1.0", + "source": { + "type": "git", + "url": "https://github.com/googleapis/php-jwt.git", + "reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/b374a5d1a4f1f67fadc2165cdb284645945e2fc0", + "reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpfastcache/phpfastcache": "^9.2", + "phpseclib/phpseclib": "~3.0", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present", + "phpseclib/phpseclib": "Support PS256 (RSASSA-PSS) signatures" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/googleapis/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/googleapis/php-jwt/issues", + "source": "https://github.com/googleapis/php-jwt/tree/v7.1.0" + }, + "time": "2026-06-11T17:54:14+00:00" + }, { "name": "fruitcake/php-cors", "version": "v1.4.0", diff --git a/config/sso.php b/config/sso.php new file mode 100644 index 000000000..a5d57fdec --- /dev/null +++ b/config/sso.php @@ -0,0 +1,61 @@ + (int) env('SSO_TOKEN_TTL', 300), + + // Batas atas masa berlaku token (detik) — tidak dapat dilewati lewat env. + 'token_ttl_max' => 600, + + // Toleransi selisih jam antar sistem (detik). + 'clock_skew_tolerance' => (int) env('SSO_CLOCK_SKEW_TOLERANCE', 30), + + // Private key RS256 (nilai PEM langsung) — HANYA di OpenKab. + 'signing_private_key' => (string) env('SSO_SIGNING_PRIVATE_KEY', ''), + + // Private key RS256 dari file PEM (relatif terhadap base_path bila bukan absolut). + 'signing_private_key_file' => (string) env('SSO_SIGNING_PRIVATE_KEY_FILE', ''), + + // Public key RS256 aktif (nilai PEM langsung); versi yang sama diberikan ke OpenSID. + 'signing_public_key' => (string) env('SSO_SIGNING_PUBLIC_KEY', ''), + + // Public key RS256 aktif dari file PEM. + 'signing_public_key_file' => (string) env('SSO_SIGNING_PUBLIC_KEY_FILE', ''), + + // Public key tambahan (file PEM, koma) untuk rotasi transisi. + 'signing_public_keys_file' => array_values(array_filter(array_map('trim', explode(',', (string) env('SSO_SIGNING_PUBLIC_KEYS_FILE', ''))))), + + // Sekret callback verify-token (dibagikan ke OpenSID). + 'callback_secret' => (string) env('SSO_CALLBACK_SECRET', ''), + + // Base URL fallback instalasi OpenSID (tanpa trailing slash). + 'opensid_base_url' => rtrim((string) env('SSO_OPENSID_BASE_URL', ''), '/'), + + // Daftar IP yang diizinkan memanggil endpoint callback (kosong = semua). + 'ip_whitelist' => array_values(array_filter(array_map('trim', explode(',', (string) env('SSO_IP_WHITELIST', ''))))), + + // Maksimum permintaan generate-session per menit per user+IP. + 'rate_limit_max' => (int) env('SSO_RATE_LIMIT_MAX', 5), + + // Klaim iss / aud pada token. + 'issuer' => env('APP_URL', 'openkab'), + + 'audience' => env('SSO_AUDIENCE', 'opensid'), + + 'endpoint' => [ + // Path login SSO di sisi OpenSID (relatif terhadap base URL OpenSID). + 'sso_login' => 'admin/sso-login', + // Path verifikasi di sisi OpenKab. + 'verify' => '/api/v1/sso/verify-token', + ], +]; diff --git a/database/migrations/2026_08_08_000001_create_openkab_sso_logs_table.php b/database/migrations/2026_08_08_000001_create_openkab_sso_logs_table.php new file mode 100644 index 000000000..47630e7a8 --- /dev/null +++ b/database/migrations/2026_08_08_000001_create_openkab_sso_logs_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('admin_id')->constrained('users')->onDelete('cascade'); + $table->string('desa_id', 20); + $table->timestamp('attempt_time'); + $table->string('status', 20); + $table->string('reason_if_failed', 255)->nullable(); + $table->string('ip_address', 45); + $table->string('user_agent', 255); + $table->string('token_fingerprint', 64)->nullable(); + $table->timestamps(); + + $table->index(['admin_id', 'attempt_time'], 'idx_admin_time'); + $table->index(['desa_id', 'attempt_time'], 'idx_desa_time'); + $table->index(['status', 'attempt_time'], 'idx_status_time'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('openkab_sso_logs'); + } +}; diff --git a/database/migrations/2026_08_08_000002_create_openkab_sso_tokens_table.php b/database/migrations/2026_08_08_000002_create_openkab_sso_tokens_table.php new file mode 100644 index 000000000..a6da051d8 --- /dev/null +++ b/database/migrations/2026_08_08_000002_create_openkab_sso_tokens_table.php @@ -0,0 +1,36 @@ +id(); + $table->uuid('jti')->unique(); + $table->foreignId('admin_id')->constrained('users')->onDelete('cascade'); + $table->string('desa_id', 20); + $table->string('token_fingerprint', 64)->unique(); + $table->timestamp('expires_at'); + $table->timestamp('used_at')->nullable(); + $table->string('consumed_by_ip', 45)->nullable(); + $table->timestamps(); + + $table->index(['used_at', 'expires_at'], 'idx_active'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('openkab_sso_tokens'); + } +}; diff --git a/database/migrations/2026_08_08_000003_create_desa_sso_configs_table.php b/database/migrations/2026_08_08_000003_create_desa_sso_configs_table.php new file mode 100644 index 000000000..f5b6b7e3a --- /dev/null +++ b/database/migrations/2026_08_08_000003_create_desa_sso_configs_table.php @@ -0,0 +1,30 @@ +id(); + $table->string('desa_id', 20)->unique(); + $table->string('opensid_url', 255); + $table->boolean('enabled')->default(true); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('desa_sso_configs'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 479f68392..21480b72d 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -16,5 +16,6 @@ public function run() { /* untuk IdentitasSeeder dan CreateAdminUserSeeder sudah dipanggil dalam migration */ $this->call(SettingSeeder::class); + $this->call(SsoPermissionsSeeder::class); } } diff --git a/database/seeders/SsoPermissionsSeeder.php b/database/seeders/SsoPermissionsSeeder.php new file mode 100644 index 000000000..429582ec7 --- /dev/null +++ b/database/seeders/SsoPermissionsSeeder.php @@ -0,0 +1,26 @@ +where('name', 'administrator')->where('guard_name', 'web')->first(); + + if ($role) { + if (! $role->hasPermissionTo($permission)) { + $role->givePermissionTo($permission); + } + } + } +} diff --git a/docs/sso-opensid.md b/docs/sso-opensid.md new file mode 100644 index 000000000..d80bad675 --- /dev/null +++ b/docs/sso-opensid.md @@ -0,0 +1,112 @@ +# SSO OpenSID: Panduan Deployment & Operasional + +**Feature**: SSO Akses Panel Admin OpenSID dari OpenKab +**Branch**: `2502-opensid-sso-access` | **Tanggal**: 2026-08-08 + +## Ringkasan + +Administrator OpenKab (sesi aktif + 2FA selesai) dapat masuk ke panel admin OpenSID desa tanpa login ulang melalui tombol "Masuk ke OpenSID" pada halaman Data Desa. OpenKab menerbitkan token JWT **RS256** (RSA-2048) berumur pendek (maksimal 10 menit, default 5) yang hanya dapat digunakan sekali; OpenSID memverifikasi token ke OpenKab sebelum membuat sesi. Private key hanya disimpan di OpenKab; public key didistribusikan ke instalasi OpenSID. + +## Prasyarat + +- PHP 8.4, Laravel 13 (repositori OpenKab ini). +- MySQL dengan 3 tabel baru hasil migrasi: + - `openkab_sso_logs` (audit append-only) + - `openkab_sso_tokens` (token sekali pakai / anti-replay) + - `desa_sso_configs` (override URL OpenSID per desa, opsional) +- Dependency baru: `firebase/php-jwt` (^7.0). v6.x tidak digunakan karena terdampak security advisory. +- Sisi OpenSID (repo terpisah) harus menerapkan `specs/001-opensid-sso-access/contracts/opensid-sso-contract.md`. + +## Langkah Deployment + +1. **Pasang dependency** + + ```bash + composer install + ``` + +2. **Generasi & konfigurasi kunci RS256** (sekali saat pemasangan) + + ```bash + openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out sso-private.pem + openssl pkey -in sso-private.pem -pubout -out sso-public.pem + chmod 600 sso-private.pem + ``` + + Sebarkan **`sso-public.pem`** ke setiap instalasi OpenSID (out-of-band); private key + **tidak pernah** dibagikan. Tambahkan ke `.env` produksi: + + ```env + # Wajib: private key hanya di OpenKab; public key versi yang sama untuk OpenSID. + SSO_SIGNING_PRIVATE_KEY_FILE=/path/sso-private.pem # atau SSO_SIGNING_PRIVATE_KEY= + SSO_SIGNING_PUBLIC_KEY_FILE=/path/sso-public.pem # atau SSO_SIGNING_PUBLIC_KEY= + SSO_SIGNING_PUBLIC_KEYS_FILE= # public key lama (koma) untuk rotasi transisi + + # Wajib, minimal 32 byte. Nilai dibagikan ke instalasi OpenSID. + SSO_CALLBACK_SECRET= + + # Opsional + SSO_TOKEN_TTL=300 # detik; maksimum 600 + SSO_OPENSID_BASE_URL= # fallback base URL OpenSID + SSO_IP_WHITELIST= # IP callback yang diizinkan (koma), kosong = semua + SSO_RATE_LIMIT_MAX=5 # generate-session per menit per user+IP + SSO_CLOCK_SKEW_TOLERANCE=30 # toleransi selisih jam callback + ``` + + Generate secret contoh: `php -r "echo bin2hex(random_bytes(32));"`. + + **Peringatan**: `SSO_SIGNING_PRIVATE_KEY`/`SSO_SIGNING_PUBLIC_KEY` (atau file) dan + `SSO_CALLBACK_SECRET` HARUS di-set. Aplikasi menolak operasi SSO bila private key + tidak valid RSA ≥2048-bit atau callback secret < 32 byte. + +3. **Migrasi & seeder** + + ```bash + php artisan migrate + php artisan db:seed --class=SsoPermissionsSeeder # menambah permission sso-audit-read + php artisan admin:menu-update # memperbarui menu admin (Audit Akses SSO) + ``` + +4. **Konfigurasi URL OpenSID per desa (opsional)** + - Bila semua desa memakai satu base URL: cukup `SSO_OPENSID_BASE_URL`. + - Bila berbeda per desa: gunakan menu Pengaturan OpenSID → "Konfigurasi SSO Desa" (`/pengaturan/sso-config`) atau isi tabel `desa_sso_configs`. + - Resolver memakai: baris `desa_sso_configs` (enabled) → fallback `SSO_OPENSID_BASE_URL`. + +5. **Integrasi sisi OpenSID** + - Implementasi `POST /admin/sso-login` dan tabel `opensid_sso_logs` sesuai kontrak. + - OpenSID memanggil `POST {openkab}/api/v1/sso/verify-token` dengan header `X-SSO-Callback-Key`, `X-SSO-Callback-Timestamp`, `X-SSO-Callback-Signature` (HMAC-SHA256 atas body memakai `SSO_CALLBACK_SECRET`). + +## Operasional + +### Rotasi kunci RS256 & secret callback +1. Terbitkan keypair baru (`openssl genpkey ... 2048`), set `SSO_SIGNING_PRIVATE_KEY(FILE)`/`SSO_SIGNING_PUBLIC_KEY(FILE)` baru di OpenKab, dan set `SSO_CALLBACK_SECRET` baru di kedua sistem. +2. Tambahkan public key lama ke `SSO_SIGNING_PUBLIC_KEYS_FILE` OpenKab selama masa transisi, dan sebarkan public key baru ke OpenSID. +3. Token yang beredar berumur pendek (≤10 menit), sehingga rotasi berdampak seketika. +4. Setelah masa transisi, hapus public key lama dari `SSO_SIGNING_PUBLIC_KEYS_FILE`. +5. Verifikasi `php artisan test tests/Feature/Sso` pada lingkungan staging setelah rotasi. + +### Pembersihan token kedaluwarsa +Tabel `openkab_sso_tokens` dapat tumbuh. Jadwalkan pembersihan berkala (contoh via cron): + +```bash +php -r "App\Models\Sso\OpenKabSsoToken::where('expires_at','<',now())->orWhereNotNull('used_at')->delete();" +``` + +### Log audit tidak dapat diubah +Model `OpenKabSsoLog` menolak `update`/`delete`. Untuk proteksi lebih kuat di level database, dapat ditambahkan trigger/revoke DELETE pada tabel `openkab_sso_logs` oleh DBA. + +### Dashboard audit +Super admin (role `administrator`) dapat meninjau seluruh percobaan akses melalui menu Pengaturan OpenSID → "Audit Akses SSO" (`/sso/audit`), dengan filter status, rentang waktu, administrator, dan kode desa. + +## Keamanan + +- Token JWT RS256 (RSA-2048); private key hanya di OpenKab, OpenSID memegang public key; verifikasi memaksa `alg=RS256` (anti downgrade). +- `exp` ≤ 600 detik; `nbf` = `iat`; toleransi selisih jam 30 detik; `jti` unik sekali pakai (konsumsi atomik). +- Generate-session: rate limit 5/menit per user+IP; wajib sesi aktif, role administrator, dan 2FA terverifikasi. +- Callback verify: otentikasi sekret bersama + HMAC + timestamp; respons error generik tanpa data pribadi. +- Semua komunikasi wajib HTTPS di produksi (URL OpenSID non-HTTPS ditolak di luar lingkungan lokal). +- Seluruh percobaan (berhasil/gagal) tercatat di `openkab_sso_logs`. + +## Referensi +- Kontrak API OpenKab: `specs/001-opensid-sso-access/contracts/openkab-sso-api.md` +- Kontrak integrasi OpenSID: `specs/001-opensid-sso-access/contracts/opensid-sso-contract.md` diff --git a/package-lock.json b/package-lock.json index 0e6204a9c..8810609d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "tinymce": "^8.5.0" }, "devDependencies": { - "@playwright/test": "^1.54.1", + "@playwright/test": "^1.61.0", "admin-lte": "^4.0.2", "axios": "^1.1.2", "jquery": "^3.6", @@ -631,66 +631,19 @@ } }, "node_modules/@playwright/test": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", - "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.57.0" + "playwright": "1.62.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" - } - }, - "node_modules/@playwright/test/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/@playwright/test/node_modules/playwright": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", - "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.57.0" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/@playwright/test/node_modules/playwright-core": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", - "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/@popperjs/core": { @@ -2875,33 +2828,33 @@ } }, "node_modules/playwright": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz", - "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.61.0" + "playwright-core": "1.62.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" }, "optionalDependencies": { "fsevents": "2.3.2" } }, "node_modules/playwright-core": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", - "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/playwright/node_modules/fsevents": { @@ -4249,37 +4202,12 @@ "optional": true }, "@playwright/test": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", - "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", "dev": true, "requires": { - "playwright": "1.57.0" - }, - "dependencies": { - "fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "optional": true - }, - "playwright": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", - "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", - "dev": true, - "requires": { - "fsevents": "2.3.2", - "playwright-core": "1.57.0" - } - }, - "playwright-core": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", - "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", - "dev": true - } + "playwright": "1.62.1" } }, "@popperjs/core": { @@ -5651,12 +5579,12 @@ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==" }, "playwright": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz", - "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", "requires": { "fsevents": "2.3.2", - "playwright-core": "1.61.0" + "playwright-core": "1.62.1" }, "dependencies": { "fsevents": { @@ -5668,9 +5596,9 @@ } }, "playwright-core": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", - "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==" + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==" }, "popper.js": { "version": "1.16.1", diff --git a/package.json b/package.json index 3892bdb23..0d749cf46 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "test:e2e:report": "playwright show-report" }, "devDependencies": { - "@playwright/test": "^1.54.1", + "@playwright/test": "^1.61.0", "admin-lte": "^4.0.2", "axios": "^1.1.2", "jquery": "^3.6", diff --git a/resources/views/desa/index.blade.php b/resources/views/desa/index.blade.php index 85118e13c..d026dd133 100644 --- a/resources/views/desa/index.blade.php +++ b/resources/views/desa/index.blade.php @@ -106,6 +106,7 @@ class="fas fa-search"> {{ config('app.sebutanDesa') }} Kecamatan Jumlah Penduduk + Aksi @@ -173,7 +174,7 @@ className: 'text-nowrap', } }, { - targets: [0, 1, 2, 3], + targets: [0, 1, 2, 3, 4], orderable: false, searchable: false, }, @@ -197,6 +198,17 @@ className: 'text-nowrap', orderable: true, className: 'text-center' }, + { + data: null, + render: function(data, type, row) { + const kodeDesa = (row.attributes && row.attributes.kode_desa) || row.id || ''; + if (!kodeDesa) return ''; + + return ``; + } + }, ], }) $('#tabel_penduduk_block').change(function(event) { @@ -221,6 +233,70 @@ className: 'text-center' '_blank'); }); + const ssoCsrfToken = '{{ csrf_token() }}'; + const ssoGenerateUrl = '/sso/generate-session'; + + document.addEventListener('click', function(e) { + const btn = e.target.closest('.btn-sso-opensid'); + if (!btn) return; + + e.preventDefault(); + + const desaId = btn.getAttribute('data-kode-desa'); + if (!desaId) return; + + btn.disabled = true; + + fetch(ssoGenerateUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': ssoCsrfToken, + 'Accept': 'application/json', + }, + body: JSON.stringify({ desa_id: desaId }), + }) + .then(function(response) { + return response.json().then(function(data) { + return { ok: response.ok, status: response.status, data: data }; + }); + }) + .then(function(result) { + if (result.data.status !== 'success') { + Swal.fire( + 'Gagal', + result.data.message || 'Autentikasi gagal. Silakan coba lagi.', + 'error' + ); + return; + } + + const form = document.createElement('form'); + form.method = 'POST'; + form.action = result.data.redirect_url; + form.style.display = 'none'; + + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = 'sso_token'; + input.value = result.data.token; + form.appendChild(input); + + document.body.appendChild(form); + form.submit(); + }) + .catch(function() { + Swal.fire( + 'Gagal', + 'Terjadi kesalahan. Silakan coba lagi.', + 'error' + ); + }) + .finally(function() { + btn.disabled = false; + }); + }); + @if ($filters['kode_kabupaten'] ?? false) $('a[href="#collapse-filter"]').click(); @endif diff --git a/resources/views/sso/audit.blade.php b/resources/views/sso/audit.blade.php new file mode 100644 index 000000000..ee9870d45 --- /dev/null +++ b/resources/views/sso/audit.blade.php @@ -0,0 +1,159 @@ +@extends('layouts.index') + +@section('title', 'Audit Akses SSO') + +@section('content_header') +

Audit Akses SSO

+@stop + +@section('content') + @include('partials.breadcrumbs') +
+
+
+
+
+
+ +
+
+
+
+ @include('sso.partials.filter') +
+ + + + + + + + + + + + + + +
NoWaktuAdministratorDesaStatusAlasanIPPerangkat
+
+
+
+
+
+@endsection + +@include('partials.asset_datepicker') + +@section('js') + +@endsection diff --git a/resources/views/sso/desa_sso_config/form.blade.php b/resources/views/sso/desa_sso_config/form.blade.php new file mode 100644 index 000000000..d3c529a05 --- /dev/null +++ b/resources/views/sso/desa_sso_config/form.blade.php @@ -0,0 +1,54 @@ +@extends('layouts.index') + +@section('title', $config ? 'Ubah Konfigurasi SSO Desa' : 'Tambah Konfigurasi SSO Desa') + +@section('content_header') +

{{ $config ? 'Ubah Konfigurasi SSO Desa' : 'Tambah Konfigurasi SSO Desa' }}

+@stop + +@section('content') + @include('partials.breadcrumbs') +
+
+ @include('common.errors') +
+ + {!! Html::form($config ? 'PUT' : 'POST', $config ? route('sso-config.update', $config) : route('sso-config.store')) + ->class('form-horizontal') !!} + +
+
+ + {!! Html::text('desa_id')->id('desa_id')->class('form-control')->placeholder('contoh: 5271010001') + ->value(old('desa_id', $config?->desa_id)) !!} + Kode desa (10-13 digit) sesuai data wilayah. +
+
+ + {!! Html::text('opensid_url')->id('opensid_url')->class('form-control') + ->placeholder('https://desa.contoh.id') + ->value(old('opensid_url', $config?->opensid_url)) !!} + Base URL instalasi OpenSID desa (tanpa trailing slash). +
+
+ +
+
+ + + + {!! Html::form()->close() !!} +
+
+
+@endsection diff --git a/resources/views/sso/desa_sso_config/index.blade.php b/resources/views/sso/desa_sso_config/index.blade.php new file mode 100644 index 000000000..477f68644 --- /dev/null +++ b/resources/views/sso/desa_sso_config/index.blade.php @@ -0,0 +1,93 @@ +@extends('layouts.index') + +@section('title', 'Konfigurasi SSO Desa') + +@section('content_header') +

Konfigurasi SSO Desa

+@stop + +@section('content') + @include('partials.breadcrumbs') +
+
+ @include('common.errors') +
+ +
+ @if (session('success')) +
+ + {{ session('success') }} +
+ @endif +
+ + + + + + + + + + + @forelse ($configs as $config) + + + + + + + @empty + + + + @endforelse + +
Kode DesaURL OpenSIDStatusAksi
{{ $config->desa_id }}{{ $config->opensid_url }} + @if ($config->enabled) + Aktif + @else + Nonaktif + @endif + + + + + {!! Html::form('DELETE', route('sso-config.destroy', $config)) + ->id('form-delete-'.$config->id) + ->style('display:inline') !!} + + {!! Html::form()->close() !!} +
Belum ada konfigurasi. Gunakan + SSO_OPENSID_BASE_URL sebagai fallback.
+
+
+ {{ $configs->links() }} +
+
+
+
+
+@endsection + +@section('js') + +@endsection diff --git a/resources/views/sso/partials/filter.blade.php b/resources/views/sso/partials/filter.blade.php new file mode 100644 index 000000000..42d25ddb8 --- /dev/null +++ b/resources/views/sso/partials/filter.blade.php @@ -0,0 +1,60 @@ +
+
+
+
+
+
+ +
+ +
+
+
+
+
+ + {{ Html::select('status', $statuses)->class('select2 form-control width-100')->attributes(['data-testid' => 'filter-status']) }} +
+
+
+
+ + {{ Html::select('admin_id', $admins)->class('select2 form-control width-100')->attributes(['data-testid' => 'filter-admin']) }} +
+
+
+
+ + +
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
diff --git a/routes/apiv1.php b/routes/apiv1.php index 980f26bac..e3dd0441c 100644 --- a/routes/apiv1.php +++ b/routes/apiv1.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Api\IdentitasController; use App\Http\Controllers\Api\OpendkSynchronizeController; use App\Http\Controllers\Api\SettingController; +use App\Http\Controllers\Api\SsoVerifyController; use App\Http\Controllers\Api\TeamController; use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; @@ -21,6 +22,10 @@ Route::post('/signin', [AuthController::class, 'login']); +// SSO OpenSID: verifikasi token oleh OpenSID (server-to-server, sekret bersama) +Route::middleware(['sso.callback', 'sso.ip-whitelist']) + ->post('/sso/verify-token', [SsoVerifyController::class, 'verify']); + Route::middleware('auth:sanctum')->get('validate-token', function (Request $request) { $user = $request->user(); diff --git a/routes/web.php b/routes/web.php index aa5d0c5a2..d5bc419df 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,33 +1,75 @@ group(function () { - Route::get('/otp', [App\Http\Controllers\Auth\OtpLoginController::class, 'showLoginForm'])->name('otp-login.form'); - Route::post('/otp/send', [App\Http\Controllers\Auth\OtpLoginController::class, 'sendOtp'])->name('otp-login.send'); - Route::post('/otp/verify', [App\Http\Controllers\Auth\OtpLoginController::class, 'verifyOtp'])->name('otp-login.verify'); - Route::post('/otp/resend', [App\Http\Controllers\Auth\OtpLoginController::class, 'resendOtp'])->name('otp-login.resend'); + Route::get('/otp', [OtpLoginController::class, 'showLoginForm'])->name('otp-login.form'); + Route::post('/otp/send', [OtpLoginController::class, 'sendOtp'])->name('otp-login.send'); + Route::post('/otp/verify', [OtpLoginController::class, 'verifyOtp'])->name('otp-login.verify'); + Route::post('/otp/resend', [OtpLoginController::class, 'resendOtp'])->name('otp-login.resend'); }); Route::get('pengaturan/logo', [IdentitasController::class, 'logo']); @@ -70,6 +113,15 @@ Route::get('/dasbor', [DasborController::class, 'index'])->name('dasbor'); Route::get('dasbor-demografi', [DasborDemografiController::class, 'index'])->name('dasbor-demografi'); + // SSO OpenSID: terbitkan token akses sekali pakai untuk administrator + Route::post('/sso/generate-session', [SsoLoginController::class, 'generateSession'])->name('sso.generate')->middleware('throttle:sso-generate'); + + // SSO OpenSID: dashboard audit percobaan akses (super admin) + Route::middleware('permission:sso-audit-read')->get('/sso/audit', [SsoAuditController::class, 'index'])->name('sso.audit'); + + // SSO OpenSID: kelola URL OpenSID per desa (opsional, pengaturan OpenSID) + Route::middleware('permission:pengaturan-opensid')->resource('sso-config', DesaSsoConfigController::class)->only(['index', 'create', 'store', 'edit', 'update', 'destroy']); + // Force Password Reset Routes Route::get('password-reset/force', [ForcePasswordResetController::class, 'showForm'])->name('password.reset.form'); Route::post('password-reset/force', [ForcePasswordResetController::class, 'reset'])->name('password.reset.force'); @@ -89,16 +141,16 @@ Route::middleware('can:pengaturan-group-edit')->get('/edit/{id}', [GroupController::class, 'edit'])->name('groups.edit'); }); Route::resource('activities', RiwayatPenggunaController::class)->only(['index', 'show'])->middleware('easyauthorize:pengaturan-activities'); - Route::resource('settings', App\Http\Controllers\SettingController::class)->except(['show', 'create', 'delete'])->middleware('easyauthorize:pengaturan-settings'); + Route::resource('settings', SettingController::class)->except(['show', 'create', 'delete'])->middleware('easyauthorize:pengaturan-settings'); // OTP & 2FA Routes - combined into one page Route::prefix('otp')->group(function () { - Route::get('/', [App\Http\Controllers\OtpController::class, 'index'])->name('otp.index'); - Route::get('/activate', [App\Http\Controllers\OtpController::class, 'activate'])->name('otp.activate'); - Route::post('/setup', [App\Http\Controllers\OtpController::class, 'setup'])->name('otp.setup'); - Route::post('/verify-activation', [App\Http\Controllers\OtpController::class, 'verifyActivation'])->name('otp.verify-activation'); - Route::post('/resend', [App\Http\Controllers\OtpController::class, 'resend'])->name('otp.resend'); - Route::post('/disable', [App\Http\Controllers\OtpController::class, 'disable'])->name('otp.disable'); + Route::get('/', [OtpController::class, 'index'])->name('otp.index'); + Route::get('/activate', [OtpController::class, 'activate'])->name('otp.activate'); + Route::post('/setup', [OtpController::class, 'setup'])->name('otp.setup'); + Route::post('/verify-activation', [OtpController::class, 'verifyActivation'])->name('otp.verify-activation'); + Route::post('/resend', [OtpController::class, 'resend'])->name('otp.resend'); + Route::post('/disable', [OtpController::class, 'disable'])->name('otp.disable'); }); // 2FA Routes - for API calls from the combined page @@ -106,22 +158,22 @@ Route::get('/', function () { return redirect()->route('otp.index'); })->name('2fa.index'); - Route::get('/activate', [App\Http\Controllers\TwoFactorController::class, 'activate'])->name('2fa.activate'); - Route::post('/enable', [App\Http\Controllers\TwoFactorController::class, 'enable'])->name('2fa.enable'); - Route::post('/verify', [App\Http\Controllers\TwoFactorController::class, 'verifyEnable'])->name('2fa.verify'); - Route::post('/disable', [App\Http\Controllers\TwoFactorController::class, 'disable'])->name('2fa.disable'); - Route::post('/resend', [App\Http\Controllers\TwoFactorController::class, 'resend'])->name('2fa.resend'); + Route::get('/activate', [TwoFactorController::class, 'activate'])->name('2fa.activate'); + Route::post('/enable', [TwoFactorController::class, 'enable'])->name('2fa.enable'); + Route::post('/verify', [TwoFactorController::class, 'verifyEnable'])->name('2fa.verify'); + Route::post('/disable', [TwoFactorController::class, 'disable'])->name('2fa.disable'); + Route::post('/resend', [TwoFactorController::class, 'resend'])->name('2fa.resend'); }); }); Route::prefix('cms')->group(function () { - Route::resource('categories', App\Http\Controllers\CMS\CategoryController::class)->except(['show'])->middleware('easyauthorize:website-categories'); - Route::resource('articles', App\Http\Controllers\CMS\ArticleController::class)->except(['show'])->middleware('easyauthorize:website-article'); - Route::resource('menus', App\Http\Controllers\CMS\MenuController::class)->except(['show'])->middleware('easyauthorize:website-menu'); + Route::resource('categories', CategoryController::class)->except(['show'])->middleware('easyauthorize:website-categories'); + Route::resource('articles', ArticleController::class)->except(['show'])->middleware('easyauthorize:website-article'); + Route::resource('menus', MenuController::class)->except(['show'])->middleware('easyauthorize:website-menu'); Route::resource('pages', App\Http\Controllers\CMS\PageController::class)->except(['show'])->middleware('easyauthorize:website-pages'); - Route::resource('slides', App\Http\Controllers\CMS\SlideController::class)->except(['show'])->middleware('easyauthorize:website-slider'); - Route::resource('downloads', App\Http\Controllers\CMS\DownloadController::class)->except(['show'])->middleware('easyauthorize:website-downloads'); - Route::get('statistik', App\Http\Controllers\CMS\StatistikPengunjungController::class)->name('cms.statistic.summary')->middleware('permission:website-statistik-read'); + Route::resource('slides', SlideController::class)->except(['show'])->middleware('easyauthorize:website-slider'); + Route::resource('downloads', DownloadController::class)->except(['show'])->middleware('easyauthorize:website-downloads'); + Route::get('statistik', StatistikPengunjungController::class)->name('cms.statistic.summary')->middleware('permission:website-statistik-read'); }); Route::prefix('sesi')->group(function () { @@ -198,9 +250,9 @@ // Lembaga routes Route::middleware(['permission:lembaga-read'])->prefix('lembaga')->group(function () { - Route::get('/', [App\Http\Controllers\LembagaController::class, 'index'])->name('lembaga.index'); - Route::get('/detail', [App\Http\Controllers\LembagaController::class, 'detail'])->name('lembaga.detail'); - Route::get('/cetak', [App\Http\Controllers\LembagaController::class, 'cetak'])->name('lembaga.cetak'); + Route::get('/', [LembagaController::class, 'index'])->name('lembaga.index'); + Route::get('/detail', [LembagaController::class, 'detail'])->name('lembaga.detail'); + Route::get('/cetak', [LembagaController::class, 'cetak'])->name('lembaga.cetak'); }); // Data Pokok Route::middleware(['permission:datapresisi-read'])->controller(DataPokokController::class) @@ -258,10 +310,10 @@ }); // Master Data - Route::middleware('easyauthorize:organisasi-departemen')->resource('departments', App\Http\Controllers\DepartmentController::class)->except(['show']); - Route::middleware('easyauthorize:organisasi-position')->resource('positions', App\Http\Controllers\PositionController::class)->except(['show']); - Route::middleware('easyauthorize:organisasi-employee')->resource('employees', App\Http\Controllers\EmployeeController::class)->except(['show']); - Route::middleware('permission:organisasi-chart-read')->get('orgchart', App\Http\Controllers\OrgChartController::class); + Route::middleware('easyauthorize:organisasi-departemen')->resource('departments', DepartmentController::class)->except(['show']); + Route::middleware('easyauthorize:organisasi-position')->resource('positions', PositionController::class)->except(['show']); + Route::middleware('easyauthorize:organisasi-employee')->resource('employees', EmployeeController::class)->except(['show']); + Route::middleware('permission:organisasi-chart-read')->get('orgchart', OrgChartController::class); Route::prefix('master') ->group(function () { @@ -279,111 +331,111 @@ // Satu Data Route::prefix('satu-data')->group(function () { Route::prefix('dtks')->group(function () { - Route::get('papan', [App\Http\Controllers\DTKSController::class, 'index'])->name('satu-data.dtks.index'); - Route::get('cetak', [App\Http\Controllers\DTKSController::class, 'cetak'])->name('satu-data.dtks.cetak'); + Route::get('papan', [DTKSController::class, 'index'])->name('satu-data.dtks.index'); + Route::get('cetak', [DTKSController::class, 'cetak'])->name('satu-data.dtks.cetak'); }); }); Route::prefix('laporan')->group(function () { Route::prefix('desa-aktif')->group(function () { - Route::get('', [App\Http\Controllers\LaporanDesaAktifController::class, 'index'])->name('laporan.desa-aktif.index'); - Route::get('cetak', [App\Http\Controllers\LaporanDesaAktifController::class, 'cetak'])->name('laporan.desa-aktif.cetak'); - Route::get('export-excel', [App\Http\Controllers\LaporanDesaAktifController::class, 'exportExcel'])->name('laporan.desa-aktif.export-excel'); + Route::get('', [LaporanDesaAktifController::class, 'index'])->name('laporan.desa-aktif.index'); + Route::get('cetak', [LaporanDesaAktifController::class, 'cetak'])->name('laporan.desa-aktif.cetak'); + Route::get('export-excel', [LaporanDesaAktifController::class, 'exportExcel'])->name('laporan.desa-aktif.export-excel'); }); }); Route::prefix('data-presisi')->group(function () { Route::prefix('laporan')->group(function () { - Route::get('/', [App\Http\Controllers\DataPresisiLaporanController::class, 'index'])->name('laporan.data-presisi.index'); - Route::get('cetak', [App\Http\Controllers\DataPresisiLaporanController::class, 'cetak'])->name('laporan.data-presisi.cetak'); - Route::get('/perdesa', [App\Http\Controllers\DataPresisiLaporanController::class, 'perdesa'])->name('laporan.data-presisi.perdesa'); - Route::get('/cetak-perdesa', [App\Http\Controllers\DataPresisiLaporanController::class, 'cetakPerdesa'])->name('laporan.data-presisi.cetak-perdesa'); + Route::get('/', [DataPresisiLaporanController::class, 'index'])->name('laporan.data-presisi.index'); + Route::get('cetak', [DataPresisiLaporanController::class, 'cetak'])->name('laporan.data-presisi.cetak'); + Route::get('/perdesa', [DataPresisiLaporanController::class, 'perdesa'])->name('laporan.data-presisi.perdesa'); + Route::get('/cetak-perdesa', [DataPresisiLaporanController::class, 'cetakPerdesa'])->name('laporan.data-presisi.cetak-perdesa'); })->middleware(['permission:datapresisi-laporan-read']); Route::prefix('kesehatan')->group(function () { - Route::get('/', [App\Http\Controllers\DataPresisiKesehatanController::class, 'index'])->name('data-pokok.data-presisi.index'); - Route::get('/detail', [App\Http\Controllers\DataPresisiKesehatanController::class, 'detail'])->name('data-pokok.data-presisi.detail'); - Route::get('/detail_data', [App\Http\Controllers\DataPresisiKesehatanController::class, 'detailData'])->name('data-pokok.data-presisi.detail_data'); - Route::get('cetak', [App\Http\Controllers\DataPresisiKesehatanController::class, 'cetak'])->name('data-pokok.data-presisi.cetak'); + Route::get('/', [DataPresisiKesehatanController::class, 'index'])->name('data-pokok.data-presisi.index'); + Route::get('/detail', [DataPresisiKesehatanController::class, 'detail'])->name('data-pokok.data-presisi.detail'); + Route::get('/detail_data', [DataPresisiKesehatanController::class, 'detailData'])->name('data-pokok.data-presisi.detail_data'); + Route::get('cetak', [DataPresisiKesehatanController::class, 'cetak'])->name('data-pokok.data-presisi.cetak'); }) ->middleware(['permission:datapresisi-kesehatan-read']); Route::prefix('seni-budaya')->group(function () { - Route::get('/', [App\Http\Controllers\DataPresisiSeniBudayaController::class, 'index'])->name('data-pokok.data-presisi-seni-budaya.index'); - Route::get('/detail', [App\Http\Controllers\DataPresisiSeniBudayaController::class, 'detail'])->name('data-pokok.data-presisi-seni-budaya.detail'); - Route::get('/detail_data', [App\Http\Controllers\DataPresisiSeniBudayaController::class, 'detailData'])->name('data-pokok.data-presisi-seni-budaya.detail_data'); - Route::get('cetak', [App\Http\Controllers\DataPresisiSeniBudayaController::class, 'cetak'])->name('data-pokok.data-presisi-seni-budaya.cetak'); + Route::get('/', [DataPresisiSeniBudayaController::class, 'index'])->name('data-pokok.data-presisi-seni-budaya.index'); + Route::get('/detail', [DataPresisiSeniBudayaController::class, 'detail'])->name('data-pokok.data-presisi-seni-budaya.detail'); + Route::get('/detail_data', [DataPresisiSeniBudayaController::class, 'detailData'])->name('data-pokok.data-presisi-seni-budaya.detail_data'); + Route::get('cetak', [DataPresisiSeniBudayaController::class, 'cetak'])->name('data-pokok.data-presisi-seni-budaya.cetak'); }) ->middleware(['permission:datapresisi-seni-budaya-read']); Route::prefix('ketenagakerjaan')->group(function () { - Route::get('/', [App\Http\Controllers\DataPresisiKetenagakerjaanController::class, 'index'])->name('data-pokok.data-presisi-ketenagakerjaan.index'); - Route::get('/detail', [App\Http\Controllers\DataPresisiKetenagakerjaanController::class, 'detail'])->name('data-pokok.data-presisi-ketenagakerjaan.detail'); - Route::get('detail_data', [App\Http\Controllers\DataPresisiKetenagakerjaanController::class, 'detailData'])->name('data-pokok.data-presisi-ketenagakerjaan.detail_data'); - Route::get('cetak', [App\Http\Controllers\DataPresisiKetenagakerjaanController::class, 'cetak'])->name('data-pokok.data-presisi-ketenagakerjaan.cetak'); + Route::get('/', [DataPresisiKetenagakerjaanController::class, 'index'])->name('data-pokok.data-presisi-ketenagakerjaan.index'); + Route::get('/detail', [DataPresisiKetenagakerjaanController::class, 'detail'])->name('data-pokok.data-presisi-ketenagakerjaan.detail'); + Route::get('detail_data', [DataPresisiKetenagakerjaanController::class, 'detailData'])->name('data-pokok.data-presisi-ketenagakerjaan.detail_data'); + Route::get('cetak', [DataPresisiKetenagakerjaanController::class, 'cetak'])->name('data-pokok.data-presisi-ketenagakerjaan.cetak'); }) ->middleware(['permission:datapresisi-ketenagakerjaan-read']); - Route::prefix('jaminan-sosial')->group(function () { - Route::get('/detail_data', [App\Http\Controllers\DataPresisiJaminanSosialController::class, 'detailData'])->name('data-pokok.data-presisi-jaminan-sosial.detail_data'); + Route::prefix('jaminan-sosial')->group(function () { + Route::get('/detail_data', [DataPresisiJaminanSosialController::class, 'detailData'])->name('data-pokok.data-presisi-jaminan-sosial.detail_data'); }) - ->middleware(['permission:datapresisi-jaminan-sosial-read']); + ->middleware(['permission:datapresisi-jaminan-sosial-read']); Route::prefix('aktivitas-keagamaan')->group(function () { - Route::get('detail_data', [App\Http\Controllers\DataPresisiAktivitasKeagamaanController::class, 'detailData'])->name('data-pokok.data-presisi-aktivitas-keagamaan.detail_data'); + Route::get('detail_data', [DataPresisiAktivitasKeagamaanController::class, 'detailData'])->name('data-pokok.data-presisi-aktivitas-keagamaan.detail_data'); }) ->middleware(['permission:datapresisi-aktivitas-keagamaan-read']); Route::prefix('pendidikan')->group(function () { - Route::get('/', [App\Http\Controllers\DataPresisiPendidikanController::class, 'index'])->name('data-pokok.data-presisi-pendidikan.index'); - Route::get('/detail', [App\Http\Controllers\DataPresisiPendidikanController::class, 'detail'])->name('data-pokok.data-presisi-pendidikan.detail'); - Route::get('detail_data', [App\Http\Controllers\DataPresisiPendidikanController::class, 'detailData'])->name('data-pokok.data-presisi-pendidikan.detail_data'); - Route::get('cetak', [App\Http\Controllers\DataPresisiPendidikanController::class, 'cetak'])->name('data-pokok.data-presisi-pendidikan.cetak'); + Route::get('/', [DataPresisiPendidikanController::class, 'index'])->name('data-pokok.data-presisi-pendidikan.index'); + Route::get('/detail', [DataPresisiPendidikanController::class, 'detail'])->name('data-pokok.data-presisi-pendidikan.detail'); + Route::get('detail_data', [DataPresisiPendidikanController::class, 'detailData'])->name('data-pokok.data-presisi-pendidikan.detail_data'); + Route::get('cetak', [DataPresisiPendidikanController::class, 'cetak'])->name('data-pokok.data-presisi-pendidikan.cetak'); }) ->middleware(['permission:datapresisi-pendidikan-read']); Route::prefix('pangan')->group(function () { - Route::get('/', [App\Http\Controllers\DataPresisiPanganController::class, 'index'])->name('data-pokok.data-presisi-pangan.index'); - Route::get('/detail', [App\Http\Controllers\DataPresisiPanganController::class, 'detail'])->name('data-pokok.data-presisi-pangan.detail'); - Route::get('detail_data', [App\Http\Controllers\DataPresisiPanganController::class, 'detailData'])->name('data-pokok.data-presisi-pangan.detail_data'); - Route::get('cetak', [App\Http\Controllers\DataPresisiPanganController::class, 'cetak'])->name('data-pokok.data-presisi-pangan.cetak'); + Route::get('/', [DataPresisiPanganController::class, 'index'])->name('data-pokok.data-presisi-pangan.index'); + Route::get('/detail', [DataPresisiPanganController::class, 'detail'])->name('data-pokok.data-presisi-pangan.detail'); + Route::get('detail_data', [DataPresisiPanganController::class, 'detailData'])->name('data-pokok.data-presisi-pangan.detail_data'); + Route::get('cetak', [DataPresisiPanganController::class, 'cetak'])->name('data-pokok.data-presisi-pangan.cetak'); }) ->middleware(['permission:datapresisi-pangan-read']); - - Route::prefix('papan')->group(function () { - Route::get('detail_data', [App\Http\Controllers\DataPresisiPapanController::class, 'detailData'])->name('data-pokok.data-presisi-papan.detail_data'); - Route::get('detail', [App\Http\Controllers\DataPresisiPapanController::class, 'detail'])->name('data-pokok.data-presisi-papan.detail_datasandang'); - Route::get('cetak', [App\Http\Controllers\DataPresisiPapanController::class, 'cetak'])->name('data-pokok.data-presisi-papan.cetak'); + + Route::prefix('papan')->group(function () { + Route::get('detail_data', [DataPresisiPapanController::class, 'detailData'])->name('data-pokok.data-presisi-papan.detail_data'); + Route::get('detail', [DataPresisiPapanController::class, 'detail'])->name('data-pokok.data-presisi-papan.detail_datasandang'); + Route::get('cetak', [DataPresisiPapanController::class, 'cetak'])->name('data-pokok.data-presisi-papan.cetak'); }) ->middleware(['permission:datapresisi-papan-read']); - Route::prefix('sandang')->group(function () { - Route::get('detail_data', [App\Http\Controllers\DataPresisiSandangController::class, 'detailData'])->name('data-pokok.data-presisi-sandang.detail_data'); + Route::prefix('sandang')->group(function () { + Route::get('detail_data', [DataPresisiSandangController::class, 'detailData'])->name('data-pokok.data-presisi-sandang.detail_data'); }) ->middleware(['permission:datapresisi-sandang-read']); Route::prefix('adat')->group(function () { - Route::get('/', [App\Http\Controllers\DataPresisiAdatController::class, 'index'])->name('data-pokok.data-presisi-adat.index'); - Route::get('/detail', [App\Http\Controllers\DataPresisiAdatController::class, 'detail'])->name('data-pokok.data-presisi-adat.detail'); - Route::get('cetak', [App\Http\Controllers\DataPresisiAdatController::class, 'cetak'])->name('data-pokok.data-presisi-adat.cetak'); + Route::get('/', [DataPresisiAdatController::class, 'index'])->name('data-pokok.data-presisi-adat.index'); + Route::get('/detail', [DataPresisiAdatController::class, 'detail'])->name('data-pokok.data-presisi-adat.detail'); + Route::get('cetak', [DataPresisiAdatController::class, 'cetak'])->name('data-pokok.data-presisi-adat.cetak'); }) ->middleware(['permission:datapresisi-adat-read']); Route::prefix('statistik')->group(function () { - Route::get('adat', [App\Http\Controllers\StatistikAdatController::class, 'index']); - Route::get('kesehatan', [App\Http\Controllers\StatistikKesehatanController::class, 'index']); - Route::get('jaminan-sosial', [App\Http\Controllers\StatistikJaminanSosialController::class, 'index']); - Route::get('aktivitas-keagamaan', [App\Http\Controllers\StatistikAktivitasKeagamaanController::class, 'index']); - Route::get('ketenagakerjaan', [App\Http\Controllers\StatistikKetenagakerjaanController::class, 'index']); - Route::get('pendidikan', [App\Http\Controllers\StatistikPendidikanController::class, 'index']); - Route::get('sandang', [App\Http\Controllers\StatistikSandangController::class, 'index']); - Route::get('papan', [App\Http\Controllers\StatistikPapanController::class, 'index']); - Route::get('senibudaya', [App\Http\Controllers\StatistikSenibudayaController::class, 'index']); - Route::get('pangan', [App\Http\Controllers\StatistikPanganController::class, 'index']); + Route::get('adat', [StatistikAdatController::class, 'index']); + Route::get('kesehatan', [StatistikKesehatanController::class, 'index']); + Route::get('jaminan-sosial', [StatistikJaminanSosialController::class, 'index']); + Route::get('aktivitas-keagamaan', [StatistikAktivitasKeagamaanController::class, 'index']); + Route::get('ketenagakerjaan', [StatistikKetenagakerjaanController::class, 'index']); + Route::get('pendidikan', [StatistikPendidikanController::class, 'index']); + Route::get('sandang', [StatistikSandangController::class, 'index']); + Route::get('papan', [StatistikPapanController::class, 'index']); + Route::get('senibudaya', [StatistikSenibudayaController::class, 'index']); + Route::get('pangan', [StatistikPanganController::class, 'index']); }); }); // Prodeskel Route::prefix('prodeskel')->group(function () { Route::prefix('ddk')->group(function () { - Route::get('pangan', [App\Http\Controllers\DDKPanganController::class, 'index'])->name('ddk.pangan'); + Route::get('pangan', [DDKPanganController::class, 'index'])->name('ddk.pangan'); }); }); @@ -423,8 +475,8 @@ // 2FA Challenge Routes (tanpa middleware 2fa untuk menghindari loop) Route::middleware('auth')->prefix('2fa')->group(function () { - Route::get('/challenge', [App\Http\Controllers\TwoFactorController::class, 'showChallenge'])->name('2fa.challenge'); - Route::post('/challenge', [App\Http\Controllers\TwoFactorController::class, 'verifyChallenge'])->name('2fa.challenge.verify'); + Route::get('/challenge', [TwoFactorController::class, 'showChallenge'])->name('2fa.challenge'); + Route::post('/challenge', [TwoFactorController::class, 'verifyChallenge'])->name('2fa.challenge.verify'); }); Route::prefix('presisi')->middleware('check.presisi')->group(function () { @@ -434,12 +486,12 @@ Route::get('/rtm', [PresisiController::class, 'rtm'])->name('presisi.rtm'); Route::get('/statistik-rtm', [PresisiController::class, 'rtm'])->name('presisi.rtm_statistik'); Route::get('/keluarga', [PresisiController::class, 'keluarga'])->name('presisi.keluarga'); -Route::get('/statistik-keluarga', [PresisiController::class, 'keluarga'])->name('presisi.keluarga_statistik_outer'); + Route::get('/statistik-keluarga', [PresisiController::class, 'keluarga'])->name('presisi.keluarga_statistik_outer'); Route::get('/kesehatan', [PresisiController::class, 'kesehatan'])->name('presisi.kesehatan'); Route::get('/kesehatan/{kuartal}/{tahun}/{id}/{kabupaten?}/{kecamatan?}/{desa?}', [PresisiController::class, 'kesehatan']); Route::get('/bantuan', [PresisiController::class, 'bantuan'])->name('presisi.bantuan'); -Route::get('/statistik-bantuan', [PresisiController::class, 'bantuan'])->name('presisi.bantuan_statistik_outer'); + Route::get('/statistik-bantuan', [PresisiController::class, 'bantuan'])->name('presisi.bantuan_statistik_outer'); Route::get('/geo-spasial', [PresisiController::class, 'geoSpasial'])->name('presisi.geo-spasial'); }); @@ -472,8 +524,9 @@ // Pest Browser Testing - Quick login route (hanya untuk testing) if (app()->environment('testing')) { Route::get('/_pest/login/{userId}', function ($userId) { - $user = App\Models\User::findOrFail($userId); + $user = User::findOrFail($userId); Auth::login($user); + return response(status: 200); })->middleware('web'); } diff --git a/scripts/sso-keygen.sh b/scripts/sso-keygen.sh new file mode 100755 index 000000000..d94a95aa0 --- /dev/null +++ b/scripts/sso-keygen.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# +# Generate keypair RSA-2048 untuk SSO OpenSID (RS256). +# Private key HANYA di OpenKab; public key didistribusikan ke instalasi OpenSID. +# +# Usage: scripts/sso-keygen.sh [output_dir] +set -euo pipefail + +OUT_DIR="${1:-storage/sso-keys}" + +if [[ -f "${OUT_DIR}/private.pem" ]]; then + echo "ERROR: ${OUT_DIR}/private.pem sudah ada. Hapus dulu bila ingin generate ulang." >&2 + exit 1 +fi + +mkdir -p "${OUT_DIR}" + +openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "${OUT_DIR}/private.pem" +openssl pkey -in "${OUT_DIR}/private.pem" -pubout -out "${OUT_DIR}/public.pem" + +chmod 600 "${OUT_DIR}/private.pem" +chmod 644 "${OUT_DIR}/public.pem" + +echo "Keypair SSO dibuat di ${OUT_DIR}/" +echo " private.pem -> isi SSO_SIGNING_PRIVATE_KEY_FILE (hanya OpenKab)" +echo " public.pem -> distribusikan ke OpenSID / isi SSO_SIGNING_PUBLIC_KEY_FILE" diff --git a/tests/Browser/SmokeSsoAccessTest.php b/tests/Browser/SmokeSsoAccessTest.php new file mode 100644 index 000000000..a42e61116 --- /dev/null +++ b/tests/Browser/SmokeSsoAccessTest.php @@ -0,0 +1,64 @@ +user = SessionState::loginAdminUser(); +}); + +afterEach(function () { + SessionState::clear(); +}); + +it('menampilkan tombol Masuk ke OpenSID pada tabel desa', function () { + $page = SessionState::loginAndNavigate($this->user, '/desa') + ->assertPathIs('/desa'); + + $page->assertScript( + "new Promise((resolve) => { + const check = () => { + const btns = document.querySelectorAll('.btn-sso-opensid').length; + if (btns > 0) { resolve(true); } else { setTimeout(check, 200); } + }; + check(); + })", + true + ); + + $page->assertVisible('.btn-sso-opensid >> nth=0'); + $page->assertSee('Masuk ke OpenSID'); + + ScreenshotHelper::saveIfEnabled($page, 'sso-desa-button'); +}); + +it('klik tombol memicu request generate-session dan menampilkan pesan generik bila gagal', function () { + $page = SessionState::loginAndNavigate($this->user, '/desa') + ->assertPathIs('/desa'); + + $page->assertScript( + "new Promise((resolve) => { + const check = () => { + const btns = document.querySelectorAll('.btn-sso-opensid').length; + if (btns > 0) { resolve(true); } else { setTimeout(check, 200); } + }; + check(); + })", + true + ); + + $page->click('.btn-sso-opensid >> nth=0'); + + // Sesi smoke tanpa 2fa_verified → endpoint menolak dengan pesan generik. + $page->assertScript( + "new Promise((resolve) => { + const check = () => { + const popup = document.querySelector('.swal2-popup'); + if (popup) { resolve(true); } else { setTimeout(check, 200); } + }; + check(); + })", + true + ); + $page->assertVisible('.swal2-popup'); +}); diff --git a/tests/Feature/Sso/SsoAuditDashboardTest.php b/tests/Feature/Sso/SsoAuditDashboardTest.php new file mode 100644 index 000000000..4ddd297bc --- /dev/null +++ b/tests/Feature/Sso/SsoAuditDashboardTest.php @@ -0,0 +1,90 @@ +seed(SsoPermissionsSeeder::class); + } + + #[Test] + public function super_admin_dengan_permission_dapat_mengakses_dashboard() + { + $this->grantAuditPermission(); + + $response = $this->get(route('sso.audit')); + + $response->assertStatus(200); + $response->assertViewIs('sso.audit'); + $response->assertSee('Audit Akses SSO'); + } + + #[Test] + public function ajax_menampilkan_data_log_sso() + { + $this->grantAuditPermission(); + $admin = User::first(); + $desaId = '9999999999'; + + OpenKabSsoLog::create([ + 'admin_id' => $admin->id, + 'desa_id' => $desaId, + 'attempt_time' => now(), + 'status' => 'failed', + 'reason_if_failed' => '2fa_belum_verifikasi', + 'ip_address' => '127.0.0.1', + 'user_agent' => 'Pest', + ]); + OpenKabSsoLog::create([ + 'admin_id' => $admin->id, + 'desa_id' => $desaId, + 'attempt_time' => now(), + 'status' => 'success', + 'ip_address' => '127.0.0.1', + 'user_agent' => 'Pest', + ]); + + $byDesa = $this->getJson(route('sso.audit').'?filter[desa_id]='.$desaId, [ + 'X-Requested-With' => 'XMLHttpRequest', + ]); + + $byDesa->assertOk(); + $byDesa->assertJsonCount(2, 'data'); + + $filtered = $this->getJson(route('sso.audit').'?filter[desa_id]='.$desaId.'&filter[status]=failed', [ + 'X-Requested-With' => 'XMLHttpRequest', + ]); + + $filtered->assertOk(); + $filtered->assertJsonCount(1, 'data'); + $filtered->assertJsonPath('data.0.attributes.status', 'failed'); + $filtered->assertJsonPath('data.0.attributes.reason_if_failed', '2fa_belum_verifikasi'); + } + + #[Test] + public function user_tanpa_permission_ditolak() + { + $user = User::factory()->create(); + + $this->actingAsAdmin($user); + + $this->getJson(route('sso.audit'))->assertStatus(403); + } + + #[Test] + public function belum_login_diarahkan_ke_login() + { + Auth::logout(); + + $this->get(route('sso.audit'))->assertRedirect(route('login')); + } +} diff --git a/tests/Feature/Sso/SsoGenerateSessionTest.php b/tests/Feature/Sso/SsoGenerateSessionTest.php new file mode 100644 index 000000000..0ed4f10bf --- /dev/null +++ b/tests/Feature/Sso/SsoGenerateSessionTest.php @@ -0,0 +1,126 @@ +forceFill([ + '2fa_enabled' => true, + 'active' => 1, + ])->save(); + + $fresh = $user->fresh(); + $this->actingAsAdmin($fresh); + + session(['2fa_verified' => true]); + + return $fresh; + } + + #[Test] + public function admin_berhak_dengan_2fa_mendapat_token_sso() + { + $user = $this->adminWith2fa(); + + $response = $this->postJson(route('sso.generate'), [ + 'desa_id' => '3201012001', + ]); + + $response->assertOk(); + $response->assertJson([ + 'status' => 'success', + 'token_method' => 'post_param', + ]); + $response->assertJsonStructure([ + 'redirect_url', + 'token', + 'expires_at', + 'retry_after', + ]); + $response->assertJsonPath('redirect_url', 'http://opensid.test/admin/sso-login'); + + $this->assertDatabaseHas('openkab_sso_tokens', [ + 'admin_id' => $user->id, + 'desa_id' => '3201012001', + 'used_at' => null, + ]); + + $this->assertDatabaseHas('openkab_sso_logs', [ + 'admin_id' => $user->id, + 'desa_id' => '3201012001', + 'status' => 'success', + ]); + } + + #[Test] + public function desa_id_format_tidak_valid_ditolak() + { + $this->adminWith2fa(); + + $response = $this->postJson(route('sso.generate'), [ + 'desa_id' => 'abc', + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors('desa_id'); + } + + #[Test] + public function pengguna_belum_login_diarahkan_ke_halaman_login() + { + Auth::logout(); + + $this->post(route('sso.generate'), ['desa_id' => '3201012001']) + ->assertRedirect(route('login')); + } + + #[Test] + public function token_yang_diterbitkan_berlaku_lima_menit() + { + $this->adminWith2fa(); + + $response = $this->postJson(route('sso.generate'), [ + 'desa_id' => '3201012001', + ]); + + $response->assertOk(); + $expiresAt = $response->json('expires_at'); + $this->assertGreaterThanOrEqual(now()->timestamp + 290, $expiresAt); + $this->assertLessThanOrEqual(now()->timestamp + 310, $expiresAt); + } + + #[Test] + public function token_ditandatangani_rs256_dan_terverifikasi_dengan_public_key() + { + $this->adminWith2fa(); + + $response = $this->postJson(route('sso.generate'), [ + 'desa_id' => '3201012001', + ]); + + $response->assertOk(); + $headers = JWT::jsonDecode(JWT::urlsafeB64Decode(explode('.', $response->json('token'))[0])); + + $this->assertSame('RS256', $headers->alg); + + $payload = JWT::decode( + $response->json('token'), + new Key(app(SsoKeyManager::class)->publicKey(), SsoKeyManager::ALG) + ); + + $this->assertSame('3201012001', $payload->desa_id); + $this->assertSame(config('sso.issuer'), $payload->iss); + $this->assertSame(config('sso.audience'), $payload->aud); + } +} diff --git a/tests/Feature/Sso/SsoSecurityTest.php b/tests/Feature/Sso/SsoSecurityTest.php new file mode 100644 index 000000000..4d599d4f0 --- /dev/null +++ b/tests/Feature/Sso/SsoSecurityTest.php @@ -0,0 +1,179 @@ +forceFill(array_merge([ + 'active' => 1, + '2fa_enabled' => $twoFaEnabled, + ], $attrs))->save(); + + $fresh = $user->fresh(); + $this->actingAsAdmin($fresh); + + if ($twoFaVerified) { + session(['2fa_verified' => true]); + } else { + session()->forget('2fa_verified'); + } + + return $fresh; + } + + protected function postGenerate(string $desaId = '5271010001') + { + return $this->postJson(route('sso.generate'), ['desa_id' => $desaId]); + } + + #[Test] + public function pengguna_non_admin_ditolak_dan_dicatat() + { + $nonAdmin = User::factory()->create(['2fa_enabled' => true]); + $this->actingAsAdmin($nonAdmin); + session(['2fa_verified' => true]); + + $response = $this->postGenerate(); + + $response->assertStatus(403); + $response->assertJson([ + 'status' => 'error', + 'code' => 'AUTH_FAILED', + ]); + $this->assertDatabaseHas('openkab_sso_logs', [ + 'admin_id' => $nonAdmin->id, + 'status' => 'failed', + 'reason_if_failed' => 'bukan_admin', + ]); + } + + #[Test] + public function sesi_tanpa_verifikasi_2fa_ditolak() + { + $this->loginAsAdminState(twoFaVerified: false); + + $response = $this->postGenerate(); + + $response->assertStatus(403); + $response->assertJsonPath('code', 'AUTH_FAILED'); + $this->assertDatabaseHas('openkab_sso_logs', [ + 'status' => 'failed', + 'reason_if_failed' => '2fa_belum_verifikasi', + ]); + } + + #[Test] + public function user_dengan_2fa_nonaktif_ditolak() + { + $user = $this->loginAsAdminState(twoFaEnabled: false); + + $response = $this->postGenerate(); + + $response->assertStatus(403); + $response->assertJsonPath('code', 'AUTH_FAILED'); + $this->assertDatabaseHas('openkab_sso_logs', [ + 'admin_id' => $user->id, + 'status' => 'failed', + 'reason_if_failed' => '2fa_nonaktif', + ]); + } + + #[Test] + public function akun_nonaktif_ditolak() + { + $user = $this->loginAsAdminState(['active' => 0]); + + $response = $this->postGenerate(); + + $response->assertStatus(403); + $response->assertJsonPath('code', 'AUTH_FAILED'); + $this->assertDatabaseHas('openkab_sso_logs', [ + 'admin_id' => $user->id, + 'status' => 'failed', + 'reason_if_failed' => 'akun_nonaktif', + ]); + } + + #[Test] + public function akun_terkunci_ditolak() + { + $user = $this->loginAsAdminState([ + 'locked_at' => now(), + 'lockout_expires_at' => now()->addMinutes(15), + ]); + + $response = $this->postGenerate(); + + $response->assertStatus(403); + $response->assertJsonPath('code', 'AUTH_FAILED'); + $this->assertDatabaseHas('openkab_sso_logs', [ + 'admin_id' => $user->id, + 'status' => 'failed', + 'reason_if_failed' => 'akun_terkunci', + ]); + } + + #[Test] + public function desa_di_luar_scope_kabupaten_ditolak() + { + $user = $this->loginAsAdminState(['kode_kabupaten' => '3273']); + + $response = $this->postGenerate('5271010001'); + + $response->assertStatus(422); + $response->assertJsonPath('code', 'VALIDATION_FAILED'); + $this->assertDatabaseHas('openkab_sso_logs', [ + 'admin_id' => $user->id, + 'status' => 'failed', + 'reason_if_failed' => 'desa_invalid', + ]); + } + + #[Test] + public function respons_error_tidak_membocorkan_data_pribadi_atau_detail_teknis() + { + $user = $this->loginAsAdminState(twoFaVerified: false); + + $response = $this->postGenerate(); + + $response->assertStatus(403); + $response->assertJsonPath('message', 'Autentikasi gagal.'); + + $content = $response->getContent(); + $this->assertStringNotContainsString($user->username, $content); + $this->assertStringNotContainsString($user->email, $content); + $this->assertStringNotContainsString('SsoTokenService', $content); + $this->assertStringNotContainsString('SQLSTATE', $content); + } + + #[Test] + public function rate_limit_memblokir_permintaan_berlebih() + { + config(['sso.rate_limit_max' => 2]); + $this->loginAsAdminState(); + + $this->postGenerate()->assertOk(); + $this->postGenerate()->assertOk(); + + $response = $this->postGenerate(); + + $response->assertStatus(429); + $response->assertJson([ + 'status' => 'error', + 'code' => 'RATE_LIMITED', + ]); + $response->assertHeader('Retry-After'); + + $this->assertDatabaseHas('openkab_sso_logs', [ + 'status' => 'failed', + 'reason_if_failed' => 'rate_limited', + ]); + } +} diff --git a/tests/Feature/Sso/SsoVerifyTokenTest.php b/tests/Feature/Sso/SsoVerifyTokenTest.php new file mode 100644 index 000000000..99d006063 --- /dev/null +++ b/tests/Feature/Sso/SsoVerifyTokenTest.php @@ -0,0 +1,253 @@ +forceFill(array_merge(['active' => 1, '2fa_enabled' => true], $attrs))->save(); + + return $this->tokens()->issue($admin->id, '5271010001'); + } + + protected function verifyRequest(array $body): TestResponse + { + $secret = (string) config('sso.callback_secret'); + + return $this->postJson('/api/v1/sso/verify-token', $body, [ + 'X-SSO-Callback-Key' => $secret, + 'X-SSO-Callback-Timestamp' => (string) time(), + 'X-SSO-Callback-Signature' => hash_hmac('sha256', json_encode($body), $secret), + ]); + } + + protected function validBody(string $token): array + { + return [ + 'token' => $token, + 'callback_nonce' => (string) Str::uuid(), + ]; + } + + #[Test] + public function token_valid_diverifikasi_dan_dikonsumsi_sekali_pakai() + { + $issued = $this->issueTokenForAdmin(); + + $response = $this->verifyRequest($this->validBody($issued['token'])); + + $response->assertOk(); + $response->assertJsonPath('status', 'success'); + $response->assertJsonPath('data.desa_id', '5271010001'); + $response->assertJsonPath('data.admin_id', User::first()->id); + $response->assertJsonPath('data.expires_at', $issued['expires_at']); + $response->assertJsonMissingPath('data.email'); + + $this->assertDatabaseHas('openkab_sso_tokens', [ + 'jti' => $issued['jti'], + 'used_at' => now(), + ]); + $this->assertDatabaseHas('openkab_sso_logs', [ + 'status' => 'success', + 'token_fingerprint' => $this->tokens()->fingerprint($issued['jti']), + ]); + } + + #[Test] + public function token_yang_sudah_dipakai_ditolak_replay() + { + $issued = $this->issueTokenForAdmin(); + + $this->verifyRequest($this->validBody($issued['token']))->assertJsonPath('status', 'success'); + + $response = $this->verifyRequest($this->validBody($issued['token'])); + + $response->assertOk(); + $response->assertJsonPath('status', 'error'); + $response->assertJsonPath('code', 'TOKEN_REPLAY'); + } + + #[Test] + public function token_yang_kedaluwarsa_ditolak() + { + $issued = $this->issueTokenForAdmin(); + OpenKabSsoToken::query()->where('jti', $issued['jti'])->update(['expires_at' => now()->subMinute()]); + + $response = $this->verifyRequest($this->validBody($issued['token'])); + + $response->assertOk(); + $response->assertJsonPath('status', 'error'); + $response->assertJsonPath('code', 'TOKEN_EXPIRED'); + } + + #[Test] + public function token_yang_diubah_ditolak() + { + $issued = $this->issueTokenForAdmin(); + + $tampered = $issued['token']; + $tampered[10] = $tampered[10] === 'a' ? 'b' : 'a'; + + $response = $this->verifyRequest($this->validBody($tampered)); + + $response->assertOk(); + $response->assertJsonPath('status', 'error'); + $response->assertJsonPath('code', 'TOKEN_INVALID'); + } + + #[Test] + public function token_dengan_karakter_acak_ditolak() + { + $response = $this->verifyRequest($this->validBody('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.abcdef.ghijkl')); + + $response->assertOk(); + $response->assertJsonPath('status', 'error'); + $response->assertJsonPath('code', 'TOKEN_INVALID'); + } + + #[Test] + public function token_ditandatangani_kunci_lain_ditolak() + { + $admin = User::first(); + $admin->forceFill(['active' => 1, '2fa_enabled' => true])->save(); + + $privateOther = (string) File::get(base_path('tests/fixtures/sso/private-other.pem')); + + $payload = [ + 'jti' => (string) Str::uuid(), + 'iat' => time(), + 'nbf' => time(), + 'exp' => time() + 300, + 'sub' => (string) $admin->id, + 'desa_id' => '5271010001', + 'iss' => config('sso.issuer'), + 'aud' => config('sso.audience'), + ]; + + $token = JWT::encode($payload, $privateOther, 'RS256'); + + $response = $this->verifyRequest($this->validBody($token)); + + $response->assertOk(); + $response->assertJsonPath('status', 'error'); + $response->assertJsonPath('code', 'TOKEN_INVALID'); + } + + #[Test] + public function token_beralgoritma_hs256_ditolak_anti_downgrade() + { + $payload = [ + 'jti' => (string) Str::uuid(), + 'iat' => time(), + 'nbf' => time(), + 'exp' => time() + 300, + 'sub' => (string) User::first()->id, + 'desa_id' => '5271010001', + 'iss' => config('sso.issuer'), + 'aud' => config('sso.audience'), + ]; + + $hs256Token = JWT::encode($payload, str_repeat('x', 32), 'HS256'); + + $response = $this->verifyRequest($this->validBody($hs256Token)); + + $response->assertOk(); + $response->assertJsonPath('status', 'error'); + $response->assertJsonPath('code', 'TOKEN_INVALID'); + } + + #[Test] + public function callback_dengan_kunci_salah_ditolak() + { + $issued = $this->issueTokenForAdmin(); + $body = $this->validBody($issued['token']); + + $response = $this->postJson('/api/v1/sso/verify-token', $body, [ + 'X-SSO-Callback-Key' => 'kunci-salah-yang-sangat-panjang-bgt-12345678', + 'X-SSO-Callback-Timestamp' => (string) time(), + 'X-SSO-Callback-Signature' => hash_hmac('sha256', json_encode($body), 'kunci-salah-yang-sangat-panjang-bgt-12345678'), + ]); + + $response->assertStatus(401); + $response->assertJsonPath('code', 'CALLBACK_UNAUTHORIZED'); + } + + #[Test] + public function callback_tanpa_signature_ditolak() + { + $issued = $this->issueTokenForAdmin(); + $secret = (string) config('sso.callback_secret'); + + $response = $this->postJson('/api/v1/sso/verify-token', $this->validBody($issued['token']), [ + 'X-SSO-Callback-Key' => $secret, + 'X-SSO-Callback-Timestamp' => (string) time(), + ]); + + $response->assertStatus(401); + $response->assertJsonPath('code', 'CALLBACK_UNAUTHORIZED'); + } + + #[Test] + public function callback_dengan_timestamp_kedaluwarsa_ditolak() + { + $issued = $this->issueTokenForAdmin(); + $body = $this->validBody($issued['token']); + $secret = (string) config('sso.callback_secret'); + + $response = $this->postJson('/api/v1/sso/verify-token', $body, [ + 'X-SSO-Callback-Key' => $secret, + 'X-SSO-Callback-Timestamp' => (string) (time() - 3600), + 'X-SSO-Callback-Signature' => hash_hmac('sha256', json_encode($body), $secret), + ]); + + $response->assertStatus(401); + $response->assertJsonPath('code', 'CALLBACK_UNAUTHORIZED'); + } + + #[Test] + public function user_dengan_state_tidak_layak_ditolak() + { + $this->issueTokenForAdmin(['active' => 0]); + $issued = $this->tokens()->issue(User::first()->id, '5271010001'); + + $response = $this->verifyRequest($this->validBody($issued['token'])); + + $response->assertOk(); + $response->assertJsonPath('status', 'error'); + $response->assertJsonPath('code', 'USER_UNAUTHORIZED'); + } + + #[Test] + public function respons_tidak_membocorkan_data_pribadi() + { + $issued = $this->issueTokenForAdmin(); + $admin = User::first(); + + $response = $this->verifyRequest($this->validBody($issued['token'])); + + $content = $response->getContent(); + $this->assertStringNotContainsString($admin->email, $content); + if ($admin->phone) { + $this->assertStringNotContainsString($admin->phone, $content); + } + $this->assertStringNotContainsString('password', $content); + $this->assertStringNotContainsString('2fa_identifier', $content); + } +} diff --git a/tests/fixtures/sso/private-other.pem b/tests/fixtures/sso/private-other.pem new file mode 100644 index 000000000..9b5fd60d5 --- /dev/null +++ b/tests/fixtures/sso/private-other.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCSkqB9YYOQ+wlC +5mOOagBceEA9pQxkDZKVk02G/vgM+IcyOuhCehRYJZ3i3O4Lh17/c7abwM5BOO15 +u8UnXh8nGWm15Yy1C39ueSO9L3c7okj1LRCR/KK0qegUXrMk8f88i5aw7jH9yi1h +S5zafHpjEeTWt2pC2EUl5y8GffX/qKns5UIo49x3h4JSKRpwFYyz0Pl/mIWiwNKG +j1HPTABFxVjVxLw08D4d80uqnykUpsZIK2/hH6V1DTKhOwiZtPEsVBBQ5MXOCkp7 +QIVUbnC4rdI76wTaK+n03F5eogTJ53YohugapNOnbvQvPDDo2kMWwM0GBCGDjyxc +RgBP0OBrAgMBAAECggEAPjQWAj/xGwU+r/Kgi5722kd6U+8IrCmKbQofm8v3L2IF +1k5jpVqz6ZjFQbMrHx9rNJRCCiWwIsPrHH+Qsx1hsTE6yKRLq8XeZd7pSO+s9VTx +C4P9hUcnMe7tfD+YXXSKMoK3CA1+j8tOqPzy61JqikLc0Db01Lt91REgxuuMC9w+ ++GRzCGg65h4nd7MTyo1OT47LWz+PhgjVBEIubErXjKNjWgtt+UhQ9agRQ1NMn7TO +5cSYGyArVGz7BKAXJnz2tqwXicE6Itpjo0m+u/+LUd4EBQc/LoYc21IdKow9mwdK +Ib068WcvNnr4FvcQ10MPCAI1zzs+b+1TEmH4jux1uQKBgQDKJc5wp4Qu3TV32Bnm +j5suDyaMv92MmCKRpDPBrxjyfueB9+dzdSRCVPrsk47QWDtEPiMvUncib7H/tcra +mdphdipkPCfCE6T9/7CV/kVXvLyczpU9ytIm/idjvAWuh2X9IRDxG0t+TmyBi9lm ++RtD2I3qmWDpbGAwLDz8JTH71QKBgQC5nq/+LztdPu4hunBLam58+QW94nyWhaYv +5sFpbSng6Ra9lmwim+8fmW351GRIB7UXTOy92WK6xngriEBYy1LXeMyfqnCQfTpk +ksf/jpScRVpVFu58EwxNu76JsSZYYi3GvbQDnrYnQ8Oo6rC998XvNVnnPk1wFDQa +TDWTMYrLPwKBgBMKXpiT3sp/QZMJQt9JtYcwHCRzyg19Ivxu6AUdVg85TtsaG63v +YbvxvzTbbT9IoER8SdoiPWB7/tskJ3hRt5UoiTOpyuDn8smN9Pcb3TI3b9ihSMNq +QEM1+0XUpwBadovSOoNLW+AIyQWmw5KEckIAutVXHx69e43uDMx/QGcFAoGAWzA+ +PZ1uiIyV6DPzXtlWlkawL2zI0BmwXu/6LJTYcgmQhmsTjJ0WiGuTv5w797bxnBTK +aSoWerNt7dTt57FbbUeHHxlYW2NqAGarojylfXAExLCTUJj6/mUxYeuJbvosiuo/ +OVgLHJcJ1D4XvfuvXajUFephmKIURqcR2tIH4XkCgYA2087lMz0nKOImrxZ0tatU +5dSoRcKPPe7qKEqFHcHzb/OtgoR+yLuqc1xJOMWM4wmuADFs4VTFcQy/PJ4fqpMT +4Mx2lgY0o/SCMa3cnQdKXfHOiwOvyIW5tZj1I1h1u7fWDxJhkVavbZeadUwd+XAd +zg8+rUYh8idWJIeQQgecbQ== +-----END PRIVATE KEY----- diff --git a/tests/fixtures/sso/private.pem b/tests/fixtures/sso/private.pem new file mode 100644 index 000000000..92681a0ea --- /dev/null +++ b/tests/fixtures/sso/private.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC44oBPAEG5WwkD +ISarLzWwMn9Qx9XYbVNcmKWHN88InlJQUlJdgOQoTDDsjeliZ/jKo2KS+1Yh+JG4 +zN9cybbyHhCCHM8PPKVYng6ka0cQShcKCqwt1cfnYhagzEWNaoel36IkMRccw3yc +266LX7r4wDbLgQErdR91TlFRDWBxSSC+6eb3p9M6l7wkFQ2XymhLhTN7kMcEjXTR +vqznyt20Xk5TFkCg7c2pJRkFo5k/YR4cxL51zGzIaLVUrrS+Hp5zWkBsBRAHkhpK +mFEFPlLAhXd3NT0GLC1PlLSrzXgh2/KaM9PVgogNas0sW5z7NK24jHEfu1Ny5qr+ +CLUA7oqZAgMBAAECggEANo9A5g9hVd2zjyTdUG8iHNkKEUCnrrK0gwLY9kpV4mk2 +SVXB0K4Upn+1gFlwogWkucf1cwKQrH26IxryX2N3hYOxw3PU9t4ivUAHeiXeVsRG +gEQeA3ivAW1E2Dbv34eseBrCsoN6Gpbl0zRREfgyKO4wPWXYfoaVvfxaqtyTtVop +oLZ4vy6la5rH/MXLohth0mgZsnzsdwWQY2hQqLBgJmP/tDeATCP/6t0jZnKZE6gK +mXFz/b0rWThI1j0Y5SxabGZupeX3uMxFFc6xz1wUmyOYuSZMQI5M7fBixHxJmTWH +EaOXe0a4GW3qKnI0du5LzlT7O9qVQMSc0rS+30efsQKBgQD5bS7hA3crv6MaeXS4 +Nxb53qsItvvk6u+MHyOfovXLEfUJ/jTT6LWFWRNTZLxfblT7GqTLtIoPB8CG7cuq +uHBtrD/i8qSSX+tyq1OKu2MS67q+7Era90RD+hQpal9JwotUQvElI34tDa034L/g +P5yu3PBqe+VePVcQbXjSTidAqwKBgQC9wd8dLQKwIkQp59zYy0v5LSGXbzImgja4 ++rXlLKy3Q4cZ+I0SqTtaRuVELY7B3HgeioQoj14Yn/C+plhUK9bA4+Uc38TN8HQe +P9xQjz7KZ48apN9nhudPM4unTlyuU3dfSq1AGoF6ZQ4RgT0wFv9CaQVsVzJxKtdF +9shOchjJywKBgHLlXZvfSc0ISOzQF/knv1i4vpaUG/K2WkR0r6dAnPK/Qd4pkFIx +NzTG9IUMuU1IeLL28gS4FISZ8JD3B0OMFJYgq4kydqbKsp2u2Bvm99tmZS6/kDfP +gU3C6/ir/Frw19enwod2ExBT5YAA8sIs61MNiV2z1tOTdACoPOi7gY9jAoGAAjEc +F6Uuc8Rhjy5bcbGfdghca/hf1uwIH8jdkwEaGZ/RDq69SmrkAZ3yBgCUto6KYKfu +8Nz/FD+8sycOvCIpf97iBZxcFJjjbnoEXHXbHtFlaMRgxDxdrZ+FzG/uSCE0rsgZ +CjWEK1rBd9hLUaPiXO0DoAW90mrRQ8Ce/bD5slMCgYEA5qQcyDCL/863N/4hUChY +QtH5UPen2TJX6QY6xHOUfu+xz18C4xZRjCxSbW1MORiznCDjvfXuUAH9PR4KC1gv +rohnc76eV5y/f6KDZbetZeCRoudI3o2fHonqhQWIfuwwnN5Hi1LH5M5162j4C3O1 +wQFMKhm0CwJCmkFrl0PY6RA= +-----END PRIVATE KEY----- diff --git a/tests/fixtures/sso/public-other.pem b/tests/fixtures/sso/public-other.pem new file mode 100644 index 000000000..da7744cc3 --- /dev/null +++ b/tests/fixtures/sso/public-other.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAkpKgfWGDkPsJQuZjjmoA +XHhAPaUMZA2SlZNNhv74DPiHMjroQnoUWCWd4tzuC4de/3O2m8DOQTjtebvFJ14f +JxlpteWMtQt/bnkjvS93O6JI9S0QkfyitKnoFF6zJPH/PIuWsO4x/cotYUuc2nx6 +YxHk1rdqQthFJecvBn31/6ip7OVCKOPcd4eCUikacBWMs9D5f5iFosDSho9Rz0wA +RcVY1cS8NPA+HfNLqp8pFKbGSCtv4R+ldQ0yoTsImbTxLFQQUOTFzgpKe0CFVG5w +uK3SO+sE2ivp9NxeXqIEyed2KIboGqTTp270Lzww6NpDFsDNBgQhg48sXEYAT9Dg +awIDAQAB +-----END PUBLIC KEY----- diff --git a/tests/fixtures/sso/public.pem b/tests/fixtures/sso/public.pem new file mode 100644 index 000000000..32fbed9d2 --- /dev/null +++ b/tests/fixtures/sso/public.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuOKATwBBuVsJAyEmqy81 +sDJ/UMfV2G1TXJilhzfPCJ5SUFJSXYDkKEww7I3pYmf4yqNikvtWIfiRuMzfXMm2 +8h4QghzPDzylWJ4OpGtHEEoXCgqsLdXH52IWoMxFjWqHpd+iJDEXHMN8nNuui1+6 ++MA2y4EBK3UfdU5RUQ1gcUkgvunm96fTOpe8JBUNl8poS4Uze5DHBI100b6s58rd +tF5OUxZAoO3NqSUZBaOZP2EeHMS+dcxsyGi1VK60vh6ec1pAbAUQB5IaSphRBT5S +wIV3dzU9BiwtT5S0q814IdvymjPT1YKIDWrNLFuc+zStuIxxH7tTcuaq/gi1AO6K +mQIDAQAB +-----END PUBLIC KEY----- From d0680d99add0b2e7d48fabb902b239fb28137e79 Mon Sep 17 00:00:00 2001 From: Ahmad Afandi Date: Mon, 10 Aug 2026 14:45:27 +0700 Subject: [PATCH 2/9] update .env.example --- .env.example | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 6f264edc6..3197980d8 100644 --- a/.env.example +++ b/.env.example @@ -120,12 +120,12 @@ SCREENSHOT_ON_SUCCESS=false # Generasi: openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out sso-private.pem # openssl pkey -in sso-private.pem -pubout -out sso-public.pem SSO_SIGNING_PRIVATE_KEY= -SSO_SIGNING_PRIVATE_KEY_FILE= +SSO_SIGNING_PRIVATE_KEY_FILE=tests/fixtures/sso/private.pem SSO_SIGNING_PUBLIC_KEY= -SSO_SIGNING_PUBLIC_KEY_FILE= +SSO_SIGNING_PUBLIC_KEY_FILE=tests/fixtures/sso/public.pem SSO_SIGNING_PUBLIC_KEYS_FILE= # Secret callback minimal 32 byte. Nilai dibagikan ke instalasi OpenSID. -SSO_CALLBACK_SECRET= +SSO_CALLBACK_SECRET=testing_sso_callback_secret_at_least_32_bytes_long SSO_TOKEN_TTL=300 SSO_OPENSID_BASE_URL= SSO_IP_WHITELIST= From 3bae2e628fedc9744868456f5c1e923fe01c7016 Mon Sep 17 00:00:00 2001 From: Ahmad Afandi Date: Mon, 10 Aug 2026 15:23:30 +0700 Subject: [PATCH 3/9] perbaikan test --- ...taPresisiKetenagakerjaanControllerTest.php | 8 +++- tests/Feature/Sso/SsoGenerateSessionTest.php | 1 + tests/Feature/Sso/SsoSecurityTest.php | 1 + .../Unit/DataPresisiPanganDetailViewTest.php | 42 +++++++++---------- 4 files changed, 30 insertions(+), 22 deletions(-) diff --git a/tests/Feature/DataPresisiKetenagakerjaanControllerTest.php b/tests/Feature/DataPresisiKetenagakerjaanControllerTest.php index c28c8991a..cb879fea8 100644 --- a/tests/Feature/DataPresisiKetenagakerjaanControllerTest.php +++ b/tests/Feature/DataPresisiKetenagakerjaanControllerTest.php @@ -103,6 +103,7 @@ public function test_can_access_ketenagakerjaan_detail_page(): void 'alamat' => 'Jl. Test No. 1', 'jumlah_anggota' => 4, 'jumlah_kk' => 1, + 'tahun' => '2024', ]); $response = $this->get(route('data-pokok.data-presisi-ketenagakerjaan.detail', ['data' => $data])); @@ -121,6 +122,7 @@ public function test_detail_page_has_decoded_json_data(): void 'alamat' => 'Jl. Test No. 1', 'jumlah_anggota' => 4, 'jumlah_kk' => 1, + 'tahun' => '2024', ]); $response = $this->get(route('data-pokok.data-presisi-ketenagakerjaan.detail', ['data' => $data])); @@ -134,6 +136,7 @@ public function test_detail_page_has_decoded_json_data(): void $this->assertEquals('Jl. Test No. 1', $viewData->alamat); $this->assertEquals(4, $viewData->jumlah_anggota); $this->assertEquals(1, $viewData->jumlah_kk); + $this->assertEquals('2024', $viewData->tahun); } #[Test] @@ -146,6 +149,7 @@ public function test_detail_page_displays_kepala_keluarga_name(): void 'alamat' => 'Jl. Test', 'jumlah_anggota' => 3, 'jumlah_kk' => 1, + 'tahun' => '2024', ]); $response = $this->get(route('data-pokok.data-presisi-ketenagakerjaan.detail', ['data' => $data])); @@ -166,6 +170,7 @@ public function test_detail_page_has_detail_table(): void 'alamat' => 'Jl. Test', 'jumlah_anggota' => 3, 'jumlah_kk' => 1, + 'tahun' => '2024', ]); $response = $this->get(route('data-pokok.data-presisi-ketenagakerjaan.detail', ['data' => $data])); @@ -185,6 +190,7 @@ public function test_detail_page_has_back_link_to_index(): void 'alamat' => 'Jl. Test', 'jumlah_anggota' => 3, 'jumlah_kk' => 1, + 'tahun' => '2024', ]); $response = $this->get(route('data-pokok.data-presisi-ketenagakerjaan.detail', ['data' => $data])); @@ -218,7 +224,7 @@ public function test_cetak_page_has_filter_view_variable(): void #[Test] public function test_cetak_page_passes_query_string_as_filter(): void { - $response = $this->get(route('data-pokok.data-presisi-ketenagakerjaan.cetak') . '?tahun=2024&status=active'); + $response = $this->get(route('data-pokok.data-presisi-ketenagakerjaan.cetak').'?tahun=2024&status=active'); $response->assertViewHas('filter'); $filter = $response->viewData('filter'); diff --git a/tests/Feature/Sso/SsoGenerateSessionTest.php b/tests/Feature/Sso/SsoGenerateSessionTest.php index 0ed4f10bf..9e0e27f7c 100644 --- a/tests/Feature/Sso/SsoGenerateSessionTest.php +++ b/tests/Feature/Sso/SsoGenerateSessionTest.php @@ -18,6 +18,7 @@ protected function adminWith2fa(): User $user->forceFill([ '2fa_enabled' => true, 'active' => 1, + 'kode_kabupaten' => null, ])->save(); $fresh = $user->fresh(); diff --git a/tests/Feature/Sso/SsoSecurityTest.php b/tests/Feature/Sso/SsoSecurityTest.php index 4d599d4f0..bbcf6c84d 100644 --- a/tests/Feature/Sso/SsoSecurityTest.php +++ b/tests/Feature/Sso/SsoSecurityTest.php @@ -14,6 +14,7 @@ protected function loginAsAdminState(array $attrs = [], bool $twoFaVerified = tr $user->forceFill(array_merge([ 'active' => 1, '2fa_enabled' => $twoFaEnabled, + 'kode_kabupaten' => null, ], $attrs))->save(); $fresh = $user->fresh(); diff --git a/tests/Unit/DataPresisiPanganDetailViewTest.php b/tests/Unit/DataPresisiPanganDetailViewTest.php index 2c20dbe32..8bc5ff524 100644 --- a/tests/Unit/DataPresisiPanganDetailViewTest.php +++ b/tests/Unit/DataPresisiPanganDetailViewTest.php @@ -2,11 +2,11 @@ namespace Tests\Unit; +use Illuminate\Foundation\Testing\DatabaseTransactions; +use Illuminate\Support\Facades\View as ViewFacade; +use Illuminate\View\View; use PHPUnit\Framework\Attributes\Test; use Tests\BaseTestCase; -use Illuminate\View\View; -use Illuminate\Support\Facades\View as ViewFacade; -use Illuminate\Foundation\Testing\DatabaseTransactions; class DataPresisiPanganDetailViewTest extends BaseTestCase { @@ -52,11 +52,11 @@ public function it_configures_datatable_columns_for_longitude_latitude() $html = $view->render(); // Assert bahwa JavaScript DataTable configuration mengandung mapping untuk longitude dan latitude - $this->assertStringContainsString("{ data: 'attributes.longitude', orderable: false }", $html); - $this->assertStringContainsString("{ data: 'attributes.latitude', orderable: false }", $html); - + $this->assertStringContainsString("{ data: 'attributes.longitude', defaultContent: \"-\", orderable: false }", $html); + $this->assertStringContainsString("{ data: 'attributes.latitude', defaultContent: \"-\", orderable: false }", $html); + // Assert bahwa koordinat dikonfigurasi sebagai kolom yang tidak dapat diurutkan - $this->assertStringContainsString("orderable: false", $html); + $this->assertStringContainsString('orderable: false', $html); } #[Test] @@ -80,14 +80,14 @@ public function it_includes_longitude_latitude_in_correct_column_order() // Longitude harus muncul sebelum latitude dalam urutan kolom $longitudePos = strpos($html, "data: 'attributes.longitude'"); $latitudePos = strpos($html, "data: 'attributes.latitude'"); - + $this->assertNotFalse($longitudePos, 'Longitude column configuration not found'); $this->assertNotFalse($latitudePos, 'Latitude column configuration not found'); $this->assertLessThan($latitudePos, $longitudePos, 'Longitude should appear before latitude in column configuration'); } #[Test] - public function it_configures_api_filter_with_rtm_id_for_coordinate_data() + public function it_configures_api_filter_with_id_rtm_for_coordinate_data() { // Mock data untuk testing view $mockData = (object) [ @@ -103,9 +103,9 @@ public function it_configures_api_filter_with_rtm_id_for_coordinate_data() $view = ViewFacade::make('data_pokok.data_presisi.pangan.detail', ['data' => $mockData]); $html = $view->render(); - // Assert bahwa filter API menggunakan rtm_id yang benar untuk mengambil data koordinat - $this->assertStringContainsString('url.searchParams.set("filter[rtm_id]", "TEST_RTM_123")', $html); - + // Assert bahwa filter API menggunakan no_kartu_rumah sebagai id_rtm untuk mengambil data anggota + $this->assertStringContainsString('url.searchParams.set("filter[id_rtm]", "KRT001")', $html); + // Assert bahwa endpoint API untuk data presisi pangan dikonfigurasi dengan benar $this->assertStringContainsString('/api/v1/data-presisi/pangan', $html); } @@ -133,7 +133,7 @@ public function it_renders_household_info_table_with_correct_data_structure() $this->assertStringContainsString('Jl. Contoh No. 123', $html); $this->assertStringContainsString('4', $html); // jumlah anggota $this->assertStringContainsString('1', $html); // jumlah kk - + // Assert struktur tabel rincian suplemen $this->assertStringContainsString('tabel-rincian', $html); $this->assertStringContainsString('No Kartu Rumah Tangga (KRT)', $html); @@ -161,7 +161,7 @@ public function it_has_proper_datatable_initialization_for_coordinate_display() $this->assertStringContainsString('processing: true', $html); $this->assertStringContainsString('serverSide: true', $html); $this->assertStringContainsString('autoWidth: false', $html); - + // Assert bahwa longitude dan latitude columns ada dalam konfigurasi $this->assertStringContainsString("data: 'attributes.longitude'", $html); $this->assertStringContainsString("data: 'attributes.latitude'", $html); @@ -187,7 +187,7 @@ public function it_includes_all_expected_column_headers_in_correct_order() // Daftar header kolom yang diharapkan sesuai urutan $expectedHeaders = [ 'NO', - 'NIK', + 'NIK', 'NOMOR KK', 'NAMA', 'JENIS LAHAN', @@ -202,13 +202,13 @@ public function it_includes_all_expected_column_headers_in_correct_order() 'JUMLAH POPULASI', 'JENIS PERIKANAN', 'FREKWENSI MAKANAN PERHARI', - 'FREKWENSI KONSUMSI SAYUR PERHARI', + 'FREKWENSI KONSUMSI SAYUR PERHARI', 'FREKWENSI KONSUMSI BUAH PERHARI', 'FREKWENSI KONSUMSI DAGING PERHARI', 'LONGITUDE', 'LATITUDE', 'TANGGAL PENGISIAN', - 'STATUS PENGISIAN' + 'STATUS PENGISIAN', ]; // Assert bahwa semua header ada dalam HTML @@ -219,7 +219,7 @@ public function it_includes_all_expected_column_headers_in_correct_order() // Assert urutan longitude dan latitude (longitude harus sebelum latitude) $longitudeHeaderPos = strpos($html, 'LONGITUDE'); $latitudeHeaderPos = strpos($html, 'LATITUDE'); - + $this->assertLessThan($latitudeHeaderPos, $longitudeHeaderPos); } @@ -242,9 +242,9 @@ public function it_validates_coordinate_columns_are_not_orderable() // Assert bahwa longitude dan latitude dikonfigurasi sebagai non-orderable // Ini penting karena koordinat biasanya tidak perlu diurutkan - $this->assertStringContainsString("{ data: 'attributes.longitude', orderable: false }", $html); - $this->assertStringContainsString("{ data: 'attributes.latitude', orderable: false }", $html); - + $this->assertStringContainsString("{ data: 'attributes.longitude', defaultContent: \"-\", orderable: false }", $html); + $this->assertStringContainsString("{ data: 'attributes.latitude', defaultContent: \"-\", orderable: false }", $html); + // Assert bahwa semua kolom selain nomor urut dikonfigurasi sebagai non-orderable $columnDefPattern = '/targets: \[0, 1, 2, 3, 4, 5\],\s*orderable: false,\s*searchable: false/'; $this->assertMatchesRegularExpression($columnDefPattern, $html); From c935802806fc70ab96b79cc1becf0c834757b1d8 Mon Sep 17 00:00:00 2001 From: Ahmad Afandi Date: Mon, 10 Aug 2026 15:57:43 +0700 Subject: [PATCH 4/9] perbaikan test --- tests/Feature/Sso/SsoGenerateSessionTest.php | 2 ++ tests/Feature/Sso/SsoSecurityTest.php | 2 ++ 2 files changed, 4 insertions(+) diff --git a/tests/Feature/Sso/SsoGenerateSessionTest.php b/tests/Feature/Sso/SsoGenerateSessionTest.php index 9e0e27f7c..f72bf6a74 100644 --- a/tests/Feature/Sso/SsoGenerateSessionTest.php +++ b/tests/Feature/Sso/SsoGenerateSessionTest.php @@ -14,6 +14,8 @@ class SsoGenerateSessionTest extends BaseTestCase { protected function adminWith2fa(): User { + config(['sso.opensid_base_url' => 'http://opensid.test']); + $user = User::first(); $user->forceFill([ '2fa_enabled' => true, diff --git a/tests/Feature/Sso/SsoSecurityTest.php b/tests/Feature/Sso/SsoSecurityTest.php index bbcf6c84d..35e856728 100644 --- a/tests/Feature/Sso/SsoSecurityTest.php +++ b/tests/Feature/Sso/SsoSecurityTest.php @@ -10,6 +10,8 @@ class SsoSecurityTest extends BaseTestCase { protected function loginAsAdminState(array $attrs = [], bool $twoFaVerified = true, bool $twoFaEnabled = true): User { + config(['sso.opensid_base_url' => 'http://opensid.test']); + $user = User::first(); $user->forceFill(array_merge([ 'active' => 1, From 351e21bbc6349b4b2c0be13c812dd4f19a572461 Mon Sep 17 00:00:00 2001 From: Ahmad Afandi Date: Tue, 11 Aug 2026 06:03:36 +0700 Subject: [PATCH 5/9] tampilkan pesan error --- resources/views/desa/index.blade.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/resources/views/desa/index.blade.php b/resources/views/desa/index.blade.php index d026dd133..f09241d11 100644 --- a/resources/views/desa/index.blade.php +++ b/resources/views/desa/index.blade.php @@ -263,9 +263,11 @@ className: 'text-center' }) .then(function(result) { if (result.data.status !== 'success') { + const code = result.data.code || ''; + const reason = result.data.message || 'Autentikasi gagal. Silakan coba lagi.'; Swal.fire( 'Gagal', - result.data.message || 'Autentikasi gagal. Silakan coba lagi.', + code ? `${reason} (${code})` : reason, 'error' ); return; From 494e3376d02839ce43961244c674677d4f83e643 Mon Sep 17 00:00:00 2001 From: Ahmad Afandi Date: Tue, 11 Aug 2026 07:21:47 +0700 Subject: [PATCH 6/9] sederhanakan alur, url website langsung ambil dari API database gabungan --- .env.example | 1 - .../Sso/DesaSsoConfigController.php | 56 ----------- app/Http/Requests/SsoDesaConfigRequest.php | 63 ------------- app/Models/Sso/DesaSsoConfig.php | 37 -------- app/Services/OpenSidUrlResolver.php | 46 ++++++--- app/Services/PendudukApiService.php | 14 +++ config/sso.php | 3 - ...8_000003_create_desa_sso_configs_table.php | 30 ------ docs/sso-opensid.md | 14 +-- resources/views/desa/index.blade.php | 12 ++- .../views/sso/desa_sso_config/form.blade.php | 54 ----------- .../views/sso/desa_sso_config/index.blade.php | 93 ------------------- routes/web.php | 4 - tests/Browser/SmokeSsoAccessTest.php | 26 ++++++ tests/Browser/fixtures/penduduk.json | 89 +++++++++--------- tests/Feature/Sso/SsoGenerateSessionTest.php | 78 +++++++++++++++- tests/Feature/Sso/SsoSecurityTest.php | 54 ++++++++++- 17 files changed, 267 insertions(+), 407 deletions(-) delete mode 100644 app/Http/Controllers/Sso/DesaSsoConfigController.php delete mode 100644 app/Http/Requests/SsoDesaConfigRequest.php delete mode 100644 app/Models/Sso/DesaSsoConfig.php delete mode 100644 database/migrations/2026_08_08_000003_create_desa_sso_configs_table.php delete mode 100644 resources/views/sso/desa_sso_config/form.blade.php delete mode 100644 resources/views/sso/desa_sso_config/index.blade.php diff --git a/.env.example b/.env.example index 3197980d8..78ccc0d3c 100644 --- a/.env.example +++ b/.env.example @@ -127,7 +127,6 @@ SSO_SIGNING_PUBLIC_KEYS_FILE= # Secret callback minimal 32 byte. Nilai dibagikan ke instalasi OpenSID. SSO_CALLBACK_SECRET=testing_sso_callback_secret_at_least_32_bytes_long SSO_TOKEN_TTL=300 -SSO_OPENSID_BASE_URL= SSO_IP_WHITELIST= SSO_RATE_LIMIT_MAX=5 SSO_CLOCK_SKEW_TOLERANCE=30 diff --git a/app/Http/Controllers/Sso/DesaSsoConfigController.php b/app/Http/Controllers/Sso/DesaSsoConfigController.php deleted file mode 100644 index 9ae591e0a..000000000 --- a/app/Http/Controllers/Sso/DesaSsoConfigController.php +++ /dev/null @@ -1,56 +0,0 @@ -orderBy('desa_id')->paginate(20); - - return view('sso.desa_sso_config.index', compact('configs')); - } - - public function create(): View - { - return view('sso.desa_sso_config.form', ['config' => null]); - } - - public function store(SsoDesaConfigRequest $request): RedirectResponse - { - DesaSsoConfig::create($request->validated()); - - session()->flash('success', 'Konfigurasi SSO desa berhasil disimpan.'); - - return redirect()->route('sso-config.index'); - } - - public function edit(DesaSsoConfig $ssoConfig): View - { - return view('sso.desa_sso_config.form', ['config' => $ssoConfig]); - } - - public function update(SsoDesaConfigRequest $request, DesaSsoConfig $ssoConfig): RedirectResponse - { - $ssoConfig->update($request->validated()); - - session()->flash('success', 'Konfigurasi SSO desa berhasil diperbarui.'); - - return redirect()->route('sso-config.index'); - } - - public function destroy(DesaSsoConfig $ssoConfig): RedirectResponse - { - $ssoConfig->delete(); - - session()->flash('success', 'Konfigurasi SSO desa berhasil dihapus.'); - - return redirect()->route('sso-config.index'); - } -} diff --git a/app/Http/Requests/SsoDesaConfigRequest.php b/app/Http/Requests/SsoDesaConfigRequest.php deleted file mode 100644 index e3cdd44fa..000000000 --- a/app/Http/Requests/SsoDesaConfigRequest.php +++ /dev/null @@ -1,63 +0,0 @@ -check(); - } - - /** - * Get the validation rules that apply to the request. - * - * @return array|string> - */ - public function rules(): array - { - $desaId = $this->route('sso_config')?->id; - - return [ - 'desa_id' => [ - 'required', - 'string', - 'regex:/^\d{10,13}$/', - 'unique:desa_sso_configs,desa_id,'.$desaId, - ], - 'opensid_url' => [ - 'required', - 'string', - 'max:255', - function ($attribute, $value, $fail) { - $scheme = parse_url($value, PHP_URL_SCHEME); - $host = parse_url($value, PHP_URL_HOST); - - if (! in_array($scheme, ['http', 'https'], true) || ! $host) { - $fail('URL OpenSID tidak valid.'); - - return; - } - - if ($scheme === 'http' && ! app()->environment(['local', 'testing'])) { - $fail('URL OpenSID wajib menggunakan HTTPS.'); - } - }, - ], - 'enabled' => ['nullable', 'boolean'], - ]; - } - - protected function prepareForValidation(): void - { - $this->merge([ - 'enabled' => $this->boolean('enabled'), - ]); - } -} diff --git a/app/Models/Sso/DesaSsoConfig.php b/app/Models/Sso/DesaSsoConfig.php deleted file mode 100644 index 0f3ddae2b..000000000 --- a/app/Models/Sso/DesaSsoConfig.php +++ /dev/null @@ -1,37 +0,0 @@ - 'boolean', - ]; - - public function scopeEnabled(Builder $query): Builder - { - return $query->where('enabled', true); - } - - /** - * Validasi dasar URL OpenSID. Pengecekan skema https dilakukan oleh - * OpenSidUrlResolver sesuai environment. - */ - public static function validateUrl(string $url): bool - { - $parsed = parse_url($url); - - if (! $parsed || empty($parsed['scheme']) || empty($parsed['host'])) { - return false; - } - - return in_array($parsed['scheme'], ['https', 'http'], true); - } -} diff --git a/app/Services/OpenSidUrlResolver.php b/app/Services/OpenSidUrlResolver.php index 8338f0496..e3825d70a 100644 --- a/app/Services/OpenSidUrlResolver.php +++ b/app/Services/OpenSidUrlResolver.php @@ -3,33 +3,49 @@ namespace App\Services; use App\Exceptions\SsoConfigurationException; -use App\Models\Sso\DesaSsoConfig; +use Illuminate\Support\Facades\Cache; /** * Resolusi URL panel admin OpenSID untuk sebuah desa. * - * Urutan: baris desa_sso_configs (enabled) → fallback config('sso.opensid_base_url'). + * Base URL diambil server-side dari field `attributes.website` desa pada API + * database gabungan (/api/v1/wilayah/penduduk). Hasil resolusi di-cache singkat + * agar tidak memanggil API eksternal setiap klik (SC-001). */ class OpenSidUrlResolver { + /** + * Durasi cache resolusi base URL (detik). + */ + protected const CACHE_TTL = 300; + + public function __construct( + protected PendudukApiService $desaApi, + ) {} + /** * Base URL instalasi OpenSID untuk desa (tanpa trailing slash). + * + * Melempar SsoConfigurationException bila website kosong/tidak valid atau + * API gabungan tidak mengembalikan data desa. */ public function resolveBaseUrl(string $desaId): string { - $config = DesaSsoConfig::query() - ->where('desa_id', $desaId) - ->enabled() - ->first(); + $base = Cache::remember($this->cacheKey($desaId), self::CACHE_TTL, function () use ($desaId) { + $desa = $this->desaApi->desaSummary([ + 'filter[kode_desa]' => $desaId, + 'page[size]' => 1, + ])->first(); - $base = rtrim((string) ($config?->opensid_url ?: config('sso.opensid_base_url')), '/'); + if (! $desa || empty($desa->website)) { + throw new SsoConfigurationException('URL website desa belum diisi.'); + } - if ($base === '') { - throw new SsoConfigurationException('URL OpenSID untuk desa ini belum dikonfigurasi.'); - } + return rtrim((string) $desa->website, '/'); + }); $scheme = parse_url($base, PHP_URL_SCHEME); - $isLocalEnv = app()->environment(['local', 'testing']); + $isLocalEnv = app()->environment(['development', 'local', 'testing']); if (! in_array($scheme, ['http', 'https'], true)) { throw new SsoConfigurationException('URL OpenSID tidak valid.'); @@ -51,4 +67,12 @@ public function resolveAdminLoginUrl(string $desaId): string return $this->resolveBaseUrl($desaId).'/'.ltrim($path, '/'); } + + /** + * Kunci cache per desa. + */ + protected function cacheKey(string $desaId): string + { + return 'sso:opensid:base_url:'.$desaId; + } } diff --git a/app/Services/PendudukApiService.php b/app/Services/PendudukApiService.php index fe1399c5e..a6fec402b 100644 --- a/app/Services/PendudukApiService.php +++ b/app/Services/PendudukApiService.php @@ -13,4 +13,18 @@ public function penduduk(array $filters = []) return collect($data)->map(fn ($item) => (object) $item['attributes']); } + + /** + * Ringkasan desa (nama, kode, jumlah penduduk, website) dari API gabungan. + * Sumber base URL instalasi OpenSID (field attributes.website). + */ + public function desaSummary(array $filters = []) + { + $data = $this->apiRequest('/api/v1/wilayah/penduduk', $filters); + if (! $data) { + return collect([]); + } + + return collect($data)->map(fn ($item) => (object) $item['attributes']); + } } diff --git a/config/sso.php b/config/sso.php index a5d57fdec..ff2d91fd3 100644 --- a/config/sso.php +++ b/config/sso.php @@ -38,9 +38,6 @@ // Sekret callback verify-token (dibagikan ke OpenSID). 'callback_secret' => (string) env('SSO_CALLBACK_SECRET', ''), - // Base URL fallback instalasi OpenSID (tanpa trailing slash). - 'opensid_base_url' => rtrim((string) env('SSO_OPENSID_BASE_URL', ''), '/'), - // Daftar IP yang diizinkan memanggil endpoint callback (kosong = semua). 'ip_whitelist' => array_values(array_filter(array_map('trim', explode(',', (string) env('SSO_IP_WHITELIST', ''))))), diff --git a/database/migrations/2026_08_08_000003_create_desa_sso_configs_table.php b/database/migrations/2026_08_08_000003_create_desa_sso_configs_table.php deleted file mode 100644 index f5b6b7e3a..000000000 --- a/database/migrations/2026_08_08_000003_create_desa_sso_configs_table.php +++ /dev/null @@ -1,30 +0,0 @@ -id(); - $table->string('desa_id', 20)->unique(); - $table->string('opensid_url', 255); - $table->boolean('enabled')->default(true); - $table->timestamps(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('desa_sso_configs'); - } -}; diff --git a/docs/sso-opensid.md b/docs/sso-opensid.md index d80bad675..affd3943d 100644 --- a/docs/sso-opensid.md +++ b/docs/sso-opensid.md @@ -10,10 +10,10 @@ Administrator OpenKab (sesi aktif + 2FA selesai) dapat masuk ke panel admin Open ## Prasyarat - PHP 8.4, Laravel 13 (repositori OpenKab ini). -- MySQL dengan 3 tabel baru hasil migrasi: +- MySQL dengan 2 tabel baru hasil migrasi: - `openkab_sso_logs` (audit append-only) - `openkab_sso_tokens` (token sekali pakai / anti-replay) - - `desa_sso_configs` (override URL OpenSID per desa, opsional) +- **API database gabungan** harus mengembalikan field `website` per desa pada `/api/v1/wilayah/penduduk` — ini satu-satunya sumber base URL instalasi OpenSID. Desa tanpa `website` tidak menampilkan tombol SSO (diganti keterangan "URL website belum diisi"). - Dependency baru: `firebase/php-jwt` (^7.0). v6.x tidak digunakan karena terdampak security advisory. - Sisi OpenSID (repo terpisah) harus menerapkan `specs/001-opensid-sso-access/contracts/opensid-sso-contract.md`. @@ -47,7 +47,6 @@ Administrator OpenKab (sesi aktif + 2FA selesai) dapat masuk ke panel admin Open # Opsional SSO_TOKEN_TTL=300 # detik; maksimum 600 - SSO_OPENSID_BASE_URL= # fallback base URL OpenSID SSO_IP_WHITELIST= # IP callback yang diizinkan (koma), kosong = semua SSO_RATE_LIMIT_MAX=5 # generate-session per menit per user+IP SSO_CLOCK_SKEW_TOLERANCE=30 # toleransi selisih jam callback @@ -67,10 +66,11 @@ Administrator OpenKab (sesi aktif + 2FA selesai) dapat masuk ke panel admin Open php artisan admin:menu-update # memperbarui menu admin (Audit Akses SSO) ``` -4. **Konfigurasi URL OpenSID per desa (opsional)** - - Bila semua desa memakai satu base URL: cukup `SSO_OPENSID_BASE_URL`. - - Bila berbeda per desa: gunakan menu Pengaturan OpenSID → "Konfigurasi SSO Desa" (`/pengaturan/sso-config`) atau isi tabel `desa_sso_configs`. - - Resolver memakai: baris `desa_sso_configs` (enabled) → fallback `SSO_OPENSID_BASE_URL`. +4. **Resolusi URL OpenSID dari website desa (API gabungan)** + - Base URL panel admin di-resolve **server-side** dari field `attributes.website` desa pada `/api/v1/wilayah/penduduk` (`OpenSidUrlResolver`, hasil di-cache singkat). URL akhir = `/admin/sso-login`. + - Input klien (frontend) **tidak pernah** menjadi sumber URL redirect (FR-022). + - Desa tanpa `website` → tombol "Masuk ke OpenSID" tidak ditampilkan; diganti keterangan "URL website belum diisi". `generate-session` untuk desa tersebut ditolak (CONFIGURATION_ERROR) dan dicatat. + - Tidak ada lagi konfigurasi manual per desa (`desa_sso_configs`/`SSO_OPENSID_BASE_URL` dihapus). 5. **Integrasi sisi OpenSID** - Implementasi `POST /admin/sso-login` dan tabel `opensid_sso_logs` sesuai kontrak. diff --git a/resources/views/desa/index.blade.php b/resources/views/desa/index.blade.php index f09241d11..e656857a1 100644 --- a/resources/views/desa/index.blade.php +++ b/resources/views/desa/index.blade.php @@ -204,6 +204,13 @@ className: 'text-center' const kodeDesa = (row.attributes && row.attributes.kode_desa) || row.id || ''; if (!kodeDesa) return ''; + const website = (row.attributes && row.attributes.website) || ''; + if (!website) { + return ` + URL website belum diisi + `; + } + return ``; @@ -262,12 +269,11 @@ className: 'text-center' }); }) .then(function(result) { - if (result.data.status !== 'success') { - const code = result.data.code || ''; + if (result.data.status !== 'success') { const reason = result.data.message || 'Autentikasi gagal. Silakan coba lagi.'; Swal.fire( 'Gagal', - code ? `${reason} (${code})` : reason, + reason, 'error' ); return; diff --git a/resources/views/sso/desa_sso_config/form.blade.php b/resources/views/sso/desa_sso_config/form.blade.php deleted file mode 100644 index d3c529a05..000000000 --- a/resources/views/sso/desa_sso_config/form.blade.php +++ /dev/null @@ -1,54 +0,0 @@ -@extends('layouts.index') - -@section('title', $config ? 'Ubah Konfigurasi SSO Desa' : 'Tambah Konfigurasi SSO Desa') - -@section('content_header') -

{{ $config ? 'Ubah Konfigurasi SSO Desa' : 'Tambah Konfigurasi SSO Desa' }}

-@stop - -@section('content') - @include('partials.breadcrumbs') -
-
- @include('common.errors') -
- - {!! Html::form($config ? 'PUT' : 'POST', $config ? route('sso-config.update', $config) : route('sso-config.store')) - ->class('form-horizontal') !!} - -
-
- - {!! Html::text('desa_id')->id('desa_id')->class('form-control')->placeholder('contoh: 5271010001') - ->value(old('desa_id', $config?->desa_id)) !!} - Kode desa (10-13 digit) sesuai data wilayah. -
-
- - {!! Html::text('opensid_url')->id('opensid_url')->class('form-control') - ->placeholder('https://desa.contoh.id') - ->value(old('opensid_url', $config?->opensid_url)) !!} - Base URL instalasi OpenSID desa (tanpa trailing slash). -
-
- -
-
- - - - {!! Html::form()->close() !!} -
-
-
-@endsection diff --git a/resources/views/sso/desa_sso_config/index.blade.php b/resources/views/sso/desa_sso_config/index.blade.php deleted file mode 100644 index 477f68644..000000000 --- a/resources/views/sso/desa_sso_config/index.blade.php +++ /dev/null @@ -1,93 +0,0 @@ -@extends('layouts.index') - -@section('title', 'Konfigurasi SSO Desa') - -@section('content_header') -

Konfigurasi SSO Desa

-@stop - -@section('content') - @include('partials.breadcrumbs') -
-
- @include('common.errors') -
- -
- @if (session('success')) -
- - {{ session('success') }} -
- @endif -
- - - - - - - - - - - @forelse ($configs as $config) - - - - - - - @empty - - - - @endforelse - -
Kode DesaURL OpenSIDStatusAksi
{{ $config->desa_id }}{{ $config->opensid_url }} - @if ($config->enabled) - Aktif - @else - Nonaktif - @endif - - - - - {!! Html::form('DELETE', route('sso-config.destroy', $config)) - ->id('form-delete-'.$config->id) - ->style('display:inline') !!} - - {!! Html::form()->close() !!} -
Belum ada konfigurasi. Gunakan - SSO_OPENSID_BASE_URL sebagai fallback.
-
-
- {{ $configs->links() }} -
-
-
-
-
-@endsection - -@section('js') - -@endsection diff --git a/routes/web.php b/routes/web.php index d5bc419df..51f51ca63 100644 --- a/routes/web.php +++ b/routes/web.php @@ -52,7 +52,6 @@ use App\Http\Controllers\RiwayatPenggunaController; use App\Http\Controllers\RtmController; use App\Http\Controllers\SettingController; -use App\Http\Controllers\Sso\DesaSsoConfigController; use App\Http\Controllers\Sso\SsoAuditController; use App\Http\Controllers\Sso\SsoLoginController; use App\Http\Controllers\StatistikAdatController; @@ -119,9 +118,6 @@ // SSO OpenSID: dashboard audit percobaan akses (super admin) Route::middleware('permission:sso-audit-read')->get('/sso/audit', [SsoAuditController::class, 'index'])->name('sso.audit'); - // SSO OpenSID: kelola URL OpenSID per desa (opsional, pengaturan OpenSID) - Route::middleware('permission:pengaturan-opensid')->resource('sso-config', DesaSsoConfigController::class)->only(['index', 'create', 'store', 'edit', 'update', 'destroy']); - // Force Password Reset Routes Route::get('password-reset/force', [ForcePasswordResetController::class, 'showForm'])->name('password.reset.form'); Route::post('password-reset/force', [ForcePasswordResetController::class, 'reset'])->name('password.reset.force'); diff --git a/tests/Browser/SmokeSsoAccessTest.php b/tests/Browser/SmokeSsoAccessTest.php index a42e61116..c871b15b1 100644 --- a/tests/Browser/SmokeSsoAccessTest.php +++ b/tests/Browser/SmokeSsoAccessTest.php @@ -11,6 +11,32 @@ SessionState::clear(); }); +it('menampilkan keterangan URL website belum diisi untuk desa tanpa website', function () { + $page = SessionState::loginAndNavigate($this->user, '/desa') + ->assertPathIs('/desa'); + + $page->assertScript( + "new Promise((resolve) => { + const check = () => { + const notices = document.querySelectorAll('.sso-website-kosong').length; + const btns = document.querySelectorAll('.btn-sso-opensid').length; + if (notices > 0 && btns > 0) { resolve(true); } else { setTimeout(check, 200); } + }; + check(); + })", + true + ); + + $page->assertVisible('.sso-website-kosong'); + $page->assertSee('URL website belum diisi'); + $page->assertScript( + "document.querySelectorAll('.sso-website-kosong').length === 1 && document.querySelectorAll('.btn-sso-opensid').length === 2", + true + ); + + ScreenshotHelper::saveIfEnabled($page, 'sso-desa-website-kosong'); +}); + it('menampilkan tombol Masuk ke OpenSID pada tabel desa', function () { $page = SessionState::loginAndNavigate($this->user, '/desa') ->assertPathIs('/desa'); diff --git a/tests/Browser/fixtures/penduduk.json b/tests/Browser/fixtures/penduduk.json index 5a3c02f6e..f750bb91c 100644 --- a/tests/Browser/fixtures/penduduk.json +++ b/tests/Browser/fixtures/penduduk.json @@ -1,44 +1,47 @@ { - "data": [ - { - "attributes": { - "nama_desa": "Desa Ampenan Tua", - "nama_kecamatan": "Kecamatan Ampenan", - "kode_desa": "5271010001", - "kode_kecamatan": "5271010", - "kode_kabupaten": "5271", - "nama_kabupaten": "Kota Mataram", - "penduduk_count": 5000 - } - }, - { - "attributes": { - "nama_desa": "Desa Taman Sari", - "nama_kecamatan": "Kecamatan Ampenan", - "kode_desa": "5271010002", - "kode_kecamatan": "5271010", - "kode_kabupaten": "5271", - "nama_kabupaten": "Kota Mataram", - "penduduk_count": 4500 - } - }, - { - "attributes": { - "nama_desa": "Desa Cakranegara", - "nama_kecamatan": "Kecamatan Cakranegara", - "kode_desa": "5271020001", - "kode_kecamatan": "5271020", - "kode_kabupaten": "5271", - "nama_kabupaten": "Kota Mataram", - "penduduk_count": 6000 - } - } - ], - "meta": { - "pagination": { - "total": 3, - "current_page": 1, - "last_page": 1 - } - } -} + "data": [ + { + "attributes": { + "nama_desa": "Desa Ampenan Tua", + "nama_kecamatan": "Kecamatan Ampenan", + "kode_desa": "5271010001", + "kode_kecamatan": "5271010", + "kode_kabupaten": "5271", + "nama_kabupaten": "Kota Mataram", + "penduduk_count": 5000, + "website": "https://ampenantua.opendesa.id" + } + }, + { + "attributes": { + "nama_desa": "Desa Taman Sari", + "nama_kecamatan": "Kecamatan Ampenan", + "kode_desa": "5271010002", + "kode_kecamatan": "5271010", + "kode_kabupaten": "5271", + "nama_kabupaten": "Kota Mataram", + "penduduk_count": 4500, + "website": "https://tamansari.opendesa.id" + } + }, + { + "attributes": { + "nama_desa": "Desa Cakranegara", + "nama_kecamatan": "Kecamatan Cakranegara", + "kode_desa": "5271020001", + "kode_kecamatan": "5271020", + "kode_kabupaten": "5271", + "nama_kabupaten": "Kota Mataram", + "penduduk_count": 6000, + "website": "" + } + } + ], + "meta": { + "pagination": { + "total": 3, + "current_page": 1, + "last_page": 1 + } + } +} \ No newline at end of file diff --git a/tests/Feature/Sso/SsoGenerateSessionTest.php b/tests/Feature/Sso/SsoGenerateSessionTest.php index f72bf6a74..b66c8d8c4 100644 --- a/tests/Feature/Sso/SsoGenerateSessionTest.php +++ b/tests/Feature/Sso/SsoGenerateSessionTest.php @@ -7,14 +7,45 @@ use Firebase\JWT\JWT; use Firebase\JWT\Key; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Http; use PHPUnit\Framework\Attributes\Test; use Tests\BaseTestCase; class SsoGenerateSessionTest extends BaseTestCase { + /** + * Fake API database gabungan (sumber field website desa). + * + * Http::fake() mengakumulasi callback dan yang terdaftar lebih dulu menang, + * sehingga perilaku di-drive lewat cache (dibaca saat request berjalan). + */ + protected function fakeDesaApi(?string $website = 'http://opensid.test', bool $fails = false): void + { + Cache::flush(); + + Cache::put('test:gabungan:website', $website, 60); + Cache::put('test:gabungan:fails', $fails, 60); + + Http::fake(function () { + if (Cache::get('test:gabungan:fails', false)) { + return Http::response([], 500); + } + + return Http::response([ + 'data' => [[ + 'attributes' => [ + 'website' => (string) Cache::get('test:gabungan:website', ''), + ], + ]], + 'meta' => ['pagination' => ['total' => 1]], + ], 200); + }); + } + protected function adminWith2fa(): User { - config(['sso.opensid_base_url' => 'http://opensid.test']); + $this->fakeDesaApi(); $user = User::first(); $user->forceFill([ @@ -126,4 +157,49 @@ public function token_ditandatangani_rs256_dan_terverifikasi_dengan_public_key() $this->assertSame(config('sso.issuer'), $payload->iss); $this->assertSame(config('sso.audience'), $payload->aud); } + + #[Test] + public function desa_tanpa_website_ditolak_dengan_pesan_generik_dan_dicatat() + { + $user = $this->adminWith2fa(); + $this->fakeDesaApi(''); + + $response = $this->postJson(route('sso.generate'), [ + 'desa_id' => '3201012001', + ]); + + $response->assertStatus(500); + $response->assertJson([ + 'status' => 'error', + 'code' => 'CONFIGURATION_ERROR', + ]); + $this->assertDatabaseHas('openkab_sso_logs', [ + 'admin_id' => $user->id, + 'desa_id' => '3201012001', + 'status' => 'failed', + 'reason_if_failed' => 'unknown', + ]); + } + + #[Test] + public function api_gabungan_gagal_ditolak_dengan_pesan_generik_dan_dicatat() + { + $user = $this->adminWith2fa(); + $this->fakeDesaApi(fails: true); + + $response = $this->postJson(route('sso.generate'), [ + 'desa_id' => '3201012001', + ]); + + $response->assertStatus(500); + $response->assertJson([ + 'status' => 'error', + 'code' => 'CONFIGURATION_ERROR', + ]); + $this->assertDatabaseHas('openkab_sso_logs', [ + 'admin_id' => $user->id, + 'desa_id' => '3201012001', + 'status' => 'failed', + ]); + } } diff --git a/tests/Feature/Sso/SsoSecurityTest.php b/tests/Feature/Sso/SsoSecurityTest.php index 35e856728..345c263ab 100644 --- a/tests/Feature/Sso/SsoSecurityTest.php +++ b/tests/Feature/Sso/SsoSecurityTest.php @@ -3,14 +3,45 @@ namespace Tests\Feature\Sso; use App\Models\User; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Http; use PHPUnit\Framework\Attributes\Test; use Tests\BaseTestCase; class SsoSecurityTest extends BaseTestCase { + /** + * Fake API database gabungan (sumber field website desa). + * + * Http::fake() mengakumulasi callback dan yang terdaftar lebih dulu menang, + * sehingga perilaku di-drive lewat cache (dibaca saat request berjalan). + */ + protected function fakeDesaApi(?string $website = 'http://opensid.test', bool $fails = false): void + { + Cache::flush(); + + Cache::put('test:gabungan:website', $website, 60); + Cache::put('test:gabungan:fails', $fails, 60); + + Http::fake(function () { + if (Cache::get('test:gabungan:fails', false)) { + return Http::response([], 500); + } + + return Http::response([ + 'data' => [[ + 'attributes' => [ + 'website' => (string) Cache::get('test:gabungan:website', ''), + ], + ]], + 'meta' => ['pagination' => ['total' => 1]], + ], 200); + }); + } + protected function loginAsAdminState(array $attrs = [], bool $twoFaVerified = true, bool $twoFaEnabled = true): User { - config(['sso.opensid_base_url' => 'http://opensid.test']); + $this->fakeDesaApi(); $user = User::first(); $user->forceFill(array_merge([ @@ -156,6 +187,27 @@ public function respons_error_tidak_membocorkan_data_pribadi_atau_detail_teknis( $this->assertStringNotContainsString('SQLSTATE', $content); } + #[Test] + public function desa_tanpa_website_ditolak_dengan_pesan_generik_dan_dicatat() + { + $user = $this->loginAsAdminState(); + $this->fakeDesaApi(''); + + $response = $this->postGenerate(); + + $response->assertStatus(500); + $response->assertJson([ + 'status' => 'error', + 'code' => 'CONFIGURATION_ERROR', + ]); + $response->assertJsonPath('message', 'Autentikasi gagal.'); + $this->assertDatabaseHas('openkab_sso_logs', [ + 'admin_id' => $user->id, + 'status' => 'failed', + 'reason_if_failed' => 'unknown', + ]); + } + #[Test] public function rate_limit_memblokir_permintaan_berlebih() { From fa0e5fc7e995519dc5d505f11fd6907aec8d9911 Mon Sep 17 00:00:00 2001 From: Ahmad Afandi Date: Tue, 11 Aug 2026 07:30:36 +0700 Subject: [PATCH 7/9] fix test --- tests/Feature/DataPresisiKetenagakerjaanControllerTest.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/Feature/DataPresisiKetenagakerjaanControllerTest.php b/tests/Feature/DataPresisiKetenagakerjaanControllerTest.php index dd0322a15..c383102a6 100644 --- a/tests/Feature/DataPresisiKetenagakerjaanControllerTest.php +++ b/tests/Feature/DataPresisiKetenagakerjaanControllerTest.php @@ -136,7 +136,6 @@ public function test_detail_page_has_decoded_json_data(): void $this->assertEquals('Jl. Test No. 1', $viewData->alamat); $this->assertEquals(4, $viewData->jumlah_anggota); $this->assertEquals(1, $viewData->jumlah_kk); - $this->assertEquals('2024', $viewData->tahun); } #[Test] @@ -224,7 +223,7 @@ public function test_cetak_page_has_filter_view_variable(): void #[Test] public function test_cetak_page_passes_query_string_as_filter(): void { - $response = $this->get(route('data-pokok.data-presisi-ketenagakerjaan.cetak').'?tahun=2024&status=active'); + $response = $this->get(route('data-pokok.data-presisi-ketenagakerjaan.cetak') . '?tahun=2024&status=active'); $response->assertViewHas('filter'); $filter = $response->viewData('filter'); From 7d34fcf916a9958a4d8d1e149d14b09542d460ef Mon Sep 17 00:00:00 2001 From: Ahmad Afandi Date: Tue, 11 Aug 2026 09:32:28 +0700 Subject: [PATCH 8/9] tambah command generate key --- .gitignore | 1 + .../Commands/SsoGenerateKeysCommand.php | 190 ++++++++++++++++++ app/Console/Commands/updateAdminMenu.php | 14 +- .../Controllers/Api/SsoVerifyController.php | 9 +- .../Controllers/Sso/SsoLoginController.php | 1 + app/Http/Requests/SsoVerifyRequest.php | 31 +++ ..._08_11_000001_add_sso_audit_permission.php | 55 +++++ database/seeders/DatabaseSeeder.php | 3 +- database/seeders/SsoPermissionsSeeder.php | 26 --- docs/sso-opensid.md | 15 +- tests/Feature/Sso/SsoAuditDashboardTest.php | 20 +- .../Sso/SsoGenerateKeysCommandTest.php | 125 ++++++++++++ 12 files changed, 448 insertions(+), 42 deletions(-) create mode 100644 app/Console/Commands/SsoGenerateKeysCommand.php create mode 100644 app/Http/Requests/SsoVerifyRequest.php create mode 100644 database/migrations/2026_08_11_000001_add_sso_audit_permission.php delete mode 100644 database/seeders/SsoPermissionsSeeder.php create mode 100644 tests/Feature/Sso/SsoGenerateKeysCommandTest.php diff --git a/.gitignore b/.gitignore index f28956df3..82c7725f7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ /public/favicon.png /public/favicon.ico /storage/*.key +/storage/sso/ /vendor .env .env.testing diff --git a/app/Console/Commands/SsoGenerateKeysCommand.php b/app/Console/Commands/SsoGenerateKeysCommand.php new file mode 100644 index 000000000..ed3a6e151 --- /dev/null +++ b/app/Console/Commands/SsoGenerateKeysCommand.php @@ -0,0 +1,190 @@ +option('bits'); + $path = (string) $this->option('path'); + $envFile = (string) $this->option('env-file'); + $force = (bool) $this->option('force'); + + if (! is_numeric($bits) || (int) $bits < SsoKeyManager::MIN_BITS) { + $this->error(sprintf('Ukuran kunci minimal %d-bit.', SsoKeyManager::MIN_BITS)); + + return self::FAILURE; + } + + $envPath = $this->resolvePath($envFile); + + if (! File::exists($envPath)) { + $this->error(sprintf('File env tidak ditemukan: %s', $envPath)); + + return self::FAILURE; + } + + if (! $force && $this->hasExistingKeys($envPath)) { + $this->error('Kunci SSO sudah terkonfigurasi. Gunakan --force untuk menimpanya.'); + + return self::FAILURE; + } + + $keypair = $this->generateKeypair((int) $bits); + + if ($keypair === null) { + $this->error('Gagal membuat keypair RSA.'); + + return self::FAILURE; + } + + $dir = $this->resolvePath($path); + $privateFile = rtrim($path, '/').'/sso-private.pem'; + $publicFile = rtrim($path, '/').'/sso-public.pem'; + + if (! File::isDirectory($dir)) { + File::makeDirectory($dir, 0777, true); + } + + File::put($privateFile, $keypair['private']); + File::put($publicFile, $keypair['public']); + chmod($this->resolvePath($privateFile), 0600); + + $this->setEnvValue($envPath, self::PRIVATE_KEY, ''); + $this->setEnvValue($envPath, self::PUBLIC_KEY, ''); + $this->setEnvValue($envPath, self::PRIVATE_FILE_KEY, $privateFile); + $this->setEnvValue($envPath, self::PUBLIC_FILE_KEY, $publicFile); + + $this->info(sprintf('Keypair RS256 (%d-bit) berhasil dibuat.', $bits)); + $this->line(sprintf(' Private: %s (chmod 0600)', $privateFile)); + $this->line(sprintf(' Public : %s', $publicFile)); + $this->line(sprintf('Env diisi: %s', $envPath)); + $this->warn('Sebarkan public key ke setiap instalasi OpenSID secara out-of-band; private key tidak pernah dibagikan.'); + + return self::SUCCESS; + } + + /** + * Buat keypair RSA dan kembalikan PEM private + public. + * + * @return array{private: string, public: string}|null + */ + protected function generateKeypair(int $bits): ?array + { + $resource = openssl_pkey_new([ + 'private_key_bits' => $bits, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + + if ($resource === false) { + return null; + } + + if (! openssl_pkey_export($resource, $privatePem)) { + return null; + } + + $details = openssl_pkey_get_details($resource); + + if ($details === false || ($details['bits'] ?? 0) < SsoKeyManager::MIN_BITS) { + return null; + } + + return [ + 'private' => $privatePem, + 'public' => (string) $details['key'], + ]; + } + + /** + * Cek apakah kunci (nilai langsung atau file) sudah terisi di file env. + */ + protected function hasExistingKeys(string $envPath): bool + { + $content = (string) File::get($envPath); + + foreach ([self::PRIVATE_KEY, self::PRIVATE_FILE_KEY, self::PUBLIC_KEY, self::PUBLIC_FILE_KEY] as $key) { + $value = $this->envValue($content, $key); + + if ($value !== '') { + return true; + } + } + + return false; + } + + /** + * Baca nilai key dari konten env. + */ + protected function envValue(string $content, string $key): string + { + if (preg_match('/^'.preg_quote($key, '/').'=(.*)$/m', $content, $matches)) { + return trim($matches[1], " \t\""); + } + + return ''; + } + + /** + * Set nilai key di file env (update bila ada, tambah bila belum). + */ + protected function setEnvValue(string $envPath, string $key, string $value): void + { + $lines = file($envPath, FILE_IGNORE_NEW_LINES) ?: []; + $found = false; + + foreach ($lines as &$line) { + if (preg_match('/^'.preg_quote($key, '/').'=/', $line)) { + $line = $key.'='.$value; + $found = true; + } + } + unset($line); + + if (! $found) { + $lines[] = $key.'='.$value; + } + + File::put($envPath, implode(PHP_EOL, $lines).PHP_EOL); + } + + /** + * Resolusi path: absolut bila diawali '/' atau drive, relatif terhadap base_path. + */ + protected function resolvePath(string $path): string + { + if (str_starts_with($path, '/') || preg_match('#^[A-Za-z]:[\\\\/]#', $path)) { + return $path; + } + + return base_path($path); + } +} diff --git a/app/Console/Commands/updateAdminMenu.php b/app/Console/Commands/updateAdminMenu.php index d46d51195..be52b52e9 100644 --- a/app/Console/Commands/updateAdminMenu.php +++ b/app/Console/Commands/updateAdminMenu.php @@ -68,7 +68,7 @@ private function collectPermissions() if(empty($menuPermission)){ continue; } - $permissionName = $main_menu['permission'].'-'.$permission; + $permissionName = $this->permissionName((string) $main_menu['permission'], $permission); Permission::findOrCreate($permissionName, 'web'); $permissions[] = $permissionName; } @@ -79,7 +79,7 @@ private function collectPermissions() if(empty($subMenuPermission)){ continue; } - $permissionName = $sub_menu['permission'].'-'.$permission; + $permissionName = $this->permissionName((string) $sub_menu['permission'], $permission); Permission::findOrCreate($permissionName, 'web'); $permissions[] = $permissionName; } @@ -89,4 +89,14 @@ private function collectPermissions() return $permissions; } + + /** + * Nama permission menu: tambahkan suffix bila belum ada. + * Menghindari hasil ganda seperti "sso-audit-read-read" untuk entry yang + * sudah memakai nama permission lengkap (mis. 'sso-audit-read'). + */ + private function permissionName(string $base, string $suffix): string + { + return str_ends_with($base, $suffix) ? $base : $base.'-'.$suffix; + } } diff --git a/app/Http/Controllers/Api/SsoVerifyController.php b/app/Http/Controllers/Api/SsoVerifyController.php index 66f7685b4..93aba834b 100644 --- a/app/Http/Controllers/Api/SsoVerifyController.php +++ b/app/Http/Controllers/Api/SsoVerifyController.php @@ -5,6 +5,7 @@ use App\Enums\SsoStatusEnum; use App\Exceptions\SsoConfigurationException; use App\Http\Controllers\Controller; +use App\Http\Requests\SsoVerifyRequest; use App\Models\Sso\OpenKabSsoToken; use App\Models\User; use App\Services\SsoAuditLogger; @@ -13,7 +14,6 @@ use Firebase\JWT\ExpiredException; use Firebase\JWT\SignatureInvalidException; use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; use UnexpectedValueException; class SsoVerifyController extends Controller @@ -27,13 +27,8 @@ public function __construct( * Verifikasi token yang dikirim OpenSID sebelum pembuatan sesi. * Dipanggil server-to-server (diotentikasi oleh SsoCallbackAuth). */ - public function verify(Request $request): JsonResponse + public function verify(SsoVerifyRequest $request): JsonResponse { - $request->validate([ - 'token' => ['required', 'string'], - 'callback_nonce' => ['required', 'string', 'min:8'], - ]); - $token = (string) $request->input('token'); try { diff --git a/app/Http/Controllers/Sso/SsoLoginController.php b/app/Http/Controllers/Sso/SsoLoginController.php index e393f3295..0be25ec01 100644 --- a/app/Http/Controllers/Sso/SsoLoginController.php +++ b/app/Http/Controllers/Sso/SsoLoginController.php @@ -43,6 +43,7 @@ public function generateSession(SsoGenerateRequest $request): JsonResponse $redirectUrl = $this->resolver->resolveAdminLoginUrl($desaId); $issued = $this->tokens->issue($user->id, $desaId); } catch (SsoConfigurationException $e) { + logger('error', [$e->getMessage()]); return $this->reject($user, $desaId, SsoStatusEnum::REASON_UNKNOWN, 'CONFIGURATION_ERROR', 500); } diff --git a/app/Http/Requests/SsoVerifyRequest.php b/app/Http/Requests/SsoVerifyRequest.php new file mode 100644 index 000000000..747fb5f10 --- /dev/null +++ b/app/Http/Requests/SsoVerifyRequest.php @@ -0,0 +1,31 @@ +|string> + */ + public function rules(): array + { + return [ + 'token' => ['required', 'string'], + 'callback_nonce' => ['required', 'string', 'min:8'], + ]; + } +} diff --git a/database/migrations/2026_08_11_000001_add_sso_audit_permission.php b/database/migrations/2026_08_11_000001_add_sso_audit_permission.php new file mode 100644 index 000000000..8fe7dd6fb --- /dev/null +++ b/database/migrations/2026_08_11_000001_add_sso_audit_permission.php @@ -0,0 +1,55 @@ +where('name', 'administrator')->first(); + + if ($team) { + setPermissionsTeamId($team->id); + + $role = Role::query()->where('name', 'administrator')->where('guard_name', 'web')->first(); + + if ($role && ! $role->hasPermissionTo($permission)) { + $role->givePermissionTo($permission); + } + } + + Artisan::call('admin:menu-update'); + } + + /** + * Hapus permission sso-audit-read saat rollback. + */ + public function down(): void + { + $team = Team::query()->where('name', 'administrator')->first(); + + if ($team) { + setPermissionsTeamId($team->id); + + $role = Role::query()->where('name', 'administrator')->where('guard_name', 'web')->first(); + + if ($role) { + $role->revokePermissionTo('sso-audit-read'); + } + } + + Permission::where('name', 'sso-audit-read')->where('guard_name', 'web')->delete(); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 21480b72d..f04091119 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -15,7 +15,6 @@ class DatabaseSeeder extends Seeder public function run() { /* untuk IdentitasSeeder dan CreateAdminUserSeeder sudah dipanggil dalam migration */ - $this->call(SettingSeeder::class); - $this->call(SsoPermissionsSeeder::class); + $this->call(SettingSeeder::class); } } diff --git a/database/seeders/SsoPermissionsSeeder.php b/database/seeders/SsoPermissionsSeeder.php deleted file mode 100644 index 429582ec7..000000000 --- a/database/seeders/SsoPermissionsSeeder.php +++ /dev/null @@ -1,26 +0,0 @@ -where('name', 'administrator')->where('guard_name', 'web')->first(); - - if ($role) { - if (! $role->hasPermissionTo($permission)) { - $role->givePermissionTo($permission); - } - } - } -} diff --git a/docs/sso-opensid.md b/docs/sso-opensid.md index affd3943d..25768fa70 100644 --- a/docs/sso-opensid.md +++ b/docs/sso-opensid.md @@ -27,6 +27,15 @@ Administrator OpenKab (sesi aktif + 2FA selesai) dapat masuk ke panel admin Open 2. **Generasi & konfigurasi kunci RS256** (sekali saat pemasangan) + ```bash + php artisan sso:generate-keys + # Opsional: --bits=4096 --path=storage/sso --env-file=.env --force + ``` + + Command ini membuat keypair (private chmod 0600 + public), menulisnya ke + `storage/sso/`, dan mengisi `SSO_SIGNING_PRIVATE_KEY_FILE`/`SSO_SIGNING_PUBLIC_KEY_FILE` + di `.env`. Alternatif manual: + ```bash openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out sso-private.pem openssl pkey -in sso-private.pem -pubout -out sso-public.pem @@ -58,14 +67,14 @@ Administrator OpenKab (sesi aktif + 2FA selesai) dapat masuk ke panel admin Open `SSO_CALLBACK_SECRET` HARUS di-set. Aplikasi menolak operasi SSO bila private key tidak valid RSA ≥2048-bit atau callback secret < 32 byte. -3. **Migrasi & seeder** +3. **Migrasi** ```bash php artisan migrate - php artisan db:seed --class=SsoPermissionsSeeder # menambah permission sso-audit-read - php artisan admin:menu-update # memperbarui menu admin (Audit Akses SSO) ``` + Migration `2026_08_11_000001_add_sso_audit_permission` otomatis membuat permission `sso-audit-read` + melampirkannya ke role `administrator` dan menjalankan `admin:menu-update` (menu "Audit Akses SSO"). + 4. **Resolusi URL OpenSID dari website desa (API gabungan)** - Base URL panel admin di-resolve **server-side** dari field `attributes.website` desa pada `/api/v1/wilayah/penduduk` (`OpenSidUrlResolver`, hasil di-cache singkat). URL akhir = `/admin/sso-login`. - Input klien (frontend) **tidak pernah** menjadi sumber URL redirect (FR-022). diff --git a/tests/Feature/Sso/SsoAuditDashboardTest.php b/tests/Feature/Sso/SsoAuditDashboardTest.php index 4ddd297bc..893cd1760 100644 --- a/tests/Feature/Sso/SsoAuditDashboardTest.php +++ b/tests/Feature/Sso/SsoAuditDashboardTest.php @@ -3,17 +3,33 @@ namespace Tests\Feature\Sso; use App\Models\Sso\OpenKabSsoLog; +use App\Models\Team; use App\Models\User; -use Database\Seeders\SsoPermissionsSeeder; use Illuminate\Support\Facades\Auth; use PHPUnit\Framework\Attributes\Test; +use Spatie\Permission\Models\Permission; +use Spatie\Permission\Models\Role; use Tests\BaseTestCase; class SsoAuditDashboardTest extends BaseTestCase { protected function grantAuditPermission(): void { - $this->seed(SsoPermissionsSeeder::class); + $permission = Permission::findOrCreate('sso-audit-read', 'web'); + + $team = Team::query()->where('name', 'administrator')->first(); + + if (! $team) { + return; + } + + setPermissionsTeamId($team->id); + + $role = Role::query()->where('name', 'administrator')->where('guard_name', 'web')->first(); + + if ($role) { + $role->givePermissionTo($permission); + } } #[Test] diff --git a/tests/Feature/Sso/SsoGenerateKeysCommandTest.php b/tests/Feature/Sso/SsoGenerateKeysCommandTest.php new file mode 100644 index 000000000..fb003a74a --- /dev/null +++ b/tests/Feature/Sso/SsoGenerateKeysCommandTest.php @@ -0,0 +1,125 @@ +dir = sys_get_temp_dir().'/openkab-sso-keys-'.uniqid(); + $this->envFile = $this->dir.'/keys.env'; + + File::makeDirectory($this->dir, 0777, true); + File::put($this->envFile, "APP_ENV=testing\n"); + } + + protected function tearDown(): void + { + File::deleteDirectory($this->dir); + + parent::tearDown(); + } + + #[Test] + public function command_membuat_keypair_dan_mengisi_file_env() + { + $this->artisan('sso:generate-keys', [ + '--path' => $this->dir, + '--env-file' => $this->envFile, + ])->assertSuccessful(); + + $this->assertFileExists($this->dir.'/sso-private.pem'); + $this->assertFileExists($this->dir.'/sso-public.pem'); + + $perms = fileperms($this->dir.'/sso-private.pem') & 0777; + $this->assertSame(0600, $perms, 'private key wajib 0600'); + + $content = File::get($this->envFile); + $this->assertStringContainsString('SSO_SIGNING_PRIVATE_KEY_FILE='.$this->dir.'/sso-private.pem', $content); + $this->assertStringContainsString('SSO_SIGNING_PUBLIC_KEY_FILE='.$this->dir.'/sso-public.pem', $content); + $this->assertStringContainsString('SSO_SIGNING_PRIVATE_KEY=', $content); + $this->assertStringContainsString('SSO_SIGNING_PUBLIC_KEY=', $content); + + $privatePem = File::get($this->dir.'/sso-private.pem'); + $publicPem = File::get($this->dir.'/sso-public.pem'); + + $this->assertNotFalse(openssl_pkey_get_private($privatePem), 'private key valid'); + $this->assertNotFalse(openssl_pkey_get_public($publicPem), 'public key valid'); + + $details = openssl_pkey_get_details(openssl_pkey_get_private($privatePem)); + $this->assertGreaterThanOrEqual(2048, $details['bits']); + } + + #[Test] + public function kunci_yang_dihasilkan_dapat_memverifikasi_jwt_rs256() + { + $this->artisan('sso:generate-keys', [ + '--path' => $this->dir, + '--env-file' => $this->envFile, + ])->assertSuccessful(); + + $privatePem = File::get($this->dir.'/sso-private.pem'); + $publicPem = File::get($this->dir.'/sso-public.pem'); + + $token = JWT::encode(['sub' => '1'], $privatePem, 'RS256'); + + $payload = JWT::decode($token, new Key($publicPem, 'RS256')); + + $this->assertSame('1', $payload->sub); + } + + #[Test] + public function command_ditolak_saat_kunci_sudah_terisi_tanpa_force() + { + File::put($this->envFile, "SSO_SIGNING_PRIVATE_KEY_FILE=storage/sso/sso-private.pem\n"); + + $this->artisan('sso:generate-keys', [ + '--path' => $this->dir, + '--env-file' => $this->envFile, + ])->assertExitCode(1); + + $this->assertFileDoesNotExist($this->dir.'/sso-private.pem'); + } + + #[Test] + public function command_menimpa_ketika_diberi_force() + { + File::put($this->envFile, "SSO_SIGNING_PRIVATE_KEY_FILE=storage/sso/sso-private.pem\n"); + + $this->artisan('sso:generate-keys', [ + '--path' => $this->dir, + '--env-file' => $this->envFile, + '--force' => true, + ])->assertSuccessful(); + + $this->assertFileExists($this->dir.'/sso-private.pem'); + $this->assertStringContainsString( + 'SSO_SIGNING_PRIVATE_KEY_FILE='.$this->dir.'/sso-private.pem', + File::get($this->envFile) + ); + } + + #[Test] + public function command_menolak_ukuran_kunci_kurang_dari_2048() + { + $this->artisan('sso:generate-keys', [ + '--bits' => 1024, + '--path' => $this->dir, + '--env-file' => $this->envFile, + ])->assertExitCode(1); + + $this->assertFileDoesNotExist($this->dir.'/sso-private.pem'); + } +} From 3bb890f0d5cecb4789916e6644a3fa82c7ed6192 Mon Sep 17 00:00:00 2001 From: Ahmad Afandi Date: Tue, 11 Aug 2026 13:36:41 +0700 Subject: [PATCH 9/9] perbaiki dokumentasi --- .../Controllers/Sso/SsoLoginController.php | 2 +- config/sso.php | 5 ++ docs/sso-opensid.md | 4 ++ tests/Feature/Sso/SsoSecurityTest.php | 63 +++++++++++++++++++ 4 files changed, 73 insertions(+), 1 deletion(-) diff --git a/app/Http/Controllers/Sso/SsoLoginController.php b/app/Http/Controllers/Sso/SsoLoginController.php index 0be25ec01..6c45f233c 100644 --- a/app/Http/Controllers/Sso/SsoLoginController.php +++ b/app/Http/Controllers/Sso/SsoLoginController.php @@ -105,7 +105,7 @@ protected function checkEligibility(User $user, string $desaId): ?array */ protected function isOriginAllowed(Request $request): bool { - if (app()->environment(['local', 'testing'])) { + if (in_array(app()->environment(), config('sso.origin_check_skip_envs', ['local', 'testing']), true)) { return true; } diff --git a/config/sso.php b/config/sso.php index ff2d91fd3..c4e4e070d 100644 --- a/config/sso.php +++ b/config/sso.php @@ -44,6 +44,11 @@ // Maksimum permintaan generate-session per menit per user+IP. 'rate_limit_max' => (int) env('SSO_RATE_LIMIT_MAX', 5), + // Environment yang melewati validasi asal (Origin/Referer) di generate-session + // agar alur development/testing tidak perlu mengirim header Origin. Daftar ini + // dapat dikosongkan lewat config untuk memaksa validasi (dipakai di pengujian). + 'origin_check_skip_envs' => ['local', 'testing'], + // Klaim iss / aud pada token. 'issuer' => env('APP_URL', 'openkab'), diff --git a/docs/sso-opensid.md b/docs/sso-opensid.md index 25768fa70..b4fbbdfb0 100644 --- a/docs/sso-opensid.md +++ b/docs/sso-opensid.md @@ -84,6 +84,7 @@ Administrator OpenKab (sesi aktif + 2FA selesai) dapat masuk ke panel admin Open 5. **Integrasi sisi OpenSID** - Implementasi `POST /admin/sso-login` dan tabel `opensid_sso_logs` sesuai kontrak. - OpenSID memanggil `POST {openkab}/api/v1/sso/verify-token` dengan header `X-SSO-Callback-Key`, `X-SSO-Callback-Timestamp`, `X-SSO-Callback-Signature` (HMAC-SHA256 atas body memakai `SSO_CALLBACK_SECRET`). + - **Proteksi lintas-situs (FR-011, wajib)**: `POST /admin/sso-login` harus menolak request yang header `Origin`/`Referer`-nya tidak cocok dengan daftar `openkab_allowed_origins` (stateless, tanpa CSRF token pre-login); halaman login SSO wajib mengirim `X-Frame-Options: DENY` + `Content-Security-Policy: frame-ancestors 'none'` (anti-clickjacking). Detail: `contracts/opensid-sso-contract.md`. ## Operasional @@ -113,6 +114,9 @@ Super admin (role `administrator`) dapat meninjau seluruh percobaan akses melalu - `exp` ≤ 600 detik; `nbf` = `iat`; toleransi selisih jam 30 detik; `jti` unik sekali pakai (konsumsi atomik). - Generate-session: rate limit 5/menit per user+IP; wajib sesi aktif, role administrator, dan 2FA terverifikasi. - Callback verify: otentikasi sekret bersama + HMAC + timestamp; respons error generik tanpa data pribadi. +- **Lintas-situs (CSRF/clickjacking, FR-011)**: + - OpenKab `generate-session`: request yang header `Origin`/`Referer`-nya tidak cocok dengan `config('app.url')` ditolak (403 `ORIGIN_INVALID`, tercatat `reason=origin_invalid`). Validasi dilewati di env `local`/`testing` — daftar env skip dapat diubah via `config('sso.origin_check_skip_envs')`. + - OpenSID `POST /admin/sso-login`: Origin/Referer wajib cocok dengan `openkab_allowed_origins` (tanpa CSRF token pre-login); halaman login SSO wajib mengirim `X-Frame-Options: DENY` + `Content-Security-Policy: frame-ancestors 'none'`. - Semua komunikasi wajib HTTPS di produksi (URL OpenSID non-HTTPS ditolak di luar lingkungan lokal). - Seluruh percobaan (berhasil/gagal) tercatat di `openkab_sso_logs`. diff --git a/tests/Feature/Sso/SsoSecurityTest.php b/tests/Feature/Sso/SsoSecurityTest.php index 345c263ab..a7ce739e1 100644 --- a/tests/Feature/Sso/SsoSecurityTest.php +++ b/tests/Feature/Sso/SsoSecurityTest.php @@ -208,6 +208,69 @@ public function desa_tanpa_website_ditolak_dengan_pesan_generik_dan_dicatat() ]); } + #[Test] + public function request_dengan_origin_tidak_sesuai_ditolak() + { + $user = $this->loginAsAdminState(); + config(['sso.origin_check_skip_envs' => []]); + + $response = $this->postJson(route('sso.generate'), ['desa_id' => '5271010001'], [ + 'Origin' => 'https://evil.example', + ]); + + $response->assertStatus(403); + $response->assertJson(['status' => 'error', 'code' => 'ORIGIN_INVALID']); + $response->assertJsonPath('message', 'Autentikasi gagal.'); + $this->assertDatabaseHas('openkab_sso_logs', [ + 'admin_id' => $user->id, + 'status' => 'failed', + 'reason_if_failed' => 'origin_invalid', + ]); + } + + #[Test] + public function request_tanpa_origin_atau_referer_ditolak() + { + $this->loginAsAdminState(); + config(['sso.origin_check_skip_envs' => []]); + + $response = $this->postGenerate(); + + $response->assertStatus(403); + $response->assertJson(['status' => 'error', 'code' => 'ORIGIN_INVALID']); + $this->assertDatabaseHas('openkab_sso_logs', [ + 'status' => 'failed', + 'reason_if_failed' => 'origin_invalid', + ]); + } + + #[Test] + public function request_dengan_origin_cocok_diterima() + { + $this->loginAsAdminState(); + config(['sso.origin_check_skip_envs' => []]); + + $response = $this->postJson(route('sso.generate'), ['desa_id' => '5271010001'], [ + 'Origin' => rtrim((string) config('app.url'), '/'), + ]); + + $response->assertOk(); + $response->assertJsonPath('status', 'success'); + $response->assertJsonPath('token_method', 'post_param'); + } + + #[Test] + public function validasi_asal_dilewati_di_environment_skip_default() + { + // Regression: di env local/testing default, permintaan tanpa Origin tetap diterima. + $this->loginAsAdminState(); + + $response = $this->postGenerate(); + + $response->assertOk(); + $response->assertJsonPath('status', 'success'); + } + #[Test] public function rate_limit_memblokir_permintaan_berlebih() {