A second factor, with recovery.
Confirmed TOTP enrollment, one-use recovery codes and expiring login/reset challenges.
Enroll an authenticator
Start from an authenticated session. POST your current password to /auth/mfa/setup. The no-store response contains a secret and otpauth_uri; add these directly to your authenticator. Never send them to a public QR-image service. Setup remains pending for ten minutes. POST your password and six-digit code to /auth/mfa/confirm within five attempts. Only successful confirmation enables MFA, returns ten recovery codes once, and signs out all existing sessions.
// Every POST also requires the existing CSRF cookie and X-CSRF-Token.
POST /auth/mfa/setup
{"password":"your-current-password"}
POST /auth/mfa/confirm
{"password":"your-current-password","code":"123456"}
GET /auth/security
// Reports mfa_enabled and recovery_codes_remaining; never the secret.Two-stage login
For an enrolled account, a correct password returns HTTP 202 with mfa_required=true and a five-minute challenge. It does not issue an authenticated session. POST the challenge plus a current authenticator code or an unused recovery code to /auth/challenge. At most five factor attempts are allowed. Successful verification consumes the challenge and issues the session. Each TOTP time step and each recovery code can be accepted only once per account.
POST /auth/login
{"email":"owner@example.com","password":"your-current-password"}
// 202: {authenticated:false, mfa_required:true, challenge:"...", expires_in:300}
POST /auth/challenge
{"challenge":"the-returned-challenge","code":"123456"}
// 200 + HttpOnly session cookie after every factor succeeds.Factor settings and password changes
POST /auth/mfa/recovery rotates all recovery codes, and /auth/mfa/disable removes MFA. Both require an authenticated session, the current password and a valid TOTP/recovery code. Both revoke sessions and pending login/reset tokens. POST /auth/password/change requires current_password, password and code; send an empty code only when MFA is disabled. Password changes preserve enabled MFA and revoke sessions. A code used for enrollment cannot be reused for login in the same time step.
Administrator-mediated password recovery
There is no public token-issuance endpoint or automatic email delivery. After independently verifying the account owner, an operator uses the CLI to save a random one-use reset token in a new private file. The file must be in a directory inaccessible to other users and is created with mode 0600. Deliver it through an established private channel. The token expires after 15 minutes; only its hash is stored. Issue a new token to replace an old one.
php bin/icom password:issue-reset owner@example.com --output /private/reset.json
POST /auth/password/reset
{"token":"privately-delivered-token","password":"a-new-long-passphrase"}
php bin/icom security:prune
php bin/icom sessions:pruneRecovery does not remove MFA
A password reset never disables an enrolled factor or creates a session. The next login still requires MFA. Recovery codes replace the second factor, not the password. Lost authenticator and lost recovery codes together require a separately designed operator identity-verification procedure; this release intentionally provides no public MFA bypass. TOTP is not phishing-resistant WebAuthn/passkey authentication.
Storage, concurrency and limits
OTPHP implements RFC 6238 with SHA-1, six digits and 30-second steps, accepting one adjacent step for clock drift. Secrets are encrypted with Sodium under a purpose-derived application key and bound to the account inside the ciphertext. Recovery codes contain 128 random bits. Account transitions lock before reading security state, and token/code consumption commits with session issuance. SQLite and actual MariaDB process races are tested. API mutation attempts share an additional connected-IP limit of 30 per five minutes; password login retains its own limits. Keep clocks synchronized and preserve the application key across releases.