From fde3e864c02898b08c2f0a8a70c2ffb748c4232c Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Fri, 14 Aug 2026 13:44:26 +0200 Subject: [PATCH] Zwei-Faktor-Anmeldung bei Calendarr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deine Zugangsdaten stimmten. Der Fehler lag im gewählten Endpunkt: auth_router.py:81 if user.totp_enabled: raise HTTPException(401, detail="2fa_required") /api/auth/token nimmt nur ein Formular mit Benutzername und Passwort entgegen und weist Konten mit zweitem Faktor grundsätzlich ab — mit demselben 401 wie ein falsches Passwort. Deshalb stand da „Benutzername oder Passwort stimmen nicht", obwohl beides stimmte. Angemeldet wird jetzt über /api/auth/login: der Weg nimmt den Code entgegen und kennt remember_me. Das ist hier keine Bequemlichkeit, sondern die Bedingung — mit zweitem Faktor kann Onyx sich nicht selbst neu anmelden, den Code hat nur der Mensch. Ein gewöhnliches Token hielte sieben Tage, remember_me hält hundertachtzig. Damit wandert auch das Token in den Schlüsselbund. Bei einem Konto mit zweitem Faktor ist es das einzige, womit Onyx noch hineinkommt; ginge es beim Beenden verloren, müsste man sich bei jedem Start neu anmelden. Die drei Fälle sind jetzt auseinandergehalten und einzeln beschriftet: falsches Passwort, fehlender Code, falscher Code. Der Unterschied ist der zwischen „Passwort neu tippen" und „App aufmachen" — und er steckt beim Server nur im Begleittext eines 401. Co-Authored-By: Claude Opus 5 --- Onyx/Localizable.xcstrings | 20 ++- Onyx/WidgetOptionsView.swift | 19 ++- .../CalendarProvider/CalendarrLive.swift | 128 ++++++++++++++---- .../CalendarProvider/Localizable.xcstrings | 32 +++++ .../CalendarrLiveTests.swift | 21 +++ 5 files changed, 185 insertions(+), 35 deletions(-) diff --git a/Onyx/Localizable.xcstrings b/Onyx/Localizable.xcstrings index 4c607d7..a63bb7c 100644 --- a/Onyx/Localizable.xcstrings +++ b/Onyx/Localizable.xcstrings @@ -1689,13 +1689,13 @@ "de": { "stringUnit": { "state": "translated", - "value": "Ohne Verbindung liest Onyx den Schnappschuss, den die Calendarr-App schreibt — der ist nur so aktuell wie ihr letzter Start." + "value": "Die Anmeldung gilt ein halbes Jahr. Mit Zwei-Faktor-Code kann Onyx sie nicht selbst erneuern — dann meldest du dich danach einmal neu an." } }, "en": { "stringUnit": { "state": "translated", - "value": "Without a connection Onyx reads the snapshot the Calendarr app writes — only as current as its last launch." + "value": "The sign-in lasts half a year. With a two-factor code Onyx can’t renew it on its own — you’ll sign in again once after that." } } } @@ -1716,6 +1716,22 @@ } } }, + "options.calendar.server.totp": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zwei-Faktor-Code (falls eingerichtet)" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Two-factor code (if enabled)" + } + } + } + }, "options.calendar.server.username": { "localizations": { "de": { diff --git a/Onyx/WidgetOptionsView.swift b/Onyx/WidgetOptionsView.swift index c8d7b82..653eb59 100644 --- a/Onyx/WidgetOptionsView.swift +++ b/Onyx/WidgetOptionsView.swift @@ -289,6 +289,7 @@ private struct CalendarrServerSettings: View { @State private var server = "" @State private var username = "" @State private var password = "" + @State private var totpCode = "" @State private var isChecking = false @State private var failure: String? @@ -324,6 +325,11 @@ private struct CalendarrServerSettings: View { .textContentType(.username) SecureField(text: $password) { Text("options.calendar.server.password") } .textContentType(.password) + // Nur ausfüllen, wer einen zweiten Faktor hat. Ein Pflichtfeld + // wäre es für alle anderen eine Frage, die sie nicht beantworten + // können. + TextField(text: $totpCode) { Text("options.calendar.server.totp") } + .textContentType(.oneTimeCode) HStack { if let failure { @@ -357,12 +363,15 @@ private struct CalendarrServerSettings: View { Task { defer { isChecking = false } switch await CalendarrLiveSource.check(server: url, username: username, - password: password) { - case .success: - credentials.save(server: url, username: username, password: password) - // Das Passwort nicht im Formular stehen lassen — es liegt jetzt - // im Schlüsselbund, und ein zweiter Ort ist einer zu viel. + password: password, + totpCode: totpCode) { + case .success(let token): + credentials.save(server: url, username: username, + password: password, token: token) + // Passwort und Code nicht im Formular stehen lassen — beide + // liegen jetzt dort, wo sie hingehören. password = "" + totpCode = "" model.calendarrAccountChanged() case .failure(let error): failure = error.localizedDescription diff --git a/Packages/OnyxKit/Sources/CalendarProvider/CalendarrLive.swift b/Packages/OnyxKit/Sources/CalendarProvider/CalendarrLive.swift index 6091a8c..e90117a 100644 --- a/Packages/OnyxKit/Sources/CalendarProvider/CalendarrLive.swift +++ b/Packages/OnyxKit/Sources/CalendarProvider/CalendarrLive.swift @@ -60,9 +60,9 @@ public final class CalendarrLiveSource: CalendarSource { } public func load(_ interval: DateInterval) async -> CalendarSourceState { - guard let account = store.account, let password = store.password() else { - return .neverWritten - } + guard let account = store.account else { return .neverWritten } + let password = store.password() + await tokens.adopt(store.token()) do { let token = try await tokens.token(for: account, password: password, @@ -110,11 +110,13 @@ public final class CalendarrLiveSource: CalendarSource { /// Einstellungen. Ein Feld, das man ausfüllt und bei dem man erst am /// nächsten Tag merkt, dass ein Tippfehler drin war, ist keins. public static func check(server: URL, username: String, password: String, - session: URLSession = .shared) async -> Result { + totpCode: String?, + session: URLSession = .shared) async -> Result { do { - _ = try await TokenCache.fetchToken(server: server, username: username, - password: password, session: session) - return .success(()) + let token = try await TokenCache.fetchToken(server: server, username: username, + password: password, totpCode: totpCode, + session: session) + return .success(token) } catch { return .failure(error) } @@ -133,36 +135,66 @@ private actor TokenCache { func discard() { token = nil; expiry = nil } - func token(for account: CalendarrAccount, password: String, + func adopt(_ stored: String?) { + guard token == nil, let stored else { return } + token = stored + expiry = CalendarrLiveDecoder.expiry(ofToken: stored) + } + + func token(for account: CalendarrAccount, password: String?, session: URLSession) async throws -> String { // Fünf Minuten Sicherheitsabstand: ein Token, das während des Abrufs // abläuft, kostet einen zweiten Durchgang. if let token, let expiry, expiry.timeIntervalSinceNow > 300 { return token } + // Ohne Passwort — oder mit zweitem Faktor — kann sich niemand von + // selbst neu anmelden. Dann muss der Mensch ran, und das sagt der + // Fehler auch. + guard let password, !password.isEmpty else { throw CalendarrLiveError.twoFactorRequired } + let fresh = try await Self.fetchToken(server: account.server, username: account.username, - password: password, session: session) + password: password, totpCode: nil, + session: session) token = fresh expiry = CalendarrLiveDecoder.expiry(ofToken: fresh) ?? Date().addingTimeInterval(6 * 24 * 3600) return fresh } + /// Meldet an und gibt ein Langzeit-Token zurück. + /// + /// Über `/api/auth/login` statt `/api/auth/token`: das Formular-Endstück + /// kennt keinen zweiten Faktor und antwortet Konten mit aktivem 2FA + /// **immer** mit „falsches Passwort" — auch wenn es stimmt. Der JSON-Weg + /// nimmt den Code entgegen und kann obendrein `remember_me`. + /// + /// Und das ist hier keine Bequemlichkeit, sondern die Bedingung: mit 2FA + /// kann Onyx sich nicht selbst neu anmelden, wenn ein Token abläuft — den + /// Code hat nur der Mensch. Ein gewöhnliches Token hielte sieben Tage, + /// `remember_me` hält hundertachtzig. static func fetchToken(server: URL, username: String, password: String, - session: URLSession) async throws -> String { - var request = URLRequest(url: server.appendingPathComponent("api/auth/token")) + totpCode: String?, session: URLSession) async throws -> String { + var request = URLRequest(url: server.appendingPathComponent("api/auth/login")) request.httpMethod = "POST" - request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - request.httpBody = CalendarrLiveDecoder.formBody(username: username, password: password) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + var body: [String: Any] = ["username": username, "password": password, + "remember_me": true] + if let totpCode, !totpCode.isEmpty { body["totp_code"] = totpCode } + request.httpBody = try JSONSerialization.data(withJSONObject: body) request.timeoutInterval = 15 let (data, response) = try await session.data(for: request) guard let http = response as? HTTPURLResponse else { throw CalendarrLiveError.badResponse } - if http.statusCode == 401 { throw CalendarrLiveError.unauthorized } - guard http.statusCode == 200 else { throw CalendarrLiveError.status(http.statusCode) } + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] - guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let token = object["access_token"] as? String, !token.isEmpty + if let failure = CalendarrLiveDecoder.loginFailure(status: http.statusCode, + detail: object?["detail"] as? String) { + throw failure + } + + guard let token = object?["access_token"] as? String, !token.isEmpty else { throw CalendarrLiveError.badResponse } return token } @@ -170,6 +202,9 @@ private actor TokenCache { public enum CalendarrLiveError: LocalizedError, Equatable { case unauthorized + /// Das Konto verlangt einen zweiten Faktor — ohne Code geht hier nichts. + case twoFactorRequired + case twoFactorWrong case badResponse case status(Int) case badServer @@ -177,6 +212,8 @@ public enum CalendarrLiveError: LocalizedError, Equatable { public var errorDescription: String? { switch self { case .unauthorized: String(localized: "calendarr.live.error.credentials", bundle: .module) + case .twoFactorRequired: String(localized: "calendarr.live.error.twoFactor", bundle: .module) + case .twoFactorWrong: String(localized: "calendarr.live.error.twoFactorWrong", bundle: .module) case .badResponse: String(localized: "calendarr.live.error.response", bundle: .module) case .status(let code): String(localized: "calendarr.live.error.status \(code)", bundle: .module) case .badServer: String(localized: "calendarr.live.error.server", bundle: .module) @@ -284,6 +321,24 @@ public enum CalendarrLiveDecoder { return nil } + /// Was eine abgelehnte Anmeldung bedeutet. + /// + /// Der Server unterscheidet die Fälle nur im Begleittext, und der + /// Unterschied ist der zwischen „Passwort neu tippen" und „App aufmachen". + /// Ohne diese Unterscheidung stand bei einem Konto mit zweitem Faktor + /// „Benutzername oder Passwort stimmen nicht" — obwohl beides stimmte. + public static func loginFailure(status: Int, detail: String?) -> CalendarrLiveError? { + switch status { + case 200: return nil + case 401: + let detail = detail ?? "" + if detail == "2fa_required" { return .twoFactorRequired } + if detail.localizedCaseInsensitiveContains("2fa") { return .twoFactorWrong } + return .unauthorized + default: return .status(status) + } + } + /// Wann das Token abläuft — steht in ihm selbst. /// /// Ein JWT trägt seine Gültigkeit im Mittelteil, base64-kodiert. Die @@ -348,23 +403,35 @@ public struct CalendarrCredentials: @unchecked Sendable { return CalendarrAccount(server: server, username: username) } - public func save(server: URL, username: String, password: String) { + /// Das Token gehört mit in den Schlüsselbund, nicht nur ins Gedächtnis des + /// laufenden Programms. + /// + /// Bei einem Konto mit zweitem Faktor ist es das einzige, womit Onyx + /// überhaupt noch hineinkommt: neu anmelden ginge nur mit einem Code, und + /// den hat nur der Mensch. Ginge es beim Beenden verloren, müsste man sich + /// bei jedem Start neu anmelden. + public func save(server: URL, username: String, password: String, token: String?) { defaults.set(server.absoluteString, forKey: Keys.server) defaults.set(username, forKey: Keys.username) - Self.setPassword(password, for: username) + Self.setSecret(password, for: username, kind: .password) + Self.setSecret(token, for: username, kind: .token) } public func clear() { if let username = defaults.string(forKey: Keys.username) { - Self.setPassword(nil, for: username) + Self.setSecret(nil, for: username, kind: .password) + Self.setSecret(nil, for: username, kind: .token) } defaults.removeObject(forKey: Keys.server) defaults.removeObject(forKey: Keys.username) } - public func password() -> String? { + public func password() -> String? { secret(.password) } + public func token() -> String? { secret(.token) } + + private func secret(_ kind: Secret) -> String? { guard let username = defaults.string(forKey: Keys.username) else { return nil } - var query = Self.query(for: username) + var query = Self.query(for: username, kind: kind) query[kSecReturnData as String] = true query[kSecMatchLimit as String] = kSecMatchLimitOne @@ -375,19 +442,24 @@ public struct CalendarrCredentials: @unchecked Sendable { return String(data: data, encoding: .utf8) } - private static func query(for username: String) -> [String: Any] { + private enum Secret: String { + case password + case token + } + + private static func query(for username: String, kind: Secret) -> [String: Any] { [kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, + kSecAttrService as String: service + "." + kind.rawValue, kSecAttrAccount as String: username] } - private static func setPassword(_ password: String?, for username: String) { - let query = query(for: username) + private static func setSecret(_ value: String?, for username: String, kind: Secret) { + let query = query(for: username, kind: kind) SecItemDelete(query as CFDictionary) - guard let password, !password.isEmpty else { return } + guard let value, !value.isEmpty else { return } var item = query - item[kSecValueData as String] = Data(password.utf8) + item[kSecValueData as String] = Data(value.utf8) // Nur auf diesem Gerät und nur nach dem ersten Entsperren: ein // Kalenderwidget braucht kein Passwort, solange niemand angemeldet ist. item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly diff --git a/Packages/OnyxKit/Sources/CalendarProvider/Localizable.xcstrings b/Packages/OnyxKit/Sources/CalendarProvider/Localizable.xcstrings index 1d0ca24..8d32cb3 100644 --- a/Packages/OnyxKit/Sources/CalendarProvider/Localizable.xcstrings +++ b/Packages/OnyxKit/Sources/CalendarProvider/Localizable.xcstrings @@ -317,6 +317,38 @@ } } }, + "calendarr.live.error.twoFactor": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Dieses Konto verlangt einen Zwei-Faktor-Code." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "This account requires a two-factor code." + } + } + } + }, + "calendarr.live.error.twoFactorWrong": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Zwei-Faktor-Code stimmt nicht — er gilt nur eine halbe Minute." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Wrong two-factor code — it’s only valid for half a minute." + } + } + } + }, "widget.calendar.allDay": { "localizations": { "de": { diff --git a/Packages/OnyxKit/Tests/CalendarProviderTests/CalendarrLiveTests.swift b/Packages/OnyxKit/Tests/CalendarProviderTests/CalendarrLiveTests.swift index 157a9da..404179e 100644 --- a/Packages/OnyxKit/Tests/CalendarProviderTests/CalendarrLiveTests.swift +++ b/Packages/OnyxKit/Tests/CalendarProviderTests/CalendarrLiveTests.swift @@ -70,6 +70,27 @@ struct CalendarrLiveTests { #expect(CalendarrLiveDecoder.expiry(ofToken: "a.b.c") == nil) } + @Test("Ein Konto mit zweitem Faktor wird als solches erkannt") + func twoFactorIsToldApart() { + // Der Fehler, der die erste Fassung unbrauchbar machte: der Server + // antwortet auf beides mit 401. Stand da „Passwort falsch", obwohl es + // stimmte, sucht man an der falschen Stelle. + #expect(CalendarrLiveDecoder.loginFailure(status: 401, detail: "2fa_required") + == .twoFactorRequired) + #expect(CalendarrLiveDecoder.loginFailure(status: 401, detail: "Ungültiger 2FA-Code") + == .twoFactorWrong) + #expect(CalendarrLiveDecoder.loginFailure(status: 401, + detail: "Benutzername oder Passwort falsch") + == .unauthorized) + #expect(CalendarrLiveDecoder.loginFailure(status: 401, detail: nil) == .unauthorized) + } + + @Test("Andere Antworten bleiben, was sie sind") + func otherStatusesPassThrough() { + #expect(CalendarrLiveDecoder.loginFailure(status: 200, detail: nil) == nil) + #expect(CalendarrLiveDecoder.loginFailure(status: 502, detail: nil) == .status(502)) + } + // MARK: - Termine private func json(_ object: Any) throws -> Data {