Files
Thijooree-iOS/Thijooree iOS/API/Fahipay/FahipayLoginFlow.swift
T

174 lines
6.4 KiB
Swift

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