diff --git a/server-connection/geonet-console/app/Http/Controllers/Api/V1/AuthController.php b/server-connection/geonet-console/app/Http/Controllers/Api/V1/AuthController.php index 859de5e..08e1082 100644 --- a/server-connection/geonet-console/app/Http/Controllers/Api/V1/AuthController.php +++ b/server-connection/geonet-console/app/Http/Controllers/Api/V1/AuthController.php @@ -21,6 +21,41 @@ class AuthController extends Controller ) { } + public function refresh(Request $request): JsonResponse + { + $authHeader = $request->header('Authorization', ''); + $jwt = str_starts_with($authHeader, 'Bearer ') + ? substr($authHeader, 7) + : ''; + + if (empty($jwt)) { + return $this->fail('No token provided.', 'missing_token', 401); + } + + $payload = $this->ldapJwt->validate($jwt); + + if ($payload === null) { + return $this->fail('Token invalid or expired.', 'invalid_token', 401); + } + + $newToken = $this->ldapJwt->issue( + $payload['username'], + $payload['is_admin'], + $payload['groups'] ?? [] + ); + + $this->audit->logPublic('api.v1.auth.refresh', $payload['username'], $request, [ + 'username' => $payload['username'], + ]); + + return $this->ok([ + 'access_token' => $newToken, + 'token_type' => 'Bearer', + 'expires_in' => config('api.ldap_jwt_ttl'), + 'auth_type' => 'ldap_jwt', + ]); + } + public function login(Request $request): JsonResponse { $credentials = $request->validate([ diff --git a/server-connection/geonet-console/app/Services/LdapJwtService.php b/server-connection/geonet-console/app/Services/LdapJwtService.php index 29478bf..dad8502 100644 --- a/server-connection/geonet-console/app/Services/LdapJwtService.php +++ b/server-connection/geonet-console/app/Services/LdapJwtService.php @@ -46,6 +46,7 @@ class LdapJwtService 'username' => (string) ($payload['sub'] ?? ''), 'is_admin' => (bool) ($payload['is_admin'] ?? false), 'scopes' => array_values((array) ($payload['scopes'] ?? [])), + 'groups' => array_values((array) ($payload['groups'] ?? [])), ]; } catch (UnexpectedValueException|\DomainException|\InvalidArgumentException) { return null; diff --git a/server-connection/geonet-console/docapi/index.md b/server-connection/geonet-console/docapi/index.md index bec1c72..cd27226 100644 --- a/server-connection/geonet-console/docapi/index.md +++ b/server-connection/geonet-console/docapi/index.md @@ -9,6 +9,7 @@ Base URL: `https://console.gisportal.id/api/v1` | Method | Endpoint | Auth | Scope | Deskripsi | Dokumen | |--------|----------|------|-------|-----------|---------| | POST | `/auth/login` | — | — | Login LDAP, dapat JWT | [login.md](./services/auth/login.md) | +| POST | `/auth/refresh` | JWT lama | — | Refresh JWT tanpa login ulang | [refresh-token.md](./services/auth/refresh-token.md) | | GET | `/auth/token-guide` | — | — | Panduan penggunaan token | [token-guide.md](./services/auth/token-guide.md) | --- diff --git a/server-connection/geonet-console/docapi/services/auth/refresh-token.md b/server-connection/geonet-console/docapi/services/auth/refresh-token.md new file mode 100644 index 0000000..0c89cdd --- /dev/null +++ b/server-connection/geonet-console/docapi/services/auth/refresh-token.md @@ -0,0 +1,187 @@ +# Refresh Token + +## Tujuan + +Memperbarui JWT yang akan habis masa berlakunya tanpa harus login ulang dengan username/password LDAP. +Digunakan oleh FE PWA untuk menjaga sesi tetap aktif. + +## Business Rule + +- Token lama harus **masih valid** (belum expired) untuk bisa di-refresh +- Token baru diterbitkan dengan TTL penuh (`expires_in` dari awal lagi) +- Token lama tidak di-invalidate (stateless JWT — tidak ada blacklist) +- Groups dan is_admin diambil dari payload token lama, bukan re-query ke LDAP +- Rate limit: 30 request / menit per IP + +## Kapan Harus Refresh? + +Strategi yang direkomendasikan untuk PWA: + +``` +Token diterima saat login → expires_in: 3600 (1 jam) + ↓ +Simpan: access_token, expires_at = Date.now() + (expires_in * 1000) + ↓ +Sebelum setiap request API: + if (Date.now() > expires_at - 5 * 60 * 1000) // 5 menit sebelum expire + → POST /auth/refresh + → Simpan token baru + ↓ +Jika refresh gagal (401) → redirect ke halaman login +``` + +## URL + +`POST https://console.gisportal.id/api/v1/auth/refresh` + +## HTTP Method + +`POST` + +## Authentication + +Token lama (yang akan di-refresh) dikirim di header: + +``` +Authorization: Bearer +``` + +## Request Body + +Tidak diperlukan (kosong). + +## Response Success + +**HTTP 200** + +```json +{ + "data": { + "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqb2huLmRvZSIsImlzX2FkbWluIjp0cnVlLCJleHAiOjE3MTk1MDM2MDB9.new_signature", + "token_type": "Bearer", + "expires_in": 3600, + "auth_type": "ldap_jwt" + } +} +``` + +## Response Error + +**HTTP 401 — Token tidak ada:** +```json +{ + "error": { + "code": "missing_token", + "message": "No token provided." + } +} +``` + +**HTTP 401 — Token expired atau invalid:** +```json +{ + "error": { + "code": "invalid_token", + "message": "Token invalid or expired." + } +} +``` + +## Status Code + +| Code | Kondisi | +|------|---------| +| 200 | Token berhasil di-refresh | +| 401 | Token tidak ada / expired / invalid | +| 429 | Rate limit (30x/menit) | + +## Contoh Request + +```bash +curl -s -X POST https://console.gisportal.id/api/v1/auth/refresh \ + -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..." +``` + +## Contoh Implementasi PWA (JavaScript) + +```javascript +const TOKEN_KEY = 'geonet_token'; +const EXPIRES_KEY = 'geonet_expires_at'; + +async function getValidToken() { + const expiresAt = parseInt(localStorage.getItem(EXPIRES_KEY) || '0'); + const token = localStorage.getItem(TOKEN_KEY); + + // Refresh jika kurang dari 5 menit lagi expire + if (Date.now() > expiresAt - 5 * 60 * 1000) { + const res = await fetch('https://console.gisportal.id/api/v1/auth/refresh', { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (!res.ok) { + // Token expired total → login ulang + window.location.href = '/login'; + return null; + } + + const data = await res.json(); + localStorage.setItem(TOKEN_KEY, data.data.access_token); + localStorage.setItem(EXPIRES_KEY, Date.now() + data.data.expires_in * 1000); + return data.data.access_token; + } + + return token; +} + +// Gunakan sebelum setiap API call +const token = await getValidToken(); +const res = await fetch('https://console.gisportal.id/api/v1/databases', { + headers: { 'Authorization': `Bearer ${token}` } +}); +``` + +## Sequence Flow + +```mermaid +sequenceDiagram + PWA->>API: POST /auth/refresh (Bearer token_lama) + API->>API: Validasi JWT signature & exp + alt Token masih valid + API->>API: Issue JWT baru (TTL penuh) + API->>AuditLog: log api.v1.auth.refresh + API-->>PWA: 200 {access_token baru, expires_in: 3600} + else Token expired/invalid + API-->>PWA: 401 invalid_token + PWA->>PWA: Redirect ke /login + end +``` + +## Database yang Diakses + +- Tidak ada (JWT stateless) +- `databaselist_audit` — write audit log + +## Audit Log + +Event: `api.v1.auth.refresh` + +## Rate Limit + +30 request / menit per IP. + +## Idempotent + +Tidak — setiap call menghasilkan token baru dengan exp baru. + +## Security + +- Token lama tidak di-blacklist (stateless JWT) +- Jika token bocor, revoke dengan cara: admin bisa rotate `APP_KEY` (invalidate semua JWT) +- Selalu gunakan HTTPS + +## Changelog + +| Tanggal | Perubahan | +|---------|-----------| +| 2026-06-23 | Endpoint dibuat untuk mendukung FE PWA my.gisportal.id | diff --git a/server-connection/geonet-console/nginx/geonet-console.conf b/server-connection/geonet-console/nginx/geonet-console.conf index b8d3b19..e981b5b 100644 --- a/server-connection/geonet-console/nginx/geonet-console.conf +++ b/server-connection/geonet-console/nginx/geonet-console.conf @@ -22,6 +22,37 @@ server { add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + # CORS untuk FE PWA my.gisportal.id — hanya berlaku di /api/ + location /api/ { + if ($http_origin = "https://my.gisportal.id") { + add_header Access-Control-Allow-Origin "https://my.gisportal.id" always; + add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always; + add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With" always; + add_header Access-Control-Max-Age 3600 always; + } + + if ($request_method = OPTIONS) { + add_header Access-Control-Allow-Origin "https://my.gisportal.id" always; + add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always; + add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With" always; + add_header Access-Control-Max-Age 3600 always; + add_header Content-Length 0; + add_header Content-Type text/plain; + return 204; + } + + limit_req zone=geonet_console_general burst=50 nodelay; + + proxy_pass http://geonet_console_backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_read_timeout 120s; + } + location = /login { proxy_pass http://geonet_console_backend; proxy_http_version 1.1; diff --git a/server-connection/geonet-console/routes/api.php b/server-connection/geonet-console/routes/api.php index 4d68b50..968fbf1 100644 --- a/server-connection/geonet-console/routes/api.php +++ b/server-connection/geonet-console/routes/api.php @@ -14,6 +14,9 @@ Route::prefix('v1')->group(function () { Route::post('/auth/login', [AuthController::class, 'login']) ->middleware('throttle:10,1') ->name('api.v1.auth.login'); + Route::post('/auth/refresh', [AuthController::class, 'refresh']) + ->middleware('throttle:30,1') + ->name('api.v1.auth.refresh'); Route::get('/auth/token-guide', [TokenGuideController::class, 'show']) ->name('api.v1.auth.token-guide');