handle mib session expirey and keepalive
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 2s
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 2s
This commit is contained in:
@@ -24,7 +24,12 @@ class BasedBankApp : Application() {
|
||||
val mibMutex = Mutex()
|
||||
|
||||
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() {
|
||||
|
||||
@@ -9,6 +9,8 @@ import java.security.MessageDigest
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.random.Random
|
||||
|
||||
class SessionExpiredException : Exception("MIB session expired")
|
||||
|
||||
class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
|
||||
|
||||
private val TAG = "MibLoginFlow"
|
||||
@@ -22,6 +24,15 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
|
||||
var lastProfiles: List<MibProfile> = emptyList()
|
||||
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()
|
||||
.connectTimeout(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.
|
||||
*/
|
||||
fun login(username: String, passwordHash: String, otpSeed: String): List<MibAccount> {
|
||||
storedUsername = username
|
||||
storedPasswordHash = passwordHash
|
||||
storedOtpSeed = otpSeed
|
||||
val appId = getOrCreateAppId()
|
||||
val key1 = prefs.getString("mib_key1_$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 {
|
||||
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 formBody = FormBody.Builder()
|
||||
.add("xxid", session.xxid)
|
||||
.add("sfunc", sfunc)
|
||||
.add("data", encrypted)
|
||||
.build()
|
||||
val response = 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)
|
||||
return post(formBody)
|
||||
}
|
||||
|
||||
private fun baseData(session: MibSession, routePath: String): JSONObject = JSONObject().apply {
|
||||
@@ -276,6 +321,7 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
|
||||
.post(body)
|
||||
.build()
|
||||
val response = client.newCall(request).execute()
|
||||
if (response.code == 419) throw SessionExpiredException()
|
||||
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.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.AuthExpiredException
|
||||
@@ -141,6 +147,32 @@ class HomeActivity : AppCompatActivity() {
|
||||
show(DashboardFragment())
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user