Implement bank transfer app flows
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
import Foundation
|
||||
|
||||
final class FahipayLoginFlow {
|
||||
|
||||
private let baseURL = "https://fahipay.mv"
|
||||
private let webUA = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
|
||||
|
||||
private let cookieStorage = HTTPCookieStorage()
|
||||
private lazy var urlSession: URLSession = {
|
||||
let cfg = URLSessionConfiguration.default
|
||||
cfg.httpCookieStorage = cookieStorage
|
||||
cfg.timeoutIntervalForRequest = 30
|
||||
cfg.httpShouldSetCookies = true
|
||||
return URLSession(configuration: cfg)
|
||||
}()
|
||||
|
||||
// MARK: - Session cookie management
|
||||
|
||||
func setSessionCookie(_ value: String) {
|
||||
guard let url = URL(string: baseURL),
|
||||
let cookie = HTTPCookie(properties: [
|
||||
.name: "__Secure-sess",
|
||||
.value: value,
|
||||
.domain: "fahipay.mv",
|
||||
.path: "/",
|
||||
.secure: "TRUE"
|
||||
]) else { return }
|
||||
cookieStorage.setCookie(cookie)
|
||||
}
|
||||
|
||||
func getSessionCookieValue() -> String? {
|
||||
cookieStorage.cookies?.first { $0.name == "__Secure-sess" }?.value
|
||||
}
|
||||
|
||||
// MARK: - Login
|
||||
|
||||
/**
|
||||
* Step 1: POST /api/app/login/
|
||||
* Returns FahipayLoginStep:
|
||||
* twoFactorRequired = false + authId set → login complete
|
||||
* twoFactorRequired = true + authId nil → call verifyTotp() next
|
||||
*/
|
||||
func login(idCard: String, password: String, deviceUuid: String) async throws -> FahipayLoginStep {
|
||||
try await initSession()
|
||||
|
||||
let parts: [(String, String)] = [
|
||||
("email", idCard),
|
||||
("password", password),
|
||||
("grant_type", "auth_id"),
|
||||
("lang", "en"),
|
||||
("version", "2.0.0"),
|
||||
("platform", "BasedBank")
|
||||
] + deviceParts(deviceUuid)
|
||||
|
||||
var req = URLRequest(url: URL(string: "\(baseURL)/api/app/login/")!)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue(webUA, forHTTPHeaderField: "User-Agent")
|
||||
req.setValue("application/json", forHTTPHeaderField: "accept")
|
||||
let (contentType, body) = buildMultipartBody(parts: parts)
|
||||
req.setValue(contentType, forHTTPHeaderField: "Content-Type")
|
||||
req.httpBody = body
|
||||
|
||||
let (data, _) = try await urlSession.data(for: req)
|
||||
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
throw FahipayError.invalidResponse
|
||||
}
|
||||
guard obj["type"] as? String == "success" else {
|
||||
throw FahipayError.loginFailed(obj["msg"] as? String ?? "Login failed — check your ID card and password")
|
||||
}
|
||||
|
||||
let authId = (obj["authID"] as? String).flatMap { $0.isEmpty ? nil : $0 }
|
||||
let twoFa = obj["two_factor_required"] as? Bool ?? false
|
||||
return FahipayLoginStep(twoFactorRequired: twoFa, authId: authId)
|
||||
}
|
||||
|
||||
// MARK: - TOTP verification
|
||||
|
||||
/**
|
||||
* Step 2 (if 2FA required): POST /api/app/otp/
|
||||
* Returns authId on success.
|
||||
*/
|
||||
func verifyTotp(code: String, deviceUuid: String) async throws -> String {
|
||||
let parts: [(String, String)] = [
|
||||
("code", code),
|
||||
("channel", "totp"),
|
||||
("action", "login"),
|
||||
("grant_type", "auth_id"),
|
||||
("lang", "en"),
|
||||
("version", "2.0.0"),
|
||||
("platform", "BasedBank")
|
||||
] + deviceParts(deviceUuid)
|
||||
|
||||
var req = URLRequest(url: URL(string: "\(baseURL)/api/app/otp/")!)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue(webUA, forHTTPHeaderField: "User-Agent")
|
||||
req.setValue("application/json", forHTTPHeaderField: "accept")
|
||||
let (contentType, body) = buildMultipartBody(parts: parts)
|
||||
req.setValue(contentType, forHTTPHeaderField: "Content-Type")
|
||||
req.httpBody = body
|
||||
|
||||
let (data, _) = try await urlSession.data(for: req)
|
||||
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
throw FahipayError.invalidResponse
|
||||
}
|
||||
guard obj["type"] as? String == "success" else {
|
||||
throw FahipayError.otpFailed(obj["msg"] as? String ?? "OTP verification failed")
|
||||
}
|
||||
guard let authId = (obj["authID"] as? String).flatMap({ $0.isEmpty ? nil : $0 }) else {
|
||||
throw FahipayError.otpFailed("No authID in OTP response")
|
||||
}
|
||||
return authId
|
||||
}
|
||||
|
||||
// MARK: - Session init
|
||||
|
||||
// Establishes the __Secure-sess cookie required for the login + OTP flow
|
||||
private func initSession() async throws {
|
||||
var req = URLRequest(url: URL(string: "\(baseURL)/api/app/lang/data/")!)
|
||||
req.setValue(webUA, forHTTPHeaderField: "User-Agent")
|
||||
_ = try await urlSession.data(for: req)
|
||||
}
|
||||
|
||||
// MARK: - Device info
|
||||
|
||||
private func deviceParts(_ uuid: String) -> [(String, String)] {
|
||||
[
|
||||
("device[available]", "true"),
|
||||
("device[platform]", "iOS"),
|
||||
("device[uuid]", uuid),
|
||||
("device[model]", "iPhone"),
|
||||
("device[manufacturer]", "Apple"),
|
||||
("device[isVirtual]", "false"),
|
||||
("device[serial]", "unknown")
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - Multipart body with lowercase content-disposition headers (Fahipay requirement)
|
||||
|
||||
private func buildMultipartBody(parts: [(String, String)]) -> (contentType: String, body: Data) {
|
||||
let boundary = UUID().uuidString
|
||||
var data = Data()
|
||||
|
||||
for (name, value) in parts {
|
||||
let valueBytes = Data(value.utf8)
|
||||
data += "--\(boundary)\r\n".utf8Data
|
||||
// Fahipay requires lowercase "content-disposition", not standard "Content-Disposition"
|
||||
data += "content-disposition: form-data; name=\"\(name)\"\r\n".utf8Data
|
||||
data += "Content-Length: \(valueBytes.count)\r\n".utf8Data
|
||||
data += "\r\n".utf8Data
|
||||
data += valueBytes
|
||||
data += "\r\n".utf8Data
|
||||
}
|
||||
data += "--\(boundary)--\r\n".utf8Data
|
||||
|
||||
return ("multipart/form-data; boundary=\(boundary)", data)
|
||||
}
|
||||
|
||||
// MARK: - Device UUID generation
|
||||
|
||||
static func generateDeviceUuid() -> String {
|
||||
var bytes = [UInt8](repeating: 0, count: 8)
|
||||
_ = SecRandomCopyBytes(kSecRandomDefault, 8, &bytes)
|
||||
return bytes.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var utf8Data: Data { Data(utf8) }
|
||||
}
|
||||
|
||||
private extension Data {
|
||||
static func += (lhs: inout Data, rhs: Data) { lhs.append(rhs) }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import Foundation
|
||||
|
||||
struct FahipaySession: Codable {
|
||||
let authId: String
|
||||
let sessionCookie: String
|
||||
}
|
||||
|
||||
struct FahipayLoginStep {
|
||||
let twoFactorRequired: Bool
|
||||
let authId: String?
|
||||
}
|
||||
|
||||
struct FahipayContactGroup: Codable, Identifiable {
|
||||
let id: String
|
||||
let name: String
|
||||
let contacts: [BankContact]
|
||||
}
|
||||
|
||||
enum FahipayError: LocalizedError {
|
||||
case networkError(String)
|
||||
case loginFailed(String)
|
||||
case otpFailed(String)
|
||||
case sessionExpired
|
||||
case invalidResponse
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .networkError(let msg): return "Network error: \(msg)"
|
||||
case .loginFailed(let msg): return "Login failed: \(msg)"
|
||||
case .otpFailed(let msg): return "OTP verification failed: \(msg)"
|
||||
case .sessionExpired: return "Session expired."
|
||||
case .invalidResponse: return "Unexpected server response."
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user