Skip to main content

Auth Service — Keamanan & Hardening

Semua item di bawah telah diimplementasikan dan diverifikasi per audit keamanan 2026-05-29–2026-06-05.


Autentikasi & Manajemen Sesi

Validasi Status User

  • Cek status user sebelum issue token via trusted device: ResolvePhone pada alur trusted device memvalidasi user.Status sebelum mengeluarkan token — user yang di-suspend tidak dapat login meskipun device mereka masih terdaftar sebagai trusted.
  • Logout gagal = sesi tidak dianggap revoked: Handler logout mengembalikan error 500 jika RevokeDeviceSessions gagal — sistem tidak pernah menganggap sesi sudah direvoke kecuali revoke benar-benar berhasil di database.

Rotasi Refresh Token yang Atomik

  • Atomic rotation dengan WHERE guard: RotateRefreshTokenHashAtomic menggunakan kondisi WHERE id=$1 AND refresh_token_hash=$2 — concurrent rotation tidak dapat saling overwrite satu sama lain; server mengembalikan 409 jika hash tidak cocok.
  • Token lama wajib diverifikasi sebelum rotate: PATCH /internal/sessions/rotate memverifikasi old_token_hash cocok dengan yang tersimpan sebelum update dilakukan — session hijack via session_id saja tidak dimungkinkan.
  • TOCTOU dimitigasi: IssueTokens hanya dipanggil setelah rotate dikonfirmasi sukses di database — tidak ada orphaned token yang terbit saat rotate gagal di tengah jalan.
  • Rotate /internal/sessions/rotate wajib atomik: Jalur internal PATCH /internal/sessions/rotate hanya memakai RotateRefreshTokenHashAtomic (guard old_token_hash); rotate tanpa old token hash tidak dimungkinkan di jalur ini. Catatan: RotateRefreshTokenHash non-atomik masih ada dan dipakai khusus oleh handler /auth/refresh-token, dengan guard WHERE id=$1 AND revoked_at IS NULL.

Rate Limiting Login Dashboard

  • Brute-force dashboard di-rate-limit: RedisDashboardLoginLimiter membatasi 10 percobaan login per 15 menit per identifier; counter di-reset otomatis saat login berhasil.
  • Fail-open saat Redis down: Rate limiter dirancang fail-open saat Redis tidak tersedia — mencegah lockout seluruh pengguna dashboard saat terjadi outage Redis, sambil tetap melindungi saat Redis normal.
  • TTL wajib terpasang: redis_dashboard_limiter.go mengembalikan error jika perintah EXPIRE gagal — counter rate limit tidak dapat hidup tanpa TTL, sehingga tidak ada akumulasi permanen yang menyebabkan lockout abadi.

Pembatalan Device Login

  • Token pembatalan via POST body: Cancel device login tersedia via POST dengan token di body request — token tidak lagi hanya lewat GET query string yang dapat muncul di access log server.

Otorisasi & Kontrol Privilege

CORS Fail-Closed

  • CORS tidak lagi merefleksikan semua origin: Origin pada CORS auth_service diperketat — whitelist dari konfigurasi di-enforce; tidak ada origin arbitrer yang diterima.
  • CORS wildcard di production diblokir saat startup: Config.Validate() menolak startup jika APP_ENV=production dan CORS_ALLOWED_ORIGINS kosong — misconfiguration yang membiarkan wildcard di production tidak dimungkinkan.
  • Komentar kode dikoreksi: Komentar CORS di server.go menyatakan "kosong = fail-closed" — developer tidak dapat tersesat mengira behavior default adalah permit-all.

Privilege Escalation pada Admin Role

  • Aktor tidak dapat memberikan privilege melebihi dirinya: ChangeAdminUserRole memvalidasi privilege level aktor menggunakan roleLevel map — seorang admin tidak dapat mengangkat user ke level yang lebih tinggi dari level aktualnya sendiri.
  • Verifikasi actorRoleRows.Err() setelah loop: Hasil query roles aktor dicek untuk partial error setelah loop selesai, dan actorRoleRows.Close() dipanggil secara eksplisit sebelum query berikutnya — partial result tidak dapat menyebabkan privilege escalation diam-diam.

