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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8d4954610b
commit
3d0ff54bf5
@@ -37,9 +37,63 @@ actor APIClient {
|
||||
}
|
||||
|
||||
private func send<T: Decodable>(_ request: URLRequest, as type: T.Type) async throws -> T {
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
try check(response, data: data)
|
||||
return try JSONDecoder().decode(T.self, from: data)
|
||||
let daten = try await sendeMitErneuerung(request)
|
||||
return try JSONDecoder().decode(T.self, from: daten)
|
||||
}
|
||||
|
||||
/// Fuehrt den Aufruf aus und wiederholt ihn EINMAL, wenn das Vorrania-Token
|
||||
/// abgelaufen ist und sich die Anmeldung still erneuern laesst.
|
||||
///
|
||||
/// Betrifft nur Anmeldungen ueber einen Anmeldedienst: dort liegt ein
|
||||
/// Refresh-Token auf dem Geraet. Nach einer Passwort-Anmeldung gibt es
|
||||
/// nichts zu erneuern, dann bleibt es beim 401 wie bisher.
|
||||
private func sendeMitErneuerung(_ request: URLRequest) async throws -> Data {
|
||||
let (daten, antwort) = try await URLSession.shared.data(for: request)
|
||||
do {
|
||||
try check(antwort, data: daten)
|
||||
return daten
|
||||
} catch APIError.unauthorized {
|
||||
guard await erneuereAnmeldung() else { throw APIError.unauthorized }
|
||||
var wiederholung = request
|
||||
if let token {
|
||||
wiederholung.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
let (daten2, antwort2) = try await URLSession.shared.data(for: wiederholung)
|
||||
try check(antwort2, data: daten2)
|
||||
return daten2
|
||||
}
|
||||
}
|
||||
|
||||
/// Laufende Erneuerung, damit zehn gleichzeitig abgewiesene Aufrufe nicht
|
||||
/// zehn Erneuerungen ausloesen. Der Actor serialisiert bis zum ersten await,
|
||||
/// wer hier ankommt, haengt sich also an eine bereits laufende an.
|
||||
private var laufendeErneuerung: Task<Bool, Never>?
|
||||
|
||||
private func erneuereAnmeldung() async -> Bool {
|
||||
if let laufend = laufendeErneuerung { return await laufend.value }
|
||||
let aufgabe = Task<Bool, Never> { await Self.erneuereJetzt() }
|
||||
laufendeErneuerung = aufgabe
|
||||
defer { laufendeErneuerung = nil }
|
||||
return await aufgabe.value
|
||||
}
|
||||
|
||||
private static func erneuereJetzt() async -> Bool {
|
||||
let sitzung = Session.shared
|
||||
guard let profil = sitzung.activeProfile,
|
||||
let slug = profil.oidcSlug, !slug.isEmpty else { return false }
|
||||
do {
|
||||
let idToken = try await OIDCLogin.shared.frischesIdToken(fuer: profil.id)
|
||||
let token = try await APIClient.shared.oidcNativeLogin(slug: slug, idToken: idToken)
|
||||
await MainActor.run {
|
||||
sitzung.store(token: token.accessToken, username: token.username,
|
||||
isAdmin: token.role == "admin", persist: sitzung.stayLoggedIn,
|
||||
oidcSlug: slug)
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
// Refresh-Token abgelaufen oder zurueckgezogen: normale Neuanmeldung.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func check(_ response: URLResponse, data: Data) throws {
|
||||
@@ -77,6 +131,27 @@ actor APIClient {
|
||||
try await send(try makeRequest("/auth/me"), as: MeResponse.self)
|
||||
}
|
||||
|
||||
/// Am Server eingerichtete Anmeldedienste. Leere Liste = nur Passwort-Login.
|
||||
func oidcProviders() async throws -> [OidcProviderResponse] {
|
||||
let (daten, antwort) = try await URLSession.shared.data(
|
||||
for: try makeRequest("/auth/oidc/providers")
|
||||
)
|
||||
try check(antwort, data: daten)
|
||||
return try JSONDecoder().decode([OidcProviderResponse].self, from: daten)
|
||||
}
|
||||
|
||||
/// Tauscht das ID-Token des Anmeldedienstes gegen ein Vorrania-Token.
|
||||
///
|
||||
/// Bewusst ohne die Erneuerungsschicht: dieser Aufruf IST die Erneuerung,
|
||||
/// ein 401 hier muss zur normalen Anmeldung fuehren.
|
||||
func oidcNativeLogin(slug: String, idToken: String, nonce: String? = nil) async throws -> TokenResponse {
|
||||
var request = try makeRequest("/auth/oidc/\(slug)/native", method: "POST")
|
||||
try jsonBody(&request, OidcNativeLoginBody(idToken: idToken, nonce: nonce))
|
||||
let (daten, antwort) = try await URLSession.shared.data(for: request)
|
||||
try check(antwort, data: daten)
|
||||
return try JSONDecoder().decode(TokenResponse.self, from: daten)
|
||||
}
|
||||
|
||||
// MARK: - Stammdaten
|
||||
|
||||
func units() async throws -> [Unit] {
|
||||
@@ -142,8 +217,12 @@ actor APIClient {
|
||||
let request = try makeRequest("/products/\(id)/image" + (thumb ? "?thumb=1" : ""))
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
if let http = response as? HTTPURLResponse, http.statusCode == 404 { return nil }
|
||||
try check(response, data: data)
|
||||
return data
|
||||
do {
|
||||
try check(response, data: data)
|
||||
return data
|
||||
} catch APIError.unauthorized {
|
||||
return try await sendeMitErneuerung(request)
|
||||
}
|
||||
}
|
||||
|
||||
/// Eigenes Foto (Kamera/Galerie) als multipart hochladen.
|
||||
@@ -330,8 +409,7 @@ actor APIClient {
|
||||
|
||||
/// Fuer Antworten ohne Rumpf (204) - dort gibt es nichts zu dekodieren.
|
||||
private func sendNoContent(_ request: URLRequest) async throws {
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
try check(response, data: data)
|
||||
_ = try await sendeMitErneuerung(request)
|
||||
}
|
||||
|
||||
// MARK: - Gegenstaende (Non-Food)
|
||||
|
||||
Reference in New Issue
Block a user