The forced TFA enrollment form submitted the raw credential JSON in the
'token' field, which set_tfa()'s webauthn case never reads. It expects the
parsed \['registration'] object that only the JSON API builds, so the
form POST inserted a row with an empty keyHandle and a NULL publicKey while
still reporting success. Every WebAuthn 2FA login then failed, because
webauthn-tfa-get-args sent an empty credential id in allowCredentials.
- base.twig: POST the attestation to /api/v1/add/webauthn-tfa-registration
like the regular WebAuthn modal does
- functions.inc.php: refuse incomplete WebAuthn registrations
- lang.en-gb.json: message for the new guard
The forced password change modal posts to the JSON API and renders
data[0].msg directly. The API returns raw language keys rather than
translated strings, so a user who fails the complexity policy is shown
the literal text "password_complexity" instead of a message.
Every other password form renders errors through alertbox_log_parser(),
which resolves the key against $lang. The modal is the only one that
talks to the API directly, and it never resolved the key.
Resolve it against lang_danger, which base.twig already exposes for this
purpose. This also covers password_mismatch, password_empty and
access_denied, and applies to the admin path as well. Unknown keys still
fall through unchanged.
The lookup is guarded with hasOwnProperty because msg is attacker-
independent but dynamic: a bare lang_danger[msg] would resolve inherited
Object.prototype members such as "constructor" to a function, which
jQuery's .text() would then invoke as a callback.
Language files are untouched: prerequisites.inc.php loads lang.en-gb.json
as the base and merges the active locale over it, so locales that lack the
key inherit the English string.
Fixes#7301
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>