Ownership Check pada KYC

  • Update status KYC dilindungi ownership: UpdateKYCSubmissionStatus menyertakan parameter ownership di klausa WHERE — caller tidak dapat mengubah status submission yang bukan miliknya.

Endpoint Internal Tidak Bocorkan Credential

  • OTP hash tidak keluar dari service: GET /internal/otp/challenges/{request_id} tidak mengembalikan otp_hash — endpoint verify dedicated menerima hash dari caller dan mengembalikan boolean tanpa hash bocor.
  • PIN hash tidak keluar dari service: GET /internal/pins/{user_id} mengembalikan has_pin boolean saja — argon2id hash tidak pernah bocor ke caller; verifikasi PIN dilakukan via endpoint verify dedicated.
  • Password hash tidak bocor via GET: GET /internal/dashboard/user-by-identifier tidak lagi mengembalikan password_hash; verifikasi password dilakukan via POST /internal/dashboard/verify-password di dalam auth_service — bcrypt comparison tidak pernah dilakukan di luar service.
  • Raw hash injection tidak dimungkinkan: PATCH /internal/admin/users/{id}/password menerima new_password plaintext dan melakukan hash di dalam auth_service — caller tidak dapat menyuntikkan hash yang sudah jadi.

Validasi Input & Pencegahan Injeksi

Body Size Limit

  • Request body sangat besar di-reject: decodeJSON menggunakan http.MaxBytesReader 1 MB di semua handler publik — request dengan body berukuran sangat besar tidak dapat menyebabkan OOM di auth_service.

Validasi Format Field

  • NIK divalidasi pattern ketat: handlePatchMe memvalidasi NIK terhadap pattern ^[0-9]{16}$ — NIK tidak valid tidak tersimpan sebagai arbitrary string di database.
  • Avatar URL divalidasi format dan domain: Avatar URL divalidasi format dan domain allowlist sebelum disimpan — URL arbitrer tidak dapat disimpan sebagai avatar.
  • UUID batch divalidasi sebelum query: FindUsersByIDs memvalidasi format UUID setiap elemen sebelum join — satu UUID malformed tidak menyebabkan seluruh batch gagal dengan error 500.

Field yang Tidak Boleh Diubah Sembarangan

  • Perubahan email hanya via alur OTP: PATCH /me tidak lagi menerima field email — penggantian email hanya dapat dilakukan melalui alur OTP dedicated, mencegah penggantian email tanpa verifikasi kepemilikan.
  • Status user terlindungi allowlist: UpdateAdminUser memvalidasi field status terhadap allowlist {active, locked, inactive, suspended} — nilai status arbitrer tidak dapat ditulis via endpoint internal.

Pagination Cap pada Admin Endpoints

  • Dump seluruh tabel tidak dimungkinkan: ListAdminSessions, ListAdminDevices, ListAdminAuthIdentities membatasi limit maksimum 200 — query ?limit=1000000 untuk dump seluruh tabel tidak dimungkinkan.

Konfigurasi Fail-Fast

  • Semua API key external divalidasi saat startup: config.go memvalidasi semua API key external service saat startup — deployment dengan key kosong gagal cepat dan tidak berjalan dalam kondisi tidak valid.
  • Google Play bypass OTP tidak aktif secara default: config.go menolak startup jika GOOGLE_PLAY_TESTER_PHONE ada di env production tanpa GOOGLE_PLAY_REVIEW_MODE=true — bypass OTP statis untuk keperluan review Google Play tidak pernah aktif secara tidak sengaja di production.

Rate Limiting & Anti-Abuse

