Files
onyx/Packages/OnyxKit/Sources/CalendarProvider/CalendarrLive.swift
Scarriffle fde3e864c0 Zwei-Faktor-Anmeldung bei Calendarr
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 <noreply@anthropic.com>
2026-08-14 13:44:26 +02:00

482 lines
21 KiB
Swift

import Foundation
import Security
import OSLog
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Calendarr")
/// Liest die Termine **direkt vom Calendarr-Server**.
///
/// Der andere Weg der Schnappschuss im gemeinsamen Ordner hängt daran, dass
/// Calendarr läuft und schreibt. Wer die Mac-App wochenlang nicht öffnet, sieht
/// wochenalte Termine, und Onyx kann nichts dafür und nichts dagegen.
///
/// Hier holt Onyx selbst. Ein einziger Aufruf genügt: `/api/caldav/events` fasst
/// CalDAV-Konten, lokale Kalender, Geburtstage, iCal-Abos und Google bereits
/// zusammen nachgebaut wird nichts.
public final class CalendarrLiveSource: CalendarSource {
public let id = "calendarr.live"
/// Keine Systemberechtigung im Spiel: hier entscheidet allein, ob
/// Zugangsdaten hinterlegt sind.
public var authorization: CalendarAuthorization { .notRequired }
public var isAuthorized: Bool { store.account != nil }
private let store: CalendarrCredentials
private let session: URLSession
private let tokens: TokenCache
public init(store: CalendarrCredentials = CalendarrCredentials(),
session: URLSession = .shared) {
self.store = store
self.session = session
self.tokens = TokenCache()
}
public func requestAccess() async -> Bool { store.account != nil }
/// Die Kalender kommen aus den Terminen selbst.
///
/// Der Server hat keinen Endpunkt, der alle Quellen zusammen aufzählt die
/// gibt es je Art einzeln. Aus den Terminen abgeleitet stimmt die Liste
/// dagegen immer mit dem überein, was tatsächlich zu sehen ist. Der Preis:
/// ein Kalender ohne Termine im Fenster taucht nicht auf. Das ist der
/// bessere von zwei Fehlern man kann nichts ausblenden, was ohnehin
/// nichts zeigt.
public func calendars() async -> [OnyxCalendar] {
guard case .ready(let window) = await load(Self.defaultWindow()) else { return [] }
var seen: [String: OnyxCalendar] = [:]
for event in window.events where seen[event.calendarID] == nil {
seen[event.calendarID] = OnyxCalendar(id: event.calendarID,
title: event.calendarTitle,
colorHex: event.colorHex)
}
return seen.values.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending }
}
private static func defaultWindow() -> DateInterval {
let now = Date()
return DateInterval(start: now.addingTimeInterval(-14 * 86400),
end: now.addingTimeInterval(46 * 86400))
}
public func load(_ interval: DateInterval) async -> CalendarSourceState {
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,
session: session)
let data: Data
do {
data = try await fetchEvents(interval, account: account, token: token)
} catch CalendarrLiveError.unauthorized {
// Abgelaufene Token sind der Normalfall, nicht die Ausnahme:
// sie halten sieben Tage. Einmal neu anmelden und noch einmal
// versuchen scheitert auch das, stimmt etwas anderes nicht.
await tokens.discard()
let fresh = try await tokens.token(for: account, password: password,
session: session)
data = try await fetchEvents(interval, account: account, token: fresh)
}
let events = try CalendarrLiveDecoder.events(from: data)
// Die Abdeckung ist genau das erfragte Fenster: mehr wurde nicht
// gefragt, weniger hat der Server nicht geliefert.
return .ready(EventWindow(events: events, coverage: interval, asOf: Date()))
} catch CalendarrLiveError.unauthorized {
return .loggedOut(asOf: nil)
} catch {
log.error("Calendarr-Server: \(error.localizedDescription, privacy: .public)")
return .unreadable(reason: error.localizedDescription)
}
}
private func fetchEvents(_ interval: DateInterval, account: CalendarrAccount,
token: String) async throws -> Data {
var request = URLRequest(url: try CalendarrLiveDecoder.eventsURL(server: account.server,
interval: interval))
request.setValue("Bearer " + token, forHTTPHeaderField: "Authorization")
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 || http.statusCode == 403 { throw CalendarrLiveError.unauthorized }
guard http.statusCode == 200 else { throw CalendarrLiveError.status(http.statusCode) }
return data
}
/// Prüft die Zugangsdaten, ohne etwas zu speichern für den Knopf in den
/// 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,
totpCode: String?,
session: URLSession = .shared) async -> Result<String, Error> {
do {
let token = try await TokenCache.fetchToken(server: server, username: username,
password: password, totpCode: totpCode,
session: session)
return .success(token)
} catch {
return .failure(error)
}
}
}
// MARK: - Token
/// Hält das Token, solange es gilt.
///
/// Ein Actor, weil mehrere Widgets gleichzeitig laden können und sich sonst
/// zwei Anmeldungen überholen.
private actor TokenCache {
private var token: String?
private var expiry: Date?
func discard() { token = nil; expiry = nil }
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, 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,
totpCode: String?, session: URLSession) async throws -> String {
var request = URLRequest(url: server.appendingPathComponent("api/auth/login"))
request.httpMethod = "POST"
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 }
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
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
}
}
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
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)
}
}
}
// MARK: - Auswertung
/// Alles, was ohne Netz prüfbar ist und deshalb auch ohne Netz geprüft wird.
public enum CalendarrLiveDecoder {
public static func eventsURL(server: URL, interval: DateInterval) throws -> URL {
guard var components = URLComponents(url: server.appendingPathComponent("api/caldav/events"),
resolvingAgainstBaseURL: false)
else { throw CalendarrLiveError.badServer }
let formatter = ISO8601DateFormatter()
components.queryItems = [
URLQueryItem(name: "start", value: formatter.string(from: interval.start)),
URLQueryItem(name: "end", value: formatter.string(from: interval.end)),
]
guard let url = components.url else { throw CalendarrLiveError.badServer }
return url
}
/// Der Anmeldekörper. Prozentkodiert von Hand, weil `+` in einem Passwort
/// sonst als Leerzeichen ankommt ein Fehler, den man erst bei genau
/// diesem einen Passwort bemerkt.
public static func formBody(username: String, password: String) -> Data {
var allowed = CharacterSet.alphanumerics
allowed.insert(charactersIn: "-._~")
func escape(_ text: String) -> String {
text.addingPercentEncoding(withAllowedCharacters: allowed) ?? text
}
return Data("username=\(escape(username))&password=\(escape(password))".utf8)
}
public static func events(from data: Data) throws -> [OnyxEvent] {
guard let object = try? JSONSerialization.jsonObject(with: data),
let list = (object as? [String: Any])?["events"] as? [[String: Any]]
?? object as? [[String: Any]]
else { throw CalendarrLiveError.badResponse }
return list.compactMap(event(from:))
.sorted { $0.start < $1.start }
}
static func event(from item: [String: Any]) -> OnyxEvent? {
guard let title = item["title"] as? String,
let startText = item["start"] as? String,
let start = date(from: startText)
else { return nil }
let isAllDay = item["allDay"] as? Bool ?? (item["all_day"] as? Bool ?? false)
let end = (item["end"] as? String).flatMap(date(from:))
// Ohne Ende ist ein Termin einen Tag lang, wenn er ganztägig ist,
// sonst eine Stunde. Beides ist geraten aber ein Termin ohne
// Länge wäre unsichtbar.
?? start.addingTimeInterval(isAllDay ? 86400 : 3600)
let identifier = item["id"] as? String
?? item["uid"] as? String
?? "\(startText)#\(title)"
return OnyxEvent(
id: identifier,
title: title,
start: start,
end: end,
isAllDay: isAllDay,
// Die Farbe des Termins schlägt die des Kalenders so macht es
// Calendarr selbst auch.
colorHex: item["color"] as? String ?? item["calendarColor"] as? String,
location: (item["location"] as? String).flatMap { $0.isEmpty ? nil : $0 },
calendarID: item["calendar_id"] as? String ?? "calendarr",
calendarTitle: item["calendar_name"] as? String ?? "Calendarr")
}
/// Ganztägige Termine kommen als `2026-08-14`, alle anderen mit Uhrzeit.
public static func date(from text: String) -> Date? {
if text.count == 10 {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = .current
let parts = text.split(separator: "-").compactMap { Int($0) }
guard parts.count == 3 else { return nil }
return calendar.date(from: DateComponents(year: parts[0], month: parts[1],
day: parts[2]))
}
let withFraction = ISO8601DateFormatter()
withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = withFraction.date(from: text) { return date }
let plain = ISO8601DateFormatter()
if let date = plain.date(from: text) { return date }
// Ohne Zeitzone im Text: dann gilt die des Rechners. Calendarr liefert
// lokale Zeiten für Termine ohne feste Zone.
let local = DateFormatter()
local.locale = Locale(identifier: "en_US_POSIX")
local.timeZone = .current
for format in ["yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm", "yyyy-MM-dd HH:mm:ss"] {
local.dateFormat = format
if let date = local.date(from: text) { return date }
}
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
/// Alternative wäre, sieben Tage anzunehmen; das steht heute so im Server
/// und morgen vielleicht nicht mehr.
public static func expiry(ofToken token: String) -> Date? {
let parts = token.split(separator: ".")
guard parts.count == 3 else { return nil }
var payload = String(parts[1])
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
// Base64 ohne Auffüllzeichen, wie in JWT üblich.
while payload.count % 4 != 0 { payload += "=" }
guard let data = Data(base64Encoded: payload),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let seconds = object["exp"] as? Double
else { return nil }
return Date(timeIntervalSince1970: seconds)
}
/// Nimmt entgegen, was Menschen eintippen: calendarr.example.com",
/// https://calendarr.example.com/" oder mit Pfad dahinter.
public static func server(from text: String) -> URL? {
var trimmed = text.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { return nil }
if !trimmed.contains("://") { trimmed = "https://" + trimmed }
while trimmed.hasSuffix("/") { trimmed.removeLast() }
guard let url = URL(string: trimmed), url.host != nil else { return nil }
return url
}
}
// MARK: - Zugangsdaten
/// Adresse und Benutzername in den Einstellungen, das Passwort im Schlüsselbund.
///
/// Getrennt, weil es zwei verschiedene Dinge sind: das eine will man sehen und
/// nachlesen können, das andere darf nirgends stehen, wo man es versehentlich
/// mitliest und `defaults read` ist genau so eine Stelle.
public struct CalendarrCredentials: @unchecked Sendable {
/// `UserDefaults` gilt Swift nicht als übertragbar, ist aber seit jeher
/// threadsicher. Die Ausnahme steht hier und nicht als Sperre um jeden
/// Zugriff.
private let defaults: UserDefaults
private static let service = "com.scarriffleservices.onyx.calendarr"
private enum Keys {
static let server = "calendarr.live.server"
static let username = "calendarr.live.username"
}
public init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}
public var account: CalendarrAccount? {
guard let server = defaults.string(forKey: Keys.server).flatMap(URL.init(string:)),
let username = defaults.string(forKey: Keys.username), !username.isEmpty
else { return nil }
return CalendarrAccount(server: server, username: username)
}
/// 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.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.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? { 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, kind: kind)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var item: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
let data = item as? Data
else { return nil }
return String(data: data, encoding: .utf8)
}
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 + "." + kind.rawValue,
kSecAttrAccount as String: 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 value, !value.isEmpty else { return }
var item = query
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
let status = SecItemAdd(item as CFDictionary, nil)
if status != errSecSuccess {
log.error("Passwort ließ sich nicht sichern (\(status, privacy: .public))")
}
}
}
public struct CalendarrAccount: Equatable, Sendable {
public let server: URL
public let username: String
public init(server: URL, username: String) {
self.server = server
self.username = username
}
}