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) } }