Keep recovery codes on screen and make two-factor optional.
Some checks failed
Deploy Development / deploy (push) Successful in 51s
Test Suite / pytest-backend (push) Successful in 46s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 15s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Failing after 4m10s

Admins can replace missed codes or turn two-factor off without a forced enrollment on every login.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-10-13 08:03:05 +02:00
parent 32b28a93f3
commit 2e22a7c8e0
8 changed files with 145 additions and 53 deletions

View File

@ -55,8 +55,8 @@ SMTP_STARTTLS=
# Produktion: false. true nur für eine bewusst leere Erstinstallation. # Produktion: false. true nur für eine bewusst leere Erstinstallation.
AUTO_ADMIN_FIRST_USER=false AUTO_ADMIN_FIRST_USER=false
# Plattform-Admins müssen TOTP einrichten. 0 schaltet die Pflicht ab. # true erzwingt TOTP für Plattform-Admins beim Login. Standard ist freiwillig.
REQUIRE_ADMIN_TOTP=true REQUIRE_ADMIN_TOTP=false
# Optional. Sonst wird das Medien-Token aus DB_PASSWORD abgeleitet. # Optional. Sonst wird das Medien-Token aus DB_PASSWORD abgeleitet.
# MEDIA_TOKEN_SECRET= # MEDIA_TOKEN_SECRET=
ADMIN_BOOTSTRAP_EMAILS= ADMIN_BOOTSTRAP_EMAILS=

View File

