Meningkatkan Keamanan Aplikasi Yang Menggunakan Firebase Authentication
Pada suatu hari, saya diminta untuk membuat sebuah halaman login. Persyaratannya cukup sederhana: pengguna harus bisa memasukkan email dan password, bila benar, pengguna akan diarahkan ke halaman utama. Saya pun segera menulis kode program yang memanfaatkan Firebase Authentication. Dengan Firebase Authentication, bahkan pemula sekalipun bisa dengan mudah membuat halaman login tanpa perlu mengkhawatirkan implementasi OAuth2, JWKS, database dan sejenisnya secara detail. Namun, setelah halaman tersebut selesai dan bekerja sebagaimana seharusnya, karena masih ada sisa waktu, saya mulai berpikir: apakah ada hal lain yang bisa saya lakukan untuk meningkatkan keamanan di halaman login tersebut? Pada tulisan ini, saya akan mengumpulkan hasil pencarian saya yang berisi semua hal-hal tambahan yang bisa dilakukan untuk meningkatkan keamanan aplikasi yang menggunakan Firebase Authentication. Semua informasi ini juga bisa dijumpai di dokumentasi Firebase Authentication.
Mengaktifkan Email Enumeration Protection
Secara bawaan, bila email yang dimasukkan oleh pengguna belum terdaftar, Firebase Authentication akan mengembalikan respon dengan
pesan EMAIL_NOT_FOUND
. Sementara itu, bila email sudah terdaftar namun password-nya salah, Firebase Authentication akan
mengembalikan pesan INVALID_PASSWORD
. Walaupun ini sangat baik untuk user experience karena pengguna jadi tahu apa yang salah,
fasilitas ini dapat disalahgunakan oleh pihak yang berniat buruk untuk memeriksa apakah sebuah email adalah email yang terdaftar
di aplikasi yang saya buat. Setelah mengetahui apakah email valid, pihak dengan niat buruk tersebut bisa menindaklanjuti dengan
mengirim email phising atau memakai password yang pernah bocor dari email tersebut.
Untuk menghindari email enumeration, saya dapat memanggil API https://identitytoolkit.googleapis.com dengan menyertakan nilai
true
pada enable_improved_email_privacy
. Sebagai contoh, saya dapat melakukan pemanggilan seperti berikut ini:
Bila tidak ada yang salah, saya akan memperoleh respon 200
. Setelah ini, bila menggunakan email yang tidak terdaftar maupun
terdaftar, bila password-nya salah, saya akan akan mendapatkan pesan kesalahan INVALID_LOGIN_CREDENTIALS
yang sama.
Mengaktifkan Multi-Factor Authentication (MFA)
Bila menggunakan Firebase Authentication bersamaan dengan Identity Platform, saya dapat menggunakan SMS sebagai perlindungan tambahan bila password berhasil diketahui oleh pihak yang tidak bertanggung jawab. Untuk mengaktifkannya, saya dapat memilih menu Sign-in method dan men-klik tombol Change pada bagian SMS Multi-factor Authentication. Saya kemudian mengaktifkan tombol Enable seperti pada gambar berikut ini:
Bagian yang lumayan kompleks disini adalah kini saya perlu membuat halaman untuk melakukan registrasi nomor telepon dan juga melakukan verifikasi kode SMS di halaman login bila pengguna memilih untuk mengaktifkan MFA.
Pendaftaran MFA
Sebelum melakukan registrasi nomor telepon, saya perlu memperbaharui token terlebih dahulu. Beberapa operasi sensitif di Firebase
Authentication seperti perubahan email juga mensyaratkan token yang segar dan akan gagal dengan pesan kesalahan auth/requires-recent-login
bila usia token sudah terlalu lama (walaupun belum kadaluarsa). Sebagai contoh, saya membuat halaman seperti pada gambar berikut ini dimana pengguna perlu memasukkan kembali password-nya:
Untuk memulai proses pembaharuan token, saya dapat memanggil reauthenticateWithCredential()
seperti pada kode program berikut ini:
Untuk mencegah penyalahgunaan, proses registrasi juga wajib diverifikasi melalui reCAPTCHA. Firebase Authentication sudah menyediakan utilitas untuk ini sehingga saya tidak perlu menyiapkan reCAPTCHA secara manual. Saya bisa menggunakan RecaptchaVerifier
bawaan Firebase seperti pada contoh berikut ini:
Pada contoh di atas, saya menggunakan reCAPTCHA v2 (invisible). Pada metode ini, tidak ada checkbox I’m not a robot karena reCAPTCHA akan berusaha sebisa mungkin melakukan pemeriksaan tanpa perlu interaksi dari pengguna. Walaupun demikian, pada trafik yang sangat mencurigakan, reCAPTCHA tetap akan menampilkan pertanyaan untuk dijawab. Saya sempat menemukan permasalahan saat melakukan verifikasi reCAPTCHA berulang kali pada halaman yang sama dengan pesan kesalahan ReCAPTCHA has already been rendered in this element
. Untuk mengatasinya, pada kode program di atas, saya terpaksa membuat ulang elemen <div>
untuk reCAPTCHA (dari parent-nya) setiap kali memakai RecaptchaVerifier
.
Sekarang, saya bisa meminta pengguna untuk mengisi nomor telepon dan memulai proses pengiriman kode verifikasi SMS dengan kode program seperti pada contoh berikut ini:
Kode program di atas akan mengembalikan sebuah verification id yang perlu saya pakai untuk verifikasi. Nilai ini perlu
dipadukan dengan kode verifikasi yang diterima oleh pengguna melalui SMS. Kombinasi dari verification id dan verification code yang
dimasukkan oleh pengguna akan dipakai untuk membuat PhoneAuthCredential
. Untuk memeriksa apakah verification code sah, saya dapat
menggunakan PhoneMultiFactorGenerator.assertion()
dengan melewatkan PhoneAuthCredential
tersebut. PhoneMultiFactorAssertion
yang sah ini
kemudian akan saya pakai untuk mendaftarkan nomor telepon melalui MultiFactorUser.enroll()
. Agar lebih jelas, saya segera
menulis kode program seperti berikut ini:
Verifikasi MFA Saat Login
Salah satu perubahan pada proses login adalah walaupun sudah memasukkan email dan password secara benar, proses login tetap
akan gagal dengan kesalahan MultiFactorError
. Hal ini akan terjadi pada pengguna yang sebelumnya telah mengaktifkan MFA
dengan menggunakan MultiFactorUser.enroll()
. Ini adalah kesalahan yang unik karena saya dapat menggunakan MultiFactorError
untuk melanjutkan proses login secara normal. Namun sebelumnya, saya perlu meminta Firebase Authentication
untuk mengirim kode verifikasi SMS ke pengguna terlebih dahulu dengan kode program seperti berikut ini:
Setelah meminta pengguna untuk mengisi kode verifikasi di SMS yang diterima, saya bisa menggunakan MultiFactorResolver.resolveSignIn()
untuk melanjutkan proses login tanpa perlu mengulang dari awal seperti pada contoh kode program berikut ini:
Bila nilai verificationCode
yang dimasukkan oleh pengguna benar (sesuai dengan yang diterima di SMS), proses login
akan sukses seperti biasanya.
Unit Testing Yang Melibatkan MFA
Untuk mencegah kuota SMS cepat habis, untuk pengujian secara lokal, saya dapat menggunakan Firebase Emulator. Bila Firebase Authentication aktif di Firebase Emulator, setiap kali saya meminta kode verifikasi, tidak akan ada SMS yang dikirim ke nomor telepon yang bersangkutan. Sebagai gantinya, kode verifikasi yang harus dimasukkan akan muncul di terminal yang menjalankan Firebase Emulator.
Selain itu, pada unit testing yang otomatis, saya tetap dapat mengakses kode verifikasi melalui
URL http://localhost:9099/emulator/v1/projects/demo-jocki/verificationCodes
. Sebagai contoh, berikut ini adalah contoh
skenario unit test Angular yang menguji alur pendaftaran MFA dan login MFA:
Bila ingin melakukan pengujian langsung ke server Firebase dan ingin menghemat kuota SMS, pada dashboard Firebase Authentication, saya juga dapat menambahkan nomor telepon yang di-hardcode agar selalu mengirimkan kode verifikasi yang telah saya tentukan.
Mengatur Seberapa Lama Status Authentication Disimpan
Secara default, Firebase Authentication akan menyimpan status authentication di browser walaupun browser sudah ditutup
(selama pengguna tidak logout secara eksplisit). Ini akan membuat pengguna merasa lebih nyaman karena tidak perlu
sering kali login (termasuk memasukkan kode verifikasi SMS dan sebagainya). Namun, untuk aplikasi yang lebih sensitif, saya
dapat mengubah perilaku ini dengan memanggil setPersistence()
dengan melewatkan salah satu Persistence
berikut ini:
browserLocalPersistence
untuk menyimpan status authentication hingga pengguna melakukan logout secara eksplisit.browserSessionPersistence
untuk menyimpan status authentication hingga tab atau browser ditutup.inMemoryPersistence
untuk tidak menyimpan status authentication sama sekali. Begitu halaman di-refresh, pengguna perlu login kembali.
Sebagai contoh, saya dapat menggunakan inMemoryPersistence
seperti pada kode program berikut ini:
Sekarang, seusai login, saya masih tetap bisa memakai aplikasi seperti biasanya. Namun begitu saya memperbaharui halaman (dengan
men-klik icon Refresh atau F5), saya akan diminta untuk login kembali. Walaupun paling merepotkan bagi pengguna, inMemoryPersistence
adalah
konfigurasi yang paling aman karena token sama sekali tidak disimpan di-browser seperti yang terlihat pada gambar berikut ini:
Beberapa jenis serangan session hijacking menggunakan celah XSS untuk mengerjakan JavaScript yang kemudian membaca token yang tersimpan di browser. Bila tidak ada token yang tersimpan di browser yang dapat dibaca melalui JavaScript, maka teknik serangan seperti ini tidak akan bisa dipakai.
Mendeteksi Aktifitas Mencurigakan Berdasarkan IP
Bila terdapat perbedaan antara IP saat pengguna login dengan IP saat token dipakai, bisa jadi token tersebut telah dicuri. Firebase
Authentication mendukung pemeriksaan seperti ini secara stateless tanpa perlu database tersendiri dengan memanfaatkan claim di JWT.
Seperti yang ditentukan oleh spesifikasi RFC 7519, sebuah token JWT dapat mengandung satu atau
lebih claim. Ada beberapa claim yang harus selalu ada di JWT seperti iss
, sub
, aud
, exp
dan sebagainya. Mereka disebut
sebagai registered claims. Selain itu, asalkan pihak yang berkomunikasi dapat saling memahami, JWT juga boleh mengandung claim
tambahan yang disebut sebagai private claims. Firebase Authentication menyebutnya sebagai custom claims.
Sebagai tambahan, selain custom claims, Firebase Authentication juga mendukung apa yang disebut session claims. Ini adalah claim
yang tidak akan disimpan secara permanen dan akan hilang saat session pengguna berakhir (misalnya saat token kadaluarsa atau
pengguna memilih logout). Nilai session claims hanya bisa ditambahkan oleh blocking functions beforeSignIn
. Sebagai contoh,
saya akan membuat blocking functions seperti berikut ini:
Sekarang, setiap kali token dihasilkan, akan ada informasi signInIPAddress
yang berisi informasi IP klien yang
membuat token tersebut, seperti yang terlihat pada gambar berikut ini:
Selanjutnya, untuk mempermudah melakukan verifikasi alamat IP di seluruh callable functions yang ada, saya akan membuat sebuah currying function seperti berikut ini:
Kode program di atas pada dasarnya akan membandingkan IP yang tercantum di JWT dengan IP dari socket saat pemanggilan function. Bila terdapat perbedaan, seluruh token yang aktif untuk pengguna tersebut akan di-revoke. Ini berarti bukan hanya pencuri token saja yang akan menemukan pesan kesalahan, pemilik akun yang sah juga akan dipaksa untuk login kembali.
Saya bisa menerapkan currying function tersebut ke seluruh callable functions yang ada seperti pada contoh berikut ini:
Untuk menguji apakah kode program yang saya buat bekerja dengan baik, saya akan melakukan langkah-langkah seperti berikut ini:
- Pada browser Chrome, buka tab Network untuk men-capture seluruh request dari browser.
- Login sebagai user yang sah dan buka halaman yang melakukan pemanggilan callable function.
- Pilih salah satu request yang mewakili pemanggilan callable function, pastikan terdapat header
authorization
pada request tersebut.
Klik kanan pada request dan pilih Copy, Copy as cURL. - Pada dashboard GCP, buka Cloud Shell. Ini akan membuka terminal baru di mesin remote dengan IP yang dinamis. Tempelkan hasil pada perintah sebelumnya dan tekan Enter untuk mengerjakan perintah cURL tersebut.
- Pastikan untuk mendapatkan kembalian dengan status
401
dan pesan kesalahan seperti{"error":{"message":"Unauthorized access","status":"UNAUTHENTICATED"}}
.
Walaupun teknik perbandingan IP terlihat efektif, ia akan menimbulkan masalah bagi pengguna yang sedang dalam perjalanan atau pengguna yang menggunakan koneksi telepor seluler dengan IP yang sangat dinamis. Mereka akan jadi lebih sering diminta untuk login kembali. Untuk mengatasi hal ini, saya dapat meningkatkan kode program, misalnya dengan memeriksa apakah IP dari dua negara yang berbeda, apakah IP bukan salah satu IP yang biasa dipakai selama 30 hari terakhir, apakah IP selalu berubah dalam waktu singat, dan sebagainya.