OTP Rate Limiting

  • Email bombing tidak dimungkinkan: Email OTP memiliki rate limiting cooldown ditambah request counter per alamat email — pengiriman OTP email secara masif ke satu alamat tidak dimungkinkan.
  • Rate limiting email OTP dibatasi 3 request per 15 menit: RequestEmailOTP membatasi maksimum 3 request per 15 menit per user; handler juga memvalidasi format email sebelum memanggil service — format email tidak valid tidak pernah mencapai layer OTP.
  • Slot OTP di-reserve secara atomik: ReserveRequest menggunakan Lua script single-roundtrip yang atomik (reserveOTPScript) — burst concurrent tidak dapat melewati rate limiter via race condition.
  • MaxAttempts tidak bisa dilewati dengan satu tebakan ekstra: VerifyEmailOTP memeriksa attempts >= maxAttempts sebelum melakukan comparison; comparison menggunakan subtle.ConstantTimeCompare — percobaan ke-(MaxAttempts+1) tidak dimungkinkan.

Constant-Time Comparison

  • OTP comparison constant-time: OTP hash comparison menggunakan subtle.ConstantTimeCompare — remote timing attack untuk brute-force OTP tidak dimungkinkan.
  • Token comparison constant-time dengan pad panjang sama: constantTimeEqual mem-pad kedua nilai ke panjang yang sama sebelum subtle.ConstantTimeCompare — timing oracle untuk panjang token tidak dimungkinkan.

IP Spoofing untuk Bypass Rate Limit

  • Trusted proxy IP di-enforce: clientIP di middleware hanya membaca X-Real-IP/X-Forwarded-For dari IP proxy yang trusted — klien tidak dapat memalsukan IP untuk bypass rate limit dengan menambahkan header X-Forwarded-For sendiri.

Password Admin

  • Password admin pendek ditolak di semua jalur: Reset password dan CreateAdminUser mensyaratkan minimal 12 karakter — password admin yang lemah tidak diterima. PATCH /internal/admin/users/{id}/password mensyaratkan minimal 12 karakter yang sama — konsistensi di seluruh jalur pengelolaan password.

Fail-Safe & Reliabilitas

Network Exposure

  • Service tidak ter-expose ke semua interface: auth_service bind ke 127.0.0.1:{port} — tidak dapat diakses langsung dari network interface publik; hanya dapat diakses via nginx atau layanan lokal di VM yang sama.

Nil-Safety pada Store Layer

  • Rate limiting tidak dapat di-bypass via misconfiguration: SaveChallenge dan IncrementAttempts mengembalikan ErrDatabaseNotConfigured saat db == nil — rate limiting tidak dapat di-bypass hanya karena dependency database tidak terkonfigurasi.
  • Update profil tidak hilang diam-diam: UpsertUserProfile mengembalikan ErrDatabaseNotConfigured saat db == nil — update yang tidak tersimpan tidak terjadi secara senyap.
  • Device trust flow tidak dapat di-bypass via misconfiguration: FindUserDevice, UpsertUserDevice, CreateDeviceLoginChallenge, MarkUserDeviceTrusted, MarkUserDeviceRevoked semuanya mengembalikan ErrDatabaseNotConfigured saat db == nil — alur kepercayaan device tidak dapat di-bypass.
  • OTP verified check tidak false-positif: HasRecentVerifiedOTP mengembalikan false + error saat db == nil — handler mengembalikan 503, bukan false-positif yang meloloskan request.

Timeout Konsisten

  • WriteTimeout server disesuaikan dengan SMTP timeout: WriteTimeout server dinaikkan ke 60s, konsisten dengan SMTP context timeout 45s — koneksi tidak di-reset di tengah pengiriman OTP via email.
  • Email client timeout konsisten dengan service lain: Email client timeout diturunkan ke 10–15 detik, konsisten dengan firebase_client.go dan whatsapp_client.go — goroutine OTP tidak terblokir lama karena email_service lambat.
  • Graceful shutdown mempengaruhi Slack request: postOnce di slack/webhook_publisher.go menggunakan http.NewRequestWithContext — cancellation saat graceful shutdown juga membatalkan in-flight Slack request.

Eliminasi Legacy Dual-Write

  • Dual-write goroutine dihapus: legacy_dualwrite.go dihapus bersama migrasi DROP SCHEMA — tidak ada lagi dual-write goroutine yang dapat partial fail secara diam-diam.
  • Goroutine context.Background() dihapus: Goroutine non-cancelable yang menggunakan context.Background() dihapus bersama legacy dualwrite — tidak ada lagi goroutine yang hidup di luar lifecycle request.

