diff --git a/README.md b/README.md index 0028f51ef7236e8dd6aaa191457d3241cff46302..aa5958c9bd0a0399c15c48b516c94e3e120be1ab 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,30 @@ -# IF3210-2024-Android-YBA +# 📱 BondoMan - IF3210-2024-Android-YBA -## Deskripsi aplikasi. -## Library yang digunakan. -## Screenshot aplikasi (dimasukkan dalam folder screenshot). +<img + src="screenshot/logo.png" + alt="Logo YBA" + style="display: block; margin: 0 auto; width: 200; max-width:800; padding: 0;"> + +**Table of content:** +- [📱 BondoMan - IF3210-2024-Android-YBA](#-bondoman---if3210-2024-android-yba) + - [📃 Deskripsi aplikasi.](#-deskripsi-aplikasi) + - [📚 Library yang digunakan.](#-library-yang-digunakan) + - [📷 Screenshot aplikasi (dimasukkan dalam folder screenshot).](#-screenshot-aplikasi-dimasukkan-dalam-folder-screenshot) + - [âŒ›ï¸ Pembagian kerja anggota kelompok.](#ï¸-pembagian-kerja-anggota-kelompok) + - [â³ Jumlah jam persiapan dan pengerjaan untuk masing-masing anggota.](#-jumlah-jam-persiapan-dan-pengerjaan-untuk-masing-masing-anggota) + - [📸 Screenshot before after ngubah accessibility](#-screenshot-before-after-ngubah-accessibility) + - [📌 Before](#-before) + - [📌 After](#-after) + - [💻 OWASP Analysis](#-owasp-analysis) + - [📌 Insufficient Input/Output Validation (M4)](#-insufficient-inputoutput-validation-m4) + - [📌 Security Misconfiguration (M8)](#-security-misconfiguration-m8) + - [📌 Insecure Data Storage (M9)](#-insecure-data-storage-m9) + - [Thank you 🫡🫡🫡](#thank-you-) + + +## 📃 Deskripsi aplikasi. +## 📚 Library yang digunakan. +## 📷 Screenshot aplikasi (dimasukkan dalam folder screenshot).    @@ -17,10 +39,13 @@   -## Pembagian kerja anggota kelompok. -## Jumlah jam persiapan dan pengerjaan untuk masing-masing anggota. -## Screenshot before after ngubah accessibility -### Before +## âŒ›ï¸ Pembagian kerja anggota kelompok. + +## â³ Jumlah jam persiapan dan pengerjaan untuk masing-masing anggota. + +## 📸 Screenshot before after ngubah accessibility + +### 📌 Before .jpg) .jpg) .jpg) @@ -29,19 +54,17 @@ .jpg) .jpg) .jpg) -.jpg)       -    -### After +### 📌 After    @@ -54,4 +77,83 @@   -## OWASP analysis \ No newline at end of file +## 💻 OWASP Analysis + +### 📌 Insufficient Input/Output Validation (M4) + +Insufficient Input/Output Validation terjadi ketika sebuah aplikasi gagal dalam melakukan validasi dan sanitasi user input dan output sehingga memungkinkan serangan seperti injection attacks (SQL injection, LDAP injection, dll.), cross-site scripting (XSS), dan sebagainya. + +Beberapa layar yang rentan terhadap hal ini adalah layar yang menerima input user seperti +1. Login Screen + Pada screen ini, aplikasi menerima input email dan password user. Perlu adanya validasi format email dan password sebelum dikirim ke server. Format email mengikuti format email pada umumnya dan format password adalah minimal 8 karakter. Hal ini sesuai dengan validasi yang ada di server. + + Selain itu, digunakan juga validasi dari EditText component yaitu maxLength untuk setiap input 255 karakter untuk email dan 100 karakter untuk password. + +<img + src="screenshot/owasp/login-validation.png" + alt="Login Validation" + style="display: block; margin: 0 auto; width: 200; max-width:800; padding: 0;"> + +2. Create/Edit Transaction Screen + Pada screen ini, aplikasi menerima input nama, nominal, kategori, dan lokasi transaksi. Validasi input disesuaikan dengan constraint pada database yaitu untuk nama maksimum 50 karakter, nominal maksimum 999999999, dan lokasi maksimum 150 karakter. Untuk kategori digunakan radio button sehingga pengguna hanya bisa memilih 2 value. + + Untuk setiap input, dipastikan bahwa tidak kosong. + +<img + src="screenshot/owasp/tc-validation.png" + alt="Transaction Validation" + style="display: block; margin: 0 auto; width: 200; max-width:800; padding: 0;"> + +Aplikasi juga menggunakan API dari luar sehingga rentan terhadap serangan jika API tersebut dieksploitasi. API yang digunakan adalah +1. API untuk login +2. API untuk mengecek JWT +3. API untuk scan struk belanja + +Server API ini dapat dieksploitasi seperti malicious user mengeksploitasi API dan membuat response dari API menjadi data lain selain JSON (misal: Script). Hal ini dapat menghasilkan beberapa skenario berikut +1. Aplikasi mengonsumsi response yang tidak benar tersebut dan mengeksekusinya + Moshi dapat mengatasi hal ini karena jika response bukan bertipe JSON maka Moshi akan menghasilkan exception +2. Aplikasi crash karena exception tersebut tidak ditangani + Oleh karena itu, kami meng-handle exception yang dihasilkan dari API calls tersebut. Handling ini dilakukan pada `/remote/ApiHandler.kt`. + +### 📌 Security Misconfiguration (M8) + +Security Misconfiguration terjadi ketika konfigurasi yang tidak baik dalam pengaturan keamanan, permissions, dan control dari aplikasi sehingga memungkinkan unauthorized access. + +Beberapa tempat yang vulnerable adalah +1. Penyimpanan token JWT + Penjelasan mengenai penyimpanan token JWT ada di [sini](#📌-insecure-data-storage-m9) +2. Intent + Aplikasi menggunakan Intent untuk berkomunikasi dari komponen dengan komponen lain. Intent dapat dikirim dari aplikasi kita atau dari aplikasi lain. + + Broadcast receiver pada aplikasi hanya digunakan untuk mendengarkan intent yang dikirim oleh aplikasi tersebut saja (lokal). Oleh karena itu digunakan LocalBroadcastManager dibandingkan broadcast global. LocalBroadcastManager tidak memiliki isu keamanan karena tidak ada cross-app communication. +3. Service + Background service yang digunakan pada aplikasi tidak di-expose ke luar sehingga aplikasi lain tidak dapat menggunakan service tersebut. Hal ini didefinisikan pada manifest Android + +  + +### 📌 Insecure Data Storage (M9) + +Insecure Data Storage (M9) terjadi ketika penyimpanan data tidak aman sehingga memungkinkan unauthorized access. + +Aplikasi ini menggunakan token JWT untuk melakukan authorization ke screen utama. Selain itu, token JWT juga diperlukan dalam melakukan request ke server. Oleh karena itu, token JWT harus disimpan secara aman, misal mengenkripsi token sebelum disimpan sehingga jika pengguna lain dapat mengakses penyimpanan tersebut, dia tidak mendapatkan value aslinya. + +Token JWT saat ini disimpan dengan menggunakan [encrypted shared preferences](https://developer.android.com/reference/androidx/security/crypto/EncryptedSharedPreferences). + +Encrypted shared preferences merupakan implementasi dari SharedPreferences yang mengenkripsi key dan valuenya. Berikut merupakan screenshot dari shared preference yang menyimpan token JWT serta alamat email dari pengguna + + + + +Dari gambar di atas, key dan value terlihat arbitary karena sudah dienkripsi sehingga tidak dapat didecode oleh mata manusia. + +Enkripsi menggunakan master key dan subkeys. Master key digunakan untuk mengenkripsi subkeys. Subkeys digunakan untuk mengenkripsi data asli. Master key disimpan pada Sistem [Android Keystore](https://developer.android.com/privacy-and-security/keystore) yang memiliki mekanisme untuk membuat key lebih susah diekstrak dari device. Subkeys disimpan langsung pada shared preferences. + +Enkripsi menggunakan skema AES256 yang saat ini merupakan algoritma enkripsi yang paling sering digunakan dan paling aman. + +Dengan menggunakan mekanisme encryption shared preferences akan menyulitkan unauthorized user untuk mendapatkan nilai token yang asli karena hanya aplikasi yang mengetahui master key yang digunakan untuk dekripsi. + +## Thank you 🫡🫡🫡 +<img + src="screenshot/yba.png" + alt="Logo YBA" + style="display: block; margin: 0 auto; width: 200; max-width:800; padding: 0;"> \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3a9e66b67af683a17318e0721aa642ecd6dc7727..53d741bac438a8d603e405bbdf788fcef56b32fb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -27,7 +27,7 @@ <service android:name=".service.JwtCheckerService" android:enabled="true" - android:exported="true" /> + android:exported="false" /> <activity android:name=".MainActivity" diff --git a/app/src/main/java/com/example/bandung_bondowoso/remote/ApiClient.kt b/app/src/main/java/com/example/bandung_bondowoso/remote/ApiClient.kt index 78cf201c866ab3771bee311667dae90726c35d82..679950ee9007c9bf602b6c4094232e4c5b126172 100644 --- a/app/src/main/java/com/example/bandung_bondowoso/remote/ApiClient.kt +++ b/app/src/main/java/com/example/bandung_bondowoso/remote/ApiClient.kt @@ -1,5 +1,6 @@ package com.example.bandung_bondowoso.remote +import com.example.bandung_bondowoso.util.Config import com.squareup.moshi.Moshi import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import retrofit2.Retrofit @@ -20,7 +21,7 @@ class RetrofitClient(private val baseUrl: String) { object ApiClient { val pbdApiService: PBDApiService by lazy { RetrofitClient( - "https://pbd-backend-2024.vercel.app/" + Config.BASE_URL ).retrofit.create(PBDApiService::class.java) } } \ No newline at end of file diff --git a/app/src/main/java/com/example/bandung_bondowoso/util/Config.kt b/app/src/main/java/com/example/bandung_bondowoso/util/Config.kt index df908a66cb29738ff0832d79da4ff00b9a78b71a..d900028cd60d83fa7dd37e9add8a4eb4e3d271c6 100644 --- a/app/src/main/java/com/example/bandung_bondowoso/util/Config.kt +++ b/app/src/main/java/com/example/bandung_bondowoso/util/Config.kt @@ -2,7 +2,7 @@ package com.example.bandung_bondowoso.util class Config { companion object { - const val BASE_URL = "https://pbd-backend-2024.vercel.app" + const val BASE_URL = "https://pbd-backend-2024.vercel.app/" const val SHARED_PREFERENCES_NAME = "secure_shared_prefs" const val TOKEN_KEY = "access_token" const val EMAIL_KEY = "email" diff --git a/app/src/main/java/com/example/bandung_bondowoso/validator/BaseValidator.kt b/app/src/main/java/com/example/bandung_bondowoso/validator/BaseValidator.kt new file mode 100644 index 0000000000000000000000000000000000000000..bf648c0bbe5b3de48a1badc73084044e47b9b24b --- /dev/null +++ b/app/src/main/java/com/example/bandung_bondowoso/validator/BaseValidator.kt @@ -0,0 +1,17 @@ +package com.example.bandung_bondowoso.validator + +import com.example.bandung_bondowoso.R + +abstract class BaseValidator : Validator { + companion object { + fun validate(vararg validators: Validator): ValidateResult { + validators.forEach { + val result = it.validate() + if (!result.isSuccess) + return result + } + + return ValidateResult(true, R.string.msg_validation_success) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/bandung_bondowoso/validator/EmailValidator.kt b/app/src/main/java/com/example/bandung_bondowoso/validator/EmailValidator.kt new file mode 100644 index 0000000000000000000000000000000000000000..75387deb13f7fec06d85669faf5dc395f33b6dc6 --- /dev/null +++ b/app/src/main/java/com/example/bandung_bondowoso/validator/EmailValidator.kt @@ -0,0 +1,15 @@ +package com.example.bandung_bondowoso.validator + +import com.example.bandung_bondowoso.R + +class EmailValidator(private val email: String) : BaseValidator() { + override fun validate(): ValidateResult { + return if (email.isEmpty()) { + ValidateResult.fail(R.string.msg_email_empty) + } else if (!android.util.Patterns.EMAIL_ADDRESS.matcher(email).matches()) { + ValidateResult.fail(R.string.msg_email_invalid) + } else { + ValidateResult.success() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/bandung_bondowoso/validator/PasswordValidator.kt b/app/src/main/java/com/example/bandung_bondowoso/validator/PasswordValidator.kt new file mode 100644 index 0000000000000000000000000000000000000000..5a4eee1f10a68d22a01e217c6f3627340676c5ae --- /dev/null +++ b/app/src/main/java/com/example/bandung_bondowoso/validator/PasswordValidator.kt @@ -0,0 +1,15 @@ +package com.example.bandung_bondowoso.validator + +import com.example.bandung_bondowoso.R + +class PasswordValidator(private val password: String) : BaseValidator(){ + override fun validate(): ValidateResult { + return if (password.isEmpty()) { + ValidateResult.fail(R.string.msg_password_empty) + } else if (password.length < 8) { + ValidateResult.fail(R.string.msg_password_too_short) + } else { + ValidateResult.success() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/bandung_bondowoso/validator/Validator.kt b/app/src/main/java/com/example/bandung_bondowoso/validator/Validator.kt new file mode 100644 index 0000000000000000000000000000000000000000..165303a55894401a28ca852427f1b9632178b204 --- /dev/null +++ b/app/src/main/java/com/example/bandung_bondowoso/validator/Validator.kt @@ -0,0 +1,12 @@ +package com.example.bandung_bondowoso.validator + +data class ValidateResult(val isSuccess: Boolean, val message: Int) { + companion object { + fun success() = ValidateResult(true, 0) + fun fail(message: Int) = ValidateResult(false, message) + } +} + +interface Validator { + fun validate(): ValidateResult +} \ No newline at end of file diff --git a/app/src/main/java/com/example/bandung_bondowoso/view/login/LoginFragment.kt b/app/src/main/java/com/example/bandung_bondowoso/view/login/LoginFragment.kt index 60c0b6f2e424c20fed344800ff1b5c216653a974..c7dcccf2bbb2b5028832ba92c9eb99eff32e6401 100644 --- a/app/src/main/java/com/example/bandung_bondowoso/view/login/LoginFragment.kt +++ b/app/src/main/java/com/example/bandung_bondowoso/view/login/LoginFragment.kt @@ -81,6 +81,18 @@ class LoginFragment : Fragment(), ConnectionChangeListener { loginFormState.error?.let { Toast.makeText(this.requireContext(), it, Toast.LENGTH_SHORT).show() } + + if (loginFormState.emailValidationError != null) { + binding.emailTextInput.error = getString(loginFormState.emailValidationError!!) + } else { + binding.emailTextInput.error = null + } + + if (loginFormState.passwordValidationError != null) { + binding.passwordTextInput.error = getString(loginFormState.passwordValidationError!!) + } else { + binding.passwordTextInput.error = null + } }) connectionStateMonitor.enable(requireContext()) diff --git a/app/src/main/java/com/example/bandung_bondowoso/viewmodel/login/LoginFormState.kt b/app/src/main/java/com/example/bandung_bondowoso/viewmodel/login/LoginFormState.kt index 5c412b4f8c90a283fafb197c4e22f279ff392da2..897b64fdea8aec8427bf8a06a4192cc26ff85bf8 100644 --- a/app/src/main/java/com/example/bandung_bondowoso/viewmodel/login/LoginFormState.kt +++ b/app/src/main/java/com/example/bandung_bondowoso/viewmodel/login/LoginFormState.kt @@ -3,5 +3,8 @@ package com.example.bandung_bondowoso.viewmodel.login data class LoginFormState( val emailError: String? = null, val passwordError: String? = null, - val error : String? = null + val error : String? = null, + + val emailValidationError: Int? = null, + val passwordValidationError: Int? = null ) diff --git a/app/src/main/java/com/example/bandung_bondowoso/viewmodel/login/LoginViewModel.kt b/app/src/main/java/com/example/bandung_bondowoso/viewmodel/login/LoginViewModel.kt index 238e5e1cb3ae12785968bac53e48ef4a638ea5bc..2f4300f4fef521c90bb6f0fbdce67c767dfea31e 100644 --- a/app/src/main/java/com/example/bandung_bondowoso/viewmodel/login/LoginViewModel.kt +++ b/app/src/main/java/com/example/bandung_bondowoso/viewmodel/login/LoginViewModel.kt @@ -7,6 +7,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.bandung_bondowoso.model.NetworkResult import com.example.bandung_bondowoso.repository.UserRepository +import com.example.bandung_bondowoso.validator.EmailValidator +import com.example.bandung_bondowoso.validator.PasswordValidator import kotlinx.coroutines.launch class LoginViewModel(private val userRepository: UserRepository) : ViewModel() { @@ -16,39 +18,58 @@ class LoginViewModel(private val userRepository: UserRepository) : ViewModel() { private val _loginFormState = MutableLiveData<LoginFormState>() val loginFormState: LiveData<LoginFormState> = _loginFormState - fun login(email: String, password: String) = viewModelScope.launch { - _loginResult.value = LoginResult.LOADING - when (val response = userRepository.login(email, password)) { - is NetworkResult.Success -> { - _loginResult.value = LoginResult.SUCCESS - } - is NetworkResult.Error -> { - Log.d("LoginViewModel", "login: ${response.errorMsg}") - if (!response.errorByPaths.isNullOrEmpty()) { - Log.d("LoginViewModel", "login: ${response.errorByPaths}") - - val emailErrorMsg = response.errorByPaths.find { - it.path == "email" - }?.message + fun login(email: String, password: String) { + val emailValidations = EmailValidator(email).validate() + if (!emailValidations.isSuccess) { + _loginFormState.value = LoginFormState(emailValidationError = emailValidations.message) + return + } else { + _loginFormState.value = LoginFormState(emailError = null) + } - val passwordErrorMsg = response.errorByPaths.find { - it.path == "password" - }?.message + val passwordValidations = PasswordValidator(password).validate() + if (!passwordValidations.isSuccess) { + _loginFormState.value = LoginFormState(passwordValidationError = passwordValidations.message) + return + } else { + _loginFormState.value = LoginFormState(passwordError = null) + } - _loginFormState.value = LoginFormState(emailErrorMsg, passwordErrorMsg) - } else { - _loginFormState.value = LoginFormState(error = response.errorMsg) + viewModelScope.launch { + _loginResult.value = LoginResult.LOADING + when (val response = userRepository.login(email, password)) { + is NetworkResult.Success -> { + _loginResult.value = LoginResult.SUCCESS } + is NetworkResult.Error -> { + Log.d("LoginViewModel", "login: ${response.errorMsg}") + if (!response.errorByPaths.isNullOrEmpty()) { + Log.d("LoginViewModel", "login: ${response.errorByPaths}") - _loginResult.value = LoginResult.ERROR - } - is NetworkResult.Exception -> { - Log.d("LoginViewModel", "login: ${response.e.message}") - _loginResult.value = LoginResult.ERROR - _loginFormState.value = LoginFormState(error = response.e.message) + val emailErrorMsg = response.errorByPaths.find { + it.path == "email" + }?.message + + val passwordErrorMsg = response.errorByPaths.find { + it.path == "password" + }?.message + + _loginFormState.value = LoginFormState(emailErrorMsg, passwordErrorMsg) + } else { + _loginFormState.value = LoginFormState(error = response.errorMsg) + } + + _loginResult.value = LoginResult.ERROR + } + is NetworkResult.Exception -> { + Log.d("LoginViewModel", "login: ${response.e.message}") + _loginResult.value = LoginResult.ERROR + _loginFormState.value = LoginFormState(error = response.e.message) + } } } } + fun getEmail(): LiveData<String?> { val email = MutableLiveData<String?>() viewModelScope.launch { @@ -63,6 +84,6 @@ class LoginViewModel(private val userRepository: UserRepository) : ViewModel() { } return token } - + } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 34271f5756f52b0605a1185e63411658f069c853..d44d7d260d991289b9b68aaa16b2c48de5bb1657 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -119,4 +119,18 @@ <string name="cd_title_twibbon">Title Twibbon</string> <string name="cd_title_scan">Title Scan</string> <string name="cd_title_chart">Title Chart</string> + <string name="msg_validation_success">Sukses</string> + <string name="msg_email_empty">Email tidak boleh kosong</string> + <string name="msg_email_invalid">Email invalid</string> + <string name="msg_validation_name_empty">Nama tidak boleh kosong</string> + <string name="msg_validation_name_max_length">Nama maksimum 50 karakter</string> + <string name="msg_amount_invalid">Besar transaksi tidak boleh negatif</string> + <string name="msg_amount_max">Maksimum transaksi bernilai Rp999.999.999,00</string> + <string name="msg_location_empty">Lokasi tidak boleh kosong</string> + <string name="msg_location_min">Lokasi minimum 3 karakter</string> + <string name="msg_location_max">Lokasi maksimum 150 karakter</string> + <string name="msg_location_invalid"><![CDATA["Lokasi hanya boleh karakter alphanumeric, spasi, dan .,#&@()- "]]></string> + <string name="msg_name_empty">Nama tidak boleh kosong</string> + <string name="msg_password_empty">Password tidak boleh kosong</string> + <string name="msg_password_too_short">Password minimal 8 karakter</string> </resources> \ No newline at end of file diff --git a/screenshot/accessibility/ScanWaitIssue.png b/screenshot/accessibility/ScanWaitIssue.png deleted file mode 100644 index a852a1d7684f41d3466ae799b621903efd99fa69..0000000000000000000000000000000000000000 Binary files a/screenshot/accessibility/ScanWaitIssue.png and /dev/null differ diff --git a/screenshot/logo.png b/screenshot/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..937c51bbc98290afac14b7a3f5270ea3f6fa34d9 Binary files /dev/null and b/screenshot/logo.png differ diff --git a/screenshot/owasp/encrypted-token.png b/screenshot/owasp/encrypted-token.png new file mode 100644 index 0000000000000000000000000000000000000000..fb04986878e6644d4bf0889a2d2b525b087c511c Binary files /dev/null and b/screenshot/owasp/encrypted-token.png differ diff --git a/screenshot/owasp/login-validation.png b/screenshot/owasp/login-validation.png new file mode 100644 index 0000000000000000000000000000000000000000..833203d65af44dc46eac6cedb0ad95c00b42efc1 Binary files /dev/null and b/screenshot/owasp/login-validation.png differ diff --git a/screenshot/owasp/raw-token.png b/screenshot/owasp/raw-token.png new file mode 100644 index 0000000000000000000000000000000000000000..8b1ad865f6d15270b2480a2c466755e91a974ad3 Binary files /dev/null and b/screenshot/owasp/raw-token.png differ diff --git a/screenshot/owasp/service.png b/screenshot/owasp/service.png new file mode 100644 index 0000000000000000000000000000000000000000..b243d2b2c7bc8d3c46f07af58f2b05f3d6627a8c Binary files /dev/null and b/screenshot/owasp/service.png differ diff --git a/screenshot/owasp/tc-validation.png b/screenshot/owasp/tc-validation.png new file mode 100644 index 0000000000000000000000000000000000000000..035cd21eb1f5c167fb3d93fb78d72e9dabc2528b Binary files /dev/null and b/screenshot/owasp/tc-validation.png differ diff --git a/screenshot/yba.png b/screenshot/yba.png new file mode 100644 index 0000000000000000000000000000000000000000..1c2036b7413f9254a7b5e3b2b5c2b0d5cd1dbaac Binary files /dev/null and b/screenshot/yba.png differ