diff --git a/CHANGELOG.md b/CHANGELOG.md index 493cb81..2648756 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,29 @@ Alle Änderungen werden hier dokumentiert. Format orientiert an ## [Unreleased] +## [0.4.0] — 2026-05-14 + +Minor — 2FA-Enrollment-UI (Mini-Sprint B). Setzt mana-swift-core +≥ 1.4.0 voraus. + +### Neu + +- `ManaTwoFactorEnrollView` + `TwoFactorEnrollmentViewModel` — + 3-Phasen-Wizard: + 1. Passwort eingeben (Re-Auth) + 2. QR-Code (via `CoreImage.CIFilter.qrCodeGenerator`, plattform- + unabhängig auf iOS+macOS) scannen + 6-stelligen Test-Code + eingeben + 3. Backup-Codes anzeigen + Copy-to-Clipboard +- `ManaTwoFactorDisableView` — Single-Step-Sheet, Re-Auth via + Passwort + destruktiver Bestätigungs-Button. + +### Tests + +- 5 neue Tests für Enroll-VM (Success, falsches PW, canSubmitVerify + 6-Ziffern-Guard, confirmVerify Phase-Wechsel, backupCodes-Accessor). +- 44/44 grün. + ## [0.3.0] — 2026-05-14 Minor — `ManaTwoFactorChallengeView` für 2FA-Login. Setzt diff --git a/Sources/ManaAuthUI/TwoFactor/ManaTwoFactorEnrollView.swift b/Sources/ManaAuthUI/TwoFactor/ManaTwoFactorEnrollView.swift new file mode 100644 index 0000000..789ba62 --- /dev/null +++ b/Sources/ManaAuthUI/TwoFactor/ManaTwoFactorEnrollView.swift @@ -0,0 +1,335 @@ +import CoreImage.CIFilterBuiltins +import ManaCore +import SwiftUI + +#if canImport(UIKit) + import UIKit +#elseif canImport(AppKit) + import AppKit +#endif + +/// Account-Sheet: TOTP-2FA aktivieren. 3-Phasen-Wizard. +/// +/// 1. Passwort eingeben (Re-Auth) +/// 2. QR-Code mit Authenticator-App scannen + Test-Code eingeben +/// 3. Backup-Codes anzeigen und vom User bestätigen lassen +public struct ManaTwoFactorEnrollView: View { + @Environment(\.manaBrand) private var brand + @State private var model: TwoFactorEnrollmentViewModel + private let onDone: () -> Void + + public init(auth: AuthClient, onDone: @escaping () -> Void) { + _model = State(initialValue: TwoFactorEnrollmentViewModel(auth: auth)) + self.onDone = onDone + } + + public var body: some View { + ManaAuthScaffold(showsHeader: false) { + switch model.phase { + case .password: + passwordPhase + case let .verify(uri, _): + verifyPhase(uri: uri) + case let .backupCodes(codes): + backupCodesPhase(codes: codes) + } + } + } + + // MARK: - Phase 1: Password + + @ViewBuilder + private var passwordPhase: some View { + VStack(spacing: 16) { + Image(systemName: "lock.shield") + .font(.system(size: 56, weight: .light)) + .foregroundStyle(brand.primary) + + Text("Zwei-Faktor aktivieren") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(brand.foreground) + + Text("Schütze deinen Account mit einem zusätzlichen Code. Bestätige dazu erst dein Passwort.") + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .multilineTextAlignment(.center) + + ManaSecureField( + "Passwort", + text: $model.password, + textContentType: .password + ) + + ManaPrimaryButton( + "Weiter", + isLoading: model.isWorking, + isEnabled: model.canSubmitPassword + ) { + Task { await model.enrollWithPassword() } + } + + if case let .error(message) = model.status { + Text(message) + .font(.footnote) + .foregroundStyle(brand.error) + .multilineTextAlignment(.center) + } + + Button("Abbrechen", action: onDone) + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .padding(.top, 12) + } + } + + // MARK: - Phase 2: QR + Verify + + @ViewBuilder + private func verifyPhase(uri: String) -> some View { + VStack(spacing: 16) { + Text("Code scannen") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(brand.foreground) + + Text("Öffne deine Authenticator-App (z.B. 1Password, Aegis, Google Authenticator) und scanne diesen QR-Code.") + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .multilineTextAlignment(.center) + + qrCode(for: uri) + .frame(width: 220, height: 220) + .padding(8) + .background(Color.white, in: RoundedRectangle(cornerRadius: 12)) + + Text("Gib zur Bestätigung den 6-stelligen Code aus der App ein:") + .font(.subheadline) + .foregroundStyle(brand.foreground) + .multilineTextAlignment(.center) + .padding(.top, 8) + + ManaTextField("123 456", text: $model.verifyCode) + .autocorrectionDisabled() + .font(.system(.title3, design: .monospaced)) + #if os(iOS) + .keyboardType(.numberPad) + .textInputAutocapitalization(.never) + #endif + + ManaPrimaryButton( + "Weiter zu Backup-Codes", + isEnabled: model.canSubmitVerify + ) { + model.confirmVerify() + } + + Button("Abbrechen", action: onDone) + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .padding(.top, 12) + } + } + + // MARK: - Phase 3: Backup-Codes + + @ViewBuilder + private func backupCodesPhase(codes: [String]) -> some View { + VStack(spacing: 16) { + Image(systemName: "checkmark.shield.fill") + .font(.system(size: 56, weight: .light)) + .foregroundStyle(brand.success) + + Text("Zwei-Faktor aktiv") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(brand.foreground) + + Text("Sichere diese Backup-Codes JETZT. Du brauchst sie wenn du dein Authenticator-Gerät verlierst. Jeder Code lässt sich nur einmal verwenden.") + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .multilineTextAlignment(.center) + + VStack(spacing: 6) { + ForEach(codes, id: \.self) { code in + Text(code) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(brand.foreground) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(brand.surface, in: RoundedRectangle(cornerRadius: 6)) + } + } + .padding(.vertical, 8) + + Button(action: { copyToClipboard(codes.joined(separator: "\n")) }) { + Label("Alle Codes kopieren", systemImage: "doc.on.doc") + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(brand.surface, in: RoundedRectangle(cornerRadius: 8)) + .foregroundStyle(brand.primary) + } + .buttonStyle(.plain) + + ManaPrimaryButton("Fertig — Codes sind gesichert") { + onDone() + } + .padding(.top, 12) + } + } + + // MARK: - QR-Code + + /// Rendert eine `otpauth://`-URI als QR-Code via `CoreImage`. Auf + /// iOS/macOS sind `CIFilter.qrCodeGenerator()` system-bordmittel. + @ViewBuilder + private func qrCode(for content: String) -> some View { + if let cgImage = makeQRCode(from: content) { + #if canImport(UIKit) + Image(uiImage: UIImage(cgImage: cgImage)) + .interpolation(.none) + .resizable() + .scaledToFit() + #elseif canImport(AppKit) + Image(nsImage: NSImage(cgImage: cgImage, size: NSSize(width: 220, height: 220))) + .interpolation(.none) + .resizable() + .scaledToFit() + #else + Text(content) + .font(.system(.caption, design: .monospaced)) + #endif + } else { + Text("QR-Code konnte nicht generiert werden — bitte URI manuell kopieren:\n\(content)") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(brand.mutedForeground) + } + } + + private func makeQRCode(from string: String) -> CGImage? { + let context = CIContext() + let filter = CIFilter.qrCodeGenerator() + filter.message = Data(string.utf8) + filter.correctionLevel = "M" + guard let output = filter.outputImage else { return nil } + // Upscale damit der QR-Code scharf bleibt (kein anti-aliasing). + let transform = CGAffineTransform(scaleX: 10, y: 10) + let scaled = output.transformed(by: transform) + return context.createCGImage(scaled, from: scaled.extent) + } + + private func copyToClipboard(_ text: String) { + #if canImport(UIKit) + UIPasteboard.general.string = text + #elseif canImport(AppKit) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + #endif + } +} + +/// Account-Sheet: TOTP-2FA wieder deaktivieren. Einfacher Single-Step +/// mit Passwort-Re-Auth. +public struct ManaTwoFactorDisableView: View { + @Environment(\.manaBrand) private var brand + @State private var password: String = "" + @State private var status: DisableStatus = .idle + private let auth: AuthClient + private let onDone: () -> Void + + public init(auth: AuthClient, onDone: @escaping () -> Void) { + self.auth = auth + self.onDone = onDone + } + + private enum DisableStatus: Equatable { + case idle + case working + case done + case error(String) + } + + public var body: some View { + ManaAuthScaffold(showsHeader: false) { + switch status { + case .done: + doneView + default: + formView + } + } + } + + @ViewBuilder + private var formView: some View { + VStack(spacing: 16) { + Image(systemName: "lock.shield") + .font(.system(size: 56, weight: .light)) + .foregroundStyle(brand.mutedForeground) + + Text("Zwei-Faktor deaktivieren") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(brand.foreground) + + Text("Dein Account wird wieder nur mit Email + Passwort geschützt. Backup-Codes verlieren ihre Gültigkeit.") + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .multilineTextAlignment(.center) + + ManaSecureField("Passwort", text: $password, textContentType: .password) + + ManaPrimaryButton( + "2FA deaktivieren", + role: .destructive, + isLoading: status == .working, + isEnabled: !password.isEmpty && status != .working + ) { + Task { await submit() } + } + + if case let .error(message) = status { + Text(message) + .font(.footnote) + .foregroundStyle(brand.error) + .multilineTextAlignment(.center) + } + + Button("Abbrechen", action: onDone) + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .padding(.top, 12) + } + } + + @ViewBuilder + private var doneView: some View { + VStack(spacing: 16) { + Image(systemName: "lock.open") + .font(.system(size: 56, weight: .light)) + .foregroundStyle(brand.mutedForeground) + + Text("Zwei-Faktor deaktiviert") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(brand.foreground) + + ManaPrimaryButton("Fertig") { onDone() } + .padding(.top, 16) + } + } + + private func submit() async { + guard !password.isEmpty else { return } + status = .working + do { + try await auth.disableTotp(password: password) + password = "" + status = .done + } catch let error as AuthError { + status = .error(error.errorDescription ?? "Deaktivieren fehlgeschlagen") + } catch { + status = .error(String(describing: error)) + } + } +} diff --git a/Sources/ManaAuthUI/TwoFactor/TwoFactorEnrollmentViewModel.swift b/Sources/ManaAuthUI/TwoFactor/TwoFactorEnrollmentViewModel.swift new file mode 100644 index 0000000..00809e0 --- /dev/null +++ b/Sources/ManaAuthUI/TwoFactor/TwoFactorEnrollmentViewModel.swift @@ -0,0 +1,107 @@ +import Foundation +import ManaCore +import Observation + +/// State-Maschine für ``ManaTwoFactorEnrollView``. 3-Phasen-Wizard: +/// +/// 1. **Re-Auth** — User gibt aktuelles Passwort ein +/// 2. **QR + Verify** — App zeigt QR-Code, User scannt mit Authenticator +/// und gibt zur Bestätigung einen 6-stelligen Code ein +/// 3. **Backup-Codes** — App zeigt die generierten Codes, User sichert +/// sie (Kopieren in die Zwischenablage) +/// +/// Schritte 1+2 sind atomar gegen den Server: `enrollTotp(password:)` +/// liefert URI **und** Backup-Codes in einem Call. Der Verify-Step +/// in der UI ist defensiv — der User muss zeigen können dass er den +/// QR-Code wirklich gescannt hat, bevor wir ihm die Backup-Codes +/// zeigen. Wenn er den Code nicht hat, kann er den Enroll-Vorgang +/// abbrechen und der Server-Side ist die TOTP-Konfiguration trotzdem +/// als aktiv markiert — er muss dann disableTotp(password:) aufrufen. +@MainActor +@Observable +public final class TwoFactorEnrollmentViewModel { + public enum Phase: Equatable, Sendable { + case password + case verify(uri: String, backupCodes: [String]) + case backupCodes([String]) + } + + public enum Status: Equatable, Sendable { + case idle + case working + case error(String) + } + + public var password: String = "" + public var verifyCode: String = "" + public private(set) var phase: Phase = .password + public private(set) var status: Status = .idle + + private let auth: AuthClient + + public init(auth: AuthClient) { + self.auth = auth + } + + // MARK: - Phase 1: Password + + public var canSubmitPassword: Bool { + guard !password.isEmpty else { return false } + if case .working = status { return false } + return true + } + + public var isWorking: Bool { + if case .working = status { return true } + return false + } + + public func enrollWithPassword() async { + guard canSubmitPassword else { return } + + status = .working + do { + let enrollment = try await auth.enrollTotp(password: password) + password = "" + phase = .verify(uri: enrollment.totpURI, backupCodes: enrollment.backupCodes) + status = .idle + } catch let error as AuthError { + status = .error(error.errorDescription ?? "Aktivierung fehlgeschlagen") + } catch { + status = .error(String(describing: error)) + } + } + + // MARK: - Phase 2: Verify + + public var canSubmitVerify: Bool { + let digits = verifyCode.filter { $0.isNumber } + return digits.count == 6 && !isWorking + } + + /// Server-seitig ist die 2FA-Konfiguration nach `enrollTotp` schon + /// aktiv — wir nutzen `verifyTotp` nicht zur Bestätigung des Setups, + /// sondern verlassen uns auf den User dass er den QR-Code richtig + /// gescannt hat. Better-Auth-API hat keinen "verify-setup-Endpoint" + /// (verify-totp ist nur im Login-Challenge-Flow gültig). Der + /// Bestätigungs-Schritt ist also rein UI-defensiv: zeigt einen + /// Code-Input, der erstmal nur lokal die Eingabe sammelt und dann + /// in den Backup-Codes-Schritt umschaltet. + public func confirmVerify() { + if case let .verify(_, codes) = phase { + verifyCode = "" + phase = .backupCodes(codes) + status = .idle + } + } + + // MARK: - Phase 3: Backup-Codes + + /// Die generierten Backup-Codes (8-stellige Strings, üblich 10 + /// Stück). UI zeigt sie zum Kopieren/Sichern. + public var backupCodes: [String] { + if case let .backupCodes(codes) = phase { return codes } + if case let .verify(_, codes) = phase { return codes } + return [] + } +} diff --git a/Tests/ManaAuthUITests/TwoFactorEnrollmentViewModelTests.swift b/Tests/ManaAuthUITests/TwoFactorEnrollmentViewModelTests.swift new file mode 100644 index 0000000..1c22ac6 --- /dev/null +++ b/Tests/ManaAuthUITests/TwoFactorEnrollmentViewModelTests.swift @@ -0,0 +1,130 @@ +import Foundation +import ManaCore +import Testing +@testable import ManaAuthUI + +@Suite("TwoFactorEnrollmentViewModel") +@MainActor +struct TwoFactorEnrollmentViewModelTests { + private func signedInAuth() async -> MockedAuth { + let mocked = makeMockedAuth() + let access = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsImV4cCI6MjAwMDAwMDAwMH0.sig" + mocked.setHandler { _ in + (200, Data(#"{"accessToken":"\#(access)","refreshToken":"session-tok"}"#.utf8)) + } + await mocked.auth.signIn(email: "u@x.de", password: "pw") + return mocked + } + + @Test("enrollWithPassword erfolgreich → phase wechselt auf verify") + func enrollSuccess() async { + let mocked = await signedInAuth() + let model = TwoFactorEnrollmentViewModel(auth: mocked.auth) + model.password = "pw" + + mocked.setHandler { _ in + (200, Data(#""" + {"totpURI":"otpauth://totp/Mana:u@x.de?secret=ABC","backupCodes":["a","b","c"]} + """#.utf8)) + } + + await model.enrollWithPassword() + if case let .verify(uri, codes) = model.phase { + #expect(uri.hasPrefix("otpauth://totp/")) + #expect(codes == ["a", "b", "c"]) + } else { + Issue.record("Expected .verify, got \(model.phase)") + } + #expect(model.password == "") // out of memory + } + + @Test("enrollWithPassword falsches PW → .error, phase bleibt password") + func enrollWrongPassword() async { + let mocked = await signedInAuth() + let model = TwoFactorEnrollmentViewModel(auth: mocked.auth) + model.password = "wrong" + + mocked.setHandler { _ in + (401, Data(#"{"error":"INVALID_CREDENTIALS","status":401}"#.utf8)) + } + + await model.enrollWithPassword() + if case .password = model.phase { + #expect(Bool(true)) + } else { + Issue.record("Expected .password, got \(model.phase)") + } + if case let .error(message) = model.status { + #expect(message == "Email oder Passwort falsch") + } else { + Issue.record("Expected .error, got \(model.status)") + } + } + + @Test("canSubmitVerify fordert 6 Ziffern") + func canSubmitVerify() async { + let mocked = await signedInAuth() + let model = TwoFactorEnrollmentViewModel(auth: mocked.auth) + model.password = "pw" + mocked.setHandler { _ in + (200, Data(#""" + {"totpURI":"otpauth://totp/X","backupCodes":["a"]} + """#.utf8)) + } + await model.enrollWithPassword() + + model.verifyCode = "" + #expect(model.canSubmitVerify == false) + model.verifyCode = "12345" + #expect(model.canSubmitVerify == false) + model.verifyCode = "123456" + #expect(model.canSubmitVerify == true) + model.verifyCode = "abcdef" + #expect(model.canSubmitVerify == false) + } + + @Test("confirmVerify wechselt von verify auf backupCodes") + func confirmVerifySwitchesPhase() async { + let mocked = await signedInAuth() + let model = TwoFactorEnrollmentViewModel(auth: mocked.auth) + model.password = "pw" + mocked.setHandler { _ in + (200, Data(#""" + {"totpURI":"otpauth://totp/X","backupCodes":["a","b","c"]} + """#.utf8)) + } + await model.enrollWithPassword() + model.verifyCode = "123456" + + model.confirmVerify() + if case let .backupCodes(codes) = model.phase { + #expect(codes == ["a", "b", "c"]) + } else { + Issue.record("Expected .backupCodes, got \(model.phase)") + } + #expect(model.verifyCode == "") + } + + @Test("backupCodes computed property returnt Codes aus verify- und backupCodes-Phase") + func backupCodesAccessor() async { + let mocked = await signedInAuth() + let model = TwoFactorEnrollmentViewModel(auth: mocked.auth) + // Phase .password → keine Codes + #expect(model.backupCodes == []) + + model.password = "pw" + mocked.setHandler { _ in + (200, Data(#""" + {"totpURI":"otpauth://totp/X","backupCodes":["c1","c2"]} + """#.utf8)) + } + await model.enrollWithPassword() + // Phase .verify → Codes verfügbar + #expect(model.backupCodes == ["c1", "c2"]) + + model.verifyCode = "123456" + model.confirmVerify() + // Phase .backupCodes → Codes weiter verfügbar + #expect(model.backupCodes == ["c1", "c2"]) + } +}