Files
Vorrania/ios/Sources/Session.swift
T
ScarriffleandClaude Opus 5 3d0ff54bf5 Anmeldung wahlweise ueber OpenID Connect (Authentik)
Neben dem Passwort-Login gibt es jetzt einen zweiten, gleichberechtigten Weg:
OpenID Connect gegen einen selbst gehosteten Anmeldedienst. Ohne Konfiguration
aendert sich nichts - keine SSO-Knoepfe, Passwort-Login wie bisher.

Der Anmeldedienst ist NUR fuer die Anmeldung zustaendig. Danach stellt Vorrania
sein eigenes Token aus wie beim Passwort-Login, deshalb bleiben deps.py, die
Rollenpruefung und die langlebigen API-Tokens unveraendert.

Backend
- Anbieter kommen aus der Umgebung (OIDC_PROVIDERS + OIDC_<KUERZEL>_*). Ein
  zweiter Anbieter ist damit reine Konfiguration, ohne Codeaenderung.
- Browser-Weg (vertraulicher Client) und App-Weg (oeffentlicher Client, PKCE)
  enden beide im selben Token-Schema.
- state/nonce/PKCE-Verifier liegen in einer kurzlebigen Tabelle statt in einem
  Cookie: Starlettes CORS-Schicht spiegelt bei allow_origins=["*"] zusammen mit
  allow_credentials die Herkunft zurueck, sobald ein Request ein Cookie traegt.
  Ohne Cookie entsteht diese Wechselwirkung erst gar nicht - und der Ablauf
  bleibt mit der db-Fixture pruefbar.
- Zuordnung ueber die sub des Anbieters; die Erstverknuepfung laeuft ueber die
  BESTAETIGTE E-Mail-Adresse. Rollen kommen nie aus dem Anbieter, und ein
  gleichlautender preferred_username uebernimmt nie ein bestehendes Konto.
- users.password_hash darf jetzt leer sein (reine SSO-Konten). verify_password
  faengt das ab, sonst antwortete der Login mit 500 statt 401.

Verknuepfen bestehender Konten
- Neue Seite "Mein Konto" (fuer alle Benutzer, nicht nur Admins): verknuepfen
  und wieder loesen. Die Berechtigung stammt aus der laufenden Sitzung, es
  braucht also weder eine hinterlegte Adresse noch Admin-Rechte.
- Der Start laeuft ueber POST, weil eine Seitennavigation das Bearer-Token
  nicht mitschicken kann.
- Die letzte Anmeldemoeglichkeit eines Kontos laesst sich nicht loesen.

iOS
- AppAuth (erste Paketabhaengigkeit) mit PKCE im System-Browser, nie in einem
  eingebetteten WebView, und ohne Client-Secret.
- offline_access nur im App-Weg: der Browser-Weg braucht kein Refresh-Token und
  bekaeme sonst einen ueberfluessigen Zustimmungsdialog.
- Anmeldezustand je Serverprofil im Keychain; abgelaufene Token erneuert die App
  still im Hintergrund. Gleichzeitige 401er teilen sich eine Erneuerung.

Deployment
- docker-compose reicht die ganze .env durch. Einzeln aufzuzaehlen ginge nur mit
  fest verdrahteten Kuerzeln - genau das soll ein weiterer Anbieter nicht
  erfordern.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 21:55:38 +02:00

279 lines
11 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
}
}
}
/// 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)
}
}