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:
ResolvePhonepada alur trusted device memvalidasiuser.Statussebelum 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
RevokeDeviceSessionsgagal — sistem tidak pernah menganggap sesi sudah direvoke kecuali revoke benar-benar berhasil di database.
Rotasi Refresh Token yang Atomik
- Atomic rotation dengan WHERE guard:
RotateRefreshTokenHashAtomicmenggunakan kondisiWHERE 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/rotatememverifikasiold_token_hashcocok dengan yang tersimpan sebelum update dilakukan — session hijack viasession_idsaja tidak dimungkinkan. - TOCTOU dimitigasi:
IssueTokenshanya dipanggil setelah rotate dikonfirmasi sukses di database — tidak ada orphaned token yang terbit saat rotate gagal di tengah jalan. - Rotate
/internal/sessions/rotatewajib atomik: Jalur internalPATCH /internal/sessions/rotatehanya memakaiRotateRefreshTokenHashAtomic(guardold_token_hash); rotate tanpa old token hash tidak dimungkinkan di jalur ini. Catatan:RotateRefreshTokenHashnon-atomik masih ada dan dipakai khusus oleh handler/auth/refresh-token, dengan guardWHERE id=$1 AND revoked_at IS NULL.
Rate Limiting Login Dashboard
- Brute-force dashboard di-rate-limit:
RedisDashboardLoginLimitermembatasi 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.gomengembalikan error jika perintahEXPIREgagal — 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
POSTdengan 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 jikaAPP_ENV=productiondanCORS_ALLOWED_ORIGINSkosong — misconfiguration yang membiarkan wildcard di production tidak dimungkinkan. - Komentar kode dikoreksi: Komentar CORS di
server.gomenyatakan "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:
ChangeAdminUserRolememvalidasi privilege level aktor menggunakanroleLevelmap — 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, danactorRoleRows.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:
UpdateKYCSubmissionStatusmenyertakan parameter ownership di klausaWHERE— 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 mengembalikanotp_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}mengembalikanhas_pinboolean 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-identifiertidak lagi mengembalikanpassword_hash; verifikasi password dilakukan viaPOST /internal/dashboard/verify-passworddi dalam auth_service — bcrypt comparison tidak pernah dilakukan di luar service. - Raw hash injection tidak dimungkinkan:
PATCH /internal/admin/users/{id}/passwordmenerimanew_passwordplaintext 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:
decodeJSONmenggunakanhttp.MaxBytesReader1 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:
handlePatchMememvalidasi 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:
FindUsersByIDsmemvalidasi 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 /metidak lagi menerima fieldemail— penggantian email hanya dapat dilakukan melalui alur OTP dedicated, mencegah penggantian email tanpa verifikasi kepemilikan. - Status user terlindungi allowlist:
UpdateAdminUsermemvalidasi fieldstatusterhadap 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,ListAdminAuthIdentitiesmembatasilimitmaksimum 200 — query?limit=1000000untuk dump seluruh tabel tidak dimungkinkan.
Konfigurasi Fail-Fast
- Semua API key external divalidasi saat startup:
config.gomemvalidasi 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.gomenolak startup jikaGOOGLE_PLAY_TESTER_PHONEada di env production tanpaGOOGLE_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:
RequestEmailOTPmembatasi 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:
ReserveRequestmenggunakan 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:
VerifyEmailOTPmemeriksaattempts >= maxAttemptssebelum melakukan comparison; comparison menggunakansubtle.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:
constantTimeEqualmem-pad kedua nilai ke panjang yang sama sebelumsubtle.ConstantTimeCompare— timing oracle untuk panjang token tidak dimungkinkan.
IP Spoofing untuk Bypass Rate Limit
- Trusted proxy IP di-enforce:
clientIPdi middleware hanya membacaX-Real-IP/X-Forwarded-Fordari IP proxy yang trusted — klien tidak dapat memalsukan IP untuk bypass rate limit dengan menambahkan headerX-Forwarded-Forsendiri.
Password Admin
- Password admin pendek ditolak di semua jalur: Reset password dan
CreateAdminUsermensyaratkan minimal 12 karakter — password admin yang lemah tidak diterima.PATCH /internal/admin/users/{id}/passwordmensyaratkan 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:
SaveChallengedanIncrementAttemptsmengembalikanErrDatabaseNotConfiguredsaatdb == nil— rate limiting tidak dapat di-bypass hanya karena dependency database tidak terkonfigurasi. - Update profil tidak hilang diam-diam:
UpsertUserProfilemengembalikanErrDatabaseNotConfiguredsaatdb == nil— update yang tidak tersimpan tidak terjadi secara senyap. - Device trust flow tidak dapat di-bypass via misconfiguration:
FindUserDevice,UpsertUserDevice,CreateDeviceLoginChallenge,MarkUserDeviceTrusted,MarkUserDeviceRevokedsemuanya mengembalikanErrDatabaseNotConfiguredsaatdb == nil— alur kepercayaan device tidak dapat di-bypass. - OTP verified check tidak false-positif:
HasRecentVerifiedOTPmengembalikanfalse + errorsaatdb == 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.godanwhatsapp_client.go— goroutine OTP tidak terblokir lama karena email_service lambat. - Graceful shutdown mempengaruhi Slack request:
postOncedislack/webhook_publisher.gomenggunakanhttp.NewRequestWithContext— cancellation saat graceful shutdown juga membatalkan in-flight Slack request.
Eliminasi Legacy Dual-Write
- Dual-write goroutine dihapus:
legacy_dualwrite.godihapus 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', HSTSmax-age=63072000 includeSubDomains, danPermissions-Policyditambahkan 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.comatau@company.comtidak 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).ParseAccessTokenSubjectmemverifikasi blacklist Redisauth:blacklist:jti:<jti>dengan timeout 50ms — fail-open jika Redis down. Kedua logout endpoint memanggilRevokeAccessTokenyang 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 indexWHERE consumed_at IS NULL. OTP verify sukses → INSERT gate TTL 60 detik. PIN reset →ConsumePinResetGateUPDATE atomikconsumed_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:
MarkEmailVerifiedmenggunakanAND user_id = $2::uuiddi klausa WHERE — token tidak dapat diklaim oleh user lain meski token ID diketahui. JikarowsAffected == 0, handler mengembalikan 404. -
Session listing tidak mengekspos refresh token di header:
GET /auth/sessionsmenggunakan query param?current_session_id=untuk marking session aktif — refresh token tidak lagi dikirim viaX-Current-Refresh-Tokenheader yang berpotensi ter-log nginx/WAF. -
Endpoint canonical revoke-others via POST:
POST /auth/sessions/revoke-otherstersedia sebagai endpoint canonical — body JSON tidak di-strip proxy, compatible dengan HTTP spec.DELETE /auth/sessionsdipertahankan deprecated untuk backward compatibility. -
JWT_SECRET divalidasi entropy saat startup:
config.gomemvalidasi minimal 8 karakter unik dalamJWT_SECRETsetelah length check — secret berulang seperti"aaaa..."ditolak saat startup..env.examplemenggunakan sentinelGENERATE_MEyang 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=truesecara eksplisit di-set. Operator wajib menghapus kedua env var segera setelah review selesai — prosedur terdokumentasi di.env.exampledan komentarconfig.go:177-182.