import Foundation import Security /// Hält Server-Adresse und Anmeldedaten. Das Token liegt im Keychain, /// die Server-URL in den UserDefaults (nicht geheim). final class Session: ObservableObject { static let shared = Session() private let urlKey = "server_url" private let usernameKey = "username" private let stayKey = "stay_logged_in" private let keychainAccount = "vorrania-token" @Published private(set) var baseURL: URL? @Published private(set) var token: String? @Published var username: String = "" @Published var isAdmin: Bool = false /// Merkt die letzte Wahl, damit der Haken im Login richtig steht. @Published private(set) var stayLoggedIn: Bool = true var isLoggedIn: Bool { token != nil && baseURL != nil } private init() { if let stored = UserDefaults.standard.string(forKey: urlKey) { baseURL = Session.normalize(stored) } // Ohne bisherige Wahl bleibt man angemeldet - das ist der Alltagsfall. stayLoggedIn = UserDefaults.standard.object(forKey: stayKey) as? Bool ?? true token = Keychain.read(account: keychainAccount) username = UserDefaults.standard.string(forKey: usernameKey) ?? "" } /// Sorgt für eine URL mit Schema und abschließendem "/", damit relative /// Pfade ("api/...") korrekt aufgelöst werden. static func normalize(_ raw: String) -> URL? { var text = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return nil } if !text.contains("://") { text = "http://" + text } if !text.hasSuffix("/") { text += "/" } return URL(string: text) } func setServer(_ raw: String) { guard let url = Session.normalize(raw) else { return } baseURL = url UserDefaults.standard.set(url.absoluteString, forKey: urlKey) } /// `persist == false` heisst: die Anmeldung gilt nur, solange die App laeuft. /// Auf dem Geraet bleibt dann nichts zurueck. func store(token newToken: String, username name: String, isAdmin admin: Bool, persist: Bool) { token = newToken username = name isAdmin = admin stayLoggedIn = persist UserDefaults.standard.set(persist, forKey: stayKey) if persist { Keychain.write(newToken, account: keychainAccount) UserDefaults.standard.set(name, forKey: usernameKey) } else { Keychain.delete(account: keychainAccount) UserDefaults.standard.removeObject(forKey: usernameKey) } } func logout() { token = nil username = "" isAdmin = false Keychain.delete(account: keychainAccount) UserDefaults.standard.removeObject(forKey: usernameKey) } } /// Minimaler Keychain-Zugriff für ein einzelnes Token. enum Keychain { private static let service = "com.scarriffle.vorrania" static func write(_ value: String, account: String) { delete(account: account) let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecValueData as String: Data(value.utf8), kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, ] SecItemAdd(query as CFDictionary, nil) } static func read(account: String) -> String? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne, ] var item: CFTypeRef? guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, let data = item as? Data else { return nil } return String(data: data, encoding: .utf8) } static func delete(account: String) { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, ] SecItemDelete(query as CFDictionary) } }