Security Headers

  • Security headers diterapkan ke semua response: CSP default-src 'none', HSTS max-age=63072000 includeSubDomains, dan Permissions-Policy ditambahkan ke semua response auth_service — browser mendapat sinyal eksplisit untuk membatasi akses resource.

PII & Pembatasan Eksposur Data

Nomor Telepon

  • Nomor telepon tidak tersimpan di Slack workspace: SHA-256 dari nomor digunakan sebagai Slack dedup key — nomor telepon lengkap pengguna tidak pernah ditulis ke log atau channel Slack.

NIK

  • NIK tidak bocor via API response: NIK tidak dikembalikan di response body endpoint create user — data identitas sensitif ini tidak pernah bocor via API response ke caller.

Label Email

  • Label email diderive dari domain aktual: Token label email diderive dari domain email aktual pengguna — pengguna dengan alamat @yahoo.com atau @company.com tidak mendapat label 'gmail' yang menyesatkan di UI atau log.

Penguatan Token & Sesi (2026-06-08)

  • Revocation access token via jti+Redis: Setiap JWT menyertakan klaim jti (128-bit random, base64url). ParseAccessTokenSubject memverifikasi blacklist Redis auth:blacklist:jti:<jti> dengan timeout 50ms — fail-open jika Redis down. Kedua logout endpoint memanggil RevokeAccessToken yang SET jti ke Redis dengan TTL = sisa lifetime token.

  • PIN reset gate single-use atomik: Gate keamanan PIN reset menggunakan tabel dedicated auth.pin_reset_gates (id, user_id, expires_at, consumed_at) dengan partial index WHERE consumed_at IS NULL. OTP verify sukses → INSERT gate TTL 60 detik. PIN reset → ConsumePinResetGate UPDATE atomik consumed_at = NOW() — single-use, tidak dapat di-reuse atau di-forge via audit log.

  • PIN reset window dipersempit ke 60 detik: Window antara OTP verify dan eksekusi PIN reset dibatasi 60 detik. Gate single-use memastikan window ini tidak dapat dieksploitasi via akses fisik singkat ke device.

  • Dashboard login challenge cleanup otomatis: Setelah OTP challenge dikonsumsi sukses, semua challenge pending lain untuk user yang sama di-DELETE dalam transaction yang sama — concurrent login attempt dari IP berbeda tidak dapat digunakan setelah OTP dikonsumsi.

  • mark-email-verified dilindungi ownership: MarkEmailVerified menggunakan AND user_id = $2::uuid di klausa WHERE — token tidak dapat diklaim oleh user lain meski token ID diketahui. Jika rowsAffected == 0, handler mengembalikan 404.

  • Session listing tidak mengekspos refresh token di header: GET /auth/sessions menggunakan query param ?current_session_id= untuk marking session aktif — refresh token tidak lagi dikirim via X-Current-Refresh-Token header yang berpotensi ter-log nginx/WAF.

  • Endpoint canonical revoke-others via POST: POST /auth/sessions/revoke-others tersedia sebagai endpoint canonical — body JSON tidak di-strip proxy, compatible dengan HTTP spec. DELETE /auth/sessions dipertahankan deprecated untuk backward compatibility.

  • JWT_SECRET divalidasi entropy saat startup: config.go memvalidasi minimal 8 karakter unik dalam JWT_SECRET setelah length check — secret berulang seperti "aaaa..." ditolak saat startup. .env.example menggunakan sentinel GENERATE_ME yang sengaja gagal length check jika tidak diganti operator.

  • Google Play tester bypass terkontrol secara operasional: Bypass OTP statis untuk reviewer Google Play hanya aktif jika GOOGLE_PLAY_REVIEW_MODE=true secara eksplisit di-set. Operator wajib menghapus kedua env var segera setelah review selesai — prosedur terdokumentasi di .env.example dan komentar config.go:177-182.