Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,22 @@ 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=tests/fixtures/sso/private.pem
SSO_SIGNING_PUBLIC_KEY=
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=testing_sso_callback_secret_at_least_32_bytes_long
SSO_TOKEN_TTL=300
SSO_IP_WHITELIST=
SSO_RATE_LIMIT_MAX=5
SSO_CLOCK_SKEW_TOLERANCE=30
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
/public/favicon.png
/public/favicon.ico
/storage/*.key
/storage/sso/
/vendor
.env
.env.testing
Expand Down Expand Up @@ -35,3 +36,6 @@ tests/Browser/screenshots/
tests/Browser/.session_state.json
graphify-out
docs
specs/
.opencode/
.specify/
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
190 changes: 190 additions & 0 deletions app/Console/Commands/SsoGenerateKeysCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
<?php

namespace App\Console\Commands;

use App\Services\SsoKeyManager;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;

class SsoGenerateKeysCommand extends Command
{
protected $signature = 'sso:generate-keys
{--bits=2048 : Ukuran kunci RSA (minimum 2048)}
{--path=storage/sso : Direktori output file kunci}
{--env-file=.env : File environment yang akan diisi}
{--force : Timpa kunci yang sudah terkonfigurasi}';

protected $description = 'Buat keypair RS256 (private + public) untuk SSO OpenSID dan isi nilainya ke file .env';

/**
* Key yang diwajibkan berisi path file kunci.
*/
protected const PRIVATE_FILE_KEY = 'SSO_SIGNING_PRIVATE_KEY_FILE';

protected const PUBLIC_FILE_KEY = 'SSO_SIGNING_PUBLIC_KEY_FILE';

/**
* Key nilai PEM langsung — dikosongkan agar file kunci yang dipakai.
*/
protected const PRIVATE_KEY = 'SSO_SIGNING_PRIVATE_KEY';

protected const PUBLIC_KEY = 'SSO_SIGNING_PUBLIC_KEY';

public function handle(): int
{
$bits = $this->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);
}
}
40 changes: 40 additions & 0 deletions app/Console/Commands/SsoPurgeTokensCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

namespace App\Console\Commands;

use App\Models\Sso\OpenKabSsoToken;
use Illuminate\Console\Command;

class SsoPurgeTokensCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'sso:purge-tokens';

/**
* The console command description.
*
* @var string
*/
protected $description = 'Hapus token SSO yang kedaluwarsa atau sudah digunakan';

/**
* Execute the console command.
*/
public function handle(): int
{
$deleted = OpenKabSsoToken::query()
->where(function ($query) {
$query->where('expires_at', '<', now())
->orWhereNotNull('used_at');
})
->delete();

$this->info("Pembersihan selesai. {$deleted} token SSO dihapus.");

return 0;
}
}
14 changes: 12 additions & 2 deletions app/Console/Commands/updateAdminMenu.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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;
}
Expand All @@ -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;
}
}
Loading
Loading