174 lines
6.4 KiB
Swift
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) }
|
|
}
|