handle mib session expirey and keepalive

This commit is contained in:
2026-05-15 10:24:51 +05:00
parent c9ec43de04
commit 1b5a417196
3 changed files with 89 additions and 6 deletions
@@ -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) {