feat: Bildschirmsperre mit Face ID und PIN
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>
This commit is contained in:
@@ -43,6 +43,22 @@ final class AppZustand {
|
||||
/// 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
|
||||
@@ -54,6 +70,9 @@ final class AppZustand {
|
||||
}
|
||||
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
|
||||
@@ -237,6 +256,9 @@ final class AppZustand {
|
||||
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
|
||||
@@ -246,6 +268,48 @@ final class AppZustand {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 }
|
||||
|
||||
@@ -3,6 +3,7 @@ import SwiftUI
|
||||
@main
|
||||
struct CertainApp: App {
|
||||
@State private var zustand = AppZustand()
|
||||
@Environment(\.scenePhase) private var phase
|
||||
|
||||
var body: some Scene {
|
||||
// Das Erscheinungsbild kommt aus den Server-Einstellungen, damit App
|
||||
@@ -22,6 +23,16 @@ struct CertainApp: App {
|
||||
// Einmal hier, damit jeder Knopf, Schalter und Link
|
||||
// die Markenfarbe traegt statt des System-Blaus.
|
||||
.tint(.marke)
|
||||
.onChange(of: phase) { _, neue in
|
||||
// `inactive` kommt, bevor iOS das Bild für den
|
||||
// App-Umschalter macht. Dort soll der Sperrbildschirm
|
||||
// stehen, nicht der Kontostand.
|
||||
switch neue {
|
||||
case .inactive, .background: zustand.gehtWeg()
|
||||
case .active: zustand.kommtZurueck()
|
||||
@unknown default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,15 @@ struct RootAnsicht: View {
|
||||
ZweitenFaktorEinrichtenHinweis()
|
||||
|
||||
case .bereit:
|
||||
Hauptnavigation()
|
||||
// Reihenfolge mit Absicht: Ohne PIN kommt niemand an die Zahlen,
|
||||
// und der Sperrbildschirm liegt vor allem anderen.
|
||||
if !zustand.hatPIN {
|
||||
PINEinrichtenAnsicht()
|
||||
} else if zustand.gesperrt {
|
||||
SperrAnsicht()
|
||||
} else {
|
||||
Hauptnavigation()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user