Files
Vorrania/ios/Sources/VorraniaApp.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

219 lines
9.7 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 SwiftUI
import UIKit
import BackgroundTasks
import UserNotifications
/// Zielbildschirm, der über Home-Screen-Shortcut, URL oder eine getippte
/// Benachrichtigung angesprungen wird.
enum Route: String {
case checkin
case checkout
case expiring
case lookup
case assign
}
final class Router: ObservableObject {
@Published var route: Route?
/// Über einen Universal Link (…/i/<UID>) zu öffnendes Einzelstück.
@Published var openItemUid: String?
func handle(shortcut type: String) {
if type.hasSuffix("checkin") { route = .checkin }
if type.hasSuffix("checkout") { route = .checkout }
}
/// Getippte Ablauf-Benachrichtigung: auf den betroffenen Server wechseln und
/// „Bald ablaufend" oeffnen.
func handle(expiryProfile raw: String) {
if let id = UUID(uuidString: raw) { Session.shared.switchTo(id) }
route = .expiring
}
/// vorrania://checkin | checkout … (Aktionen) oder vorrania://i/<UID>
/// (Einzelstück – aus einem gescannten QR, auch über die normale Kamera-App).
///
/// Früher lief das Einzelstück über einen Universal Link auf eine feste Domain.
/// Das war für einen selbstgehosteten Dienst untauglich (jede Instanz hat eine
/// andere URL, und die Domain stünde im offenen Quellcode). Das Custom-Scheme
/// braucht keine Domain: Die Kamera-App öffnet den QR trotzdem, und der Code
/// verrät keinen Server.
func handle(url: URL) {
if url.scheme == "vorrania" {
// Host + Pfad zu Bestandteilen ohne Trenner: ["i","ABC123"] bzw. ["checkin"].
let comps = ([url.host].compactMap { $0 } + url.pathComponents)
.filter { $0 != "/" && !$0.isEmpty }
// Einzelstück: vorrania://i/<UID> – am aktuell aktiven Server öffnen.
if let i = comps.firstIndex(where: { $0.lowercased() == "i" }), i + 1 < comps.count {
openItemUid = comps[i + 1].uppercased()
return
}
if let route = comps.first.flatMap({ Route(rawValue: $0.lowercased()) }) { self.route = route }
return
}
// Alt-Fall: Universal Link (…/i/<UID>) – nur noch relevant, falls je wieder
// eine Associated Domain hinterlegt würde (dann Serverwechsel per Host).
let parts = url.pathComponents
if let idx = parts.firstIndex(of: "i"), idx + 1 < parts.count {
Session.shared.switchToProfile(matching: url)
openItemUid = parts[idx + 1].uppercased()
}
}
}
@main
struct VorraniaApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@Environment(\.scenePhase) private var scenePhase
@StateObject private var session = Session.shared
@StateObject private var router = Router()
@StateObject private var display = DisplaySettings.shared
var body: some Scene {
WindowGroup {
RootView()
.environmentObject(session)
.environmentObject(router)
.environmentObject(display)
.onOpenURL { url in
// Rueckweg einer laufenden SSO-Anmeldung zuerst anbieten.
// ASWebAuthenticationSession faengt ihn normalerweise selbst
// ab; kommt er doch hier an, wuerde ihn der Router still
// schlucken (vorrania://oauth/... passt auf keine Route).
if OIDCLogin.shared.rueckwegVerarbeiten(url) { return }
router.handle(url: url)
}
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
if let url = activity.webpageURL { router.handle(url: url) }
}
.onAppear {
if let type = AppDelegate.pendingShortcut {
router.handle(shortcut: type)
AppDelegate.pendingShortcut = nil
}
// Kaltstart aus einer getippten Benachrichtigung.
if let profile = AppDelegate.pendingExpiryProfile {
router.handle(expiryProfile: profile)
AppDelegate.pendingExpiryProfile = nil
}
// Kaltstart aus einem gescannten Universal Link.
if let url = AppDelegate.pendingURL {
router.handle(url: url)
AppDelegate.pendingURL = nil
}
}
.onReceive(NotificationCenter.default.publisher(for: AppDelegate.shortcutNotification)) { note in
if let type = note.object as? String { router.handle(shortcut: type) }
}
.onReceive(NotificationCenter.default.publisher(for: AppDelegate.expiryTapNotification)) { note in
if let profile = note.object as? String { router.handle(expiryProfile: profile) }
}
.onReceive(NotificationCenter.default.publisher(for: AppDelegate.urlNotification)) { note in
if let url = note.object as? URL { router.handle(url: url) }
}
}
// Im Vordergrund den Meldungsplan auffrischen und den naechsten
// Hintergrundlauf einplanen.
.onChange(of: scenePhase) { phase in
switch phase {
case .active:
Task { await NotificationScheduler.shared.rescheduleAll() }
case .background:
AppDelegate.scheduleBackgroundRefresh()
default:
break
}
}
}
}
/// Nimmt Home-Screen-Quick-Actions und getippte Benachrichtigungen entgegen
/// (auch beim Kaltstart) und treibt die Hintergrund-Aktualisierung.
final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
static let shortcutNotification = Notification.Name("VorraniaShortcut")
static let expiryTapNotification = Notification.Name("VorraniaExpiryTap")
static let urlNotification = Notification.Name("VorraniaOpenURL")
static var pendingShortcut: String?
static var pendingExpiryProfile: String?
static var pendingURL: URL?
static let refreshTaskID = "com.scarriffle.vorrania.refresh"
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
if let item = launchOptions?[.shortcutItem] as? UIApplicationShortcutItem {
AppDelegate.pendingShortcut = item.type
}
UNUserNotificationCenter.current().delegate = self
// Der Handler muss registriert sein, bevor das Starten abgeschlossen ist.
BGTaskScheduler.shared.register(
forTaskWithIdentifier: AppDelegate.refreshTaskID, using: nil
) { task in
AppDelegate.handleBackgroundRefresh(task as? BGAppRefreshTask)
}
return true
}
// Bewusst die Variante mit Completion-Handler: Bei der async-Fassung muesste
// das nicht-Sendable UIApplicationShortcutItem ueber eine Actor-Grenze
// gereicht werden (Warnung heute, Fehler im Swift-6-Sprachmodus).
func application(_ application: UIApplication,
performActionFor shortcutItem: UIApplicationShortcutItem,
completionHandler: @escaping (Bool) -> Void) {
NotificationCenter.default.post(name: AppDelegate.shortcutNotification, object: shortcutItem.type)
completionHandler(true)
}
// Universal Link (…/i/<UID>) – auch beim Kaltstart, falls die SwiftUI-Hooks
// ihn nicht mitbekommen.
func application(_ application: UIApplication, continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let url = userActivity.webpageURL else { return false }
AppDelegate.pendingURL = url
NotificationCenter.default.post(name: AppDelegate.urlNotification, object: url)
return true
}
// MARK: - Benachrichtigungen
/// Auch im Vordergrund als Banner zeigen - sonst bliebe eine faellige
/// Erinnerung unbemerkt, solange die App offen ist.
func userNotificationCenter(_ center: UNUserNotificationCenter,
willPresent notification: UNNotification) async
-> UNNotificationPresentationOptions {
[.banner, .sound]
}
/// Antippen: auf den betroffenen Server wechseln und „Bald ablaufend" oeffnen.
func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse) async {
guard let profile = response.notification.request.content.userInfo["profileID"] as? String
else { return }
AppDelegate.pendingExpiryProfile = profile
NotificationCenter.default.post(name: AppDelegate.expiryTapNotification, object: profile)
}
// MARK: - Hintergrund-Aktualisierung
static func scheduleBackgroundRefresh() {
let request = BGAppRefreshTaskRequest(identifier: refreshTaskID)
// Fruehestens in ein paar Stunden - iOS entscheidet den genauen Zeitpunkt.
request.earliestBeginDate = Date(timeIntervalSinceNow: 4 * 3600)
try? BGTaskScheduler.shared.submit(request)
}
private static func handleBackgroundRefresh(_ task: BGAppRefreshTask?) {
guard let task else { return }
// Gleich den naechsten Lauf einplanen, sonst gibt es nur einen.
scheduleBackgroundRefresh()
let arbeit = Task { @MainActor in
await NotificationScheduler.shared.rescheduleAll()
task.setTaskCompleted(success: true)
}
task.expirationHandler = { arbeit.cancel() }
}
}