@ -85,12 +85,9 @@ limiter = Limiter(key_func=_client_ip)
def _admin_totp_required() -> bool: def _admin_totp_required() -> bool:
"""Nur wenn REQUIRE_ADMIN_TOTP ausdrücklich an ist. Standard: der Nutzer entscheidet selbst."""
raw = (os.getenv("REQUIRE_ADMIN_TOTP") or "").strip().lower() raw = (os.getenv("REQUIRE_ADMIN_TOTP") or "").strip().lower()
if raw in ("1", "true", "yes"): return raw in ("1", "true", "yes")
return True
if raw in ("0", "false", "no"):
return False
return (os.getenv("ENVIRONMENT") or "development").strip().lower() in ("production", "prod")
def _issue_session(cur, profile_id: int, *, days: int, purpose: str = "full") -> tuple[str, datetime]: def _issue_session(cur, profile_id: int, *, days: int, purpose: str = "full") -> tuple[str, datetime]:
@ -394,16 +391,7 @@ def totp_confirm(req: TotpCodeRequest, x_auth_token: Optional[str] = Header(defa
""", """,
(prof["totp_pending_secret"], pid), (prof["totp_pending_secret"], pid),
) )
cur.execute("DELETE FROM profile_recovery_codes WHERE profile_id=%s", (pid,)) plain_codes = _replace_recovery_codes(cur, pid)
plain_codes = generate_recovery_codes()
for code in plain_codes:
cur.execute(
"""
INSERT INTO profile_recovery_codes (profile_id, code_hash)
VALUES (%s, %s)
""",
(pid, hash_recovery_code(code)),
)
prof = dict(prof) prof = dict(prof)
prof["totp_enabled"] = True prof["totp_enabled"] = True
prof["totp_secret"] = None prof["totp_secret"] = None
@ -414,6 +402,38 @@ def totp_confirm(req: TotpCodeRequest, x_auth_token: Optional[str] = Header(defa
return payload return payload
def _replace_recovery_codes(cur, profile_id: int) -> list[str]:
cur.execute("DELETE FROM profile_recovery_codes WHERE profile_id=%s", (profile_id,))
plain_codes = generate_recovery_codes()
for code in plain_codes:
cur.execute(
"""
INSERT INTO profile_recovery_codes (profile_id, code_hash)
VALUES (%s, %s)
""",
(profile_id, hash_recovery_code(code)),
)
return plain_codes
@router.post("/totp/recovery-codes")
def regenerate_recovery_codes(req: TotpDisableRequest, session: dict = Depends(require_auth)):
"""Neue Codes ausgeben. Die bisherigen werden ungültig. Passwort und aktueller App-Code sind Pflicht."""
pid = int(session["profile_id"])
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
prof = cur.fetchone()
if not prof or not prof.get("totp_enabled"):
raise HTTPException(400, "Zwei-Faktor ist nicht aktiv")
if not verify_pin(req.current_password, prof["pin_hash"]):
raise HTTPException(400, "Aktuelles Passwort ist falsch")
if not verify_totp(prof.get("totp_secret"), req.code):
raise HTTPException(400, "Code aus der App stimmt nicht")
plain_codes = _replace_recovery_codes(cur, pid)
return {"recovery_codes": plain_codes}
@router.post("/totp/disable") @router.post("/totp/disable")
def totp_disable(req: TotpDisableRequest, session: dict = Depends(require_auth)): def totp_disable(req: TotpDisableRequest, session: dict = Depends(require_auth)):
pid = int(session["profile_id"]) pid = int(session["profile_id"])

View File

@ -1,12 +1,12 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.239" APP_VERSION = "0.8.240"
BUILD_DATE = "2026-10-12" BUILD_DATE = "2026-10-13"
DB_SCHEMA_VERSION = "20261012096" DB_SCHEMA_VERSION = "20261012096"
MODULE_VERSIONS = { MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste) "legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
"auth": "1.3.0", # Login-Sperre, TOTP für Plattform-Admins, Medien-Grant statt Sitzungstoken in URLs "auth": "1.3.1", # TOTP freiwillig; Wiederherstellungscodes bleiben bis zur Bestätigung stehen
"profiles": "1.8.1", # GET /profiles/me: account_state + club_roles "profiles": "1.8.1", # GET /profiles/me: account_state + club_roles
"tenant_context": "1.1.0", # M3: account_state + email_verified im TenantContext "tenant_context": "1.1.0", # M3: account_state + email_verified im TenantContext
"capabilities": "1.2.0", # Registry-first: module-Spalte; Admin nur registrierte Rechte "capabilities": "1.2.0", # Registry-first: module-Spalte; Admin nur registrierte Rechte
@ -53,6 +53,14 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ CHANGELOG = [
{
"version": "0.8.240",
"date": "2026-10-13",
"changes": [
"Zwei-Faktor ist freiwillig. REQUIRE_ADMIN_TOTP erzwingt es nur noch, wenn die Variable ausdrücklich true ist.",
"Wiederherstellungscodes bleiben sichtbar, bis sie bestätigt sind. Neue Codes lassen sich in den Einstellungen erzeugen.",
],
},
{ {
"version": "0.8.239", "version": "0.8.239",
"date": "2026-10-12", "date": "2026-10-12",

View File

@ -39,7 +39,7 @@ services:
ALLOWED_ORIGINS: "${ALLOWED_ORIGINS:-https://dev.shinkan.jinkendo.de,http://192.168.2.49:3098}" ALLOWED_ORIGINS: "${ALLOWED_ORIGINS:-https://dev.shinkan.jinkendo.de,http://192.168.2.49:3098}"
ENVIRONMENT: "${ENVIRONMENT:-development}" ENVIRONMENT: "${ENVIRONMENT:-development}"
AUTO_ADMIN_FIRST_USER: "${AUTO_ADMIN_FIRST_USER:-false}" AUTO_ADMIN_FIRST_USER: "${AUTO_ADMIN_FIRST_USER:-false}"
REQUIRE_ADMIN_TOTP: "${REQUIRE_ADMIN_TOTP:-true}" REQUIRE_ADMIN_TOTP: "${REQUIRE_ADMIN_TOTP:-false}"
# M5: Hard-Block Vereins-Kontingente (Default aus — in .env auf 1 setzen zum Testen) # M5: Hard-Block Vereins-Kontingente (Default aus — in .env auf 1 setzen zum Testen)
CLUB_FEATURE_ENFORCE: "${CLUB_FEATURE_ENFORCE:-1}" CLUB_FEATURE_ENFORCE: "${CLUB_FEATURE_ENFORCE:-1}"
MEDIAWIKI_API_URL: "${MEDIAWIKI_API_URL:-https://karatetrainer.net/api.php}" MEDIAWIKI_API_URL: "${MEDIAWIKI_API_URL:-https://karatetrainer.net/api.php}"

View File

@ -39,7 +39,7 @@ services:
SMTP_STARTTLS: ${SMTP_STARTTLS:-} SMTP_STARTTLS: ${SMTP_STARTTLS:-}
# Erste Self-Registration → Admin; oder ADMIN_BOOTSTRAP_EMAILS=mail@…,weitere@… # Erste Self-Registration → Admin; oder ADMIN_BOOTSTRAP_EMAILS=mail@…,weitere@…
AUTO_ADMIN_FIRST_USER: "${AUTO_ADMIN_FIRST_USER:-false}" AUTO_ADMIN_FIRST_USER: "${AUTO_ADMIN_FIRST_USER:-false}"
REQUIRE_ADMIN_TOTP: "${REQUIRE_ADMIN_TOTP:-true}" REQUIRE_ADMIN_TOTP: "${REQUIRE_ADMIN_TOTP:-false}"
ADMIN_BOOTSTRAP_EMAILS: "${ADMIN_BOOTSTRAP_EMAILS:-}" ADMIN_BOOTSTRAP_EMAILS: "${ADMIN_BOOTSTRAP_EMAILS:-}"
# Werte wie in .env (APP_URL, ALLOWED_ORIGINS, ENVIRONMENT) — keine zweite „Wahrheit“ in YAML # Werte wie in .env (APP_URL, ALLOWED_ORIGINS, ENVIRONMENT) — keine zweite „Wahrheit“ in YAML
APP_URL: "${APP_URL:-https://shinkan.jinkendo.de}" APP_URL: "${APP_URL:-https://shinkan.jinkendo.de}"

View File

@ -37,7 +37,6 @@ export default function TotpSetupCard({ onEnabled }) {
setRecoveryCodes(Array.isArray(data?.recovery_codes) ? data.recovery_codes : []) setRecoveryCodes(Array.isArray(data?.recovery_codes) ? data.recovery_codes : [])
setSecret('') setSecret('')
setCode('') setCode('')
if (onEnabled) await onEnabled(data)
} catch (err) { } catch (err) {
setError(err.message || 'Code nicht akzeptiert') setError(err.message || 'Code nicht akzeptiert')
} finally { } finally {
@ -85,15 +84,33 @@ export default function TotpSetupCard({ onEnabled }) {
{recoveryCodes.length > 0 ? ( {recoveryCodes.length > 0 ? (
<div> <div>
<p style={{ fontSize: '0.875rem' }}> <p style={{ fontSize: '0.875rem' }}>
Diese Wiederherstellungscodes jetzt sichern. Sie werden nur dieses eine Mal angezeigt. Diese acht Codes jetzt sichern. Die Seite bleibt stehen, bis du unten bestätigst.
Ein Neuladen löscht die Anzeige. Neue Codes kannst du danach in den Einstellungen erzeugen.
</p> </p>
<ul> <textarea
{recoveryCodes.map((item) => ( readOnly
<li key={item} style={{ fontFamily: 'ui-monospace, monospace' }}> rows={8}
{item} className="form-input"
</li> value={recoveryCodes.join('\n')}
))} style={{ fontFamily: 'ui-monospace, monospace', marginBottom: '0.75rem' }}
</ul> />
<button
type="button"
className="btn btn-secondary"
style={{ marginRight: '0.5rem' }}
onClick={() => navigator.clipboard?.writeText(recoveryCodes.join('\n'))}
>
Kopieren
</button>
<button
type="button"
className="btn btn-primary"
onClick={() => {
if (onEnabled) onEnabled()
}}
>
Ich habe die Codes gespeichert
</button>
</div> </div>
) : null} ) : null}
</div> </div>

View File

@ -32,6 +32,7 @@ function AccountSettingsPage() {
const [disablePw, setDisablePw] = useState('') const [disablePw, setDisablePw] = useState('')
const [disableCode, setDisableCode] = useState('') const [disableCode, setDisableCode] = useState('')
const [disablingTotp, setDisablingTotp] = useState(false) const [disablingTotp, setDisablingTotp] = useState(false)
const [freshRecoveryCodes, setFreshRecoveryCodes] = useState([])
const [newPw1, setNewPw1] = useState('') const [newPw1, setNewPw1] = useState('')
const [newPw2, setNewPw2] = useState('') const [newPw2, setNewPw2] = useState('')
const [savingPw, setSavingPw] = useState(false) const [savingPw, setSavingPw] = useState(false)
@ -473,24 +474,41 @@ function AccountSettingsPage() {
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Zwei-Faktor ist aktiv</h2> <h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Zwei-Faktor ist aktiv</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.875rem' }}> <p style={{ color: 'var(--text2)', fontSize: '0.875rem' }}>
Beim Anmelden wird zusätzlich der Code aus der Authenticator-App verlangt. Beim Anmelden wird zusätzlich der Code aus der Authenticator-App verlangt.
Die ersten Codes lassen sich nicht erneut anzeigen. Hier entstehen neue, die alten gelten danach nicht mehr.
</p> </p>
{user?.role === 'admin' || user?.role === 'superadmin' ? ( {freshRecoveryCodes.length > 0 ? (
<p style={{ color: 'var(--text2)', fontSize: '0.875rem' }}> <div style={{ marginBottom: '1rem' }}>
Für Plattform-Admins bleibt das so. <textarea
</p> readOnly
) : ( rows={8}
className="form-input"
value={freshRecoveryCodes.join('\n')}
style={{ fontFamily: 'ui-monospace, monospace', marginBottom: '0.75rem' }}
/>
<button
type="button"
className="btn btn-secondary"
style={{ marginRight: '0.5rem' }}
onClick={() => navigator.clipboard?.writeText(freshRecoveryCodes.join('\n'))}
>
Kopieren
</button>
<button type="button" className="btn btn-primary" onClick={() => setFreshRecoveryCodes([])}>
Ich habe die Codes gespeichert
</button>
</div>
) : null}
<form <form
onSubmit={async (e) => { onSubmit={async (e) => {
e.preventDefault() e.preventDefault()
setDisablingTotp(true) setDisablingTotp(true)
try { try {
await api.disableTotp(disablePw, disableCode.trim()) const data = await api.regenerateRecoveryCodes(disablePw, disableCode.trim())
setDisablePw('') setFreshRecoveryCodes(Array.isArray(data?.recovery_codes) ? data.recovery_codes : [])
setDisableCode('') setDisableCode('')
showOk('Zwei-Faktor ist aus.') showOk('Neue Wiederherstellungscodes sind da. Die bisherigen gelten nicht mehr.')
await checkAuth()
} catch (err) { } catch (err) {
showErr(err.message || 'Abschalten fehlgeschlagen') showErr(err.message || 'Neue Codes konnten nicht erzeugt werden')
} finally { } finally {
setDisablingTotp(false) setDisablingTotp(false)
} }
@ -501,14 +519,35 @@ function AccountSettingsPage() {
<input id="totp-off-pw" type="password" className="form-input" value={disablePw} onChange={(e) => setDisablePw(e.target.value)} autoComplete="current-password" /> <input id="totp-off-pw" type="password" className="form-input" value={disablePw} onChange={(e) => setDisablePw(e.target.value)} autoComplete="current-password" />
</div> </div>
<div className="form-row" style={{ marginBottom: '0.75rem' }}> <div className="form-row" style={{ marginBottom: '0.75rem' }}>
<label className="form-label" htmlFor="totp-off-code">Code oder Wiederherstellungscode</label> <label className="form-label" htmlFor="totp-off-code">Aktueller Code aus der App</label>
<input id="totp-off-code" className="form-input" value={disableCode} onChange={(e) => setDisableCode(e.target.value)} autoComplete="one-time-code" /> <input id="totp-off-code" className="form-input" value={disableCode} onChange={(e) => setDisableCode(e.target.value)} autoComplete="one-time-code" />
</div> </div>
<button type="submit" className="btn btn-secondary" disabled={disablingTotp}> <button type="submit" className="btn btn-secondary" disabled={disablingTotp} style={{ marginRight: '0.5rem' }}>
{disablingTotp ? 'Wird gespeichert…' : 'Zwei-Faktor ausschalten'} {disablingTotp ? 'Bitte warten…' : 'Neue Wiederherstellungscodes'}
</button>
<button
type="button"
className="btn btn-secondary"
disabled={disablingTotp}
onClick={async () => {
setDisablingTotp(true)
try {
await api.disableTotp(disablePw, disableCode.trim())
setDisablePw('')
setDisableCode('')
setFreshRecoveryCodes([])
showOk('Zwei-Faktor ist aus. Den Eintrag in der Authenticator-App kannst du löschen.')
await checkAuth()
} catch (err) {
showErr(err.message || 'Abschalten fehlgeschlagen')
} finally {
setDisablingTotp(false)
}
}}
>
Zwei-Faktor ausschalten
</button> </button>
</form> </form>
)}
</div> </div>
) : ( ) : (
<TotpSetupCard onEnabled={() => checkAuth()} /> <TotpSetupCard onEnabled={() => checkAuth()} />

View File

@ -47,6 +47,13 @@ export async function confirmTotp(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) { export async function disableTotp(currentPassword, code) {
return request('/api/auth/totp/disable', { return request('/api/auth/totp/disable', {
method: 'POST', method: 'POST',
@ -1032,6 +1039,7 @@ export const api = {
getTotpStatus, getTotpStatus,
setupTotp, setupTotp,
confirmTotp, confirmTotp,
regenerateRecoveryCodes,
disableTotp, disableTotp,
issueMediaGrant, issueMediaGrant,
register, register,