From ad5a1025653d5ede39e89247cdc4a75db09939f8 Mon Sep 17 00:00:00 2001 From: Eduard Wisch Date: Thu, 1 Oct 2026 10:00:16 +0200 Subject: [PATCH] Mit AWL-Konto anmelden: awlauth-Seite im Chrome-Tab statt Passwort in der App [apk] Der Knopf oeffnet die awlauth-Anmeldeseite im Chrome-Tab (@capacitor/browser), der die Anmeldung mit Chrome teilt - wer im Handy-Browser angemeldet ist, ist ohne Eingabe zurueck in der App. Ruecksprung ueber das eigene Schema de.data-it-solution.netdiag:/sso (Intent-Filter), abgesichert mit state und PKCE; verarbeitet im Layout (appUrlOpen + getLaunchUrl). Passwort und Fingerabdruck bleiben. Mitgenommen: die bisher nicht committete README-Ergaenzung vom 19.08. (Plugin-Dateien, "Was noch offen ist"). Co-Authored-By: Claude Sonnet 5 --- README.md | 51 ++++++++- android/app/src/main/AndroidManifest.xml | 13 +++ package.json | 1 + src/lib/api.ts | 28 +++++ src/lib/auth.svelte.ts | 18 +++- src/lib/sso.ts | 128 +++++++++++++++++++++++ src/routes/+layout.svelte | 29 +++++ src/routes/login/+page.svelte | 61 +++++++++-- 8 files changed, 320 insertions(+), 9 deletions(-) create mode 100644 src/lib/sso.ts diff --git a/README.md b/README.md index bcec546..db5d882 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ die APK in die Package Registry (`netdiag-apk`). Siehe `.forgejo/workflows/build src/lib/ api.ts JSON-API-Client (JWT, 401-Refresh, Timeout) auth.svelte.ts Anmelde-Status + sso.ts Anmeldung per AWL-Konto (Chrome-Tab, PKCE, Rücksprung) db.ts Offline-Speicher (SQLite nativ / localStorage Browser) sync.svelte.ts Sync-Queue -> Dolibarr (idempotent über clientUuid) scanner.ts Brücke zum nativen Plugin (+ Browser-Mock) @@ -69,6 +70,13 @@ src/routes/ Werkzeuge mit Live-Anzeige — eigene Seite statt Dialog, weil sie über Minuten/Stunden laufen android/ natives Android-Projekt inkl. Kotlin-Plugin (einzige Quelle) + NetDiagScannerPlugin.kt alle Plugin-Methoden (Scan, Monitor, WLAN, SIP, Update, + Biometrie) — @PluginMethod-Namen = JS-Aufrufe + Dns.kt / Snmp.kt / Sip.kt eigene Protokoll-Clients (UDP/TCP), je ein + Ergebnistyp statt null — ein nicht erreichbarer Dienst + darf nie wie ein fehlerfreier aussehen + Biometrie.kt BiometricPrompt + Keystore (siehe „Anmeldung") + MonitorService.kt Foreground-Service für Dauertest und Geräte-Monitor native-plugin/ nur noch Anleitung/Hinweise zum Plugin tools/ Prüfwerkzeuge ohne Gerät (siehe unten) ``` @@ -137,12 +145,31 @@ die im Feld nicht gilt (KB #1086). ## Anmeldung Die App hängt am zentralen Auth-Modul **awlauth** (7. AWL-App). Das Token kommt aus -`auth.php` und geht bei jeder Anfrage als `Authorization: Bearer` mit — auch beim +`auth.php` (Passwort) oder `sso.php` (AWL-Konto, siehe unten) und geht bei jeder Anfrage als +`Authorization: Bearer` mit — auch beim APK-Download und beim PDF-Abruf. **Nie in der Adresse:** ein Token in der URL steht in jedem Zugriffs- und Proxy-Log und gilt sieben Tage für die gesamte Kunden-API. (Einzige, befristete Ausnahme: `update.php?download=1` für App-Fassungen im Feld — siehe ChangeLog des Moduls 1.3.0.) +### Mit AWL-Konto anmelden (seit Modul 1.4.0 / awlauth 1.6.0) + +Knopf „Mit AWL-Konto anmelden" öffnet die awlauth-Anmeldeseite im **Chrome-Tab** +(`@capacitor/browser`). Der Tab teilt sich die Anmeldung mit Chrome: wer im Handy-Browser +schon angemeldet ist (PWA, ERP), ist ohne Eingabe zurück in der App. Sonst Passwort oder +Fingerabdruck auf der awlauth-Seite, danach „Zurück zu NetDiag". + +- Rücksprung über das eigene Schema `de.data-it-solution.netdiag:/sso?code=…&state=…` — + Intent-Filter im `AndroidManifest.xml`, Konstante `SSO_REDIRECT` in `src/lib/sso.ts`. + **Beide müssen übereinstimmen**, und awlauth vergleicht die Adresse Zeichen für Zeichen. +- PKCE (S256) + state: Geheimnis und state liegen bis zum Rücksprung in den Preferences + (Android darf die App beenden, während der Chrome-Tab vorne ist), gelten einmal und + höchstens zehn Minuten. +- Der Rücksprung wird im Layout verarbeitet (`appUrlOpen` + `getLaunchUrl`), nicht auf der + Login-Seite. +- Fragt `sso.php` (GET) `available:false` zurück (awlauth zu alt, App dort abgeschaltet), + zeigt die App den Grund; das Passwortfeld bleibt immer. + ### Fingerabdruck statt Passwort Einschaltbar unter Einstellungen → Konto. Was dabei passiert: @@ -174,7 +201,7 @@ verfügbar (KB #855). ## Bedienung -1. **Anmelden** (Dolibarr-Zugang; auf dem Gerät zusätzlich Server-URL). +1. **Anmelden** — „Mit AWL-Konto anmelden" oder Dolibarr-Zugang (auf dem Gerät zusätzlich Server-URL). 2. **Aufträge** — aktive direkt sichtbar, abgeschlossene per Checkbox, Suche. Alternativ über **Kunden** suchen. 3. Auftrag/Kunde antippen → Diagnose-Protokoll öffnet sich. @@ -188,6 +215,26 @@ verfügbar (KB #855). Selten gebrauchte Aktionen (Protokoll löschen) liegen hinter den drei Punkten in der Kopfzeile — bewusst nicht neben dem grünen Abschließen-Knopf. +## Was noch offen ist + +Die Arbeitsdateien `ROADMAP.md` / `ROADMAP_UMSETZUNG.md` sind abgearbeitet und entfernt +(19.08.2026). Was aus ihnen **nicht** erledigt war, steht hier — damit es nicht +mit den Dateien verschwindet: + +- **WLAN-Kanalanalyse auf echtem Empfang gegenprüfen.** Der Emulator hat keine + WLAN-Hardware und liefert grundsätzlich 0 Netze; alle Diagramme sind bisher nur mit + Demodaten geprüft. Auf dem Handy nachfahren: Kanal am Router wechseln, zwei + Momentaufnahmen vergleichen, Zeitverlauf ansehen. +- **Ortung-aus-Zweig (Android < 13).** Auf einem API-33-AVD nicht erreichbar, deshalb + nur per Codereview geprüft — die Meldung „Ortung ausschalten verhindert den WLAN-Scan" + samt Sprung in die Systemeinstellung ist am echten Altgerät noch nie gelaufen. +- **Vorschlags-Chips im ToolDialog.** Im Kopfbereich erledigt (`inputmode`), die Chips + für häufige Werte (Gateway, letzte Ziele) fehlen weiterhin. + +Dazu einer, der beim Modul liegt: die befristete `?jwt=`-Ausnahme in `update.php` fällt, +sobald die APK-Fassung ≥ 20260819-2141 überall installiert ist (Hinweis steht dort im +Code und im ChangeLog 1.3.0). + ## Ohne Gerät prüfen - **Browser** (`npm run dev`): Mock-Scanner liefert Beispieldaten. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index e99466a..b400f65 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -23,6 +23,19 @@ + + + + + + + + { + return request('sso.php'); +} + +/** + * Einmal-Code der awlauth-Seite einlösen — speichert das Token bei Erfolg. + * Antwortform wie bei `login()`. + */ +export async function ssoEinloesen(code: string, verifier: string): Promise { + const res = await request('sso.php', { + method: 'POST', + body: JSON.stringify({ code, code_verifier: verifier }), + }); + await setToken(res.token); + return res; +} + /** * Mit einem bereits vorhandenen Token anmelden (Fingerabdruck-Weg). * diff --git a/src/lib/auth.svelte.ts b/src/lib/auth.svelte.ts index 1408561..377862d 100644 --- a/src/lib/auth.svelte.ts +++ b/src/lib/auth.svelte.ts @@ -13,7 +13,9 @@ import { loginMitToken, setAuthFailureHandler, tokenNurImSpeicher, + type LoginResult, } from './api'; +import { ssoAbschliessen } from './sso'; import { scanner } from './scanner'; import type { NetUser } from './types'; @@ -112,7 +114,21 @@ class AuthState { /** Anmelden */ async login(loginName: string, password: string): Promise { - const res = await apiLogin(loginName, password); + await this.uebernehmen(await apiLogin(loginName, password)); + } + + /** + * Rücksprung von der awlauth-Anmeldeseite verarbeiten (siehe sso.ts). + * Liefert false, wenn gar keine Anmeldung offen war — dann ist nichts passiert. + */ + async loginMitSso(url: string): Promise { + const res = await ssoAbschliessen(url); + if (!res) return false; + await this.uebernehmen(res); + return true; + } + + private async uebernehmen(res: LoginResult): Promise { this.user = res.user; this.loggedIn = true; await Preferences.set({ key: 'user', value: JSON.stringify(res.user) }); diff --git a/src/lib/sso.ts b/src/lib/sso.ts new file mode 100644 index 0000000..7077816 --- /dev/null +++ b/src/lib/sso.ts @@ -0,0 +1,128 @@ +/** + * Anmeldung über die awlauth-Seite (SSO) statt Passwort in der App. + * + * Ablauf: + * 1. `ssoStarten()` fragt den Server (netdiag/api/sso.php), ob der Weg + * eingerichtet ist, erzeugt state + PKCE-Geheimnis und öffnet die + * awlauth-Seite im Chrome-Tab (`@capacitor/browser`). + * 2. Ist der Benutzer im Handy-Browser schon angemeldet (z. B. aus einer der + * PWAs), springt awlauth sofort zurück — sonst nach Passwort oder + * Fingerabdruck auf der awlauth-Seite. Rücksprung über das eigene Schema + * `de.data-it-solution.netdiag:/sso?code=…&state=…` (Intent-Filter im + * AndroidManifest). + * 3. `ssoAbschliessen(url)` prüft state, schließt den Tab und löst den Code + * mit dem PKCE-Geheimnis beim Server ein. + * + * Der Chrome-Tab und nicht der WebView: nur der Tab teilt sich den + * Cookie-Speicher mit Chrome — der WebView der App hat einen eigenen, dort + * käme eine bestehende Anmeldung nie an (KB #1451). + * + * state + Geheimnis liegen in den Preferences, nicht nur im Speicher: Android + * darf die App beenden, während der Benutzer im Chrome-Tab tippt. Der + * Rücksprung startet sie dann neu, und ohne die Ablage wäre die Anmeldung + * verloren. Der Eintrag gilt genau einmal und höchstens zehn Minuten. + */ + +import { Browser } from '@capacitor/browser'; +import { Preferences } from '@capacitor/preferences'; +import { ApiError, getServerUrl, ssoEinloesen, ssoKonfig, type LoginResult } from './api'; + +/** Rücksprungadresse — muss zum Intent-Filter im AndroidManifest passen */ +export const SSO_REDIRECT = 'de.data-it-solution.netdiag:/sso'; + +const OFFEN_KEY = 'sso_offen'; +const MAX_ALTER_MS = 10 * 60_000; + +interface OffeneAnmeldung { + state: string; + verifier: string; + seit: number; +} + +/** Base64url ohne Auffüllzeichen (RFC 7636 Anhang A) */ +function b64url(bytes: Uint8Array): string { + let s = ''; + for (const b of bytes) s += String.fromCharCode(b); + return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function zufall(laenge: number): string { + const a = new Uint8Array(laenge); + crypto.getRandomValues(a); + return b64url(a); +} + +/** PKCE-Prüfwert (S256) zum Geheimnis */ +export async function pkceChallenge(verifier: string): Promise { + const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)); + return b64url(new Uint8Array(hash)); +} + +/** Ist diese Adresse ein Rücksprung von der awlauth-Seite? */ +export function istSsoRuecksprung(url: string): boolean { + return url.startsWith(`${SSO_REDIRECT}?`); +} + +/** + * awlauth-Seite im Chrome-Tab öffnen. Wirft mit Klartextgrund, wenn der + * Server den Weg nicht anbietet (awlauth zu alt, App dort abgeschaltet …). + */ +export async function ssoStarten(): Promise { + const k = await ssoKonfig(); + if (!k.available) { + throw new ApiError(503, k.reason || 'Anmeldung per AWL-Konto ist auf dem Server nicht eingerichtet'); + } + if (k.redirectUri !== SSO_REDIRECT) { + throw new ApiError(0, 'Server und App passen nicht zusammen (Rücksprungadresse) — App aktualisieren'); + } + + const verifier = zufall(32); // 43 Zeichen + const state = zufall(16); + const challenge = await pkceChallenge(verifier); + const offen: OffeneAnmeldung = { state, verifier, seit: Date.now() }; + await Preferences.set({ key: OFFEN_KEY, value: JSON.stringify(offen) }); + + const q = new URLSearchParams({ + client_id: k.clientId, + redirect_uri: SSO_REDIRECT, + state, + code_challenge: challenge, + code_challenge_method: 'S256', + }); + await Browser.open({ url: `${getServerUrl()}${k.authorizePath}?${q.toString()}` }); +} + +/** + * Rücksprung verarbeiten. + * + * Liefert `null`, wenn gar keine Anmeldung offen ist — etwa weil derselbe + * Rücksprung beim Kaltstart einmal über `getLaunchUrl()` und einmal über das + * Ereignis ankommt. Das ist kein Fehler und wird still übergangen. + */ +export async function ssoAbschliessen(url: string): Promise { + const roh = (await Preferences.get({ key: OFFEN_KEY })).value; + if (!roh) return null; + // Einmalig: sofort entfernen, auch wenn das Einlösen gleich scheitert + await Preferences.remove({ key: OFFEN_KEY }); + + // Tab schließen, sobald die App wieder vorne ist — er hat seine Arbeit getan + Browser.close().catch(() => {}); + + let offen: OffeneAnmeldung; + try { + offen = JSON.parse(roh) as OffeneAnmeldung; + } catch { + return null; + } + if (!offen.verifier || Date.now() - offen.seit > MAX_ALTER_MS) { + throw new ApiError(0, 'Die Anmeldung hat zu lange gedauert — bitte erneut versuchen'); + } + + const params = new URL(url).searchParams; + const code = params.get('code') ?? ''; + // state schützt davor, eine fremde Anmeldung untergeschoben zu bekommen + if (!code || params.get('state') !== offen.state) { + throw new ApiError(0, 'Ungültige Rückmeldung der Anmeldeseite — bitte erneut versuchen'); + } + return ssoEinloesen(code, offen.verifier); +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 40a4773..6df000d 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -12,6 +12,9 @@ import { closeTopOverlay } from '$lib/overlay.svelte'; import { checkForUpdate, installUpdate, type UpdateInfo } from '$lib/updater'; import { initDebugLog } from '$lib/debuglog.svelte'; + import { istSsoRuecksprung } from '$lib/sso'; + import { App } from '@capacitor/app'; + import { Capacitor } from '@capacitor/core'; import Toast from '$lib/components/Toast.svelte'; let { children } = $props(); @@ -36,6 +39,24 @@ } }); + /** + * Rücksprung von der awlauth-Anmeldeseite (Anmeldung per AWL-Konto). + * Kommt je nach Lage als Ereignis (App lief im Hintergrund) oder als + * Start-Adresse (Android hatte die App beendet, während der Chrome-Tab vorne + * war). Ist keine Anmeldung offen, passiert nichts. + */ + async function ssoRuecksprung(url: string) { + if (!istSsoRuecksprung(url)) return; + try { + if (!(await auth.loginMitSso(url))) return; + await sync.start(); + toast.show('Angemeldet', 'success'); + goto(HOME); + } catch (e) { + toast.show(e instanceof Error ? e.message : 'Anmeldung fehlgeschlagen', 'error', 8000); + } + } + // Update herunterladen und Installer öffnen — Fortschritt im Banner async function runUpdate() { if (!updateInfo || updateBusy) return; @@ -56,6 +77,14 @@ await initDb(); if (auth.loggedIn) await sync.start(); + // Anmeldung per AWL-Konto: Rücksprung aus dem Chrome-Tab (erst nach + // auth.init(), sonst fehlt die Server-Adresse fürs Einlösen) + if (Capacitor.isNativePlatform()) { + void App.addListener('appUrlOpen', ({ url }) => void ssoRuecksprung(url)); + const start = await App.getLaunchUrl(); + if (start?.url) void ssoRuecksprung(start.url); + } + // Hardware-Backbutton (Modul-Scope, Single-Instance — KB #480/#549) registerBackListener({ handleOverlay: () => closeTopOverlay(), diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte index 496d593..0aea81c 100644 --- a/src/routes/login/+page.svelte +++ b/src/routes/login/+page.svelte @@ -5,13 +5,15 @@ import { getServerUrl, setServerUrl, ApiError } from '$lib/api'; import { toast } from '$lib/toast.svelte'; import { Capacitor } from '@capacitor/core'; - import { Eye, EyeOff, Fingerprint } from 'lucide-svelte'; + import { Eye, EyeOff, Fingerprint, KeyRound } from 'lucide-svelte'; + import { ssoStarten } from '$lib/sso'; let server = $state(getServerUrl()); let loginName = $state(''); let password = $state(''); let busy = $state(false); let fingerBusy = $state(false); + let ssoBusy = $state(false); /** * Fingerabdruck-Anmeldung. Erscheint nur, wenn auf diesem Gerät auch @@ -54,15 +56,41 @@ if (pwEl) pwEl.type = pwVisible ? 'text' : 'password'; } + /** Server-Adresse übernehmen — gilt für Passwort und AWL-Konto gleichermaßen */ + async function serverUebernehmen() { + if (!needsServer) return; + if (!server.trim()) throw new ApiError(0, 'Server-Adresse fehlt'); + await setServerUrl(server.trim()); + } + + /** + * Anmeldung per AWL-Konto: öffnet die awlauth-Seite im Chrome-Tab. Das + * Ergebnis kommt als Rücksprung im Layout an (+layout.svelte, ssoRuecksprung), + * nicht hier — die Seite kann bis dahin längst neu aufgebaut worden sein. + */ + async function mitAwlKonto() { + if (ssoBusy) return; + ssoBusy = true; + try { + await serverUebernehmen(); + await ssoStarten(); + } catch (err) { + toast.show( + err instanceof ApiError ? err.message : 'Anmeldeseite konnte nicht geöffnet werden', + 'error', + 8000, + ); + } finally { + ssoBusy = false; + } + } + async function submit(e: Event) { e.preventDefault(); if (busy) return; busy = true; try { - if (needsServer) { - if (!server.trim()) throw new ApiError(0, 'Server-Adresse fehlt'); - await setServerUrl(server.trim()); - } + await serverUebernehmen(); await auth.login(loginName.trim(), password); await sync.start(); toast.show('Angemeldet', 'success'); @@ -101,6 +129,25 @@ /> {/if} + + {#if needsServer} + + +
+ + oder mit Passwort + +
+ {/if} +