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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user