+ ) : null}
- {mode === 'login' && (
+ {mode === 'login' && !challengeToken && (
setPreview(null)}
onReport={
(preview.lifecycle_state || 'active') === 'active' && !preview.legal_hold_active
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js
index 0b1774a..a004f82 100644
--- a/frontend/src/utils/api.js
+++ b/frontend/src/utils/api.js
@@ -25,6 +25,46 @@ export async function login(email, password) {
})
}
+export async function loginTotp(challengeToken, code) {
+ return request('/api/auth/login/totp', {
+ method: 'POST',
+ body: JSON.stringify({ challenge_token: challengeToken, code }),
+ })
+}
+
+export async function getTotpStatus() {
+ return request('/api/auth/totp/status')
+}
+
+export async function setupTotp() {
+ return request('/api/auth/totp/setup', { method: 'POST', body: '{}' })
+}
+
+export async function confirmTotp(code) {
+ return request('/api/auth/totp/confirm', {
+ method: 'POST',
+ body: JSON.stringify({ code }),
+ })
+}
+
+export async function regenerateRecoveryCodes(currentPassword, code) {
+ return request('/api/auth/totp/recovery-codes', {
+ method: 'POST',
+ body: JSON.stringify({ current_password: currentPassword, code }),
+ })
+}
+
+export async function disableTotp(currentPassword, code) {
+ return request('/api/auth/totp/disable', {
+ method: 'POST',
+ body: JSON.stringify({ current_password: currentPassword, code }),
+ })
+}
+
+export async function issueMediaGrant() {
+ return request('/api/auth/media-grant', { method: 'POST', body: '{}' })
+}
+
export async function register(email, password, name, extra = {}) {
return request('/api/auth/register', {
method: 'POST',
@@ -111,6 +151,11 @@ export async function updateProfile(profileId, data) {
})
}
+/** Superadmin: Zwei-Faktor eines anderen Kontos abschalten. */
+export async function resetUserTotp(profileId) {
+ return request(`/api/profiles/${profileId}/totp-reset`, { method: 'POST', body: '{}' })
+}
+
/**
* Passwort anderer Konten: Standard leerer Body → E-Mail mit Reset-Link (wie Passwort vergessen).
* Nur Super-Admins dürfen `newPassword` setzen (direktes Überschreiben des Passwort-Hashes).
@@ -126,10 +171,13 @@ export async function managementPasswordReset(profileId, newPassword = null) {
})
}
-export async function changePassword(newPassword) {
+export async function changePassword(currentPassword, newPassword) {
return request('/api/auth/pin', {
method: 'PUT',
- body: JSON.stringify({ pin: newPassword }),
+ body: JSON.stringify({
+ current_password: currentPassword,
+ new_password: newPassword,
+ }),
})
}
@@ -992,6 +1040,13 @@ export async function healthCheck() {
export const api = {
// Auth
login,
+ loginTotp,
+ getTotpStatus,
+ setupTotp,
+ confirmTotp,
+ regenerateRecoveryCodes,
+ disableTotp,
+ issueMediaGrant,
register,
logout,
getCurrentProfile,
@@ -1005,6 +1060,7 @@ export const api = {
deleteAdminUserContentItem,
updateProfile,
managementPasswordReset,
+ resetUserTotp,
changePassword,
verifyEmail,
resendVerification,
diff --git a/frontend/src/utils/exerciseMediaUrl.js b/frontend/src/utils/exerciseMediaUrl.js
index 9ca4026..48d71f2 100644
--- a/frontend/src/utils/exerciseMediaUrl.js
+++ b/frontend/src/utils/exerciseMediaUrl.js
@@ -1,31 +1,77 @@
-/** URL für Übungs-Mediendateien: API mit Token (Legacy /media/ ohne Auth ist abgeschaltet). */
+/** URL für Übungs-Mediendateien. Query-Token ist ein kurzlebiges Medien-Grant, kein Sitzungstoken. */
+
+import { useEffect, useState } from 'react'
+import api from './api'
const API_BASE = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '')
+let grant = { token: '', expiresAt: 0 }
+const listeners = new Set()
+
+function notify() {
+ listeners.forEach((fn) => fn())
+}
+
+export function peekMediaAccessToken() {
+ if (grant.expiresAt > Date.now() + 15_000) return grant.token
+ return ''
+}
+
+export function clearMediaGrant() {
+ grant = { token: '', expiresAt: 0 }
+ notify()
+}
+
+export async function refreshMediaGrant() {
+ const data = await api.issueMediaGrant()
+ const expiresAt = data?.expires_at ? new Date(data.expires_at).getTime() : Date.now() + 9 * 60 * 1000
+ grant = { token: data?.token || '', expiresAt }
+ notify()
+ return grant.token
+}
+
+export function useMediaAccessToken() {
+ const [token, setToken] = useState(peekMediaAccessToken)
+ useEffect(() => {
+ const pull = () => setToken(peekMediaAccessToken())
+ listeners.add(pull)
+ pull()
+ if (!peekMediaAccessToken()) {
+ refreshMediaGrant().catch(() => {})
+ }
+ return () => listeners.delete(pull)
+ }, [])
+ return token
+}
+
+function withGrant(path, accessToken) {
+ const token = accessToken || peekMediaAccessToken()
+ const q = token ? `?ssetoken=${encodeURIComponent(token)}` : ''
+ return `${API_BASE}${path}${q}`
+}
+
/**
* @param {number|string} exerciseId
* @param {object} media — exercise_media Zeile mit id, file_path
+ * @param {string} [accessToken]
* @returns {string|null}
*/
-export function resolveExerciseMediaFileUrl(exerciseId, media) {
+export function resolveExerciseMediaFileUrl(exerciseId, media, accessToken) {
if (!media?.file_path) return null
const fp = String(media.file_path)
if (fp.startsWith('http://') || fp.startsWith('https://')) return fp
- const token = typeof localStorage !== 'undefined' ? localStorage.getItem('authToken') : ''
- const q = token ? `?ssetoken=${encodeURIComponent(token)}` : ''
const id = media.id
if (id == null || exerciseId == null) return null
- return `${API_BASE}/api/exercises/${exerciseId}/media/${id}/file${q}`
+ return withGrant(`/api/exercises/${exerciseId}/media/${id}/file`, accessToken)
}
/**
* Direkt-URL für Archiv-Asset (Picker/Vorschau ohne exercise_media-Zeile).
* @param {number|string} assetId — media_assets.id
+ * @param {string} [accessToken]
* @returns {string|null}
*/
-export function resolveMediaAssetFileUrl(assetId) {
+export function resolveMediaAssetFileUrl(assetId, accessToken) {
if (assetId == null) return null
- const token = typeof localStorage !== 'undefined' ? localStorage.getItem('authToken') : ''
- const q = token ? `?ssetoken=${encodeURIComponent(token)}` : ''
- return `${API_BASE}/api/media-assets/${assetId}/file${q}`
+ return withGrant(`/api/media-assets/${assetId}/file`, accessToken)
}
diff --git a/frontend/src/utils/exerciseRichTextSanitize.js b/frontend/src/utils/exerciseRichTextSanitize.js
index 6a99dd2..4314c7c 100644
--- a/frontend/src/utils/exerciseRichTextSanitize.js
+++ b/frontend/src/utils/exerciseRichTextSanitize.js
@@ -1,111 +1,79 @@
/**
* Sanitizer für Übungs-Rich-HTML inkl. §11 Platzhalter (span data-shinkan-exercise-media).
- * Restriktiver als sanitizeTrainerHtml: Allowlist für XSS-Minimierung.
+ * DOMPurify mit fester Allowlist.
*/
+import DOMPurify from 'dompurify'
import { sanitizeInlineMediaCaption } from './inlineMediaCaption'
-const ALLOWED_BLOCK = new Set(['p', 'div', 'br', 'ul', 'ol', 'li', 'h3'])
-const ALLOWED_INLINE = new Set(['b', 'strong', 'i', 'em', 'u', 'span', 'a'])
+const ALLOWED_TAGS = ['p', 'div', 'br', 'ul', 'ol', 'li', 'h3', 'b', 'strong', 'i', 'em', 'u', 'span', 'a']
+const ALLOWED_ATTR = [
+ 'href',
+ 'class',
+ 'data-shinkan-exercise-media',
+ 'data-shinkan-exercise-media-size',
+ 'data-shinkan-exercise-media-caption',
+]
+const SIZE_OK = new Set(['small', 'medium', 'full'])
-function isHttpsUrl(val) {
- if (!val || typeof val !== 'string') return false
- const s = val.trim()
- return s.startsWith('http://') || s.startsWith('https://')
+function hookAttribute(_node, data) {
+ const name = data.attrName
+ if (name === 'href') {
+ const href = String(data.attrValue || '').trim()
+ if (!/^https?:\/\//i.test(href)) data.keepAttr = false
+ else data.attrValue = href
+ return
+ }
+ if (name === 'class') {
+ const keep = String(data.attrValue || '')
+ .split(/\s+/)
+ .filter((c) => c === 'shinkan-inline-media')
+ if (!keep.length) data.keepAttr = false
+ else data.attrValue = keep.join(' ')
+ return
+ }
+ if (name === 'data-shinkan-exercise-media') {
+ if (!/^\d+$/.test(String(data.attrValue || '').trim())) data.keepAttr = false
+ return
+ }
+ if (name === 'data-shinkan-exercise-media-size') {
+ const size = String(data.attrValue || '').trim().toLowerCase()
+ if (!SIZE_OK.has(size)) data.keepAttr = false
+ else data.attrValue = size
+ return
+ }
+ if (name === 'data-shinkan-exercise-media-caption') {
+ const caption = sanitizeInlineMediaCaption(String(data.attrValue || ''))
+ if (!caption) data.keepAttr = false
+ else data.attrValue = caption
+ }
}
-/** Nur für unsere Embed-Markierung: erlaubt data-attribut und optionale Marker-Klasse + Größe. */
-const _SIZE_OK = new Set(['small', 'medium', 'full'])
-function isInlineExerciseMediaPlaceholderSpan(el) {
- if (!el?.getAttribute || el.tagName.toLowerCase() !== 'span') return false
- const raw = el.getAttribute('data-shinkan-exercise-media')
- if (!raw || !String(raw).trim().match(/^\d+$/)) return false
- return true
-}
+const instances = new WeakMap()
-function sanitizeAttributes(el, tagLower) {
- if (tagLower === 'a') {
- const href = el.getAttribute('href')
- if (href && isHttpsUrl(href)) {
- const out = document.createElement('a')
- out.setAttribute('href', href.trim())
- return out.attributes
- }
- return []
- }
- if (tagLower === 'span' && isInlineExerciseMediaPlaceholderSpan(el)) {
- const out = document.createElement('span')
- out.setAttribute('data-shinkan-exercise-media', el.getAttribute('data-shinkan-exercise-media').trim())
- const sz = (el.getAttribute('data-shinkan-exercise-media-size') || '').trim().toLowerCase()
- if (sz && _SIZE_OK.has(sz)) {
- out.setAttribute('data-shinkan-exercise-media-size', sz)
- }
- const capRaw = el.getAttribute('data-shinkan-exercise-media-caption')
- if (capRaw != null && String(capRaw).trim()) {
- const cap = sanitizeInlineMediaCaption(String(capRaw))
- if (cap) out.setAttribute('data-shinkan-exercise-media-caption', cap)
- }
- const cls = (el.getAttribute('class') || '').trim().split(/\s+/).filter(Boolean)
- const keep = cls.filter((c) => c === 'shinkan-inline-media')
- if (keep.length) out.setAttribute('class', keep.join(' '))
- return out.attributes
- }
- return []
-}
-
-function cloneAsAllowed(tagLower, el) {
- const fresh = document.createElement(tagLower)
- const attrs = sanitizeAttributes(el, tagLower)
- for (const a of attrs) {
- fresh.setAttribute(a.name, a.value)
- }
- return fresh
-}
-
-function cleanTree(parent) {
- const nodes = Array.from(parent.childNodes)
- for (const node of nodes) {
- if (node.nodeType === Node.TEXT_NODE) continue
- if (node.nodeType === Node.COMMENT_NODE) {
- parent.removeChild(node)
- continue
- }
- if (node.nodeType !== Node.ELEMENT_NODE) {
- parent.removeChild(node)
- continue
- }
- const tag = node.tagName.toLowerCase()
- if (tag === 'script' || tag === 'iframe' || tag === 'object' || tag === 'embed') {
- parent.removeChild(node)
- continue
- }
- if (ALLOWED_BLOCK.has(tag) || ALLOWED_INLINE.has(tag)) {
- const repl = cloneAsAllowed(tag, node)
- while (node.firstChild) {
- repl.appendChild(node.firstChild)
- }
- cleanTree(repl)
- parent.replaceChild(repl, node)
- continue
- }
- while (node.firstChild) {
- parent.insertBefore(node.firstChild, node)
- }
- parent.removeChild(node)
- }
+function purifyFor(win) {
+ const existing = instances.get(win)
+ if (existing) return existing
+ const purify = DOMPurify(win)
+ purify.addHook('uponSanitizeAttribute', hookAttribute)
+ instances.set(win, purify)
+ return purify
}
/**
* @param {string|null|undefined} html
+ * @param {Window=} win
* @returns {string}
*/
-export function sanitizeExerciseRichDisplayHtml(html) {
+export function sanitizeExerciseRichDisplayHtml(html, win) {
if (html == null || typeof html !== 'string') return ''
const trimmed = html.trim()
if (!trimmed) return ''
-
- const tpl = document.createElement('template')
- tpl.innerHTML = trimmed
- cleanTree(tpl.content)
- return tpl.innerHTML
+ const target = win || (typeof window !== 'undefined' ? window : null)
+ if (!target) return ''
+ return purifyFor(target).sanitize(trimmed, {
+ ALLOWED_TAGS,
+ ALLOWED_ATTR,
+ ALLOW_DATA_ATTR: false,
+ })
}
diff --git a/frontend/src/utils/exerciseRichTextSanitize.test.js b/frontend/src/utils/exerciseRichTextSanitize.test.js
new file mode 100644
index 0000000..fbde898
--- /dev/null
+++ b/frontend/src/utils/exerciseRichTextSanitize.test.js
@@ -0,0 +1,21 @@
+import { JSDOM } from 'jsdom'
+import { describe, expect, it } from 'vitest'
+import { sanitizeExerciseRichDisplayHtml } from './exerciseRichTextSanitize'
+
+const { window } = new JSDOM('')
+
+describe('sanitizeExerciseRichDisplayHtml', () => {
+ it('entfernt Skripte und Handler und behält den Medien-Platzhalter', () => {
+ const dirty =
+ '
Hallo
' +
+ '
Link' +
+ '
'
+ const clean = sanitizeExerciseRichDisplayHtml(dirty, window)
+ expect(clean.toLowerCase()).not.toContain('script')
+ expect(clean.toLowerCase()).not.toContain('onclick')
+ expect(clean.toLowerCase()).not.toContain('javascript:')
+ expect(clean).toContain('Hallo')
+ expect(clean).toContain('data-shinkan-exercise-media="7"')
+ expect(clean).not.toContain('evil')
+ })
+})
diff --git a/frontend/src/utils/htmlUtils.js b/frontend/src/utils/htmlUtils.js
index 5bb20a8..fdf61f4 100644
--- a/frontend/src/utils/htmlUtils.js
+++ b/frontend/src/utils/htmlUtils.js
@@ -1,5 +1,7 @@
/** Einfache HTML-Hilfen für Rich-Text (Trainer-Content, kein öffentliches CMS). */
+import { sanitizeExerciseRichDisplayHtml } from './exerciseRichTextSanitize'
+
export function stripHtmlToText(html) {
if (!html || typeof html !== 'string') return ''
const d = document.createElement('div')
@@ -7,17 +9,7 @@ export function stripHtmlToText(html) {
return (d.textContent || '').replace(/\s+/g, ' ').trim()
}
-/** Entfernt script/iframes und Event-Handler-Attribute grob. */
+/** Entfernt alles außerhalb der Übungs-Allowlist. */
export function sanitizeTrainerHtml(html) {
- if (!html || typeof html !== 'string') return ''
- const d = document.createElement('div')
- d.innerHTML = html
- d.querySelectorAll('script, iframe, object, embed').forEach((n) => n.remove())
- d.querySelectorAll('*').forEach((el) => {
- for (const attr of [...el.attributes]) {
- const n = attr.name.toLowerCase()
- if (n.startsWith('on') || n === 'srcdoc') el.removeAttribute(attr.name)
- }
- })
- return d.innerHTML
+ return sanitizeExerciseRichDisplayHtml(html)
}
diff --git a/frontend/src/utils/sanitizeHtml.js b/frontend/src/utils/sanitizeHtml.js
index a2b5a31..a677c8a 100644
--- a/frontend/src/utils/sanitizeHtml.js
+++ b/frontend/src/utils/sanitizeHtml.js
@@ -1,41 +1,11 @@
/**
- * Reduziert HTML aus Übungs-Kurztexten auf eine kleine erlaubte Menge von Tags (ohne Attribute).
- * Für Anzeige mit dangerouslySetInnerHTML.
+ * Reduziert HTML aus Übungs-Kurztexten auf die Allowlist von sanitizeExerciseRichDisplayHtml.
*/
-const ALLOWED_TAGS = new Set(['b', 'strong', 'i', 'em', 'br', 'p', 'span', 'ul', 'ol', 'li'])
-function cleanTree(parent) {
- const nodes = Array.from(parent.childNodes)
- for (const node of nodes) {
- if (node.nodeType === Node.TEXT_NODE) continue
- if (node.nodeType !== Node.ELEMENT_NODE) {
- parent.removeChild(node)
- continue
- }
- const tag = node.tagName.toLowerCase()
- if (!ALLOWED_TAGS.has(tag)) {
- while (node.firstChild) {
- parent.insertBefore(node.firstChild, node)
- }
- parent.removeChild(node)
- continue
- }
- while (node.attributes.length > 0) {
- node.removeAttribute(node.attributes[0].name)
- }
- cleanTree(node)
- }
-}
+import { sanitizeExerciseRichDisplayHtml } from './exerciseRichTextSanitize'
export function sanitizeExerciseRichText(html) {
- if (html == null || typeof html !== 'string') return ''
- const trimmed = html.trim()
- if (!trimmed) return ''
-
- const tpl = document.createElement('template')
- tpl.innerHTML = trimmed
- cleanTree(tpl.content)
- return tpl.innerHTML
+ return sanitizeExerciseRichDisplayHtml(html)
}
export function coerceApiNameList(value) {