#8 add app lock with passcode and biometric unlock
- Gate every layer behind AppLockView when the lock is enabled and lock on launch and on backgrounding; the passcode lives only in the Keychain (AppLockVault) and the enable state defaults to off. - Add the lock section to Settings: enable/setup, change passcode and disable all require the current passcode via a shared passcode sheet; Face ID / Touch ID unlock prompts automatically and can be toggled with passcode confirmation. - Add NSFaceIDUsageDescription to both app targets, cover the passcode policy with tests, guard the feature in verify.sh and document it in SECURITY.md.
This commit is contained in:
1 parent
b0eb44d7a7
commit
0d78e02d2b
13 files changed
+663
-21
No files matched your search
@@ -0,0 +1,314 @@
|
||||
import LocalAuthentication
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct AppLockView: View {
|
||||
@ObservedObject var model: AppModel
|
||||
@State private var passcode = ""
|
||||
@State private var attemptFailed = false
|
||||
@FocusState private var isFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 0.08, green: 0.38, blue: 0.78),
|
||||
Color(red: 0.12, green: 0.62, blue: 0.96),
|
||||
Color(red: 0.44, green: 0.84, blue: 0.96),
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 22) {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.system(size: 44, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text("Luma заблокирован")
|
||||
.font(.title2.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
SecureField("Пароль", text: $passcode)
|
||||
.focused($isFocused)
|
||||
.textContentType(.password)
|
||||
#if os(iOS)
|
||||
.textInputAutocapitalization(.never)
|
||||
#endif
|
||||
.autocorrectionDisabled()
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 260)
|
||||
.onSubmit(submit)
|
||||
|
||||
if attemptFailed {
|
||||
Text("Неверный пароль")
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.white.opacity(0.95))
|
||||
}
|
||||
|
||||
Button(action: submit) {
|
||||
Text("Разблокировать")
|
||||
.fontWeight(.semibold)
|
||||
.frame(maxWidth: 260)
|
||||
.frame(height: 34)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(passcode.isEmpty)
|
||||
|
||||
if biometricAvailable, model.appLockBiometricIsEnabled {
|
||||
Button {
|
||||
Task { await biometricUnlock() }
|
||||
} label: {
|
||||
Label("Войти по \(biometricName)", systemImage: biometricIcon)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(28)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous))
|
||||
.padding(24)
|
||||
}
|
||||
.onAppear {
|
||||
isFocused = true
|
||||
}
|
||||
.task {
|
||||
// Auto-prompt biometrics once when the lock screen appears.
|
||||
guard biometricAvailable, model.appLockBiometricIsEnabled else { return }
|
||||
await biometricUnlock()
|
||||
}
|
||||
}
|
||||
|
||||
private var biometricContext: LAContext {
|
||||
LAContext()
|
||||
}
|
||||
|
||||
private var biometricAvailable: Bool {
|
||||
var error: NSError?
|
||||
return biometricContext.canEvaluatePolicy(
|
||||
.deviceOwnerAuthenticationWithBiometrics,
|
||||
error: &error
|
||||
)
|
||||
}
|
||||
|
||||
private var biometryType: LABiometryType {
|
||||
biometricContext.biometryType
|
||||
}
|
||||
|
||||
private var biometricIcon: String {
|
||||
biometryType == .faceID ? "faceid" : "touchid"
|
||||
}
|
||||
|
||||
private var biometricName: String {
|
||||
biometryType == .faceID ? "Face ID" : "Touch ID"
|
||||
}
|
||||
|
||||
private func submit() {
|
||||
guard !passcode.isEmpty else { return }
|
||||
if model.unlockWithPasscode(passcode) {
|
||||
attemptFailed = false
|
||||
passcode = ""
|
||||
} else {
|
||||
attemptFailed = true
|
||||
passcode = ""
|
||||
}
|
||||
}
|
||||
|
||||
private func biometricUnlock() async {
|
||||
_ = await model.unlockWithBiometrics()
|
||||
}
|
||||
}
|
||||
|
||||
/// Configurable passcode sheet: setting up a new passcode, changing it, or
|
||||
/// verifying the current one to disable the lock / toggle biometrics.
|
||||
@MainActor
|
||||
struct AppLockPasscodeSheet: View {
|
||||
enum Mode: Identifiable {
|
||||
case setup
|
||||
case change
|
||||
case verify(VerificationAction)
|
||||
|
||||
enum VerificationAction: String {
|
||||
case disableLock
|
||||
case enableBiometrics
|
||||
case disableBiometrics
|
||||
}
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .setup: return "setup"
|
||||
case .change: return "change"
|
||||
case .verify(let action): return "verify-\(action.rawValue)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let model: AppModel
|
||||
let mode: Mode
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var oldPasscode = ""
|
||||
@State private var passcode = ""
|
||||
@State private var repeatedPasscode = ""
|
||||
@State private var errorText: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
if needsOldPasscode {
|
||||
Section("Текущий пароль") {
|
||||
SecureField("Текущий пароль", text: $oldPasscode)
|
||||
.textContentType(.password)
|
||||
}
|
||||
}
|
||||
Section(title) {
|
||||
SecureField(fieldLabel, text: $passcode)
|
||||
.textContentType(.password)
|
||||
if needsRepeatedPasscode {
|
||||
SecureField("Повторите пароль", text: $repeatedPasscode)
|
||||
.textContentType(.password)
|
||||
}
|
||||
}
|
||||
if let errorText {
|
||||
Section {
|
||||
Text(errorText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(navigationTitle)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Отмена") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Готово", action: submit)
|
||||
.disabled(!canSubmit)
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 420, minHeight: 320)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var needsOldPasscode: Bool {
|
||||
switch mode {
|
||||
case .change, .verify:
|
||||
return true
|
||||
case .setup:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private var needsRepeatedPasscode: Bool {
|
||||
switch mode {
|
||||
case .setup, .change:
|
||||
return true
|
||||
case .verify:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private var title: String {
|
||||
switch mode {
|
||||
case .setup: return "Новый пароль"
|
||||
case .change: return "Новый пароль"
|
||||
case .verify: return "Пароль"
|
||||
}
|
||||
}
|
||||
|
||||
private var navigationTitle: String {
|
||||
switch mode {
|
||||
case .setup: return "Включить блокировку"
|
||||
case .change: return "Сменить пароль"
|
||||
case .verify(let action):
|
||||
switch action {
|
||||
case .disableLock: return "Выключить блокировку"
|
||||
case .enableBiometrics, .disableBiometrics: return "Подтвердите пароль"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var fieldLabel: String {
|
||||
switch mode {
|
||||
case .setup, .change: return "Пароль (минимум \(AppLockPolicy.minimumLength) символа)"
|
||||
case .verify: return "Пароль"
|
||||
}
|
||||
}
|
||||
|
||||
private var canSubmit: Bool {
|
||||
switch mode {
|
||||
case .setup:
|
||||
return !passcode.isEmpty && !repeatedPasscode.isEmpty
|
||||
case .change:
|
||||
return !oldPasscode.isEmpty && !passcode.isEmpty && !repeatedPasscode.isEmpty
|
||||
case .verify:
|
||||
return !oldPasscode.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
private func submit() {
|
||||
errorText = nil
|
||||
switch mode {
|
||||
case .setup:
|
||||
guard AppLockPolicy.isValid(passcode) else {
|
||||
errorText = "Пароль слишком короткий: минимум \(AppLockPolicy.minimumLength) символа."
|
||||
return
|
||||
}
|
||||
guard passcode == repeatedPasscode else {
|
||||
errorText = "Пароли не совпадают."
|
||||
repeatedPasscode = ""
|
||||
return
|
||||
}
|
||||
if model.enableAppLock(passcode: passcode) {
|
||||
dismiss()
|
||||
} else {
|
||||
errorText = model.errorMessage ?? "Не удалось включить блокировку."
|
||||
}
|
||||
case .change:
|
||||
guard AppLockPolicy.isValid(passcode) else {
|
||||
errorText = "Пароль слишком короткий: минимум \(AppLockPolicy.minimumLength) символа."
|
||||
return
|
||||
}
|
||||
guard passcode == repeatedPasscode else {
|
||||
errorText = "Пароли не совпадают."
|
||||
repeatedPasscode = ""
|
||||
return
|
||||
}
|
||||
if model.changeAppLockPasscode(from: oldPasscode, to: passcode) {
|
||||
dismiss()
|
||||
} else {
|
||||
errorText = "Неверный текущий пароль."
|
||||
oldPasscode = ""
|
||||
}
|
||||
case .verify(let action):
|
||||
switch action {
|
||||
case .disableLock:
|
||||
if model.disableAppLock(passcode: oldPasscode) {
|
||||
dismiss()
|
||||
} else {
|
||||
errorText = "Неверный пароль."
|
||||
oldPasscode = ""
|
||||
}
|
||||
case .enableBiometrics:
|
||||
if model.setAppLockBiometricUnlock(true, passcode: oldPasscode) {
|
||||
dismiss()
|
||||
} else {
|
||||
errorText = "Неверный пароль."
|
||||
oldPasscode = ""
|
||||
}
|
||||
case .disableBiometrics:
|
||||
if model.setAppLockBiometricUnlock(false, passcode: oldPasscode) {
|
||||
dismiss()
|
||||
} else {
|
||||
errorText = "Неверный пароль."
|
||||
oldPasscode = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user