feat(auth): implement token rotation and session management for persistent Android login
This commit is contained in:
@@ -12,7 +12,8 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
|
||||
- Bild-Loading: Coil
|
||||
- Lokale DB / Caching: Room + DataStore (Preferences)
|
||||
- Background/Sync: WorkManager
|
||||
- Auth: JWT-Handling, Unterstützung für Passkeys (Android Passkeys / WebAuthn Interop über FIDO2 APIs)
|
||||
- Auth: kurzlebiges JWT-Access-Token plus rotierendes, widerrufbares Refresh-Token pro Android-Gerätesitzung; Speicherung in `EncryptedSharedPreferences`/Android Keystore; Unterstützung für Passkeys (Android Passkeys / WebAuthn Interop über FIDO2 APIs)
|
||||
- Auth-Sicherheitsentscheidung: kein statischer App-Key bzw. kein in der APK hinterlegtes Client-Secret. Native Apps können ein gemeinsames Secret nicht vertraulich halten. Optional später: Refresh-Sitzung an ein pro Installation im Android Keystore erzeugtes Schlüsselpaar binden.
|
||||
- Rich-Text: WebView-basierte Anzeige; Editoren: ggf. hybride Lösung (Server-side HTML editor + WebView) oder `RichEditor`-Libs
|
||||
- Crash-Reporting & Monitoring: Firebase Crashlytics oder Sentry
|
||||
|
||||
@@ -96,10 +97,18 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
|
||||
- [x] Mitgliedschafts-PDF-Endpunkte mit Cookie-Jar und `FileProvider`
|
||||
- [x] Passwort-Login-Endpunkt und Token-Übergabe an den Interceptor
|
||||
- [x] Verschlüsselte Token-Persistenz sowie Status/Logout per Bearer-Token
|
||||
- [ ] Bearer-Unterstützung aller später portierten geschützten Bereiche und Refresh-Strategie
|
||||
- [ ] Bearer-Unterstützung aller später portierten geschützten Bereiche
|
||||
- [ ] `POST /api/auth/refresh` anbinden und Access-Token bei Ablauf automatisch erneuern
|
||||
- [ ] OkHttp-`Authenticator` mit genau einem synchronisierten Refresh-Versuch pro fehlgeschlagenem Request ergänzen
|
||||
[x] 12. Auth: Login/Register/Logout + sichere Token-Speicherung (EncryptedSharedPreferences)
|
||||
- [x] Login/Logout und verschlüsselte Token-Speicherung
|
||||
- [x] Registrierung und Passwort-Reset
|
||||
- [ ] Backend: JWT-Access-Token von aktuell 7 Tagen auf kurze Laufzeit (Ziel: ca. 15 Minuten) reduzieren
|
||||
- [ ] Backend: langlebige, zufällige Refresh-Token pro Gerätesitzung mit serverseitig gespeichertem Token-Hash einführen
|
||||
- [ ] Backend: Refresh-Token bei jeder Erneuerung rotieren und Wiederverwendung eines verbrauchten Tokens als Sitzungsdiebstahl behandeln
|
||||
- [ ] Backend: Logout, Kontodeaktivierung und Passwortänderung widerrufen betroffene Refresh-Sitzungen
|
||||
- [ ] App: Access- und Refresh-Token verschlüsselt speichern, Sitzung beim App-Start durch Refresh wiederherstellen
|
||||
- [ ] Optional nach MVP: pro Installation ein nicht exportierbares Keystore-Schlüsselpaar erzeugen und Refresh-Requests daran binden
|
||||
[ ] 13. Passkeys: Integration prüfen (FIDO2 / Passkeys) und Fallback auf Passwort
|
||||
[ ] 14. Image-Upload: Multipart-Upload + Coil für Anzeige + Bildkompression (u. a. Sharp-Äquivalent evtl. serverseitig)
|
||||
[ ] 15. Rich-Text: Anzeige von HTML (Compose + WebView) und ggf. Editor via WebView-bridge
|
||||
@@ -118,12 +127,14 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
|
||||
- [x] A. Auth (Login/Logout)
|
||||
- [x] Passwort-Login und Logout in der aktuellen App-Sitzung
|
||||
- [x] Persistente Statuswiederherstellung/Logout für die Auth-Endpunkte
|
||||
- [ ] Dauerhaftes Eingeloggtbleiben durch rotierendes Refresh-Token pro Android-Gerätesitzung
|
||||
- [x] B. Home, Termine, Spielplan, Galerie (anzeigen)
|
||||
- [x] C. Kontaktformular (absenden)
|
||||
- [ ] D. Bildanzeige + Caching
|
||||
- [x] E. Theme & Fonts
|
||||
|
||||
6) Nächste Aktionen (sofort)
|
||||
- Dauerhaftes Android-Login umsetzen: Backend-Refresh-Sitzungen, Token-Rotation, serverseitigen Widerruf und App-Refresh-Flow ergänzen.
|
||||
- Passkey-Anmeldung über Android Credential Manager anbinden.
|
||||
- Die noch fehlenden öffentlichen Routen aus `10a` und die Newsletter-Screens aus `10b` nativ portieren.
|
||||
- Saisonwahl für Mannschaftsübersicht/-details wie in der Web-UI ergänzen.
|
||||
@@ -146,6 +157,7 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
|
||||
- 2026-05-27: Mannschaftsdetail um die Web-Untertabs `Matches` und `Tabelle` erweitert; Tabellenzeilen werden aus `/api/spielplan/table` geladen und die eigene Mannschaft hervorgehoben.
|
||||
- 2026-05-27: Tabellenraster in den Mannschaftsdetails mit gemeinsamen Spaltenbreiten für Tablet und Smartphone ausgerichtet; die Zustandswiederherstellung dynamischer Mannschaftslinks korrigiert.
|
||||
- 2026-05-27: Die verbleibenden öffentlichen Screens aus Punkt 10 portiert: Verein/CMS-Inhalte, Vorstand, Satzung/PDF, Links, Vereinsmeisterschaften mit Personenbild-Dialog, Spielsysteme und TT-Regeln.
|
||||
- 2026-05-27: Architektur für dauerhaftes Android-Login festgelegt: kein eingebetteter App-Key, sondern kurzlebige Access-Tokens und rotierende, widerrufbare Refresh-Tokens pro Gerätesitzung; optionale spätere Gerätebindung per Keystore-Schlüsselpaar.
|
||||
|
||||
8) Android-Testumgebungen
|
||||
- Lokal im Emulator: `./gradlew :app:installLocalDebug` verwendet `http://10.0.2.2:3100/` und die App-ID `de.harheimertc.local`.
|
||||
@@ -154,5 +166,31 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
|
||||
- Produktion: `./gradlew :app:installProductionDebug` verwendet `https://harheimertc.de/` und die App-ID `de.harheimertc`.
|
||||
- Nur APKs erzeugen: `./gradlew :app:assembleLocalDebug :app:assembleInstantTestDebug :app:assembleProductionDebug`.
|
||||
|
||||
9) Dauerhaftes Android-Login: Architektur und Umsetzung
|
||||
- Ausgangslage: Das Backend gibt derzeit ein sieben Tage gültiges JWT aus. Die App speichert es bereits verschlüsselt und sendet es als Bearer-Token. Die vorhandene serverseitige Sessiondatei wird beim Authentifizieren geschützter Requests derzeit nicht zur Widerrufsprüfung herangezogen.
|
||||
- Ziel: Ein Benutzer bleibt auf einem bekannten Gerät angemeldet, ohne dass ein langfristig gültiges Bearer-JWT oder ein extrahierbares App-Secret verwendet wird.
|
||||
- Token-Modell:
|
||||
- Access-Token: JWT mit kurzer Laufzeit, Zielwert ca. 15 Minuten; wird für normale API-Requests als Bearer-Token verwendet.
|
||||
- Refresh-Token: kryptografisch zufälliges, undurchsichtiges Token mit längerer Laufzeit, Zielwert z. B. 90 Tage mit Erneuerung bei aktiver Nutzung.
|
||||
- Server speichert ausschließlich den Hash des Refresh-Tokens zusammen mit `sessionId`, `userId`, `createdAt`, `lastUsedAt`, `expiresAt`, `revokedAt` und optional Gerätebezeichnung.
|
||||
- Backend-Arbeitspaket:
|
||||
- Login-Antwort um `accessToken`, `refreshToken`, `sessionId` und Ablaufmetadaten erweitern; bestehendes `token` nur befristet kompatibel halten.
|
||||
- `POST /api/auth/refresh` implementieren: gültiges Refresh-Token konsumieren, rotieren und ein neues Token-Paar zurückgeben.
|
||||
- Token-Wiederverwendung erkennen: Wird ein rotiertes Refresh-Token erneut präsentiert, die betroffene Token-Familie bzw. Gerätesitzung widerrufen.
|
||||
- `POST /api/auth/logout` auf Widerruf der Gerätesitzung erweitern; optional Endpunkte zum Anzeigen und Widerrufen eigener Geräte-Sitzungen vorsehen.
|
||||
- Kontodeaktivierung und Passwortänderung müssen sämtliche Refresh-Sitzungen des Benutzers widerrufen.
|
||||
- Rate-Limits und Audit-Events für Login, Refresh-Erfolg/-Fehlschlag, Wiederverwendung und Widerruf ergänzen.
|
||||
- Android-Arbeitspaket:
|
||||
- `AuthRepository` auf Access-Token, Refresh-Token und Session-ID erweitern; Speicherung weiter über Keystore-geschützte Preferences.
|
||||
- `ApiService`/DTOs um Refresh-Request und Token-Paar-Antwort ergänzen.
|
||||
- Einen OkHttp-`Authenticator` einsetzen, der auf `401` einmalig ein Access-Token erneuert, parallele Refreshes synchronisiert und den ursprünglichen Request wiederholt.
|
||||
- Beim App-Start zunächst Access-Token prüfen und bei Ablauf transparent mit dem Refresh-Token erneuern; nur bei fehlgeschlagenem Refresh zum Login zurückkehren.
|
||||
- Beim Logout lokale Tokens auch bei Netzwerkfehler entfernen; serverseitiger Widerruf erfolgt best effort bzw. bei nächster Konnektivität.
|
||||
- Sicherheitsregeln:
|
||||
- Kein gemeinsamer App-Key und kein statisches Client-Secret in Sourcecode, `BuildConfig` oder APK.
|
||||
- Refresh-Tokens nie im Klartext serverseitig speichern oder protokollieren.
|
||||
- Nur HTTPS für Test-/Produktionsumgebungen; Token-Werte nicht in Logging-Interceptors ausgeben.
|
||||
- Optional nach MVP: App erzeugt pro Installation ein Keystore-Schlüsselpaar; Backend bindet Refresh-Sitzungen an den öffentlichen Schlüssel und prüft signierte Refresh-Anfragen.
|
||||
|
||||
---
|
||||
Datei: [ANDROID_KOTLIN_PLAN.md](ANDROID_KOTLIN_PLAN.md)
|
||||
|
||||
@@ -403,6 +403,7 @@ const isEditing = ref(false)
|
||||
const editingIndex = ref(-1)
|
||||
const formData = ref({ mannschaft: '', liga: '', staffelleiter: '', telefon: '', heimspieltag: '', spielsystem: '', mannschaftsfuehrer: '', spielerListe: [], weitere_informationen_link: '', letzte_aktualisierung: '' })
|
||||
const moveTargetBySpielerId = ref({})
|
||||
const initialMoveTargetBySpielerId = ref({})
|
||||
const pendingSpielerNamesByTeamIndex = ref({})
|
||||
|
||||
function nowIsoDate() { return new Date().toISOString().split('T')[0] }
|
||||
@@ -449,7 +450,7 @@ async function loadSeasons() {
|
||||
if (!selectedSeason.value || !seasons.value.includes(selectedSeason.value)) {
|
||||
selectedSeason.value = result.defaultSeason || seasons.value[0]
|
||||
}
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
if (!seasons.value.length) seasons.value = ['']
|
||||
if (!selectedSeason.value) selectedSeason.value = seasons.value[0] || ''
|
||||
}
|
||||
@@ -461,7 +462,7 @@ const mannschaftenSelectOptions = computed(() => {
|
||||
return [...new Set([current, ...names])].filter(Boolean)
|
||||
})
|
||||
|
||||
function resetSpielerDraftState() { moveTargetBySpielerId.value = {}; pendingSpielerNamesByTeamIndex.value = {} }
|
||||
function resetSpielerDraftState() { moveTargetBySpielerId.value = {}; initialMoveTargetBySpielerId.value = {}; pendingSpielerNamesByTeamIndex.value = {} }
|
||||
function getPendingSpielerNamesForTeamIndex(teamIndex) {
|
||||
if (pendingSpielerNamesByTeamIndex.value[teamIndex]) return pendingSpielerNamesByTeamIndex.value[teamIndex]
|
||||
const existing = mannschaften.value[teamIndex]; const list = existing ? getSpielerListe(existing) : []
|
||||
@@ -497,29 +498,60 @@ const openEditModal = (mannschaft, index) => {
|
||||
formData.value = { mannschaft: mannschaft.mannschaft || '', liga: mannschaft.liga || '', staffelleiter: mannschaft.staffelleiter || '', telefon: mannschaft.telefon || '', heimspieltag: mannschaft.heimspieltag || '', spielsystem: mannschaft.spielsystem || '', mannschaftsfuehrer: mannschaft.mannschaftsfuehrer || '', spielerListe: parseSpielerString(mannschaft.spieler || ''), weitere_informationen_link: mannschaft.weitere_informationen_link || '', letzte_aktualisierung: mannschaft.letzte_aktualisierung || nowIsoDate() }
|
||||
isEditing.value = true; editingIndex.value = index; showModal.value = true; errorMessage.value = ''; resetSpielerDraftState()
|
||||
const currentTeam = (formData.value.mannschaft || '').trim()
|
||||
for (const s of formData.value.spielerListe) { moveTargetBySpielerId.value[s.id] = currentTeam }
|
||||
for (const s of formData.value.spielerListe) {
|
||||
moveTargetBySpielerId.value[s.id] = currentTeam
|
||||
initialMoveTargetBySpielerId.value[s.id] = currentTeam
|
||||
}
|
||||
}
|
||||
const addSpieler = () => {
|
||||
const item = newSpielerItem('')
|
||||
const currentTeam = (formData.value.mannschaft || '').trim()
|
||||
formData.value.spielerListe.push(item)
|
||||
moveTargetBySpielerId.value[item.id] = currentTeam
|
||||
initialMoveTargetBySpielerId.value[item.id] = currentTeam
|
||||
}
|
||||
const removeSpieler = (id) => {
|
||||
const idx = formData.value.spielerListe.findIndex(s => s.id === id)
|
||||
if (idx === -1) return
|
||||
formData.value.spielerListe.splice(idx, 1)
|
||||
delete moveTargetBySpielerId.value[id]
|
||||
delete initialMoveTargetBySpielerId.value[id]
|
||||
}
|
||||
const addSpieler = () => { const item = newSpielerItem(''); formData.value.spielerListe.push(item); moveTargetBySpielerId.value[item.id] = (formData.value.mannschaft || '').trim() }
|
||||
const removeSpieler = (id) => { const idx = formData.value.spielerListe.findIndex(s => s.id === id); if (idx === -1) return; formData.value.spielerListe.splice(idx, 1); if (moveTargetBySpielerId.value[id]) delete moveTargetBySpielerId.value[id] }
|
||||
const moveSpielerUp = (index) => { if (index <= 0) return; const arr = formData.value.spielerListe; const item = arr[index]; arr.splice(index, 1); arr.splice(index - 1, 0, item) }
|
||||
const moveSpielerDown = (index) => { const arr = formData.value.spielerListe; if (index < 0 || index >= arr.length - 1) return; const item = arr[index]; arr.splice(index, 1); arr.splice(index + 1, 0, item) }
|
||||
const canMoveSpieler = (id) => { const t = (moveTargetBySpielerId.value[id] || '').trim(); const c = (formData.value.mannschaft || '').trim(); return Boolean(t) && Boolean(c) && t !== c }
|
||||
const canMoveSpieler = (id) => {
|
||||
const target = (moveTargetBySpielerId.value[id] || '').trim()
|
||||
const initialTarget = (initialMoveTargetBySpielerId.value[id] || '').trim()
|
||||
return Boolean(target) && Boolean(initialTarget) && target !== initialTarget
|
||||
}
|
||||
|
||||
const moveSpielerToMannschaft = (spielerId) => {
|
||||
if (!isEditing.value || editingIndex.value < 0) return
|
||||
const targetName = (moveTargetBySpielerId.value[spielerId] || '').trim(); if (!targetName) return
|
||||
if (!isEditing.value || editingIndex.value < 0) return false
|
||||
const targetName = (moveTargetBySpielerId.value[spielerId] || '').trim(); if (!targetName) return false
|
||||
const targetIndex = mannschaften.value.findIndex((m, idx) => { if (idx === editingIndex.value) return false; return (m?.mannschaft || '').trim() === targetName })
|
||||
if (targetIndex === -1) { errorMessage.value = 'Ziel-Mannschaft nicht gefunden.'; return }
|
||||
const idx = formData.value.spielerListe.findIndex(s => s.id === spielerId); if (idx === -1) return
|
||||
const spielerName = (formData.value.spielerListe[idx]?.name || '').trim(); if (!spielerName) { errorMessage.value = 'Bitte zuerst einen Spielernamen eintragen.'; return }
|
||||
if (targetIndex === -1) { errorMessage.value = 'Ziel-Mannschaft nicht gefunden.'; return false }
|
||||
const idx = formData.value.spielerListe.findIndex(s => s.id === spielerId); if (idx === -1) return false
|
||||
const spielerName = (formData.value.spielerListe[idx]?.name || '').trim(); if (!spielerName) { errorMessage.value = 'Bitte zuerst einen Spielernamen eintragen.'; return false }
|
||||
formData.value.spielerListe.splice(idx, 1)
|
||||
const pendingList = getPendingSpielerNamesForTeamIndex(targetIndex); pendingList.push(spielerName)
|
||||
delete moveTargetBySpielerId.value[spielerId]
|
||||
delete initialMoveTargetBySpielerId.value[spielerId]
|
||||
return true
|
||||
}
|
||||
|
||||
const applySelectedSpielerTransfers = () => {
|
||||
if (!isEditing.value || editingIndex.value < 0) return true
|
||||
const pendingIds = formData.value.spielerListe
|
||||
.filter(spieler => canMoveSpieler(spieler.id))
|
||||
.map(spieler => spieler.id)
|
||||
|
||||
return pendingIds.every(spielerId => moveSpielerToMannschaft(spielerId))
|
||||
}
|
||||
|
||||
const saveMannschaft = async () => {
|
||||
isSaving.value = true; errorMessage.value = ''
|
||||
try {
|
||||
if (!applySelectedSpielerTransfers()) return
|
||||
const spielerString = serializeSpielerList(formData.value.spielerListe)
|
||||
const updated = { mannschaft: formData.value.mannschaft || '', liga: formData.value.liga || '', staffelleiter: formData.value.staffelleiter || '', telefon: formData.value.telefon || '', heimspieltag: formData.value.heimspieltag || '', spielsystem: formData.value.spielsystem || '', mannschaftsfuehrer: formData.value.mannschaftsfuehrer || '', spieler: spielerString, weitere_informationen_link: formData.value.weitere_informationen_link || '', letzte_aktualisierung: formData.value.letzte_aktualisierung || nowIsoDate() }
|
||||
if (isEditing.value && editingIndex.value >= 0) { mannschaften.value[editingIndex.value] = { ...updated } } else { mannschaften.value.push({ ...updated }) }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "harheimertc-website",
|
||||
"version": "1.5.2",
|
||||
"version": "1.6.0",
|
||||
"description": "Moderne Webseite für den Harheimer Tischtennis Club",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
Reference in New Issue
Block a user