Neuer Ordner ios/ mit dem Geruest: Netzwerkschicht, Modelle, Offline-Ablage, Texte in Deutsch und Englisch, Navigation, der komplette Anmeldeweg (Server waehlen, anmelden, zweiter Faktor, Pflicht-Passwortwechsel) und als erste Fachansicht die Kontenliste. Die Anmeldung laeuft einmal ueber das Sitzungs-Cookie, holt sich dabei eine App-Marke und legt sie in den Schluesselbund. Danach traegt nur noch der Header. Kein Passwort auf dem Geraet, kein Ablauf nach 30 Tagen. Der Code ist nie uebersetzt worden - hier gibt es kein Xcode. Geprueft ist dafuer jedes Modellfeld gegen echte Antworten des laufenden Backends; dabei kam heraus, dass created_at ohne Zeitzone und mit Mikrosekunden kommt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
127 lines
4.5 KiB
Swift
127 lines
4.5 KiB
Swift
import Foundation
|
||
import Security
|
||
|
||
/// Wo die App ihre Einstellungen und ihr Geheimnis hinlegt.
|
||
///
|
||
/// Zwei getrennte Orte, mit Absicht: Die Server-Adresse ist keine geheime
|
||
/// Angabe und liegt in den Voreinstellungen. Die Gerätemarke ist ein
|
||
/// Vollzugang und gehört in den Schlüsselbund – dort überlebt sie kein
|
||
/// unverschlüsseltes Backup und keine Gerätesicherung ohne Passcode.
|
||
enum Ablage {
|
||
/// Setze das hier auf deine App-Gruppe, sobald es Erweiterungen gibt
|
||
/// (`group.<deine-bundle-id>`). Solange `nil`, benutzt die App ihren
|
||
/// eigenen Bereich – das genügt, bis Teilen-Menü und Kurzbefehle dazukommen.
|
||
static let gruppe: String? = nil
|
||
|
||
/// Dieselbe Gruppe für den Schlüsselbund. Erst nötig, wenn eine
|
||
/// Erweiterung an dieselbe Marke muss.
|
||
static let schluesselbundGruppe: String? = nil
|
||
|
||
private static var voreinstellungen: UserDefaults {
|
||
if let gruppe, let geteilt = UserDefaults(suiteName: gruppe) { return geteilt }
|
||
return .standard
|
||
}
|
||
|
||
private enum Schluessel {
|
||
static let serverAdresse = "certain.serverAdresse"
|
||
static let marke = "certain.marke"
|
||
static let benutzername = "certain.benutzername"
|
||
}
|
||
|
||
// MARK: - Server-Adresse
|
||
|
||
static var serverAdresse: URL? {
|
||
get {
|
||
guard let text = voreinstellungen.string(forKey: Schluessel.serverAdresse) else {
|
||
return nil
|
||
}
|
||
return URL(string: text)
|
||
}
|
||
set {
|
||
if let neue = newValue {
|
||
voreinstellungen.set(neue.absoluteString, forKey: Schluessel.serverAdresse)
|
||
} else {
|
||
voreinstellungen.removeObject(forKey: Schluessel.serverAdresse)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Nur zur Anzeige („angemeldet als …"), nicht zur Prüfung.
|
||
static var benutzername: String? {
|
||
get { voreinstellungen.string(forKey: Schluessel.benutzername) }
|
||
set {
|
||
if let neuer = newValue {
|
||
voreinstellungen.set(neuer, forKey: Schluessel.benutzername)
|
||
} else {
|
||
voreinstellungen.removeObject(forKey: Schluessel.benutzername)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Gerätemarke im Schlüsselbund
|
||
|
||
static var marke: String? {
|
||
get { schluesselbundLesen(Schluessel.marke) }
|
||
set {
|
||
if let neue = newValue {
|
||
schluesselbundSchreiben(Schluessel.marke, neue)
|
||
} else {
|
||
schluesselbundLoeschen(Schluessel.marke)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Alles vergessen – beim Abmelden und wenn der Server die Marke ablehnt.
|
||
static func raeumeAuf() {
|
||
marke = nil
|
||
benutzername = nil
|
||
serverAdresse = nil
|
||
Zwischenspeicher.leere()
|
||
}
|
||
|
||
// MARK: - Schlüsselbund, schmucklos
|
||
|
||
private static func grundlage(_ konto: String) -> [String: Any] {
|
||
var abfrage: [String: Any] = [
|
||
kSecClass as String: kSecClassGenericPassword,
|
||
kSecAttrService as String: "com.certain.app",
|
||
kSecAttrAccount as String: konto,
|
||
]
|
||
if let schluesselbundGruppe {
|
||
abfrage[kSecAttrAccessGroup as String] = schluesselbundGruppe
|
||
}
|
||
return abfrage
|
||
}
|
||
|
||
private static func schluesselbundLesen(_ konto: String) -> String? {
|
||
var abfrage = grundlage(konto)
|
||
abfrage[kSecReturnData as String] = true
|
||
abfrage[kSecMatchLimit as String] = kSecMatchLimitOne
|
||
|
||
var ergebnis: CFTypeRef?
|
||
let status = SecItemCopyMatching(abfrage as CFDictionary, &ergebnis)
|
||
guard status == errSecSuccess, let daten = ergebnis as? Data else { return nil }
|
||
return String(data: daten, encoding: .utf8)
|
||
}
|
||
|
||
private static func schluesselbundSchreiben(_ konto: String, _ wert: String) {
|
||
let abfrage = grundlage(konto)
|
||
let inhalt: [String: Any] = [
|
||
kSecValueData as String: Data(wert.utf8),
|
||
// Nicht in ein Backup und nicht auf ein anderes Gerät: Die Marke
|
||
// gehoert genau diesem Telefon.
|
||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
||
]
|
||
let status = SecItemUpdate(abfrage as CFDictionary, inhalt as CFDictionary)
|
||
if status == errSecItemNotFound {
|
||
var neu = abfrage
|
||
neu.merge(inhalt) { _, zweites in zweites }
|
||
SecItemAdd(neu as CFDictionary, nil)
|
||
}
|
||
}
|
||
|
||
private static func schluesselbundLoeschen(_ konto: String) {
|
||
SecItemDelete(grundlage(konto) as CFDictionary)
|
||
}
|
||
}
|