Beim ersten Start fuehrt kein Weg an einer sechsstelligen PIN vorbei. Danach sperrt die App beim Kaltstart und beim Wegschalten; kommt man binnen einer Minute zurueck, geht sie ohne Nachfrage wieder auf. Face ID zuerst, die PIN als Rueckfallweg und als einziger Weg ohne Biometrie. Nach fuenf falschen PINs meldet die App ab. Gesperrt wird schon bei inactive, nicht erst im Hintergrund - sonst stuenden die Kontostaende im App-Umschalter. Von der PIN liegt nur ein gesalzener SHA-256 im Schluesselbund. Die Gerätemarke selbst bleibt wie bisher abgelegt: Die Sperre verdeckt die Oberflaeche, sie verschluesselt nichts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
345 lines
12 KiB
Swift
345 lines
12 KiB
Swift
import Foundation
|
||
import Observation
|
||
#if canImport(UIKit)
|
||
import UIKit
|
||
#endif
|
||
|
||
/// Wo die App gerade steht – und damit, welcher Bildschirm zu sehen ist.
|
||
@MainActor
|
||
@Observable
|
||
final class AppZustand {
|
||
|
||
enum Lage: Equatable {
|
||
/// Beim Start: Gibt es eine Adresse, gibt es eine Marke, gilt sie noch?
|
||
case startet
|
||
/// Keine Server-Adresse hinterlegt.
|
||
case brauchtServer
|
||
/// Adresse steht, aber keine gültige Marke.
|
||
case brauchtAnmeldung
|
||
/// Passwort stimmt, der Code fehlt noch.
|
||
case brauchtZweitenFaktor
|
||
/// Das Konto muss erst sein Passwort wechseln.
|
||
case brauchtPasswortwechsel
|
||
/// Die Richtlinie verlangt einen zweiten Faktor, das Konto hat keinen.
|
||
/// Einrichten geht in Stufe 1 noch nicht in der App.
|
||
case brauchtZweitenFaktorEinrichtung
|
||
/// Alles gut.
|
||
case bereit(Benutzer)
|
||
}
|
||
|
||
private(set) var lage: Lage = .startet
|
||
private(set) var einstellungen = Einstellungen()
|
||
private(set) var client: CertainClient?
|
||
|
||
/// Kategorien, Schlagwörter und Konten – einmal geladen, von allen
|
||
/// Listen benutzt. Jede Zeile will zu einer ID den Namen, das Symbol und
|
||
/// die Farbe; das je Ansicht neu zu holen wäre genau das N+1, das das
|
||
/// Backend sich selbst verbietet.
|
||
private(set) var nachschlag = Nachschlag.leer
|
||
|
||
/// Die letzte Meldung für den Anmeldebildschirm. Keine Fehleransicht,
|
||
/// sondern eine Zeile unter dem Formular.
|
||
var meldung: String?
|
||
/// Läuft gerade ein Anmeldeschritt? Sperrt die Knöpfe.
|
||
private(set) var beschaeftigt = false
|
||
|
||
// MARK: - Bildschirmsperre
|
||
|
||
/// Liegt der Sperrbildschirm über der App?
|
||
private(set) var gesperrt = false
|
||
/// Ob eine PIN eingerichtet ist. Als Eigenschaft und nicht direkt aus der
|
||
/// Ablage gelesen, damit die Ansicht nach dem Einrichten neu zeichnet.
|
||
private(set) var hatPIN = Ablage.hatPIN
|
||
private var seitWannWeg: Date?
|
||
private var fehlversuche = 0
|
||
|
||
/// So lange darf man weg sein, ohne erneut zu entsperren.
|
||
private static let gnadenfrist: TimeInterval = 60
|
||
/// Danach fliegt die Marke raus und es geht zur Anmeldung. Ohne Grenze
|
||
/// wäre eine sechsstellige PIN in Ruhe durchprobierbar.
|
||
private static let maxFehlversuche = 5
|
||
|
||
var serverAdresse: URL? { Ablage.serverAdresse }
|
||
|
||
// MARK: - Start
|
||
|
||
func starte() async {
|
||
guard let adresse = Ablage.serverAdresse else {
|
||
lage = .brauchtServer
|
||
return
|
||
}
|
||
let neuer = CertainClient(basis: adresse, marke: Ablage.marke)
|
||
client = neuer
|
||
// Kaltstart: wer eine PIN hat, sieht zuerst den Sperrbildschirm.
|
||
hatPIN = Ablage.hatPIN
|
||
gesperrt = hatPIN
|
||
guard Ablage.marke != nil else {
|
||
lage = .brauchtAnmeldung
|
||
return
|
||
}
|
||
await ladeEigenenZustand()
|
||
}
|
||
|
||
/// Fragt den Server, wer wir sind. Das ist zugleich die Prüfung, ob die
|
||
/// Marke noch gilt.
|
||
private func ladeEigenenZustand() async {
|
||
guard let client else { return }
|
||
do {
|
||
let ich = try await client.ichSelbst()
|
||
// Muss gemerkt werden, sonst greift der Offline-Zweig unten nie:
|
||
// Niemand sonst schreibt unter diesem Schlüssel.
|
||
Zwischenspeicher.lege(ich, unter: "auth/me")
|
||
einstellungen = (try? await client.einstellungen()) ?? Einstellungen()
|
||
lage = .bereit(ich)
|
||
await ladeNachschlag()
|
||
} catch APIFehler.nichtAngemeldet {
|
||
// Marke widerrufen oder gelöscht – neu anmelden, Adresse behalten.
|
||
Ablage.marke = nil
|
||
await client.setzeMarke(nil)
|
||
lage = .brauchtAnmeldung
|
||
} catch APIFehler.pflichtOffen(.passwortWechseln) {
|
||
lage = .brauchtPasswortwechsel
|
||
} catch APIFehler.pflichtOffen(.zweiterFaktor) {
|
||
lage = .brauchtZweitenFaktorEinrichtung
|
||
} catch {
|
||
// Offline beim Start: Wir lassen die App herein und zeigen in den
|
||
// Ansichten den letzten Stand. Die Marke ist ja da.
|
||
if let gemerkt = Zwischenspeicher.hole(Benutzer.self, unter: "auth/me") {
|
||
lage = .bereit(gemerkt.wert)
|
||
await ladeNachschlag()
|
||
} else {
|
||
meldung = error.localizedDescription
|
||
lage = .brauchtAnmeldung
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Server festlegen
|
||
|
||
/// Nimmt, was der Nutzer tippt, und macht daraus eine brauchbare Adresse.
|
||
/// `budget.example.com` wird zu `https://budget.example.com`.
|
||
///
|
||
/// `nonisolated`, weil es reine Textarbeit ist – so lässt es sich ohne
|
||
/// Hauptthread testen.
|
||
nonisolated static func deuteAdresse(_ eingabe: String) -> URL? {
|
||
var text = eingabe.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !text.isEmpty else { return nil }
|
||
if !text.contains("://") { text = "https://" + text }
|
||
// Ein angehängter Pfad hilft nicht – die API hängt immer unter /api.
|
||
guard var teile = URLComponents(string: text), teile.host?.isEmpty == false else {
|
||
return nil
|
||
}
|
||
teile.path = ""
|
||
teile.query = nil
|
||
teile.fragment = nil
|
||
return teile.url
|
||
}
|
||
|
||
func pruefeUndMerkeServer(_ eingabe: String) async -> Bool {
|
||
guard let adresse = Self.deuteAdresse(eingabe) else {
|
||
meldung = String(localized: "server.keineAdresse")
|
||
return false
|
||
}
|
||
beschaeftigt = true
|
||
defer { beschaeftigt = false }
|
||
meldung = nil
|
||
|
||
let versuch = CertainClient(basis: adresse)
|
||
do {
|
||
_ = try await versuch.serverkennung()
|
||
Ablage.serverAdresse = adresse
|
||
client = versuch
|
||
lage = .brauchtAnmeldung
|
||
return true
|
||
} catch {
|
||
meldung = (error as? APIFehler)?.errorDescription ?? error.localizedDescription
|
||
return false
|
||
}
|
||
}
|
||
|
||
// MARK: - Anmelden
|
||
|
||
func melde(benutzer: String, passwort: String) async {
|
||
guard let client else { return }
|
||
beschaeftigt = true
|
||
defer { beschaeftigt = false }
|
||
meldung = nil
|
||
do {
|
||
let antwort = try await client.melde(benutzer: benutzer, passwort: passwort)
|
||
Ablage.benutzername = benutzer
|
||
if antwort.brauchtZweitenFaktor {
|
||
lage = .brauchtZweitenFaktor
|
||
return
|
||
}
|
||
await nachDerAnmeldung(antwort.user)
|
||
} catch {
|
||
meldung = (error as? APIFehler)?.errorDescription ?? error.localizedDescription
|
||
}
|
||
}
|
||
|
||
func pruefeZweitenFaktor(code: String, wiederherstellung: Bool) async {
|
||
guard let client else { return }
|
||
beschaeftigt = true
|
||
defer { beschaeftigt = false }
|
||
meldung = nil
|
||
do {
|
||
let antwort = try await client.pruefeZweitenFaktor(
|
||
code: code, wiederherstellung: wiederherstellung)
|
||
await nachDerAnmeldung(antwort.user)
|
||
} catch APIFehler.nichtAngemeldet {
|
||
// Zu viele Fehlversuche: Das Zwischen-Cookie ist weg.
|
||
meldung = String(localized: "zweifaktor.verbraucht")
|
||
lage = .brauchtAnmeldung
|
||
} catch {
|
||
meldung = (error as? APIFehler)?.errorDescription ?? error.localizedDescription
|
||
}
|
||
}
|
||
|
||
/// Nach geglückter Anmeldung: Marke holen und das Cookie wegwerfen.
|
||
private func nachDerAnmeldung(_ benutzer: Benutzer?) async {
|
||
guard let client else { return }
|
||
|
||
// Offene Pflichten zuerst – mit ihnen lässt sich keine Marke anlegen,
|
||
// das Backend sperrt den Endpunkt (`enforce_account_state`).
|
||
if let benutzer, benutzer.mustChangePassword {
|
||
lage = .brauchtPasswortwechsel
|
||
return
|
||
}
|
||
if let benutzer, benutzer.mfaSetupRequired {
|
||
lage = .brauchtZweitenFaktorEinrichtung
|
||
return
|
||
}
|
||
|
||
do {
|
||
let marke = try await client.legeAppMarkeAn(name: Self.geraetename)
|
||
guard let klartext = marke.token else {
|
||
meldung = String(localized: "anmeldung.markeOhneSchluessel")
|
||
return
|
||
}
|
||
Ablage.marke = klartext
|
||
await client.setzeMarke(klartext)
|
||
// Ab hier trägt die Marke. Das Cookie hat seinen Zweck erfüllt.
|
||
await client.vergissCookies()
|
||
await ladeEigenenZustand()
|
||
} catch {
|
||
meldung = (error as? APIFehler)?.errorDescription ?? error.localizedDescription
|
||
}
|
||
}
|
||
|
||
func wechslePasswort(alt: String, neu: String) async {
|
||
guard let client else { return }
|
||
beschaeftigt = true
|
||
defer { beschaeftigt = false }
|
||
meldung = nil
|
||
do {
|
||
let ich = try await client.wechslePasswort(alt: alt, neu: neu)
|
||
await nachDerAnmeldung(ich)
|
||
} catch {
|
||
meldung = (error as? APIFehler)?.errorDescription ?? error.localizedDescription
|
||
}
|
||
}
|
||
|
||
// MARK: - Abmelden
|
||
|
||
/// Meldet ab und räumt auf. Die Marke wird dabei am Server widerrufen –
|
||
/// sonst bliebe sie dort stehen, bis jemand sie von Hand entfernt.
|
||
func meldeAb(adresseBehalten: Bool = true) async {
|
||
if let client, Ablage.marke != nil {
|
||
// Welche der Marken unsere ist, lässt sich nicht am Klartext
|
||
// ablesen – am Server liegt nur der Hash. Also über den Namen,
|
||
// unter dem wir sie angelegt haben.
|
||
let meine = (try? await client.marken())?.first {
|
||
$0.scope == "app" && $0.name == Self.geraetename
|
||
}
|
||
if let meine { try? await client.widerrufeMarke(id: meine.id) }
|
||
}
|
||
let adresse = adresseBehalten ? Ablage.serverAdresse : nil
|
||
Ablage.raeumeAuf()
|
||
Ablage.serverAdresse = adresse
|
||
hatPIN = false
|
||
gesperrt = false
|
||
fehlversuche = 0
|
||
if let adresse {
|
||
client = CertainClient(basis: adresse)
|
||
lage = .brauchtAnmeldung
|
||
} else {
|
||
client = nil
|
||
lage = .brauchtServer
|
||
}
|
||
}
|
||
|
||
// MARK: - Sperren und entsperren
|
||
|
||
/// iOS fotografiert die App beim Wegschalten für den App-Umschalter.
|
||
/// Darum wird sofort gesperrt und nicht erst beim Zurückkommen – sonst
|
||
/// stünden die Kontostände im Umschalter.
|
||
func gehtWeg() {
|
||
guard hatPIN, !gesperrt else { return }
|
||
seitWannWeg = Date()
|
||
gesperrt = true
|
||
}
|
||
|
||
/// Kurz weggewischt – etwa für eine eintreffende Nachricht – geht ohne
|
||
/// Nachfrage wieder auf. Erst nach der Gnadenfrist wird gefragt.
|
||
func kommtZurueck() {
|
||
guard let weg = seitWannWeg else { return }
|
||
seitWannWeg = nil
|
||
if Date().timeIntervalSince(weg) <= Self.gnadenfrist { gesperrt = false }
|
||
}
|
||
|
||
func entsperrt() {
|
||
gesperrt = false
|
||
fehlversuche = 0
|
||
}
|
||
|
||
/// Meldet einen Fehlversuch. Gibt zurück, ob noch Versuche übrig sind;
|
||
/// beim letzten meldet die App ab.
|
||
func falschePIN() async -> Bool {
|
||
fehlversuche += 1
|
||
guard fehlversuche >= Self.maxFehlversuche else { return true }
|
||
await meldeAb()
|
||
return false
|
||
}
|
||
|
||
var verbleibendeVersuche: Int { max(0, Self.maxFehlversuche - fehlversuche) }
|
||
|
||
func setzePIN(_ pin: String) {
|
||
Ablage.setzePIN(pin)
|
||
hatPIN = true
|
||
gesperrt = false
|
||
fehlversuche = 0
|
||
}
|
||
|
||
func aktualisiereEinstellungen() async {
|
||
guard let client else { return }
|
||
if let frisch = try? await client.einstellungen() { einstellungen = frisch }
|
||
}
|
||
|
||
/// Holt das Nachschlagewerk. Geht das schief, bleibt der letzte Stand –
|
||
/// eine Buchungsliste ohne Kategorienamen wäre schlechter als eine mit
|
||
/// alten.
|
||
func ladeNachschlag() async {
|
||
guard let client else { return }
|
||
if let frisch = try? await client.nachschlag() {
|
||
Zwischenspeicher.lege(frisch, unter: "nachschlag")
|
||
nachschlag = frisch
|
||
} else if nachschlag.gruppen.isEmpty,
|
||
let gemerkt = Zwischenspeicher.hole(Nachschlag.self, unter: "nachschlag") {
|
||
nachschlag = gemerkt.wert
|
||
}
|
||
}
|
||
|
||
// MARK: - Kleinkram
|
||
|
||
/// Der Name, unter dem die Marke am Rechner auftaucht. Soll erkennbar
|
||
/// machen, welches Gerät man widerruft.
|
||
static var geraetename: String {
|
||
#if canImport(UIKit)
|
||
let roh = UIDevice.current.name
|
||
return roh.isEmpty ? "iPhone" : roh
|
||
#else
|
||
return "iOS-Gerät"
|
||
#endif
|
||
}
|
||
}
|