139 lines
6.0 KiB
Swift
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) }
|
|
}
|
|
}
|