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
@@ -0,0 +1,222 @@
|
||||
import AppAuth
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
/// Anmeldung ueber einen OpenID-Connect-Dienst (z. B. Authentik).
|
||||
///
|
||||
/// Der gesamte Vorgang laeuft in AppAuth und damit im **System-Browser**
|
||||
/// (`ASWebAuthenticationSession`) – nie in einem eingebetteten WebView. Das ist
|
||||
/// keine Geschmacksfrage: nur so sieht der Nutzer die echte Adresszeile, und die
|
||||
/// App bekommt sein Passwort nie zu Gesicht.
|
||||
///
|
||||
/// Die App ist beim Anmeldedienst ein **oeffentlicher** Client: kein
|
||||
/// Client-Secret, dafuer PKCE (das setzt AppAuth bei diesem Aufbau von selbst).
|
||||
///
|
||||
/// Der Anmeldezustand (inkl. Refresh-Token) liegt je Serverprofil im Keychain.
|
||||
@MainActor
|
||||
final class OIDCLogin {
|
||||
static let shared = OIDCLogin()
|
||||
|
||||
/// Muss gehalten werden, solange der Browser offen ist – sonst kann der
|
||||
/// Rueckweg nicht mehr zugestellt werden.
|
||||
private var laufenderFlow: OIDExternalUserAgentSession?
|
||||
|
||||
/// Laufende Erneuerungen je Profil. Ohne das loesen mehrere gleichzeitige
|
||||
/// 401er ebenso viele parallele Erneuerungen aus – und alle bis auf eine
|
||||
/// scheitern, weil Authentik ein Refresh-Token nur einmal einloest.
|
||||
private var laufendeErneuerung: [UUID: Task<String, Error>] = [:]
|
||||
|
||||
private init() {}
|
||||
|
||||
nonisolated static func keychainAccount(for id: UUID) -> String {
|
||||
"vorrania-oidc-\(id.uuidString)"
|
||||
}
|
||||
|
||||
// MARK: - Anmelden
|
||||
|
||||
/// Fuehrt den Anmeldevorgang durch und liefert das ID-Token, das der Server
|
||||
/// gegen ein Vorrania-Token tauscht.
|
||||
func anmelden(
|
||||
provider: OidcProviderResponse, profileID: UUID
|
||||
) async throws -> (idToken: String, nonce: String?) {
|
||||
guard let clientID = provider.nativeClientID, !clientID.isEmpty else {
|
||||
throw OIDCFehler.keinAppClient
|
||||
}
|
||||
guard let discoveryURL = URL(string: provider.discoveryURL),
|
||||
let redirectURL = URL(string: provider.nativeRedirectURI) else {
|
||||
throw OIDCFehler.konfigurationUnbrauchbar
|
||||
}
|
||||
guard let presenting = Self.oberstesFenster() else {
|
||||
throw OIDCFehler.keinFenster
|
||||
}
|
||||
|
||||
let konfiguration = try await Self.konfiguration(von: discoveryURL)
|
||||
|
||||
let anfrage = OIDAuthorizationRequest(
|
||||
configuration: konfiguration,
|
||||
clientId: clientID,
|
||||
// Ausdruecklich nil: ein oeffentlicher Client hat kein Secret und darf
|
||||
// auch nie eines mitschicken.
|
||||
clientSecret: nil,
|
||||
// offline_access ist Pflicht – ohne diesen Scope liefert Authentik
|
||||
// seit 2024.2 kein Refresh-Token, und die App muesste sich staendig
|
||||
// neu anmelden.
|
||||
scopes: provider.scopeListe,
|
||||
redirectURL: redirectURL,
|
||||
responseType: OIDResponseTypeCode,
|
||||
additionalParameters: nil
|
||||
)
|
||||
|
||||
let zustand: OIDAuthState = try await withCheckedThrowingContinuation { fortsetzung in
|
||||
laufenderFlow = OIDAuthState.authState(
|
||||
byPresenting: anfrage,
|
||||
presenting: presenting
|
||||
) { zustand, fehler in
|
||||
if let zustand {
|
||||
fortsetzung.resume(returning: zustand)
|
||||
} else {
|
||||
fortsetzung.resume(throwing: fehler ?? OIDCFehler.abgebrochen)
|
||||
}
|
||||
}
|
||||
}
|
||||
laufenderFlow = nil
|
||||
|
||||
sichern(zustand, profileID: profileID)
|
||||
guard let idToken = zustand.lastTokenResponse?.idToken else {
|
||||
throw OIDCFehler.keinIdToken
|
||||
}
|
||||
// AppAuth erzeugt die nonce selbst; der Server prueft sie damit erneut.
|
||||
return (idToken, zustand.lastAuthorizationResponse.request.nonce)
|
||||
}
|
||||
|
||||
/// Fuehrt den Rueckweg aus dem Browser dem laufenden Vorgang zu.
|
||||
/// Bei `ASWebAuthenticationSession` faengt die Sitzung den Aufruf selbst ab;
|
||||
/// dies ist der Rueckfall, falls die Adresse doch ueber `onOpenURL` ankommt.
|
||||
@discardableResult
|
||||
func rueckwegVerarbeiten(_ url: URL) -> Bool {
|
||||
guard let flow = laufenderFlow else { return false }
|
||||
if flow.resumeExternalUserAgentFlow(with: url) {
|
||||
laufenderFlow = nil
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Erneuern
|
||||
|
||||
/// Holt ein frisches ID-Token, notfalls ueber das Refresh-Token.
|
||||
///
|
||||
/// Ohne nonce: ein per Refresh ausgestelltes ID-Token muss sie laut
|
||||
/// OIDC-Spezifikation nur uebernehmen, *wenn* es sie mitfuehrt – viele
|
||||
/// Anbieter lassen sie weg. Signatur, iss, aud und exp prueft der Server
|
||||
/// unveraendert.
|
||||
///
|
||||
/// Mehrere gleichzeitige Aufrufe teilen sich denselben Vorgang: das
|
||||
/// Refresh-Token laesst sich nur einmal einloesen.
|
||||
func frischesIdToken(fuer profileID: UUID) async throws -> String {
|
||||
if let laufend = laufendeErneuerung[profileID] {
|
||||
return try await laufend.value
|
||||
}
|
||||
let aufgabe = Task<String, Error> { [weak self] in
|
||||
guard let self, let zustand = self.geladenerZustand(profileID: profileID) else {
|
||||
throw OIDCFehler.nichtAngemeldet
|
||||
}
|
||||
let idToken: String = try await withCheckedThrowingContinuation { fortsetzung in
|
||||
zustand.performAction { _, idToken, fehler in
|
||||
if let idToken {
|
||||
fortsetzung.resume(returning: idToken)
|
||||
} else {
|
||||
fortsetzung.resume(throwing: fehler ?? OIDCFehler.erneuerungGescheitert)
|
||||
}
|
||||
}
|
||||
}
|
||||
// performAction kann den Zustand veraendert haben (neues Refresh-Token).
|
||||
self.sichern(zustand, profileID: profileID)
|
||||
return idToken
|
||||
}
|
||||
laufendeErneuerung[profileID] = aufgabe
|
||||
defer { laufendeErneuerung[profileID] = nil }
|
||||
return try await aufgabe.value
|
||||
}
|
||||
|
||||
func istAngemeldet(profileID: UUID) -> Bool {
|
||||
geladenerZustand(profileID: profileID) != nil
|
||||
}
|
||||
|
||||
// MARK: - Ablage
|
||||
|
||||
private func sichern(_ zustand: OIDAuthState, profileID: UUID) {
|
||||
guard let daten = try? NSKeyedArchiver.archivedData(
|
||||
withRootObject: zustand, requiringSecureCoding: true
|
||||
) else { return }
|
||||
// Enthaelt das Refresh-Token – gehoert deshalb in den Keychain und nicht
|
||||
// in die UserDefaults.
|
||||
Keychain.writeData(daten, account: Self.keychainAccount(for: profileID))
|
||||
}
|
||||
|
||||
private func geladenerZustand(profileID: UUID) -> OIDAuthState? {
|
||||
guard let daten = Keychain.readData(account: Self.keychainAccount(for: profileID)) else {
|
||||
return nil
|
||||
}
|
||||
return try? NSKeyedUnarchiver.unarchivedObject(ofClass: OIDAuthState.self, from: daten)
|
||||
}
|
||||
|
||||
nonisolated static func abmelden(profileID: UUID) {
|
||||
Keychain.delete(account: keychainAccount(for: profileID))
|
||||
}
|
||||
|
||||
// MARK: - Hilfen
|
||||
|
||||
private static func konfiguration(von url: URL) async throws -> OIDServiceConfiguration {
|
||||
try await withCheckedThrowingContinuation { fortsetzung in
|
||||
OIDAuthorizationService.discoverConfiguration(forDiscoveryURL: url) { konfig, fehler in
|
||||
if let konfig {
|
||||
fortsetzung.resume(returning: konfig)
|
||||
} else {
|
||||
fortsetzung.resume(throwing: fehler ?? OIDCFehler.discoveryGescheitert)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func oberstesFenster() -> UIViewController? {
|
||||
let szene = UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.first { $0.activationState == .foregroundActive }
|
||||
guard var oben = szene?.keyWindow?.rootViewController else { return nil }
|
||||
while let naechst = oben.presentedViewController { oben = naechst }
|
||||
return oben
|
||||
}
|
||||
}
|
||||
|
||||
enum OIDCFehler: LocalizedError {
|
||||
case keinAppClient
|
||||
case konfigurationUnbrauchbar
|
||||
case keinFenster
|
||||
case discoveryGescheitert
|
||||
case keinIdToken
|
||||
case abgebrochen
|
||||
case nichtAngemeldet
|
||||
case erneuerungGescheitert
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .keinAppClient:
|
||||
return "Für diesen Anmeldedienst ist kein App-Zugang eingerichtet."
|
||||
case .konfigurationUnbrauchbar:
|
||||
return "Der Server hat eine unbrauchbare Anmelde-Konfiguration geliefert."
|
||||
case .keinFenster:
|
||||
return "Die Anmeldung konnte nicht angezeigt werden."
|
||||
case .discoveryGescheitert:
|
||||
return "Der Anmeldedienst ist nicht erreichbar."
|
||||
case .keinIdToken:
|
||||
return "Der Anmeldedienst hat kein ID-Token geliefert."
|
||||
case .abgebrochen:
|
||||
return "Die Anmeldung wurde abgebrochen."
|
||||
case .nichtAngemeldet:
|
||||
return "Für diesen Server liegt keine Anmeldung beim Anmeldedienst vor."
|
||||
case .erneuerungGescheitert:
|
||||
return "Die Anmeldung konnte nicht erneuert werden. Bitte neu anmelden."
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user