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
@@ -43,6 +43,13 @@ Beim Start nach der Server-Adresse fragen lassen, z. B. `http://192.168.1.50:808
|
||||
(dieselbe Adresse wie im Browser), dann mit deinem Benutzer anmelden. Adresse und
|
||||
Token bleiben gespeichert – das Token liegt im Keychain.
|
||||
|
||||
Hat der Server einen Anmeldedienst eingerichtet (Single Sign-on, siehe
|
||||
[../README.md](../README.md#single-sign-on-optional)), erscheint darunter ein
|
||||
Knopf „Mit … anmelden". Die Anmeldung läuft dann im System-Browser (AppAuth mit
|
||||
PKCE, kein eingebettetes WebView); der Anmeldezustand samt Refresh-Token liegt
|
||||
je Serverprofil getrennt im Keychain, und die App erneuert ein abgelaufenes
|
||||
Token still im Hintergrund.
|
||||
|
||||
## Home-Screen-Shortcuts
|
||||
Es gibt zwei Wege direkt in den Scan-Bildschirm:
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -10,6 +10,8 @@ struct LoginView: View {
|
||||
@State private var busy = false
|
||||
@State private var stayLoggedIn = Session.shared.stayLoggedIn
|
||||
@State private var manageShown = false
|
||||
/// Am eingetragenen Server eingerichtete Anmeldedienste. Leer = nur Passwort.
|
||||
@State private var anbieter: [OidcProviderResponse] = []
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -19,6 +21,12 @@ struct LoginView: View {
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.keyboardType(.URL)
|
||||
// Erst mit hinterlegter Adresse laesst sich der Server
|
||||
// nach seinen Anmeldediensten fragen.
|
||||
.onSubmit {
|
||||
session.setServer(server)
|
||||
Task { await anbieterLaden() }
|
||||
}
|
||||
// Mit mehreren Servern ist das hier der Weg zurueck, wenn man
|
||||
// am falschen gelandet ist.
|
||||
if session.profiles.count > 1 {
|
||||
@@ -49,6 +57,18 @@ struct LoginView: View {
|
||||
Button(busy ? "Anmelden…" : "Anmelden") { Task { await login() } }
|
||||
.disabled(busy || server.isEmpty || username.isEmpty)
|
||||
}
|
||||
// Nur wenn der Server einen App-Zugang eingerichtet hat. Ohne
|
||||
// Anmeldedienst sieht die Maske genauso aus wie bisher.
|
||||
if !anbieter.isEmpty {
|
||||
Section("Single Sign-on") {
|
||||
ForEach(anbieter) { dienst in
|
||||
Button("Mit \(dienst.displayName) anmelden") {
|
||||
Task { await ssoLogin(dienst) }
|
||||
}
|
||||
.disabled(busy || dienst.nativeClientID == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(session.activeProfile?.name ?? "Vorrania")
|
||||
.toolbar {
|
||||
@@ -60,7 +80,10 @@ struct LoginView: View {
|
||||
}
|
||||
// Beim Start und bei jedem Serverwechsel die Felder auf das aktive
|
||||
// Profil setzen - sonst steht die Adresse des vorigen Servers da.
|
||||
.task(id: session.activeProfileID) { syncFromProfile() }
|
||||
.task(id: session.activeProfileID) {
|
||||
syncFromProfile()
|
||||
await anbieterLaden()
|
||||
}
|
||||
}
|
||||
|
||||
private var profileSelection: Binding<UUID> {
|
||||
@@ -77,6 +100,36 @@ struct LoginView: View {
|
||||
error = nil
|
||||
}
|
||||
|
||||
/// Fragt den Server, welche Anmeldedienste er kennt. Der APIClient liest die
|
||||
/// Adresse aus der Sitzung – die Abfrage geht also erst, wenn ein Profil da
|
||||
/// ist. Ein Fehler bleibt still: der Passwort-Login muss auch dann gehen,
|
||||
/// wenn der Server alt ist und den Endpunkt gar nicht kennt.
|
||||
private func anbieterLaden() async {
|
||||
guard session.activeProfile != nil else { anbieter = []; return }
|
||||
anbieter = (try? await APIClient.shared.oidcProviders()) ?? []
|
||||
}
|
||||
|
||||
private func ssoLogin(_ dienst: OidcProviderResponse) async {
|
||||
error = nil
|
||||
busy = true
|
||||
defer { busy = false }
|
||||
session.setServer(server)
|
||||
guard let profilID = session.activeProfileID else { return }
|
||||
do {
|
||||
let (idToken, nonce) = try await OIDCLogin.shared.anmelden(
|
||||
provider: dienst, profileID: profilID
|
||||
)
|
||||
let token = try await APIClient.shared.oidcNativeLogin(
|
||||
slug: dienst.slug, idToken: idToken, nonce: nonce
|
||||
)
|
||||
session.store(token: token.accessToken, username: token.username,
|
||||
isAdmin: token.role == "admin", persist: stayLoggedIn,
|
||||
oidcSlug: dienst.slug)
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func login() async {
|
||||
error = nil
|
||||
busy = true
|
||||
|
||||
@@ -13,6 +13,48 @@ struct TokenResponse: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein am Server eingerichteter Anmeldedienst (Single Sign-on).
|
||||
///
|
||||
/// Kommt vom jeweiligen Server, nicht aus der App: So laesst sich ein weiterer
|
||||
/// Anbieter allein durch Serverkonfiguration ergaenzen, und bei mehreren
|
||||
/// Serverprofilen kann jedes einen eigenen haben.
|
||||
struct OidcProviderResponse: Codable, Identifiable {
|
||||
let slug: String
|
||||
let displayName: String
|
||||
let discoveryURL: String
|
||||
/// Client-ID des oeffentlichen App-Clients. Fehlt sie, ist fuer diesen
|
||||
/// Anbieter nur die Anmeldung im Webbrowser eingerichtet.
|
||||
let nativeClientID: String?
|
||||
let nativeRedirectURI: String
|
||||
let scopes: String
|
||||
|
||||
var id: String { slug }
|
||||
var scopeListe: [String] { scopes.split(separator: " ").map(String.init) }
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case slug
|
||||
case displayName = "display_name"
|
||||
case discoveryURL = "discovery_url"
|
||||
case nativeClientID = "native_client_id"
|
||||
case nativeRedirectURI = "native_redirect_uri"
|
||||
case scopes
|
||||
}
|
||||
}
|
||||
|
||||
/// Anfragekoerper fuer POST /auth/oidc/<slug>/native.
|
||||
struct OidcNativeLoginBody: Encodable {
|
||||
let idToken: String
|
||||
/// Die in der Anmeldeanfrage verwendete nonce. Nur beim ersten Anmelden
|
||||
/// vorhanden – ein per Refresh geholtes ID-Token traegt sie nicht
|
||||
/// zwangslaeufig, deshalb optional.
|
||||
let nonce: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case idToken = "id_token"
|
||||
case nonce
|
||||
}
|
||||
}
|
||||
|
||||
struct MeResponse: Codable {
|
||||
let id: Int
|
||||
let username: String
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,14 +17,22 @@ struct ServerProfile: Codable, Identifiable, Equatable {
|
||||
/// 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) {
|
||||
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) }
|
||||
|
||||
@@ -122,6 +122,7 @@ final class Session: ObservableObject {
|
||||
/// wenn der Server aus der Liste verschwindet.
|
||||
func removeProfile(id: UUID) {
|
||||
Keychain.delete(account: Session.keychainAccount(for: id))
|
||||
OIDCLogin.abmelden(profileID: id)
|
||||
NotificationStore.remove(for: id)
|
||||
AppDashboardStore.remove(for: id)
|
||||
profiles.removeAll { $0.id == id }
|
||||
@@ -166,7 +167,11 @@ final class Session: ObservableObject {
|
||||
|
||||
/// `persist == false` heisst: die Anmeldung gilt nur, solange die App laeuft.
|
||||
/// Auf dem Geraet bleibt dann nichts zurueck.
|
||||
func store(token newToken: String, username name: String, isAdmin admin: Bool, persist: Bool) {
|
||||
/// `oidcSlug` merkt, ueber welchen Anmeldedienst die Anmeldung lief – nur so
|
||||
/// weiss die App spaeter, wo sie ein abgelaufenes Token erneuern kann. Bei
|
||||
/// Passwort-Anmeldung bleibt es leer.
|
||||
func store(token newToken: String, username name: String, isAdmin admin: Bool,
|
||||
persist: Bool, oidcSlug: String? = nil) {
|
||||
token = newToken
|
||||
stayLoggedIn = persist
|
||||
UserDefaults.standard.set(persist, forKey: stayKey)
|
||||
@@ -175,6 +180,7 @@ final class Session: ObservableObject {
|
||||
let index = profiles.firstIndex(where: { $0.id == id }) else { return }
|
||||
profiles[index].isAdmin = admin
|
||||
profiles[index].username = persist ? name : ""
|
||||
profiles[index].oidcSlug = oidcSlug
|
||||
ProfileStore.save(profiles)
|
||||
|
||||
if persist {
|
||||
@@ -189,9 +195,13 @@ final class Session: ObservableObject {
|
||||
token = nil
|
||||
guard let id = activeProfileID else { return }
|
||||
Keychain.delete(account: Session.keychainAccount(for: id))
|
||||
// Der Anmeldezustand beim Dienst enthaelt das Refresh-Token. Bliebe er
|
||||
// liegen, waere man nach dem Abmelden faktisch weiter angemeldet.
|
||||
OIDCLogin.abmelden(profileID: id)
|
||||
if let index = profiles.firstIndex(where: { $0.id == id }) {
|
||||
profiles[index].username = ""
|
||||
profiles[index].isAdmin = false
|
||||
profiles[index].oidcSlug = nil
|
||||
ProfileStore.save(profiles)
|
||||
}
|
||||
}
|
||||
@@ -221,18 +231,29 @@ enum Keychain {
|
||||
private static let service = "com.scarriffle.vorrania"
|
||||
|
||||
static func write(_ value: String, account: String) {
|
||||
writeData(Data(value.utf8), account: account)
|
||||
}
|
||||
|
||||
static func read(account: String) -> String? {
|
||||
guard let data = readData(account: account) else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
/// Der AppAuth-Anmeldezustand ist ein Archiv und kein Text – deshalb neben
|
||||
/// den String-Varianten auch der direkte Weg ueber `Data`.
|
||||
static func writeData(_ value: Data, account: String) {
|
||||
delete(account: account)
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecValueData as String: Data(value.utf8),
|
||||
kSecValueData as String: value,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
|
||||
]
|
||||
SecItemAdd(query as CFDictionary, nil)
|
||||
}
|
||||
|
||||
static func read(account: String) -> String? {
|
||||
static func readData(account: String) -> Data? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
@@ -243,7 +264,7 @@ enum Keychain {
|
||||
var item: CFTypeRef?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
|
||||
let data = item as? Data else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
return data
|
||||
}
|
||||
|
||||
static func delete(account: String) {
|
||||
|
||||
@@ -75,7 +75,14 @@ struct VorraniaApp: App {
|
||||
.environmentObject(session)
|
||||
.environmentObject(router)
|
||||
.environmentObject(display)
|
||||
.onOpenURL { router.handle(url: $0) }
|
||||
.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) }
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
4C486D190D1AB338925D29B5 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */; };
|
||||
4F86620DF8303DB02314DB5A /* CheckInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0365A16FEAE3F2BEC321E68A /* CheckInView.swift */; };
|
||||
56A5DFAC86DF972B1481A691 /* ItemViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69B59B9F69BC30451D45A3C7 /* ItemViews.swift */; };
|
||||
56C15BE80330E00D82AF3BD1 /* OIDCLogin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C5F87AE1C7EA366862DB22E /* OIDCLogin.swift */; };
|
||||
5A0C3254B45CA13B1C3D9265 /* AssignScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76393ADA26304E5BC5AD7E7F /* AssignScanView.swift */; };
|
||||
6816C33381DF52A96E5BB303 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314B1BB7220691A7423E8929 /* Models.swift */; };
|
||||
728CF5124D2D5BBF826BCD2C /* ServerFetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1847E65D41ACEDD01CD108BC /* ServerFetch.swift */; };
|
||||
@@ -40,6 +41,7 @@
|
||||
D70CB183C868FF0143A6A5E7 /* DisplaySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */; };
|
||||
D7717F44D2CDB75DC2693B59 /* ObjectStockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B5BB49FBEFF88768B0BC902 /* ObjectStockView.swift */; };
|
||||
DFA55EF4ACA34537F445E998 /* Session.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD2F9406BD0D4F0D30FED345 /* Session.swift */; };
|
||||
E216EFADBC6D6AC39C0547DF /* AppAuth in Frameworks */ = {isa = PBXBuildFile; productRef = 31DDA98DEF7F8937A8719C80 /* AppAuth */; };
|
||||
E9EA455CACB6ED5951B114C3 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A75926511854BB8EE316ED3A /* LoginView.swift */; };
|
||||
ED61DAE4824165D6D87F8C1D /* DateScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 717C8EB336170526F5F3E695 /* DateScanView.swift */; };
|
||||
F3F718BFC60BF945CFA32BD2 /* AppDashboards.swift in Sources */ = {isa = PBXBuildFile; fileRef = 660ACEFA22BC77D52FBE736A /* AppDashboards.swift */; };
|
||||
@@ -76,6 +78,7 @@
|
||||
939C7B7C2F2F58927ED5F2F1 /* NotificationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsView.swift; sourceTree = "<group>"; };
|
||||
9948219CDDC4188EA4298F22 /* VorraniaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VorraniaApp.swift; sourceTree = "<group>"; };
|
||||
99E56353CB483EFB1CCD09D8 /* CheckInFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckInFormView.swift; sourceTree = "<group>"; };
|
||||
9C5F87AE1C7EA366862DB22E /* OIDCLogin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OIDCLogin.swift; sourceTree = "<group>"; };
|
||||
A6785CD9174067EFBB2B9043 /* ListViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListViews.swift; sourceTree = "<group>"; };
|
||||
A75926511854BB8EE316ED3A /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
|
||||
AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BestBeforeText.swift; sourceTree = "<group>"; };
|
||||
@@ -90,6 +93,17 @@
|
||||
EA706060734632DE78FA1073 /* ServerListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListView.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
6ABD1700B66B20DDD542B777 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
E216EFADBC6D6AC39C0547DF /* AppAuth in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
5216EA3D3DA41052FD0202A7 /* Sources */ = {
|
||||
isa = PBXGroup;
|
||||
@@ -121,6 +135,7 @@
|
||||
3731608B8D48960DC98912BC /* NotificationSettings.swift */,
|
||||
939C7B7C2F2F58927ED5F2F1 /* NotificationSettingsView.swift */,
|
||||
7B5BB49FBEFF88768B0BC902 /* ObjectStockView.swift */,
|
||||
9C5F87AE1C7EA366862DB22E /* OIDCLogin.swift */,
|
||||
6F574168AA0F849D46C384EE /* ProductDetailView.swift */,
|
||||
5312AFE88B7284401AA7B0A3 /* ProductPhotoView.swift */,
|
||||
369B9841E43E727ACA2E2A2A /* ProductViews.swift */,
|
||||
@@ -162,6 +177,7 @@
|
||||
buildPhases = (
|
||||
08E03A34841D4B2D2C7E2E81 /* Sources */,
|
||||
049B3FB4A4D974E54814E40C /* Resources */,
|
||||
6ABD1700B66B20DDD542B777 /* Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -169,6 +185,7 @@
|
||||
);
|
||||
name = Vorrania;
|
||||
packageProductDependencies = (
|
||||
31DDA98DEF7F8937A8719C80 /* AppAuth */,
|
||||
);
|
||||
productName = Vorrania;
|
||||
productReference = 4BF4E4F5524B15728CEEE116 /* Vorrania.app */;
|
||||
@@ -198,6 +215,9 @@
|
||||
);
|
||||
mainGroup = 5612AB3DD4CD09397A8F1BAB;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
AA6EA5DEB9627D245AB25D7E /* XCRemoteSwiftPackageReference "AppAuth-iOS" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 9BD53E938DBCB7F0CF657753 /* Products */;
|
||||
projectDirPath = "";
|
||||
@@ -250,6 +270,7 @@
|
||||
38133EE3EC920B462BDAA29F /* NotificationScheduler.swift in Sources */,
|
||||
4B4A74E8A2964FF8F3D2CE02 /* NotificationSettings.swift in Sources */,
|
||||
17032C3F3B89BAD6CB249443 /* NotificationSettingsView.swift in Sources */,
|
||||
56C15BE80330E00D82AF3BD1 /* OIDCLogin.swift in Sources */,
|
||||
D7717F44D2CDB75DC2693B59 /* ObjectStockView.swift in Sources */,
|
||||
AAF27320D2A67C994C29C26F /* ProductDetailView.swift in Sources */,
|
||||
4AC141151264AF22701079B6 /* ProductPhotoView.swift in Sources */,
|
||||
@@ -452,6 +473,25 @@
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
AA6EA5DEB9627D245AB25D7E /* XCRemoteSwiftPackageReference "AppAuth-iOS" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/openid/AppAuth-iOS";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 1.7.6;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
31DDA98DEF7F8937A8719C80 /* AppAuth */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = AA6EA5DEB9627D245AB25D7E /* XCRemoteSwiftPackageReference "AppAuth-iOS" */;
|
||||
productName = AppAuth;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = B35524A6F4D28034E6687EE2 /* Project object */;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"originHash" : "c2c3123823fbf9ecb5ff108c887e3a41cb72f13d86620f12b66cac13738096c1",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "appauth-ios",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/openid/AppAuth-iOS",
|
||||
"state" : {
|
||||
"revision" : "2781038865a80e2c425a1da12cc1327bcd56501f",
|
||||
"version" : "1.7.6"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
@@ -12,6 +12,14 @@ options:
|
||||
iOS: "16.0"
|
||||
createIntermediateGroups: true
|
||||
|
||||
# Erste (und einzige) Fremdabhaengigkeit: AppAuth uebernimmt den
|
||||
# OpenID-Connect-Anmeldevorgang. Selbst gebaut waere das ein Sicherheitsrisiko –
|
||||
# PKCE, State-Pruefung und der Ablauf im System-Browser stecken hier drin.
|
||||
packages:
|
||||
AppAuth:
|
||||
url: https://github.com/openid/AppAuth-iOS
|
||||
from: 1.7.6
|
||||
|
||||
targets:
|
||||
Vorrania:
|
||||
type: application
|
||||
@@ -26,6 +34,9 @@ targets:
|
||||
- Info.plist
|
||||
# App-Icon im Icon-Composer-Format (Xcode 26).
|
||||
- path: AppIcon.icon
|
||||
dependencies:
|
||||
- package: AppAuth
|
||||
product: AppAuth
|
||||
settings:
|
||||
base:
|
||||
INFOPLIST_FILE: Sources/Info.plist
|
||||
|
||||
Reference in New Issue
Block a user