feat: optional TOTP multi-factor authentication - #495
Open
cyberpescadito wants to merge 1 commit into
Open
Conversation
cyberpescadito
force-pushed
the
feature/mfa-totp
branch
from
August 21, 2026 16:11
68db220 to
5e66e80
Compare
Adds opt-in two-factor authentication (TOTP, RFC 6238) for password logins, so a leaked or guessed Cortex password is no longer enough to reach an organization's analyzers and job history. Backend - TOTPSrv: secret generation, otpauth URI, enrolment QR rendered as an inline SVG data URI, single-use backup codes stored hashed, and a per-instance attempt counter that locks the second factor after auth.multifactor.maxAttempts wrong codes. - The gate sits in CortexAuthSrv, on top of whichever provider owns the password (local, ldap, ad), and deliberately outside MultiAuthSrv's provider fold, where a rejected code would be swallowed as a fall-through to the next provider. - POST /api/login accepts an optional "code" field; a missing or wrong second factor answers 401 with type MultiFactorCodeRequired or MultiFactorCodeInvalid, so an MFA-unaware client still reads it as an authentication failure. - Three new routes under /api/user/:userId/mfa (init, set, unset). Only the user can enrol; an org admin or superadmin can reset a user who lost their authenticator. - New user attributes totpSecret and totpScratchCodes, both sensitive and unaudited, and rejected by the generic user update endpoint. - New AuthCapability "mfa", advertised so the UI can hide the feature when it is turned off. API keys are never challenged, since that is how TheHive and MISP integrate with Cortex; with oauth2 the identity provider owns MFA. HTTP basic auth has nowhere to carry a code, so an enrolled user is refused rather than silently exempted. Front-end - Login page asks for the code as a second step, and accepts a backup code in the same field. - Settings page carries enrolment (QR, manual key, verification) and the one-time backup code list, plus self-service disable. - User admin list shows MFA status and the admin reset. Configuration lives under auth.multifactor, documented in conf/application.sample; enabling it only makes the feature available, it never forces anyone to enrol. modelVersion is deliberately left at 6: bumping it would make a full reindex mandatory on every existing instance, and both attributes are only ever read back from _source, never queried. The reasoning is recorded next to modelVersion so a future change that does need to query them knows to bump it. New dependencies: com.warrenstrange:googleauth (TOTP) and com.google.zxing:core (QR encoding, "core" only — no image writers).
cyberpescadito
force-pushed
the
feature/mfa-totp
branch
from
August 21, 2026 16:17
5e66e80 to
c125ecf
Compare
cyberpescadito
marked this pull request as ready for review
August 21, 2026 16:18
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds opt-in two-factor authentication (TOTP, RFC 6238) to Cortex, so a leaked or
guessed password is no longer enough to reach an organization's analyzers, responders and
job history.
Users enrol themselves from their settings page by scanning a QR code with any
authenticator app (Google Authenticator, Aegis, 1Password, …). Nothing is forced: enabling
the feature server-side only makes it available.
Scope: what is and isn't challenged
local,ldap,ad)oauth2/ SSODesign notes
MultiAuthSrv's provider fold. Inside it, any failure isswallowed and falls through to the next provider, which would turn a rejected MFA code
into a silent retry. So
CortexAuthSrvlets whichever provider owns the password verifyit, and only then checks the code.
TOTPSrvis deliberately not anAuthSrv.Module.scalareflectively binds everyconcrete
AuthSrvinto the provider list; this is a gate on top of a provider, not aprovider.
POST /api/loginanswers401withtype: MultiFactorCodeRequiredwhen the password is right but no code was supplied, andMultiFactorCodeInvalidwhen the code is wrong. Both are401so a client that knowsnothing about MFA still reads them as an authentication failure; the
typeis what letsan MFA-aware client tell "ask for a code" from "code refused".
leaves ~333k guesses, so wrong codes are counted and the second factor is refused for
lockoutDuration. The counter is per-instance (Play cache), so behind a load balancerthe effective limit is
maxAttemptsper instance — documented as such.across a horizontally scaled Cortex without sticky sessions, and the client can't choose
its own secret.
<seed>,<sha256(seed+code)>, compared withMessageDigest.isEqual), shown once, and consumed one at a time.javasemodule,which drags in
jai-imageio; rendering to SVG keeps the dependency to zxingcoreandscales to whatever the browser needs.
API
A new
mfaAuthCapabilityis advertised on/api/status, so the UI hides the featurewhen it is off.
PATCH /api/user/:userIdexplicitly rejectstotpSecretandtotpScratchCodes, the sameway it already rejects
passwordandkey.Configuration
Documented in
conf/application.sample, defaults inconf/reference.conf.Data model
Two new
userattributes, bothsensitiveandunaudited:totpSecret— optional, base32 shared secrettotpScratchCodes— multi-valued, hashes of the unused backup codestoJsonexposes onlyhasMFAandremainingBackupCodes.modelVersionis deliberately left at 6, so there is no migration. Bumping it would make afull reindex of every job, report and artifact mandatory on each existing instance before Cortex
serves again, and
migratedoes not delete the index it replaces. That is a steep price here: bothattributes are only ever read back from
_source(user.totpSecret()), and nothing queries, sortsor aggregates on them.
The trade-off is a mapping difference on upgraded instances. A fresh index declares both fields as
keyword; on an index created before they existed, Elasticsearch maps them on first write astextwith a
keywordsub-field. Verified against Elasticsearch 8.14: the write succeeds and the_sourceround-trips unchanged, so the feature behaves identically. Anexistsquery — the naturalway to ask "who has MFA enabled" — also matches under both shapes; only an exact
termon thevalue would need the declared
keyword. The reasoning is recorded next tomodelVersion, so afuture change that does need to query either field knows to bump it and add the
DatabaseStatecase.
Existing users have neither attribute, which reads as "not enrolled".
New dependencies
com.warrenstrange:googleauth:1.5.0— TOTP. Its only transitives (commons-codec,httpclient) are already on the classpath at higher versions viaelasticsearch-rest-clientanddocker-java, so they are evicted upward and this addsa single jar.
com.google.zxing:core:3.5.4— QR encoding.coreonly, which has no transitivedependencies.
Tests
test/org/thp/cortex/services/TOTPSrvSpec.scalacovers code normalisation, TOTP-vs-backup-codediscrimination, backup code hashing and constant-time comparison, the otpauth URI against
the Key-Uri-Format spec, and QR/SVG generation. It includes the RFC 6238 test vectors.
Beyond the unit tests, the API was exercised end to end against Elasticsearch 8.14 on a freshly
created index: bootstrap, login without MFA, enrolment (rejecting a wrong code, then accepting a
valid one), login requiring the second factor, login with a TOTP code, login with a backup code and
its single-use enforcement, a wrong password with a valid code, the cross-user authorization checks,
lockout after
maxAttempts, self-service disable, and login without a code afterwards.Not covered: Cortex was not run against an index created by an earlier release — the
pre-existing-index case was only checked at the Elasticsearch level, as described under Data
model. Upgrading an installed package (Debian/RPM/Docker) to this build was not tested. Neither
ldap,adnoroauth2was exercised; only thelocalprovider was.UI
"use a different account" to go back.
with a copy button, and self-service disable.
their authenticator. An admin can never enrol on someone else's behalf.
Not included
Documentation lives in a separate repository; happy to open the matching docs PR.