Skip to content

feat: optional TOTP multi-factor authentication - #495

Open
cyberpescadito wants to merge 1 commit into
TheHive-Project:masterfrom
cyberpescadito:feature/mfa-totp
Open

feat: optional TOTP multi-factor authentication#495
cyberpescadito wants to merge 1 commit into
TheHive-Project:masterfrom
cyberpescadito:feature/mfa-totp

Conversation

@cyberpescadito

@cyberpescadito cyberpescadito commented Aug 21, 2026

Copy link
Copy Markdown

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

Authentication Second factor
Password (local, ldap, ad) Challenged
API key Never — this is how TheHive and MISP integrate with Cortex
oauth2 / SSO Never — the identity provider owns MFA
HTTP Basic Nowhere to carry a code, so an enrolled user is refused rather than silently exempted

Design notes

  • The gate sits outside MultiAuthSrv's provider fold. Inside it, any failure is
    swallowed and falls through to the next provider, which would turn a rejected MFA code
    into a silent retry. So CortexAuthSrv lets whichever provider owns the password verify
    it, and only then checks the code.
  • TOTPSrv is deliberately not an AuthSrv. Module.scala reflectively binds every
    concrete AuthSrv into the provider list; this is a gate on top of a provider, not a
    provider.
  • Two-step login, not a third form field. POST /api/login answers 401 with
    type: MultiFactorCodeRequired when the password is right but no code was supplied, and
    MultiFactorCodeInvalid when the code is wrong. Both are 401 so a client that knows
    nothing about MFA still reads them as an authentication failure; the type is what lets
    an MFA-aware client tell "ask for a code" from "code refused".
  • Brute-force protection is load-bearing. A 6-digit code with a 3-window tolerance
    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 balancer
    the effective limit is maxAttempts per instance — documented as such.
  • The pending secret lives in the signed session, not server memory. Enrolment works
    across a horizontally scaled Cortex without sticky sessions, and the client can't choose
    its own secret.
  • Backup codes are stored like passwords (<seed>,<sha256(seed+code)>, compared with
    MessageDigest.isEqual), shown once, and consumed one at a time.
  • The QR is an inline SVG data URI. zxing's raster writers live in the javase module,
    which drags in jai-imageio; rendering to SVG keeps the dependency to zxing core and
    scales to whatever the browser needs.

API

POST /api/user/:userId/mfa/init    -> { secret, uri, qrCode }   (self only)
POST /api/user/:userId/mfa/set     -> { backupCodes }           (self only, one-time display)
POST /api/user/:userId/mfa/unset   -> 204                       (self, or org admin / superadmin reset)
POST /api/login                    +  optional "code" field

A new mfa AuthCapability is advertised on /api/status, so the UI hides the feature
when it is off.

PATCH /api/user/:userId explicitly rejects totpSecret and totpScratchCodes, the same
way it already rejects password and key.

Configuration

auth.multifactor {
  enabled         = true
  issuer          = "Cortex"     # name shown in the authenticator app
  windowSize      = 3            # 30s windows accepted, centred on now
  backupCodes     = 10
  maxAttempts     = 5
  lockoutDuration = 15 minutes
}

Documented in conf/application.sample, defaults in conf/reference.conf.

Data model

Two new user attributes, both sensitive and unaudited:

  • totpSecret — optional, base32 shared secret
  • totpScratchCodes — multi-valued, hashes of the unused backup codes

toJson exposes only hasMFA and remainingBackupCodes.

modelVersion is deliberately left at 6, so there is no migration. Bumping it would make a
full reindex of every job, report and artifact mandatory on each existing instance before Cortex
serves again, and migrate does not delete the index it replaces. That is a steep price here: both
attributes are only ever read back from _source (user.totpSecret()), and nothing queries, sorts
or 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 as text
with a keyword sub-field. Verified against Elasticsearch 8.14: the write succeeds and the
_source round-trips unchanged, so the feature behaves identically. An exists query — the natural
way to ask "who has MFA enabled" — also matches under both shapes; only an exact term on the
value would need the declared keyword. The reasoning is recorded next to modelVersion, so a
future change that does need to query either field knows to bump it and add the DatabaseState
case.

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 via
    elasticsearch-rest-client and docker-java, so they are evicted upward and this adds
    a single jar.
  • com.google.zxing:core:3.5.4 — QR encoding. core only, which has no transitive
    dependencies.

Tests

test/org/thp/cortex/services/TOTPSrvSpec.scala covers code normalisation, TOTP-vs-backup-code
discrimination, 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, ad nor oauth2 was exercised; only the local provider was.

UI

  • Login — a second step asking for the 6-digit code, which also accepts a backup code;
    "use a different account" to go back.
  • Settings — enrolment (QR + manual key + verification), the one-time backup code list
    with a copy button, and self-service disable.
  • Admin › Users — a 2FA column showing status, with a reset action for a user who lost
    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.

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
cyberpescadito marked this pull request as ready for review August 21, 2026 16:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant