// // UserStore.swift // Werkout_ios // // Created by Trey Tartt on 6/25/23. // import Foundation import SharedCore class UserStore: ObservableObject { static let tokenKeychainValue = "auth_token" static let userDefaultsRegisteredUserKey = "registeredUserKey" private static let userDefaultsTokenExpirationKey = "registeredUserTokenExpiration" static let shared = UserStore() private let runtimeReporter = RuntimeReporter.shared private let authRefreshQueue = DispatchQueue(label: "com.werkout.auth.refresh") private var lastTokenRefreshAttempt = Date.distantPast private let tokenRotationWindow: TimeInterval = 30 * 60 private let tokenRefreshThrottle: TimeInterval = 5 * 60 @Published public private(set) var registeredUser: RegisteredUser? @Published public private(set) var plannedWorkouts = [PlannedWorkout]() init(registeredUser: RegisteredUser? = nil) { self.registeredUser = registeredUser ?? loadPersistedUser() } public var token: String? { guard let rawToken = normalizedToken(from: registeredUser?.token) else { return nil } if isTokenExpired(rawToken) { runtimeReporter.recordWarning("Auth token expired before request", metadata: ["action": "force_logout"]) DispatchQueue.main.async { self.logout(reason: "token_expired") } return nil } maybeRefreshTokenIfNearExpiry(rawToken) return "Token \(rawToken)" } func handleUnauthorizedResponse(statusCode: Int, responseBody: String?) { guard statusCode == 401 || statusCode == 403 else { return } runtimeReporter.recordError( "Unauthorized response from server", metadata: [ "status_code": "\(statusCode)", "has_body": responseBody == nil ? "false" : "true" ] ) DispatchQueue.main.async { self.logout(reason: "unauthorized_\(statusCode)") } } private func loadPersistedUser() -> RegisteredUser? { guard let data = UserDefaults.standard.data(forKey: UserStore.userDefaultsRegisteredUserKey), let model = try? JSONDecoder().decode(RegisteredUser.self, from: data) else { return nil } if let keychainToken = loadTokenFromKeychain() { if isTokenExpired(keychainToken) { runtimeReporter.recordWarning("Persisted token is expired", metadata: ["source": "keychain"]) clearPersistedTokenOnly() return userByReplacingToken(model, token: nil) } persistTokenExpirationMetadata(token: keychainToken) return userByReplacingToken(model, token: keychainToken) } if let legacyToken = normalizedToken(from: model.token) { migrateLegacyTokenToKeychain(legacyToken) if isTokenExpired(legacyToken) { runtimeReporter.recordWarning("Persisted token is expired", metadata: ["source": "legacy_defaults"]) clearPersistedTokenOnly() return userByReplacingToken(model, token: nil) } persistSanitizedModel(userByReplacingToken(model, token: nil)) persistTokenExpirationMetadata(token: legacyToken) return userByReplacingToken(model, token: legacyToken) } persistTokenExpirationMetadata(token: nil) return userByReplacingToken(model, token: nil) } private func persistRegisteredUser(_ model: RegisteredUser) { let sanitizedToken = normalizedToken(from: model.token) persistSanitizedModel(userByReplacingToken(model, token: nil)) if let sanitizedToken, let tokenData = sanitizedToken.data(using: .utf8) { do { try KeychainInterface.save(password: tokenData, account: UserStore.tokenKeychainValue) } catch KeychainInterface.KeychainError.duplicateItem { try? KeychainInterface.update(password: tokenData, account: UserStore.tokenKeychainValue) } catch { runtimeReporter.recordError( "Failed saving token in keychain", metadata: ["error": error.localizedDescription] ) } } else { try? KeychainInterface.deletePassword(account: UserStore.tokenKeychainValue) } persistTokenExpirationMetadata(token: sanitizedToken) } private func persistSanitizedModel(_ model: RegisteredUser) { if let data = try? JSONEncoder().encode(model) { UserDefaults.standard.set(data, forKey: UserStore.userDefaultsRegisteredUserKey) } } func login(postData: [String: Any], completion: @escaping (Bool)-> Void) { LoginFetchable(postData: postData).fetch(completion: { result in switch result { case .success(let model): let sanitizedModel = self.userByReplacingToken(model, token: self.normalizedToken(from: model.token)) DispatchQueue.main.async { self.registeredUser = sanitizedModel self.persistRegisteredUser(sanitizedModel) completion(true) } case .failure(let error): self.runtimeReporter.recordError("Login failed", metadata: ["error": error.localizedDescription]) DispatchQueue.main.async { completion(false) } } }) } public func refreshUserData() { RefreshUserInfoFetcable().fetch(completion: { result in switch result { case .success(let registeredUser): let sanitizedModel = self.userByReplacingToken(registeredUser, token: self.normalizedToken(from: registeredUser.token)) DispatchQueue.main.async { self.persistRegisteredUser(sanitizedModel) self.registeredUser = sanitizedModel } case .failure(let failure): self.runtimeReporter.recordError("Failed refreshing user", metadata: ["error": failure.localizedDescription]) } }) } func logout() { logout(reason: "manual_logout") } private func logout(reason: String) { let email = registeredUser?.email self.registeredUser = nil UserDefaults.standard.removeObject(forKey: UserStore.userDefaultsRegisteredUserKey) persistTokenExpirationMetadata(token: nil) try? KeychainInterface.deletePassword(account: UserStore.tokenKeychainValue) if let email, email.isEmpty == false { try? KeychainInterface.deletePassword(account: email) } plannedWorkouts.removeAll() runtimeReporter.recordInfo("User logged out", metadata: ["reason": reason]) DispatchQueue.main.async { NotificationCenter.default.post(name: AppNotifications.createdNewWorkout, object: nil, userInfo: nil) } } func setFakeUser() { self.registeredUser = PreviewData.parseRegisterdUser() } func fetchPlannedWorkouts() { PlannedWorkoutFetchable().fetch(completion: { result in switch result { case .success(let models): self.plannedWorkouts = models case .failure(let failure): self.runtimeReporter.recordError("Failed fetching planned workouts", metadata: ["error": failure.localizedDescription]) } }) } func plannedWorkoutFor(date: Date) -> PlannedWorkout? { for plannedWorkout in plannedWorkouts { if let plannedworkoutDate = plannedWorkout.date { if Calendar.current.isDate(date, equalTo: plannedworkoutDate, toGranularity: .day) { return plannedWorkout } } } return nil } func setTreyDevRegisterdUser() { self.registeredUser = RegisteredUser(id: 1, firstName: "t", lastName: "t", image: nil, nickName: "t", token: nil, email: nil, hasNSFWToggle: nil) } private func migrateLegacyTokenToKeychain(_ token: String) { guard let tokenData = token.data(using: .utf8) else { return } do { try KeychainInterface.save(password: tokenData, account: UserStore.tokenKeychainValue) } catch KeychainInterface.KeychainError.duplicateItem { try? KeychainInterface.update(password: tokenData, account: UserStore.tokenKeychainValue) } catch { runtimeReporter.recordError("Failed migrating legacy token", metadata: ["error": error.localizedDescription]) } } private func loadTokenFromKeychain() -> String? { guard let tokenData = try? KeychainInterface.readPassword(account: UserStore.tokenKeychainValue), let token = String(data: tokenData, encoding: .utf8) else { return nil } return normalizedToken(from: token) } private func normalizedToken(from rawToken: String?) -> String? { TokenSecurity.sanitizeToken(rawToken) } private func persistTokenExpirationMetadata(token: String?) { guard let token, let expirationDate = TokenSecurity.jwtExpiration(token) else { UserDefaults.standard.removeObject(forKey: UserStore.userDefaultsTokenExpirationKey) return } UserDefaults.standard.set(expirationDate.timeIntervalSince1970, forKey: UserStore.userDefaultsTokenExpirationKey) } private func clearPersistedTokenOnly() { try? KeychainInterface.deletePassword(account: UserStore.tokenKeychainValue) persistTokenExpirationMetadata(token: nil) } private func maybeRefreshTokenIfNearExpiry(_ token: String) { guard TokenSecurity.shouldRotate(token, rotationWindow: tokenRotationWindow) else { return } authRefreshQueue.async { let throttleCutoff = Date().addingTimeInterval(-self.tokenRefreshThrottle) guard self.lastTokenRefreshAttempt <= throttleCutoff else { return } self.lastTokenRefreshAttempt = Date() self.runtimeReporter.recordInfo("Token nearing expiry; refreshing user") DispatchQueue.main.async { self.refreshUserData() } } } private func isTokenExpired(_ token: String) -> Bool { TokenSecurity.isExpired(token) } private func userByReplacingToken(_ model: RegisteredUser, token: String?) -> RegisteredUser { RegisteredUser(id: model.id, firstName: model.firstName, lastName: model.lastName, image: model.image, nickName: model.nickName, token: token, email: model.email, hasNSFWToggle: model.hasNSFWToggle) } }