Browse Source

feat: CORS my.gisportal.id + token refresh endpoint untuk FE PWA

- nginx/geonet-console.conf: tambah CORS header /api/ untuk my.gisportal.id
- routes/api.php: POST /auth/refresh (throttle 30/menit)
- AuthController: method refresh()  validate JWT lama, issue JWT baru
- LdapJwtService: expose groups di return validate()
- docapi: tambah refresh-token.md + update index.md
master
Budi Setiawan 3 weeks ago
parent
commit
8f72e74ec1
  1. 35
      server-connection/geonet-console/app/Http/Controllers/Api/V1/AuthController.php
  2. 1
      server-connection/geonet-console/app/Services/LdapJwtService.php
  3. 1
      server-connection/geonet-console/docapi/index.md
  4. 187
      server-connection/geonet-console/docapi/services/auth/refresh-token.md
  5. 31
      server-connection/geonet-console/nginx/geonet-console.conf
  6. 3
      server-connection/geonet-console/routes/api.php

35
server-connection/geonet-console/app/Http/Controllers/Api/V1/AuthController.php

@ -21,6 +21,41 @@ class AuthController extends Controller @@ -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([

1
server-connection/geonet-console/app/Services/LdapJwtService.php

@ -46,6 +46,7 @@ class LdapJwtService @@ -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;

1
server-connection/geonet-console/docapi/index.md

@ -9,6 +9,7 @@ Base URL: `https://console.gisportal.id/api/v1` @@ -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) |
---

187
server-connection/geonet-console/docapi/services/auth/refresh-token.md

@ -0,0 +1,187 @@ @@ -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 <jwt_lama_yang_masih_valid>
```
## 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 |

31
server-connection/geonet-console/nginx/geonet-console.conf

@ -22,6 +22,37 @@ server { @@ -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;

3
server-connection/geonet-console/routes/api.php

@ -14,6 +14,9 @@ Route::prefix('v1')->group(function () { @@ -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');

Loading…
Cancel
Save