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:
Scarriffle
2026-09-09 21:55:38 +02:00
co-authored by Claude Opus 5
parent 8d4954610b
commit 3d0ff54bf5
35 changed files with 2772 additions and 24 deletions
+222
View File
@@ -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."
}
}
}