add bml login, accounts and contacts

This commit is contained in:
2026-05-14 05:24:28 +05:00
parent 135f0659dd
commit e4684ec017
14 changed files with 634 additions and 33 deletions
@@ -0,0 +1,263 @@
package sh.sar.basedbank.api.bml
import android.net.Uri
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.FormBody
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.MediaType.Companion.toMediaType
import org.json.JSONArray
import org.json.JSONObject
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.util.Totp
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.Base64
import java.util.concurrent.TimeUnit
class BmlLoginFlow {
private val BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
private val CLIENT_ID = "98C83590-513F-4716-B02B-EC68B7D9E7E7"
private val REDIRECT_URI = "https://app.bankofmaldives.com.mv/oauth/mobile-callback"
private val APP_USER_AGENT = "bml-mobile-banking/345 (POCO; Android 14; 22101320I)"
private val APP_VERSION = "2.1.43.345"
private val WEB_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"
private val cookieStore = mutableMapOf<String, MutableList<Cookie>>()
private val cookieJar = object : CookieJar {
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
val list = cookieStore.getOrPut(url.host) { mutableListOf() }
for (c in cookies) {
list.removeAll { it.name == c.name }
list.add(c)
}
}
override fun loadForRequest(url: HttpUrl): List<Cookie> =
cookieStore[url.host] ?: emptyList()
}
private val client = OkHttpClient.Builder()
.cookieJar(cookieJar)
.followRedirects(false)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
private val apiClient = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
/** Full login: returns a BmlSession and the account list. */
fun login(username: String, password: String, otpSeed: String): Pair<BmlSession, List<MibAccount>> {
// Step 1: GET login page — seeds XSRF-TOKEN + blaze_session cookies
client.newCall(
Request.Builder().url("$BASE_URL/web/login")
.header("User-Agent", WEB_USER_AGENT).build()
).execute().close()
val xsrf = xsrfToken() ?: throw Exception("Could not fetch login page")
// Step 2: POST credentials
val loginBody = """{"username":${quote(username)},"password":${quote(password)},"code":""}"""
.toRequestBody("application/json".toMediaType())
val loginResp = client.newCall(
Request.Builder().url("$BASE_URL/web/login").post(loginBody)
.header("X-XSRF-TOKEN", xsrf)
.header("User-Agent", WEB_USER_AGENT).build()
).execute()
loginResp.close()
if (loginResp.code != 302) throw Exception("Login failed — check your username/password")
// Step 3: GET 2FA page (refreshes blaze_session)
client.newCall(
Request.Builder().url("$BASE_URL/web/login/2fa")
.header("X-XSRF-TOKEN", xsrf)
.header("User-Agent", WEB_USER_AGENT).build()
).execute().close()
val xsrf2 = xsrfToken() ?: xsrf
// Step 4: POST OTP
val otp = Totp.generate(otpSeed)
val twoFaBody = """{"code":${quote(otp)},"channel":"authenticator"}"""
.toRequestBody("application/json".toMediaType())
val twoFaResp = client.newCall(
Request.Builder().url("$BASE_URL/web/login/2fa").post(twoFaBody)
.header("X-XSRF-TOKEN", xsrf2)
.header("User-Agent", WEB_USER_AGENT).build()
).execute()
twoFaResp.close()
if (twoFaResp.code != 302) throw Exception("OTP verification failed — check your OTP seed")
// Step 5: GET /web/profile (sets blaze_identity cookie for profile selection)
client.newCall(
Request.Builder().url("$BASE_URL/web/profile")
.header("X-XSRF-TOKEN", xsrf2)
.header("User-Agent", WEB_USER_AGENT).build()
).execute().close()
// Step 6: PKCE OAuth authorize → extract auth code
val codeVerifier = generateCodeVerifier()
val codeChallenge = generateCodeChallenge(codeVerifier)
val deviceId = generateDeviceId()
val authorizeUrl = HttpUrl.Builder()
.scheme("https").host("www.bankofmaldives.com.mv")
.addPathSegments("internetbanking/oauth/authorize")
.addQueryParameter("redirect_uri", REDIRECT_URI)
.addQueryParameter("client_id", CLIENT_ID)
.addQueryParameter("response_type", "code")
.addQueryParameter("state", randomUrlSafe(16))
.addQueryParameter("nonce", randomUrlSafe(12))
.addQueryParameter("code_challenge", codeChallenge)
.addQueryParameter("code_challenge_method", "S256")
.addQueryParameter("Device-ID", deviceId)
.addQueryParameter("User-Agent", APP_USER_AGENT)
.addQueryParameter("x-app-version", APP_VERSION)
.build()
val authorizeResp = client.newCall(
Request.Builder().url(authorizeUrl)
.header("User-Agent", WEB_USER_AGENT).build()
).execute()
authorizeResp.close()
val location = authorizeResp.header("Location")
?: throw Exception("OAuth authorize did not redirect")
val authCode = Uri.parse(location).getQueryParameter("code")
?: throw Exception("No auth code in OAuth redirect")
// Step 7: Exchange auth code for access token
val tokenBody = FormBody.Builder()
.add("Device-ID", deviceId)
.add("code", authCode)
.add("grant_type", "authorization_code")
.add("User-Agent", APP_USER_AGENT)
.add("redirect_uri", REDIRECT_URI)
.add("code_verifier", codeVerifier)
.add("client_id", CLIENT_ID)
.add("x-app-version", APP_VERSION)
.build()
val tokenResp = client.newCall(
Request.Builder().url("$BASE_URL/oauth/token").post(tokenBody)
.header("User-Agent", WEB_USER_AGENT).build()
).execute()
val tokenJson = tokenResp.body?.string() ?: throw Exception("Empty token response")
tokenResp.close()
val tokenObj = JSONObject(tokenJson)
val accessToken = tokenObj.optString("access_token")
.takeIf { it.isNotBlank() } ?: throw Exception("Token exchange failed")
val session = BmlSession(accessToken = accessToken, deviceId = deviceId)
val accounts = fetchAccounts(session)
return Pair(session, accounts)
}
fun fetchAccounts(session: BmlSession): List<MibAccount> {
val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/dashboard")).execute()
val json = resp.body?.string() ?: return emptyList()
resp.close()
return parseDashboard(json)
}
fun fetchContacts(session: BmlSession): List<MibBeneficiary> {
val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/contacts")).execute()
val json = resp.body?.string() ?: return emptyList()
resp.close()
return parseContacts(json)
}
private fun apiRequest(session: BmlSession, url: String) =
Request.Builder().url(url)
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", APP_USER_AGENT)
.header("x-app-version", APP_VERSION)
.build()
private fun parseDashboard(json: String): List<MibAccount> {
val root = JSONObject(json)
if (!root.optBoolean("success")) return emptyList()
val dashboard = root.optJSONObject("payload")?.optJSONArray("dashboard") ?: return emptyList()
return (0 until dashboard.length()).map { i ->
val item = dashboard.getJSONObject(i)
val currency = item.optString("currency", "MVR")
val available = item.optDouble("availableBalance", 0.0)
MibAccount(
profileName = "Bank of Maldives",
profileType = "BML",
accountNumber = item.optString("account"),
accountBriefName = item.optString("alias"),
currencyName = currency,
accountTypeName = item.optString("product"),
availableBalance = "%.2f".format(available),
currentBalance = "%.2f".format(item.optDouble("ledgerBalance", 0.0)),
blockedAmount = "%.2f".format(item.optDouble("lockedAmount", 0.0)),
mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00",
statusDesc = item.optString("account_status", "Active"),
profileImageHash = null
)
}
}
private fun parseContacts(json: String): List<MibBeneficiary> {
val root = JSONObject(json)
if (!root.optBoolean("success")) return emptyList()
val payload: JSONArray = root.optJSONArray("payload") ?: return emptyList()
val result = mutableListOf<MibBeneficiary>()
for (i in 0 until payload.length()) {
val item = payload.getJSONObject(i)
val account = item.optString("account", "")
if (account.isBlank()) continue
result.add(MibBeneficiary(
benefNo = "bml_${item.optInt("id")}",
benefName = item.optString("name"),
benefNickName = item.optString("alias", item.optString("name")),
benefAccount = account,
benefType = "I",
bankColor = "#0066A1",
benefBankName = "Bank of Maldives",
bankCode = "",
benefStatus = item.optString("status", "S"),
transferCyDesc = item.optString("currency", "MVR"),
customerImgHash = null,
benefCategoryId = "BML"
))
}
return result
}
private fun xsrfToken(): String? =
cookieStore["www.bankofmaldives.com.mv"]?.firstOrNull { it.name == "XSRF-TOKEN" }?.value
private fun generateCodeVerifier(): String {
val bytes = ByteArray(72)
SecureRandom().nextBytes(bytes)
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
}
private fun generateCodeChallenge(verifier: String): String {
val digest = MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray(Charsets.US_ASCII))
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest)
}
private fun randomUrlSafe(byteCount: Int): String {
val b = ByteArray(byteCount)
SecureRandom().nextBytes(b)
return Base64.getUrlEncoder().withoutPadding().encodeToString(b)
}
private fun generateDeviceId(): String {
val bytes = ByteArray(8)
SecureRandom().nextBytes(bytes)
return bytes.joinToString("") { "%02x".format(it) }
}
private fun quote(s: String) = "\"${s.replace("\\", "\\\\").replace("\"", "\\\"")}\""
}
@@ -0,0 +1,6 @@
package sh.sar.basedbank.api.bml
data class BmlSession(
val accessToken: String,
val deviceId: String
)