Die Anmeldung galt fest 7 Tage - und die Frist lief ab dem *Anmelden*, nicht ab der letzten Nutzung. Wer die App taeglich benutzte, flog trotzdem punktgenau nach einer Woche hinaus. Beides ist jetzt geregelt: - Einstellungen -> Anmeldung: 7 Tage bis 1 Jahr oder unbegrenzt. Gilt fuer Website und App gemeinsam, weil beide dasselbe Token bekommen - auch nach Single Sign-on. - POST /auth/refresh schiebt die Frist beim Start nach vorn. Damit zaehlt die eingestellte Dauer ab dem letzten Besuch, und eine geaenderte Einstellung greift ohne Neuanmeldung. "Unbegrenzt" heisst ein Token ganz ohne exp. Das ist bewusst folgenreich: Es gibt keine Sperrliste, eine erteilte Anmeldung laesst sich nicht einzeln zurueckziehen. Die Oberflaeche sagt das dazu und nennt den einzigen Ausweg (JWT_SECRET aendern). /auth/refresh nimmt bewusst nur JWTs an, keine langlebigen API-Token: aus einem jederzeit widerrufbaren Zugang (Home Assistant) waere sonst ein unwiderruflicher geworden. Der Rueckfallwert aus der Umgebung steigt von 7 auf 30 Tage - er gilt nur noch, solange nichts eingestellt ist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
297 lines
12 KiB
Swift
297 lines
12 KiB
Swift
import Foundation
|
||
import Security
|
||
|
||
/// Haelt die bekannten Server und die Anmeldung am gerade aktiven.
|
||
///
|
||
/// Jedes Profil hat ein eigenes Token im Keychain. Dadurch bleibt man an
|
||
/// mehreren Servern gleichzeitig angemeldet und der Wechsel ist ein Tipp.
|
||
/// Die Adressen liegen in den UserDefaults (nicht geheim), die Token im
|
||
/// Keychain.
|
||
final class Session: ObservableObject {
|
||
static let shared = Session()
|
||
|
||
private let stayKey = "stay_logged_in"
|
||
|
||
// Schluessel aus der Zeit mit genau einem Server. Werden beim ersten Start
|
||
// nach dem Update in ein Profil ueberfuehrt und danach entfernt.
|
||
private let legacyURLKey = "server_url"
|
||
private let legacyUsernameKey = "username"
|
||
private let legacyAccount = "vorrania-token"
|
||
|
||
@Published private(set) var profiles: [ServerProfile] = []
|
||
@Published private(set) var activeProfileID: UUID?
|
||
@Published private(set) var token: String?
|
||
/// Merkt die letzte Wahl, damit der Haken im Login richtig steht.
|
||
@Published private(set) var stayLoggedIn: Bool = true
|
||
|
||
var activeProfile: ServerProfile? {
|
||
profiles.first { $0.id == activeProfileID }
|
||
}
|
||
|
||
var baseURL: URL? { activeProfile?.url }
|
||
var username: String { activeProfile?.username ?? "" }
|
||
var isAdmin: Bool { activeProfile?.isAdmin ?? false }
|
||
var isLoggedIn: Bool { token != nil && baseURL != nil }
|
||
|
||
private init() {
|
||
// Ohne bisherige Wahl bleibt man angemeldet - das ist der Alltagsfall.
|
||
stayLoggedIn = UserDefaults.standard.object(forKey: stayKey) as? Bool ?? true
|
||
profiles = ProfileStore.load()
|
||
migrateSingleServerIfNeeded()
|
||
activeProfileID = ProfileStore.loadActiveID() ?? profiles.first?.id
|
||
loadTokenForActiveProfile()
|
||
}
|
||
|
||
/// Uebernimmt eine Anmeldung aus der Zeit vor den Profilen. Ohne das waere
|
||
/// man nach dem Update abgemeldet und muesste die Serveradresse neu tippen.
|
||
private func migrateSingleServerIfNeeded() {
|
||
guard profiles.isEmpty,
|
||
let stored = UserDefaults.standard.string(forKey: legacyURLKey),
|
||
!stored.isEmpty
|
||
else { return }
|
||
|
||
let name = ServerProfile.suggestedName(for: stored)
|
||
let profile = ServerProfile(
|
||
name: name,
|
||
urlString: stored,
|
||
username: UserDefaults.standard.string(forKey: legacyUsernameKey) ?? ""
|
||
)
|
||
profiles = [profile]
|
||
ProfileStore.save(profiles)
|
||
ProfileStore.saveActiveID(profile.id)
|
||
|
||
// Token auf das profilbezogene Konto umziehen.
|
||
if let old = Keychain.read(account: legacyAccount) {
|
||
Keychain.write(old, account: Session.keychainAccount(for: profile.id))
|
||
Keychain.delete(account: legacyAccount)
|
||
}
|
||
UserDefaults.standard.removeObject(forKey: legacyURLKey)
|
||
UserDefaults.standard.removeObject(forKey: legacyUsernameKey)
|
||
}
|
||
|
||
static func keychainAccount(for id: UUID) -> String {
|
||
"vorrania-token-\(id.uuidString)"
|
||
}
|
||
|
||
private func loadTokenForActiveProfile() {
|
||
guard let id = activeProfileID else { token = nil; return }
|
||
token = Keychain.read(account: Session.keychainAccount(for: id))
|
||
}
|
||
|
||
/// Sorgt für eine URL mit Schema und abschließendem "/", damit relative
|
||
/// Pfade ("api/...") korrekt aufgelöst werden.
|
||
static func normalize(_ raw: String) -> URL? {
|
||
var text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !text.isEmpty else { return nil }
|
||
if !text.contains("://") { text = "http://" + text }
|
||
if !text.hasSuffix("/") { text += "/" }
|
||
return URL(string: text)
|
||
}
|
||
|
||
// MARK: - Profile
|
||
|
||
/// Legt ein Profil an und macht es zum aktiven. Ohne Namen wird der
|
||
/// Rechnername genommen.
|
||
@discardableResult
|
||
func addProfile(name: String = "", urlString: String) -> ServerProfile? {
|
||
guard Session.normalize(urlString) != nil else { return nil }
|
||
let title = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let profile = ServerProfile(
|
||
name: title.isEmpty ? ServerProfile.suggestedName(for: urlString) : title,
|
||
urlString: urlString
|
||
)
|
||
profiles.append(profile)
|
||
ProfileStore.save(profiles)
|
||
switchTo(profile.id)
|
||
return profile
|
||
}
|
||
|
||
func updateProfile(id: UUID, name: String? = nil, urlString: String? = nil) {
|
||
guard let index = profiles.firstIndex(where: { $0.id == id }) else { return }
|
||
if let name {
|
||
let title = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
if !title.isEmpty { profiles[index].name = title }
|
||
}
|
||
if let urlString, Session.normalize(urlString) != nil {
|
||
profiles[index].urlString = urlString
|
||
}
|
||
ProfileStore.save(profiles)
|
||
}
|
||
|
||
/// Entfernt das Profil samt Token. Das Geheimnis darf nicht zurueckbleiben,
|
||
/// wenn der Server aus der Liste verschwindet.
|
||
func removeProfile(id: UUID) {
|
||
Keychain.delete(account: Session.keychainAccount(for: id))
|
||
OIDCLogin.abmelden(profileID: id)
|
||
NotificationStore.remove(for: id)
|
||
AppDashboardStore.remove(for: id)
|
||
profiles.removeAll { $0.id == id }
|
||
ProfileStore.save(profiles)
|
||
|
||
if activeProfileID == id {
|
||
switchTo(profiles.first?.id)
|
||
}
|
||
}
|
||
|
||
/// Wechselt den Server. Ist fuer das Ziel ein Token hinterlegt, ist man
|
||
/// sofort angemeldet, sonst erscheint die Anmeldung fuer dieses Profil.
|
||
func switchTo(_ id: UUID?) {
|
||
activeProfileID = id
|
||
ProfileStore.saveActiveID(id)
|
||
loadTokenForActiveProfile()
|
||
}
|
||
|
||
/// Wechselt (falls vorhanden und noetig) auf das Profil, dessen Adresse zum
|
||
/// Host eines Links passt – damit ein gescannter QR den richtigen Server
|
||
/// abfragt, auch wenn gerade ein anderer aktiv ist.
|
||
func switchToProfile(matching url: URL) {
|
||
guard let host = url.host?.lowercased() else { return }
|
||
if activeProfile?.url?.host?.lowercased() == host { return }
|
||
if let match = profiles.first(where: { $0.url?.host?.lowercased() == host }) {
|
||
switchTo(match.id)
|
||
}
|
||
}
|
||
|
||
// MARK: - Anmeldung
|
||
|
||
/// Legt die Adresse fuer die Anmeldung fest: aktualisiert das aktive Profil
|
||
/// oder legt das erste an, wenn die App noch keinen Server kennt.
|
||
func setServer(_ raw: String) {
|
||
guard Session.normalize(raw) != nil else { return }
|
||
if let id = activeProfileID, profiles.contains(where: { $0.id == id }) {
|
||
updateProfile(id: id, urlString: raw)
|
||
} else {
|
||
addProfile(urlString: raw)
|
||
}
|
||
}
|
||
|
||
/// `persist == false` heisst: die Anmeldung gilt nur, solange die App laeuft.
|
||
/// Auf dem Geraet bleibt dann nichts zurueck.
|
||
/// `oidcSlug` merkt, ueber welchen Anmeldedienst die Anmeldung lief – nur so
|
||
/// weiss die App spaeter, wo sie ein abgelaufenes Token erneuern kann. Bei
|
||
/// Passwort-Anmeldung bleibt es leer.
|
||
func store(token newToken: String, username name: String, isAdmin admin: Bool,
|
||
persist: Bool, oidcSlug: String? = nil) {
|
||
token = newToken
|
||
stayLoggedIn = persist
|
||
UserDefaults.standard.set(persist, forKey: stayKey)
|
||
|
||
guard let id = activeProfileID,
|
||
let index = profiles.firstIndex(where: { $0.id == id }) else { return }
|
||
profiles[index].isAdmin = admin
|
||
profiles[index].username = persist ? name : ""
|
||
profiles[index].oidcSlug = oidcSlug
|
||
ProfileStore.save(profiles)
|
||
|
||
if persist {
|
||
Keychain.write(newToken, account: Session.keychainAccount(for: id))
|
||
} else {
|
||
Keychain.delete(account: Session.keychainAccount(for: id))
|
||
}
|
||
}
|
||
|
||
/// Meldet nur vom aktiven Server ab. Andere Profile behalten ihr Token.
|
||
func logout() {
|
||
token = nil
|
||
guard let id = activeProfileID else { return }
|
||
Keychain.delete(account: Session.keychainAccount(for: id))
|
||
// Der Anmeldezustand beim Dienst enthaelt das Refresh-Token. Bliebe er
|
||
// liegen, waere man nach dem Abmelden faktisch weiter angemeldet.
|
||
OIDCLogin.abmelden(profileID: id)
|
||
if let index = profiles.firstIndex(where: { $0.id == id }) {
|
||
profiles[index].username = ""
|
||
profiles[index].isAdmin = false
|
||
profiles[index].oidcSlug = nil
|
||
ProfileStore.save(profiles)
|
||
}
|
||
}
|
||
|
||
/// Prueft nach einem Wechsel, ob das hinterlegte Token noch gilt, und holt
|
||
/// die aktuelle Rolle. Ein abgelaufenes Token fuehrt zur Anmeldung.
|
||
@MainActor
|
||
func refreshMe() async {
|
||
guard isLoggedIn, let id = activeProfileID else { return }
|
||
do {
|
||
let me = try await APIClient.shared.me()
|
||
guard let index = profiles.firstIndex(where: { $0.id == id }) else { return }
|
||
profiles[index].username = me.username
|
||
profiles[index].isAdmin = me.role == "admin"
|
||
ProfileStore.save(profiles)
|
||
} catch APIError.unauthorized {
|
||
logout()
|
||
} catch {
|
||
// Server gerade nicht erreichbar: angemeldet bleiben, damit ein
|
||
// kurzer Netzausfall einen nicht aus der App wirft.
|
||
}
|
||
}
|
||
|
||
/// Holt beim Start ein frisches Token, damit die am Server eingestellte
|
||
/// Sitzungsdauer ab dem letzten Besuch zaehlt und nicht ab dem Anmelden.
|
||
///
|
||
/// Schluckt jeden Fehler: Ein aelterer Server kennt /auth/refresh noch gar
|
||
/// nicht (404), und eine wirklich abgelaufene Sitzung hat refreshMe() einen
|
||
/// Schritt vorher schon beendet. Hier deshalb nie abmelden.
|
||
@MainActor
|
||
func sitzungVerlaengern() async {
|
||
guard isLoggedIn, let profil = activeProfile else { return }
|
||
guard let neu = try? await APIClient.shared.sitzungVerlaengern() else { return }
|
||
// oidcSlug MUSS mitgegeben werden: store() setzt das Feld immer, und
|
||
// ohne ihn verloere das Profil seinen Anmeldedienst – die stille
|
||
// Erneuerung nach einem 401 fiele damit aus.
|
||
store(token: neu.accessToken, username: neu.username,
|
||
isAdmin: neu.role == "admin", persist: stayLoggedIn,
|
||
oidcSlug: profil.oidcSlug)
|
||
}
|
||
}
|
||
|
||
/// Minimaler Keychain-Zugriff für ein einzelnes Token.
|
||
enum Keychain {
|
||
private static let service = "com.scarriffle.vorrania"
|
||
|
||
static func write(_ value: String, account: String) {
|
||
writeData(Data(value.utf8), account: account)
|
||
}
|
||
|
||
static func read(account: String) -> String? {
|
||
guard let data = readData(account: account) else { return nil }
|
||
return String(data: data, encoding: .utf8)
|
||
}
|
||
|
||
/// Der AppAuth-Anmeldezustand ist ein Archiv und kein Text – deshalb neben
|
||
/// den String-Varianten auch der direkte Weg ueber `Data`.
|
||
static func writeData(_ value: Data, account: String) {
|
||
delete(account: account)
|
||
let query: [String: Any] = [
|
||
kSecClass as String: kSecClassGenericPassword,
|
||
kSecAttrService as String: service,
|
||
kSecAttrAccount as String: account,
|
||
kSecValueData as String: value,
|
||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
|
||
]
|
||
SecItemAdd(query as CFDictionary, nil)
|
||
}
|
||
|
||
static func readData(account: String) -> Data? {
|
||
let query: [String: Any] = [
|
||
kSecClass as String: kSecClassGenericPassword,
|
||
kSecAttrService as String: service,
|
||
kSecAttrAccount as String: account,
|
||
kSecReturnData as String: true,
|
||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||
]
|
||
var item: CFTypeRef?
|
||
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
|
||
let data = item as? Data else { return nil }
|
||
return data
|
||
}
|
||
|
||
static func delete(account: String) {
|
||
let query: [String: Any] = [
|
||
kSecClass as String: kSecClassGenericPassword,
|
||
kSecAttrService as String: service,
|
||
kSecAttrAccount as String: account,
|
||
]
|
||
SecItemDelete(query as CFDictionary)
|
||
}
|
||
}
|