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>
81 lines
3.1 KiB
Swift
81 lines
3.1 KiB
Swift
import Foundation
|
||
|
||
/// Ein hinterlegter Server. Die App kann beliebig viele davon kennen und
|
||
/// zwischen ihnen umschalten, ohne sich jedes Mal neu anzumelden.
|
||
///
|
||
/// Das Token liegt **nicht** hier, sondern im Keychain unter einem Konto, das
|
||
/// aus der `id` gebildet wird (siehe `Session.keychainAccount`). Diese Struktur
|
||
/// landet als JSON in den UserDefaults und darf deshalb nichts Geheimes
|
||
/// enthalten.
|
||
struct ServerProfile: Codable, Identifiable, Equatable {
|
||
let id: UUID
|
||
var name: String
|
||
var urlString: String
|
||
/// Zuletzt angemeldeter Benutzer - nur fuer die Anzeige und damit das
|
||
/// Anmeldefeld beim Wechsel schon ausgefuellt ist.
|
||
var username: String
|
||
/// Zuletzt bekannte Rolle. Entscheidet, ob der Verwaltungs-Tab erscheint,
|
||
/// bevor `me()` den Wert frisch bestaetigt hat.
|
||
var isAdmin: Bool
|
||
/// Kuerzel des Anmeldedienstes, ueber den man sich hier angemeldet hat
|
||
/// (z. B. "authentik"); leer bei Passwort-Anmeldung. Nur damit weiss die App
|
||
/// spaeter, an welchem Endpunkt sie das abgelaufene Token erneuern muss.
|
||
///
|
||
/// Optional deklariert, damit bereits gespeicherte Profile weiterhin gelesen
|
||
/// werden koennen – der Decoder liefert fuer den fehlenden Schluessel nil.
|
||
var oidcSlug: String?
|
||
|
||
init(id: UUID = UUID(), name: String, urlString: String,
|
||
username: String = "", isAdmin: Bool = false, oidcSlug: String? = nil) {
|
||
self.id = id
|
||
self.name = name
|
||
self.urlString = urlString
|
||
self.username = username
|
||
self.isAdmin = isAdmin
|
||
self.oidcSlug = oidcSlug
|
||
}
|
||
|
||
var url: URL? { Session.normalize(urlString) }
|
||
|
||
/// Ohne eigenen Namen ist der Rechnername die brauchbarste Bezeichnung:
|
||
/// aus "http://192.168.1.50:8080/" wird "192.168.1.50".
|
||
static func suggestedName(for raw: String) -> String {
|
||
guard let url = Session.normalize(raw), let host = url.host, !host.isEmpty else {
|
||
return "Server"
|
||
}
|
||
return host
|
||
}
|
||
}
|
||
|
||
/// Liest und schreibt die Profilliste. Bewusst getrennt von `Session`, damit
|
||
/// die Ablage fuer sich verstaendlich bleibt.
|
||
enum ProfileStore {
|
||
static let profilesKey = "server_profiles"
|
||
static let activeKey = "active_profile_id"
|
||
|
||
static func load() -> [ServerProfile] {
|
||
guard let data = UserDefaults.standard.data(forKey: profilesKey),
|
||
let profiles = try? JSONDecoder().decode([ServerProfile].self, from: data)
|
||
else { return [] }
|
||
return profiles
|
||
}
|
||
|
||
static func save(_ profiles: [ServerProfile]) {
|
||
guard let data = try? JSONEncoder().encode(profiles) else { return }
|
||
UserDefaults.standard.set(data, forKey: profilesKey)
|
||
}
|
||
|
||
static func loadActiveID() -> UUID? {
|
||
guard let raw = UserDefaults.standard.string(forKey: activeKey) else { return nil }
|
||
return UUID(uuidString: raw)
|
||
}
|
||
|
||
static func saveActiveID(_ id: UUID?) {
|
||
if let id {
|
||
UserDefaults.standard.set(id.uuidString, forKey: activeKey)
|
||
} else {
|
||
UserDefaults.standard.removeObject(forKey: activeKey)
|
||
}
|
||
}
|
||
}
|