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>
This commit is contained in:
Scarriffle
2026-08-14 13:44:26 +02:00
parent e2b86c0216
commit fde3e864c0
5 changed files with 185 additions and 35 deletions

View File

@@ -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<Void, Error> {
totpCode: String?,
session: URLSession = .shared) async -> Result<String, Error> {
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

View File

@@ -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 — its only valid for half a minute."
}
}
}
},
"widget.calendar.allDay": {
"localizations": {
"de": {