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
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:
parent
32b28a93f3
commit
2e22a7c8e0
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()} />
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user