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.
AUTO_ADMIN_FIRST_USER=false
# Plattform-Admins müssen TOTP einrichten. 0 schaltet die Pflicht ab.
REQUIRE_ADMIN_TOTP=true
# true erzwingt TOTP für Plattform-Admins beim Login. Standard ist freiwillig.
REQUIRE_ADMIN_TOTP=false
# Optional. Sonst wird das Medien-Token aus DB_PASSWORD abgeleitet.
# MEDIA_TOKEN_SECRET=
ADMIN_BOOTSTRAP_EMAILS=

View File

@ -85,12 +85,9 @@ limiter = Limiter(key_func=_client_ip)
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()
if 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")
return raw in ("1", "true", "yes")
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),
)
cur.execute("DELETE FROM profile_recovery_codes WHERE profile_id=%s", (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)),
)
plain_codes = _replace_recovery_codes(cur, pid)
prof = dict(prof)
prof["totp_enabled"] = True
prof["totp_secret"] = None
@ -414,6 +402,38 @@ def totp_confirm(req: TotpCodeRequest, x_auth_token: Optional[str] = Header(defa
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")
def totp_disable(req: TotpDisableRequest, session: dict = Depends(require_auth)):
pid = int(session["profile_id"])

View File

@ -1,12 +1,12 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.239"
BUILD_DATE = "2026-10-12"
APP_VERSION = "0.8.240"
BUILD_DATE = "2026-10-13"
DB_SCHEMA_VERSION = "20261012096"
MODULE_VERSIONS = {
"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
"tenant_context": "1.1.0", # M3: account_state + email_verified im TenantContext
"capabilities": "1.2.0", # Registry-first: module-Spalte; Admin nur registrierte Rechte
@ -53,6 +53,14 @@ MODULE_VERSIONS = {
}
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",
"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}"
ENVIRONMENT: "${ENVIRONMENT:-development}"
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)
CLUB_FEATURE_ENFORCE: "${CLUB_FEATURE_ENFORCE:-1}"
MEDIAWIKI_API_URL: "${MEDIAWIKI_API_URL:-https://karatetrainer.net/api.php}"

View File

@ -39,7 +39,7 @@ services:
SMTP_STARTTLS: ${SMTP_STARTTLS:-}
# Erste Self-Registration → Admin; oder ADMIN_BOOTSTRAP_EMAILS=mail@…,weitere@…
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:-}"
# Werte wie in .env (APP_URL, ALLOWED_ORIGINS, ENVIRONMENT) — keine zweite „Wahrheit“ in YAML
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 : [])
setSecret('')
setCode('')
if (onEnabled) await onEnabled(data)
} catch (err) {
setError(err.message || 'Code nicht akzeptiert')
} finally {
@ -85,15 +84,33 @@ export default function TotpSetupCard({ onEnabled }) {
{recoveryCodes.length > 0 ? (
<div>
<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>
<ul>
{recoveryCodes.map((item) => (
<li key={item} style={{ fontFamily: 'ui-monospace, monospace' }}>
{item}
</li>
))}
</ul>
<textarea
readOnly
rows={8}
className="form-input"
value={recoveryCodes.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(recoveryCodes.join('\n'))}
>
Kopieren
</button>
<button
type="button"
className="btn btn-primary"
onClick={() => {
if (onEnabled) onEnabled()
}}
>
Ich habe die Codes gespeichert
</button>
</div>
) : null}
</div>

View File

@ -32,6 +32,7 @@ function AccountSettingsPage() {
const [disablePw, setDisablePw] = useState('')
const [disableCode, setDisableCode] = useState('')
const [disablingTotp, setDisablingTotp] = useState(false)
const [freshRecoveryCodes, setFreshRecoveryCodes] = useState([])
const [newPw1, setNewPw1] = useState('')
const [newPw2, setNewPw2] = useState('')
const [savingPw, setSavingPw] = useState(false)
@ -473,21 +474,69 @@ function AccountSettingsPage() {
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Zwei-Faktor ist aktiv</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.875rem' }}>
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>
{user?.role === 'admin' || user?.role === 'superadmin' ? (
<p style={{ color: 'var(--text2)', fontSize: '0.875rem' }}>
Für Plattform-Admins bleibt das so.
</p>
) : (
<form
onSubmit={async (e) => {
e.preventDefault()
{freshRecoveryCodes.length > 0 ? (
<div style={{ marginBottom: '1rem' }}>
<textarea
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
onSubmit={async (e) => {
e.preventDefault()
setDisablingTotp(true)
try {
const data = await api.regenerateRecoveryCodes(disablePw, disableCode.trim())
setFreshRecoveryCodes(Array.isArray(data?.recovery_codes) ? data.recovery_codes : [])
setDisableCode('')
showOk('Neue Wiederherstellungscodes sind da. Die bisherigen gelten nicht mehr.')
} catch (err) {
showErr(err.message || 'Neue Codes konnten nicht erzeugt werden')
} finally {
setDisablingTotp(false)
}
}}
>
<div className="form-row" style={{ marginBottom: '0.75rem' }}>
<label className="form-label" htmlFor="totp-off-pw">Aktuelles Passwort</label>
<input id="totp-off-pw" type="password" className="form-input" value={disablePw} onChange={(e) => setDisablePw(e.target.value)} autoComplete="current-password" />
</div>
<div className="form-row" style={{ marginBottom: '0.75rem' }}>
<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" />
</div>
<button type="submit" className="btn btn-secondary" disabled={disablingTotp} style={{ marginRight: '0.5rem' }}>
{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('')
showOk('Zwei-Faktor ist aus.')
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')
@ -496,19 +545,9 @@ function AccountSettingsPage() {
}
}}
>
<div className="form-row" style={{ marginBottom: '0.75rem' }}>
<label className="form-label" htmlFor="totp-off-pw">Aktuelles Passwort</label>
<input id="totp-off-pw" type="password" className="form-input" value={disablePw} onChange={(e) => setDisablePw(e.target.value)} autoComplete="current-password" />
</div>
<div className="form-row" style={{ marginBottom: '0.75rem' }}>
<label className="form-label" htmlFor="totp-off-code">Code oder Wiederherstellungscode</label>
<input id="totp-off-code" className="form-input" value={disableCode} onChange={(e) => setDisableCode(e.target.value)} autoComplete="one-time-code" />
</div>
<button type="submit" className="btn btn-secondary" disabled={disablingTotp}>
{disablingTotp ? 'Wird gespeichert…' : 'Zwei-Faktor ausschalten'}
</button>
</form>
)}
Zwei-Faktor ausschalten
</button>
</form>
</div>
) : (
<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) {
return request('/api/auth/totp/disable', {
method: 'POST',
@ -1032,6 +1039,7 @@ export const api = {
getTotpStatus,
setupTotp,
confirmTotp,
regenerateRecoveryCodes,
disableTotp,
issueMediaGrant,
register,