Files
Thijooree-iOS/Thijooree iOS/ViewModels/LoginViewModel.swift
T

139 lines
6.0 KiB
Swift

import Foundation
import Observation
@Observable
final class LoginViewModel {
enum LoginState {
case idle
case loading(String)
case fahipayNeedTotp
case error(String)
}
var state: LoginState = .idle
// Persisted across navigation for Fahipay 2-step flow
private var fahipayFlow: FahipayLoginFlow?
private var fahipayIdCard = ""
private var fahipayPassword = ""
// MARK: - MIB
func loginMib(username: String, password: String, otpSeed: String) async throws -> [BankAccount] {
let hash = MibCrypto.hashPassword(password)
let flow = MibLoginFlow()
let accounts = try await flow.login(username: username, passwordHash: hash, otpSeed: otpSeed)
try? CredentialStore.shared.save(hash, forKey: CredentialStore.Keys.mibPassword(username))
try? CredentialStore.shared.save(otpSeed, forKey: CredentialStore.Keys.mibOtpSeed(username))
try? CredentialStore.shared.addLoginId(username, toBank: "MIB")
return accounts
}
// MARK: - BML
func loginBml(username: String, password: String, otpSeed: String) async throws -> [BankAccount] {
let loginTag = "bml_\(username)"
let flow = BmlLoginFlow()
let profiles = try await flow.login(username: username, password: password, otpSeed: otpSeed)
guard !profiles.isEmpty else { throw BmlError.loginFailed("No profiles found for this account") }
var accumulated: [BankAccount] = []
for profile in profiles where profile.profileType != "business" {
let result = try await flow.activateProfile(profile, loginTag: loginTag)
if case .success(let session, let accs) = result {
accumulated += accs
saveSession(session, profileId: profile.profileId)
}
}
try? CredentialStore.shared.save(password, forKey: CredentialStore.Keys.bmlPassword(username))
try? CredentialStore.shared.save(otpSeed, forKey: CredentialStore.Keys.bmlOtpSeed(username))
try? CredentialStore.shared.addLoginId(username, toBank: "BML")
let ids = profiles.map { $0.profileId }
try? CredentialStore.shared.saveStringArray(ids, forKey: CredentialStore.Keys.bmlProfiles(username))
return accumulated
}
private func saveSession(_ session: BmlSession, profileId: String) {
try? CredentialStore.shared.save(session.accessToken, forKey: CredentialStore.Keys.bmlAccessToken(profileId))
try? CredentialStore.shared.save(session.deviceId, forKey: CredentialStore.Keys.bmlDeviceId(profileId))
if !session.refreshToken.isEmpty {
try? CredentialStore.shared.save(session.refreshToken, forKey: CredentialStore.Keys.bmlRefreshToken(profileId))
}
if session.expiresAt > 0 {
try? CredentialStore.shared.save(String(session.expiresAt), forKey: CredentialStore.Keys.bmlExpTime(profileId))
}
}
// MARK: - Fahipay step 1
func loginFahipay(idCard: String, password: String) async throws -> FahipayLoginStep {
let flow = FahipayLoginFlow()
fahipayFlow = flow
fahipayIdCard = idCard
fahipayPassword = password
return try await flow.login(idCard: idCard, password: password, deviceUuid: fahipayDeviceUuid())
}
// MARK: - Fahipay step 2 (TOTP)
func verifyFahipayTotp(_ code: String) async throws {
guard let flow = fahipayFlow else { throw FahipayError.sessionExpired }
let authId = try await flow.verifyTotp(code: code, deviceUuid: fahipayDeviceUuid())
let cookie = flow.getSessionCookieValue() ?? ""
saveFahipaySession(authId: authId, sessionCookie: cookie)
}
// Called when no 2FA is needed (authId already returned from step 1)
func completeFahipayLogin(authId: String) {
let cookie = fahipayFlow?.getSessionCookieValue() ?? ""
saveFahipaySession(authId: authId, sessionCookie: cookie)
}
private func saveFahipaySession(authId: String, sessionCookie: String) {
// Use authId as the persisted login identifier
let loginId = authId
try? CredentialStore.shared.save(fahipayIdCard, forKey: CredentialStore.Keys.fahipayIdCard(loginId))
try? CredentialStore.shared.save(fahipayPassword, forKey: CredentialStore.Keys.fahipayPassword(loginId))
try? CredentialStore.shared.save(sessionCookie, forKey: CredentialStore.Keys.fahipaySessionCookie(loginId))
try? CredentialStore.shared.save(authId, forKey: CredentialStore.Keys.fahipayAuthId(loginId))
try? CredentialStore.shared.addLoginId(loginId, toBank: "FAHIPAY")
}
// MARK: - Helpers
private func fahipayDeviceUuid() -> String {
let key = "fahipay_device_uuid"
if let existing = CredentialStore.shared.load(forKey: key) { return existing }
let uuid = FahipayLoginFlow.generateDeviceUuid()
try? CredentialStore.shared.save(uuid, forKey: key)
return uuid
}
// Strips otpauth:// URI and whitespace/dashes, uppercases — mirrors Android resolveOtpSeed
static func resolveOtpSeed(_ raw: String) -> String {
var secret = raw.trimmingCharacters(in: .whitespaces)
if secret.lowercased().hasPrefix("otpauth://totp/"),
let url = URLComponents(string: secret),
let s = url.queryItems?.first(where: { $0.name == "secret" })?.value {
secret = s
}
return secret.replacingOccurrences(of: " ", with: "")
.replacingOccurrences(of: "-", with: "")
.uppercased()
}
// Returns true if the string looks like a valid Base32 OTP seed (not a 6-digit code)
static func isValidOtpSeed(_ raw: String) -> Bool {
let seed = resolveOtpSeed(raw)
if seed.isEmpty { return false }
if seed.count == 6 && seed.allSatisfy({ $0.isNumber }) { return false } // reject plain codes
let base32 = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567=")
return seed.unicodeScalars.allSatisfy { base32.contains($0) }
}
}