forked from shihaam/thijooree
handle mib session expirey and keepalive
This commit is contained in:
@@ -24,7 +24,12 @@ class BasedBankApp : Application() {
|
|||||||
val mibMutex = Mutex()
|
val mibMutex = Mutex()
|
||||||
|
|
||||||
val mibLoginFlow by lazy {
|
val mibLoginFlow by lazy {
|
||||||
MibLoginFlow(getSharedPreferences("mib_prefs", MODE_PRIVATE))
|
MibLoginFlow(getSharedPreferences("mib_prefs", MODE_PRIVATE)).also { flow ->
|
||||||
|
flow.onSessionRefreshed = { session, profiles ->
|
||||||
|
mibSession = session
|
||||||
|
mibProfiles = profiles
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import java.security.MessageDigest
|
|||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
class SessionExpiredException : Exception("MIB session expired")
|
||||||
|
|
||||||
class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
|
class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
|
||||||
|
|
||||||
private val TAG = "MibLoginFlow"
|
private val TAG = "MibLoginFlow"
|
||||||
@@ -22,6 +24,15 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
|
|||||||
var lastProfiles: List<MibProfile> = emptyList()
|
var lastProfiles: List<MibProfile> = emptyList()
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
/** Called after automatic session recovery so callers can update their session reference. */
|
||||||
|
var onSessionRefreshed: ((MibSession, List<MibProfile>) -> Unit)? = null
|
||||||
|
|
||||||
|
// Stored after login so the session can be silently recovered on 419
|
||||||
|
@Volatile private var storedUsername: String? = null
|
||||||
|
@Volatile private var storedPasswordHash: String? = null
|
||||||
|
@Volatile private var storedOtpSeed: String? = null
|
||||||
|
private var inRelogin = false
|
||||||
|
|
||||||
private val client = OkHttpClient.Builder()
|
private val client = OkHttpClient.Builder()
|
||||||
.connectTimeout(30, TimeUnit.SECONDS)
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
.readTimeout(30, TimeUnit.SECONDS)
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
@@ -46,6 +57,9 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
|
|||||||
* Returns list of accounts from all profiles on success.
|
* Returns list of accounts from all profiles on success.
|
||||||
*/
|
*/
|
||||||
fun login(username: String, passwordHash: String, otpSeed: String): List<MibAccount> {
|
fun login(username: String, passwordHash: String, otpSeed: String): List<MibAccount> {
|
||||||
|
storedUsername = username
|
||||||
|
storedPasswordHash = passwordHash
|
||||||
|
storedOtpSeed = otpSeed
|
||||||
val appId = getOrCreateAppId()
|
val appId = getOrCreateAppId()
|
||||||
val key1 = prefs.getString("mib_key1_$username", null)
|
val key1 = prefs.getString("mib_key1_$username", null)
|
||||||
val key2 = prefs.getString("mib_key2_$username", null)
|
val key2 = prefs.getString("mib_key2_$username", null)
|
||||||
@@ -172,17 +186,48 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun doRequest(session: MibSession, data: JSONObject, sfunc: String): JSONObject {
|
private fun doRequest(session: MibSession, data: JSONObject, sfunc: String): JSONObject {
|
||||||
val routePath = data.optString("routePath", "?")
|
val response = try {
|
||||||
|
sendRequest(session, data, sfunc)
|
||||||
|
} catch (e: SessionExpiredException) {
|
||||||
|
if (inRelogin) throw e
|
||||||
|
"" // fall through to recovery below
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect expired session: HTTP 419 (empty response) or JSON with reasonCode 505
|
||||||
|
val isExpired = response.isEmpty() ||
|
||||||
|
(response.trimStart().startsWith("{") &&
|
||||||
|
JSONObject(response).optString("reasonCode") == "505")
|
||||||
|
|
||||||
|
if (isExpired && !inRelogin) {
|
||||||
|
val u = storedUsername ?: throw SessionExpiredException()
|
||||||
|
val ph = storedPasswordHash ?: throw SessionExpiredException()
|
||||||
|
val os = storedOtpSeed ?: throw SessionExpiredException()
|
||||||
|
inRelogin = true
|
||||||
|
try { login(u, ph, os) } finally { inRelogin = false }
|
||||||
|
val newSession = lastSession ?: throw SessionExpiredException()
|
||||||
|
onSessionRefreshed?.invoke(newSession, lastProfiles)
|
||||||
|
// Refresh nonce/xxid in the payload for the retry
|
||||||
|
data.put("nonce", MibNonce.generate(newSession.nonceGenerator))
|
||||||
|
data.put("appId", newSession.appId)
|
||||||
|
data.put("sodium", MibNonce.randomSodium())
|
||||||
|
data.put("xxid", newSession.xxid)
|
||||||
|
val retryResponse = sendRequest(newSession, data, sfunc)
|
||||||
|
if (retryResponse.trimStart().startsWith("{")) return JSONObject(retryResponse)
|
||||||
|
return MibCrypto.decrypt(retryResponse, newSession.sessionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.trimStart().startsWith("{")) return JSONObject(response)
|
||||||
|
return MibCrypto.decrypt(response, session.sessionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendRequest(session: MibSession, data: JSONObject, sfunc: String): String {
|
||||||
val encrypted = MibCrypto.encrypt(data, session.sessionKey)
|
val encrypted = MibCrypto.encrypt(data, session.sessionKey)
|
||||||
val formBody = FormBody.Builder()
|
val formBody = FormBody.Builder()
|
||||||
.add("xxid", session.xxid)
|
.add("xxid", session.xxid)
|
||||||
.add("sfunc", sfunc)
|
.add("sfunc", sfunc)
|
||||||
.add("data", encrypted)
|
.add("data", encrypted)
|
||||||
.build()
|
.build()
|
||||||
val response = post(formBody)
|
return post(formBody)
|
||||||
// Server returns plain JSON (not encrypted) for error responses (e.g. expired session)
|
|
||||||
if (response.trimStart().startsWith("{")) return JSONObject(response)
|
|
||||||
return MibCrypto.decrypt(response, session.sessionKey)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun baseData(session: MibSession, routePath: String): JSONObject = JSONObject().apply {
|
private fun baseData(session: MibSession, routePath: String): JSONObject = JSONObject().apply {
|
||||||
@@ -276,6 +321,7 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
|
|||||||
.post(body)
|
.post(body)
|
||||||
.build()
|
.build()
|
||||||
val response = client.newCall(request).execute()
|
val response = client.newCall(request).execute()
|
||||||
|
if (response.code == 419) throw SessionExpiredException()
|
||||||
return response.body?.string() ?: throw IllegalStateException("Empty response body")
|
return response.body?.string() ?: throw IllegalStateException("Empty response body")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,16 @@ import androidx.appcompat.app.ActionBarDrawerToggle
|
|||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import sh.sar.basedbank.BasedBankApp
|
import sh.sar.basedbank.BasedBankApp
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.bml.AuthExpiredException
|
import sh.sar.basedbank.api.bml.AuthExpiredException
|
||||||
@@ -141,6 +147,32 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
show(DashboardFragment())
|
show(DashboardFragment())
|
||||||
binding.navigationView.setCheckedItem(R.id.nav_dashboard)
|
binding.navigationView.setCheckedItem(R.id.nav_dashboard)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep MIB session alive every 25 seconds while the app is in the foreground
|
||||||
|
lifecycleScope.launch {
|
||||||
|
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
|
while (true) {
|
||||||
|
delay(25_000)
|
||||||
|
val session = (application as BasedBankApp).mibSession ?: continue
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val cookieHeader = "mbmodel=IOS-1.0; xxid=${session.xxid}; " +
|
||||||
|
"IBSID=${session.xxid}; mbnonce=${session.nonceGenerator}; time-tracker=597"
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("https://faisamobilex-wv.mib.com.mv/aProfile/keepAlive")
|
||||||
|
.post(ByteArray(0).toRequestBody())
|
||||||
|
.header("Cookie", cookieHeader)
|
||||||
|
.header("User-Agent", "okhttp/4.11.0")
|
||||||
|
.header("Accept", "application/json, text/plain, */*")
|
||||||
|
.header("Accept-Encoding", "gzip")
|
||||||
|
.header("Connection", "Keep-Alive")
|
||||||
|
.build()
|
||||||
|
OkHttpClient().newCall(request).execute().close()
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun show(fragment: Fragment) {
|
private fun show(fragment: Fragment) {
|
||||||
|
|||||||
Reference in New Issue
Block a user