diff --git a/app/org/thp/cortex/controllers/AuthenticationCtrl.scala b/app/org/thp/cortex/controllers/AuthenticationCtrl.scala
index c447f4bda..30ae4a0b9 100644
--- a/app/org/thp/cortex/controllers/AuthenticationCtrl.scala
+++ b/app/org/thp/cortex/controllers/AuthenticationCtrl.scala
@@ -3,11 +3,10 @@ package org.thp.cortex.controllers
import javax.inject.{Inject, Singleton}
import org.elastic4play.controllers.{Authenticated, Fields, FieldsBodyParser, Renderer}
import org.elastic4play.database.DBIndex
-import org.elastic4play.services.AuthSrv
import org.elastic4play.services.JsonFormat.authContextWrites
import org.elastic4play.{AuthorizationError, MissingAttributeError, Timed}
import org.thp.cortex.models.UserStatus
-import org.thp.cortex.services.UserSrv
+import org.thp.cortex.services.{CortexAuthSrv, UserSrv}
import play.api.Configuration
import play.api.mvc._
@@ -16,7 +15,8 @@ import scala.concurrent.{ExecutionContext, Future}
@Singleton
class AuthenticationCtrl @Inject() (
configuration: Configuration,
- authSrv: AuthSrv,
+ // CortexAuthSrv rather than the AuthSrv trait: the second-factor-aware authenticate overload lives there
+ authSrv: CortexAuthSrv,
userSrv: UserSrv,
authenticated: Authenticated,
dbIndex: DBIndex,
@@ -32,9 +32,12 @@ class AuthenticationCtrl @Inject() (
case false => Future.successful(Results.Status(520))
case _ =>
for {
- user <- request.body.getString("user").fold[Future[String]](Future.failed(MissingAttributeError("user")))(Future.successful)
- password <- request.body.getString("password").fold[Future[String]](Future.failed(MissingAttributeError("password")))(Future.successful)
- authContext <- authSrv.authenticate(user, password)
+ user <- request.body.getString("user").fold[Future[String]](Future.failed(MissingAttributeError("user")))(Future.successful)
+ password <- request.body.getString("password").fold[Future[String]](Future.failed(MissingAttributeError("password")))(Future.successful)
+ // Optional second factor. Absent on the first attempt; the client retries with it after a
+ // MultiFactorCodeRequired error. Also accepts a single-use backup code.
+ code = request.body.getString("code")
+ authContext <- authSrv.authenticate(user, password, code)
} yield authenticated.setSessingUser(renderer.toOutput(OK, authContext), authContext)
}
}
diff --git a/app/org/thp/cortex/controllers/UserCtrl.scala b/app/org/thp/cortex/controllers/UserCtrl.scala
index 0e102d9d5..3e27bf25f 100644
--- a/app/org/thp/cortex/controllers/UserCtrl.scala
+++ b/app/org/thp/cortex/controllers/UserCtrl.scala
@@ -6,7 +6,7 @@ import org.elastic4play.models.JsonFormat.baseModelEntityWrites
import org.elastic4play.services.JsonFormat.queryReads
import org.elastic4play.services.{AuthContext, AuthSrv, QueryDSL, QueryDef}
import org.thp.cortex.models.{OrganizationStatus, Roles}
-import org.thp.cortex.services.{OrganizationSrv, UserSrv}
+import org.thp.cortex.services.{OrganizationSrv, TOTPSrv, UserSrv}
import play.api.Logger
import play.api.http.Status
import play.api.libs.json.{JsObject, Json}
@@ -20,6 +20,7 @@ import scala.util.Try
class UserCtrl @Inject() (
userSrv: UserSrv,
authSrv: AuthSrv,
+ totpSrv: TOTPSrv,
organizationSrv: OrganizationSrv,
authenticated: Authenticated,
renderer: Renderer,
@@ -112,6 +113,8 @@ class UserCtrl @Inject() (
if (request.body.contains("password"))
Future.failed(AuthorizationError("You must use dedicated API (setPassword, changePassword) to update password"))
else if (request.body.contains("key")) Future.failed(AuthorizationError("You must use dedicated API (renewKey, removeKey) to update key"))
+ else if (request.body.contains("totpSecret") || request.body.contains("totpScratchCodes"))
+ Future.failed(AuthorizationError("You must use dedicated API (mfa/init, mfa/set, mfa/unset) to update multi-factor authentication"))
else Future.successful(())
for {
@@ -254,4 +257,68 @@ class UserCtrl @Inject() (
key <- authSrv.setKey(userId, keyInput)
} yield Ok(key)
}
+
+ /** Session key holding the secret between `mfaInit` and `mfaSet`.
+ *
+ * Keeping the pending secret in the (signed) session rather than server-side memory keeps enrolment working across a horizontally scaled Cortex
+ * without sticky sessions, and stops the client from choosing its own secret. The value is readable by the user's own browser, which is fine —
+ * it is the very thing they are about to scan.
+ */
+ private val pendingMfaSecret = "mfaSecret"
+
+ @Timed
+ def mfaInit(userId: String): Action[AnyContent] = authenticated().async { implicit request =>
+ if (userId != request.authContext.userId)
+ Future.failed(AuthorizationError("You can't enable multi-factor authentication for another user"))
+ else if (!totpSrv.enabled)
+ Future.failed(BadRequestError("Multi-factor authentication is disabled on this instance"))
+ else {
+ val secret = totpSrv.generateSecret()
+ Future.successful(
+ renderer
+ .toOutput(
+ OK,
+ Json.obj(
+ "secret" -> secret, // for manual entry
+ "uri" -> totpSrv.otpAuthUri(userId, secret), // for a click-through on mobile
+ "qrCode" -> totpSrv.qrCode(userId, secret) // SVG data URI, for an
+ )
+ )
+ .addingToSession(pendingMfaSecret -> secret)
+ )
+ }
+ }
+
+ @Timed
+ def mfaSet(userId: String): Action[Fields] = authenticated().async(fieldsBodyParser) { implicit request =>
+ if (userId != request.authContext.userId)
+ Future.failed(AuthorizationError("You can't enable multi-factor authentication for another user"))
+ else
+ for {
+ secret <- request
+ .session
+ .get(pendingMfaSecret)
+ .fold(Future.failed[String](BadRequestError("No multi-factor enrolment in progress, start it again")))(Future.successful)
+ code <- request.body.getString("code").fold(Future.failed[String](MissingAttributeError("code")))(Future.successful)
+ _ <- totpSrv.verifyForEnrolment(userId, secret, code)
+ (backupCodes, backupCodeHashes) = totpSrv.generateBackupCodes()
+ _ <- totpSrv.enable(userId, secret, backupCodeHashes)
+ _ = logger.info(s"User $userId enabled multi-factor authentication")
+ } yield renderer
+ .toOutput(OK, Json.obj("backupCodes" -> backupCodes))
+ .removingFromSession(pendingMfaSecret)
+ }
+
+ /** Self-service disable, and the admin reset for a user who lost their authenticator. */
+ @Timed
+ def mfaUnset(userId: String): Action[AnyContent] = authenticated().async { implicit request =>
+ val isAdmin = request.roles.contains(Roles.orgAdmin) || request.roles.contains(Roles.superAdmin)
+ for {
+ _ <- if (userId == request.authContext.userId || isAdmin) Future.successful(())
+ else Future.failed(AuthorizationError("You are not authorized to perform this operation"))
+ _ <- checkUserOrganization(userId)
+ _ <- totpSrv.disable(userId)
+ _ = logger.info(s"Multi-factor authentication of user $userId reset by user ${request.userId}")
+ } yield NoContent
+ }
}
diff --git a/app/org/thp/cortex/models/Errors.scala b/app/org/thp/cortex/models/Errors.scala
index 302a7d4dd..4c9df40e0 100644
--- a/app/org/thp/cortex/models/Errors.scala
+++ b/app/org/thp/cortex/models/Errors.scala
@@ -9,3 +9,9 @@ case class RateLimitExceeded(analyzer: Worker)
extends CortexError(
s"Rate limit of ${analyzer.rate().getOrElse("(not set ?!)")} per ${analyzer.rateUnit().getOrElse("(not set ?!)")} reached for the analyzer ${analyzer.name()}. Job cannot be started"
)
+
+/** The credentials are valid but the user has MFA enabled and didn't supply a code. Clients must retry the login with a "code" field. */
+case class MultiFactorCodeRequired(message: String = "Multi-factor authentication code is required") extends CortexError(message)
+
+/** The credentials are valid but the supplied MFA code is wrong, or too many wrong codes have been submitted. */
+case class MultiFactorCodeInvalid(message: String = "Multi-factor authentication code is invalid") extends CortexError(message)
diff --git a/app/org/thp/cortex/models/User.scala b/app/org/thp/cortex/models/User.scala
index 6b2319d50..5e96954ec 100644
--- a/app/org/thp/cortex/models/User.scala
+++ b/app/org/thp/cortex/models/User.scala
@@ -1,7 +1,7 @@
package org.thp.cortex.models
import scala.concurrent.Future
-import play.api.libs.json.{Format, JsArray, JsBoolean, JsObject, JsString}
+import play.api.libs.json.{Format, JsArray, JsBoolean, JsNumber, JsObject, JsString}
import org.elastic4play.models.JsonFormat.enumFormat
import org.elastic4play.models.{AttributeDef, BaseEntity, EntityDef, HiveEnumeration, ModelDef, AttributeFormat => F, AttributeOption => O}
import org.elastic4play.services.{User => EUser}
@@ -23,6 +23,9 @@ trait UserAttributes { _: AttributeDef =>
val avatar = optionalAttribute("avatar", F.rawFmt, "Base64 representation of user avatar image", O.unaudited)
val preferences = attribute("preferences", F.rawFmt, "User preferences", "{}", O.sensitive, O.unaudited)
val organization = attribute("organization", F.stringFmt, "User organization")
+ val totpSecret = optionalAttribute("totpSecret", F.stringFmt, "TOTP shared secret (base32)", O.sensitive, O.unaudited)
+ // Single-use backup codes, consumed one by one when the user can't produce a TOTP code
+ val totpScratchCodes = multiAttribute("totpScratchCodes", F.stringFmt, "Unused MFA backup codes", O.sensitive, O.unaudited)
}
class UserModel extends ModelDef[UserModel, User]("user", "User", "/user") with UserAttributes with AuditedModel {
@@ -40,7 +43,9 @@ class User(model: UserModel, attributes: JsObject) extends EntityDef[UserModel,
override def toJson: JsObject =
super.toJson +
- ("roles" -> JsArray(roles().map(r => JsString(r.name.toLowerCase())))) +
- ("hasKey" -> JsBoolean(key().isDefined)) +
- ("hasPassword" -> JsBoolean(password().isDefined))
+ ("roles" -> JsArray(roles().map(r => JsString(r.name.toLowerCase())))) +
+ ("hasKey" -> JsBoolean(key().isDefined)) +
+ ("hasPassword" -> JsBoolean(password().isDefined)) +
+ ("hasMFA" -> JsBoolean(totpSecret().isDefined)) +
+ ("remainingBackupCodes" -> JsNumber(totpScratchCodes().size))
}
diff --git a/app/org/thp/cortex/models/package.scala b/app/org/thp/cortex/models/package.scala
index c3a30f93a..8b6dd000a 100644
--- a/app/org/thp/cortex/models/package.scala
+++ b/app/org/thp/cortex/models/package.scala
@@ -1,5 +1,22 @@
package org.thp.cortex
package object models {
+
+ /** Version of the stored document shape. Bumping it makes `POST /api/maintenance/migrate` create a new index from the current models and copy
+ * every document into it, so a bump is also an upgrade step every operator has to run before Cortex serves again.
+ *
+ * Deliberately *not* bumped for the multi-factor attributes (`totpSecret`, `totpScratchCodes`). A full reindex of every job, report and artifact
+ * is a steep price for two fields that are only ever read back from `_source` — nothing queries, sorts or aggregates on them. On an index created
+ * before they existed, Elasticsearch maps them on first write (`text` with a `keyword` sub-field, rather than the declared `keyword`), which
+ * changes nothing for the feature.
+ *
+ * If a future change needs to *query* either field, bump this and add the matching `DatabaseState` case in `Migration.scala`. An `exists` query —
+ * the natural way to ask "who has MFA enabled" — matches under both mapping shapes; only an exact `term` on the value needs the declared
+ * `keyword`.
+ *
+ * Test coverage behind the above: enrolment and login were exercised end to end against a freshly created index. The pre-existing-index case was
+ * only checked at the Elasticsearch level — the dynamic mapping it produces, and a `_source` round-trip — not by running Cortex against an index
+ * created by an earlier release. Upgrading an installed package to this build was not tested.
+ */
val modelVersion = 6
}
diff --git a/app/org/thp/cortex/services/CortexAuthSrv.scala b/app/org/thp/cortex/services/CortexAuthSrv.scala
index 57f44e2bf..fe84d125d 100644
--- a/app/org/thp/cortex/services/CortexAuthSrv.scala
+++ b/app/org/thp/cortex/services/CortexAuthSrv.scala
@@ -3,11 +3,12 @@ package org.thp.cortex.services
import javax.inject.{Inject, Singleton}
import scala.collection.immutable
-import scala.concurrent.ExecutionContext
+import scala.concurrent.{ExecutionContext, Future}
import play.api.{Configuration, Logger}
+import play.api.mvc.RequestHeader
-import org.elastic4play.services.AuthSrv
+import org.elastic4play.services.{AuthCapability, AuthContext, AuthSrv}
import org.elastic4play.services.auth.MultiAuthSrv
object CortexAuthSrv {
@@ -30,13 +31,46 @@ class CortexAuthSrv @Inject() (
configuration: Configuration,
authModules: immutable.Set[AuthSrv],
userSrv: UserSrv,
+ totpSrv: TOTPSrv,
implicit override val ec: ExecutionContext
) extends MultiAuthSrv(
CortexAuthSrv.getAuthSrv(configuration.getDeprecated[Option[Seq[String]]]("auth.provider", "auth.type").getOrElse(Seq("local")), authModules),
ec
) {
- // Uncomment the following lines if you want to prevent user with key to use password to authenticate
+ // Recomputed from the providers rather than delegated to MultiAuthSrv: `capabilities` is a val, so
+ // it can't be reached through `super`. The front-end reads this from /api/status to decide whether
+ // to offer MFA enrolment in the settings page.
+ override val capabilities: Set[AuthCapability.Type] = {
+ val providerCapabilities = authProviders.flatMap(_.capabilities).toSet
+ if (totpSrv.enabled) providerCapabilities + AuthCapability.mfa else providerCapabilities
+ }
+
+ /** Password authentication with a second factor.
+ *
+ * The MFA gate deliberately 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. Here the password is checked by whichever provider owns it (local, ldap,
+ * ad) and only then is the code verified, so an MFA failure propagates to the client.
+ *
+ * API keys and SSO are intentionally not gated: keys are how TheHive and MISP integrate, and with SSO the identity provider owns MFA.
+ */
+ def authenticate(username: String, password: String, code: Option[String])(implicit request: RequestHeader): Future[AuthContext] =
+ super
+ .authenticate(username, password)
+ .flatMap { authContext =>
+ userSrv
+ .get(authContext.userId)
+ .flatMap(user => totpSrv.checkAtLogin(user, code))
+ .map(_ => authContext)
+ }
+
+ /** Password authentication with no way to supply a code — HTTP Basic auth. An enrolled user is refused rather than silently exempted. */
+ override def authenticate(username: String, password: String)(implicit request: RequestHeader): Future[AuthContext] =
+ authenticate(username, password, None)
+
+ // Uncomment the following lines if you want to prevent user with key to use password to authenticate.
+ // NOTE: authenticate(username, password) is now overridden above, so fold this logic into that
+ // override rather than pasting a second one.
// override def authenticate(username: String, password: String)(implicit request: RequestHeader): Future[AuthContext] =
// userSrv.get(username)
// .transformWith {
diff --git a/app/org/thp/cortex/services/ErrorHandler.scala b/app/org/thp/cortex/services/ErrorHandler.scala
index 8c1e4583c..809d3151c 100644
--- a/app/org/thp/cortex/services/ErrorHandler.scala
+++ b/app/org/thp/cortex/services/ErrorHandler.scala
@@ -2,7 +2,7 @@ package org.thp.cortex.services
import org.elastic4play.JsonFormat.attributeCheckingExceptionWrites
import org.elastic4play._
-import org.thp.cortex.models.{JobNotFoundError, RateLimitExceeded, WorkerNotFoundError}
+import org.thp.cortex.models.{JobNotFoundError, MultiFactorCodeInvalid, MultiFactorCodeRequired, RateLimitExceeded, WorkerNotFoundError}
import play.api.Logger
import play.api.http.Status.{BAD_REQUEST, FORBIDDEN, NOT_FOUND}
import play.api.http.{HttpErrorHandler, Status, Writeable}
@@ -33,8 +33,13 @@ class ErrorHandler extends HttpErrorHandler {
case AuthorizationError(message) => Status.FORBIDDEN -> Json.obj("type" -> "AuthorizationError", "message" -> message)
case UpdateError(_, message, attributes) =>
Status.INTERNAL_SERVER_ERROR -> Json.obj("type" -> "UpdateError", "message" -> message, "object" -> attributes)
- case rle: RateLimitExceeded => Status.TOO_MANY_REQUESTS -> Json.obj("type" -> "RateLimitExceeded", "message" -> rle.getMessage)
- case InternalError(message) => Status.INTERNAL_SERVER_ERROR -> Json.obj("type" -> "InternalError", "message" -> message)
+ case rle: RateLimitExceeded => Status.TOO_MANY_REQUESTS -> Json.obj("type" -> "RateLimitExceeded", "message" -> rle.getMessage)
+ // The password was correct but the second factor is missing or wrong. Both are 401 so that
+ // a client which knows nothing about MFA still treats them as an authentication failure;
+ // the "type" field is what lets an MFA-aware client tell "ask for a code" from "code refused".
+ case mfa: MultiFactorCodeRequired => Status.UNAUTHORIZED -> Json.obj("type" -> "MultiFactorCodeRequired", "message" -> mfa.getMessage)
+ case mfa: MultiFactorCodeInvalid => Status.UNAUTHORIZED -> Json.obj("type" -> "MultiFactorCodeInvalid", "message" -> mfa.getMessage)
+ case InternalError(message) => Status.INTERNAL_SERVER_ERROR -> Json.obj("type" -> "InternalError", "message" -> message)
case nfe: NumberFormatException =>
Status.BAD_REQUEST -> Json.obj("type" -> "NumberFormatException", "message" -> ("Invalid format " + nfe.getMessage))
case NotFoundError(message) => Status.NOT_FOUND -> Json.obj("type" -> "NotFoundError", "message" -> message)
diff --git a/app/org/thp/cortex/services/TOTPSrv.scala b/app/org/thp/cortex/services/TOTPSrv.scala
new file mode 100644
index 000000000..121caa78e
--- /dev/null
+++ b/app/org/thp/cortex/services/TOTPSrv.scala
@@ -0,0 +1,249 @@
+package org.thp.cortex.services
+
+import com.google.zxing.qrcode.QRCodeWriter
+import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
+import com.google.zxing.{BarcodeFormat, EncodeHintType}
+import com.warrenstrange.googleauth.{GoogleAuthenticator, GoogleAuthenticatorConfig}
+import org.elastic4play.controllers.Fields
+import org.elastic4play.services.AuthContext
+import org.elastic4play.utils.Hasher
+import org.thp.cortex.models.{MultiFactorCodeInvalid, MultiFactorCodeRequired, User}
+import play.api.cache.AsyncCacheApi
+import play.api.libs.json.{JsArray, JsNull, JsString}
+import play.api.{Configuration, Logger}
+
+import java.net.URLEncoder
+import java.nio.charset.StandardCharsets
+import java.security.{MessageDigest, SecureRandom}
+import java.util.Base64
+import javax.inject.{Inject, Singleton}
+import scala.concurrent.duration.{Duration, DurationInt, FiniteDuration}
+import scala.concurrent.{ExecutionContext, Future}
+import scala.util.Try
+
+object TOTPSrv {
+
+ /** Alphabet for backup codes: Crockford-ish base32 without the characters users misread (I, L, O, U, 0, 1). */
+ private[services] val backupCodeAlphabet: String = "ABCDEFGHJKMNPQRSTVWXYZ23456789"
+
+ private[services] val backupCodeLength: Int = 10
+
+ /** Authenticator apps and password managers render codes as "123 456"; operators paste backup codes as "ABCDE-FGHIJ". */
+ private[services] def normalizeCode(code: String): String = code.replaceAll("[\\s-]", "").toUpperCase
+
+ /** A TOTP code is 6 digits. Anything else is either a backup code or garbage. */
+ private[services] def asTotpCode(code: String): Option[Int] = {
+ val normalized = normalizeCode(code)
+ if (normalized.length == 6 && normalized.forall(_.isDigit)) Try(normalized.toInt).toOption
+ else None
+ }
+
+ /** Backup codes are stored the way passwords are: ",". */
+ private[services] def hashBackupCode(seed: String, code: String): String =
+ seed + "," + Hasher("SHA-256").fromString(seed + normalizeCode(code)).head.toString
+
+ private[services] def backupCodeMatches(storedHash: String, code: String): Boolean =
+ storedHash.split(",", 2) match {
+ case Array(seed, _) =>
+ MessageDigest.isEqual(
+ hashBackupCode(seed, code).getBytes(StandardCharsets.UTF_8),
+ storedHash.getBytes(StandardCharsets.UTF_8)
+ )
+ case _ => false
+ }
+
+ /** Human-readable form of a backup code, shown once at enrolment. */
+ private[services] def formatBackupCode(code: String): String = code.grouped(5).mkString("-")
+
+ /** RFC 6238 / Key-Uri-Format. The label is "issuer:account" and issuer is repeated as a parameter. */
+ private[services] def otpAuthUri(issuer: String, account: String, secret: String): String = {
+ def enc(s: String) = URLEncoder.encode(s, StandardCharsets.UTF_8.name).replace("+", "%20")
+ s"otpauth://totp/${enc(issuer)}:${enc(account)}" +
+ s"?secret=$secret&issuer=${enc(issuer)}&algorithm=SHA1&digits=6&period=30"
+ }
+
+ /** Renders the QR as an SVG rather than a bitmap: zxing's raster writers live in the "javase"
+ * module, which drags in jai-imageio, and an SVG scales to whatever the browser needs anyway.
+ * Dark modules are emitted as horizontal runs to keep the document small.
+ */
+ private[services] def qrCodeSvg(content: String, quietZone: Int = 2): String = {
+ val hints = new java.util.EnumMap[EncodeHintType, Any](classOf[EncodeHintType])
+ hints.put(EncodeHintType.CHARACTER_SET, StandardCharsets.UTF_8.name)
+ hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M)
+ hints.put(EncodeHintType.MARGIN, Int.box(0))
+
+ val matrix = new QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, 0, 0, hints)
+ val size = matrix.getWidth
+ val side = size + 2 * quietZone
+
+ val runs = new StringBuilder
+ for (y <- 0 until size) {
+ var x = 0
+ while (x < size) {
+ if (matrix.get(x, y)) {
+ val start = x
+ while (x < size && matrix.get(x, y)) x += 1
+ runs.append(s"M${start + quietZone} ${y + quietZone}h${x - start}v1h-${x - start}z")
+ } else x += 1
+ }
+ }
+
+ s""""""
+ }
+
+ /** Inline-able by an tag, which is how the UI already renders avatars. */
+ private[services] def qrCodeDataUri(content: String): String =
+ "data:image/svg+xml;base64," + Base64.getEncoder.encodeToString(qrCodeSvg(content).getBytes(StandardCharsets.UTF_8))
+}
+
+/** TOTP (RFC 6238) multi-factor authentication.
+ *
+ * Deliberately *not* an `AuthSrv`: `Module.scala` reflectively binds every concrete `AuthSrv` into the provider list, and this is a gate applied
+ * on top of whichever provider authenticated the password, not a provider of its own.
+ */
+@Singleton
+class TOTPSrv @Inject() (
+ configuration: Configuration,
+ userSrv: UserSrv,
+ cache: AsyncCacheApi,
+ implicit val ec: ExecutionContext
+) {
+ import TOTPSrv._
+
+ private[TOTPSrv] lazy val logger = Logger(getClass)
+
+ val enabled: Boolean = configuration.getOptional[Boolean]("auth.multifactor.enabled").getOrElse(true)
+ val issuer: String = configuration.getOptional[String]("auth.multifactor.issuer").getOrElse("Cortex")
+
+ /** Number of 30s windows accepted, centred on now: 3 tolerates one step of clock drift either way. */
+ private val windowSize: Int = configuration.getOptional[Int]("auth.multifactor.windowSize").getOrElse(3)
+ private val backupCodeCount: Int = configuration.getOptional[Int]("auth.multifactor.backupCodes").getOrElse(10)
+
+ /** A 6-digit code with a 3-window tolerance leaves ~333k guesses, so the attempt counter is what
+ * actually makes the second factor worth having. It is per-instance (caffeine), not cluster-wide.
+ */
+ private val maxAttempts: Int = configuration.getOptional[Int]("auth.multifactor.maxAttempts").getOrElse(5)
+
+ private val lockoutDuration: FiniteDuration = configuration
+ .getOptional[Duration]("auth.multifactor.lockoutDuration")
+ .collect { case fd: FiniteDuration => fd }
+ .getOrElse(15.minutes)
+
+ private val random = new SecureRandom()
+
+ private val googleAuthenticator = new GoogleAuthenticator(
+ new GoogleAuthenticatorConfig.GoogleAuthenticatorConfigBuilder()
+ .setWindowSize(windowSize)
+ .setNumberOfScratchCodes(0) // backup codes are generated and hashed here, not by the library
+ .build()
+ )
+
+ def generateSecret(): String = googleAuthenticator.createCredentials().getKey
+
+ def otpAuthUri(account: String, secret: String): String = TOTPSrv.otpAuthUri(issuer, account, secret)
+
+ /** The enrolment QR, ready to drop into an . */
+ def qrCode(account: String, secret: String): String = TOTPSrv.qrCodeDataUri(otpAuthUri(account, secret))
+
+ /** Returns the plaintext codes to show the user once, paired with the hashes to persist. */
+ def generateBackupCodes(): (Seq[String], Seq[String]) = {
+ val codes = Seq.fill(backupCodeCount) {
+ val raw = Seq.fill(backupCodeLength)(backupCodeAlphabet.charAt(random.nextInt(backupCodeAlphabet.length))).mkString
+ formatBackupCode(raw)
+ }
+ val hashes = codes.map { code =>
+ val seed = Seq.fill(10)(backupCodeAlphabet.charAt(random.nextInt(backupCodeAlphabet.length))).mkString
+ hashBackupCode(seed, code)
+ }
+ codes -> hashes
+ }
+
+ def isEnrolled(user: User): Boolean = user.totpSecret().isDefined
+
+ private def attemptKey(userId: String) = s"mfa-attempts-$userId"
+
+ private def checkNotLockedOut(userId: String): Future[Unit] =
+ cache.get[Int](attemptKey(userId)).flatMap {
+ case Some(attempts) if attempts >= maxAttempts =>
+ logger.warn(s"MFA verification refused for user $userId: too many failed attempts")
+ Future.failed(MultiFactorCodeInvalid(s"Too many invalid codes, try again in ${lockoutDuration.toMinutes} minutes"))
+ case _ => Future.successful(())
+ }
+
+ private def registerFailure(userId: String): Future[Unit] =
+ cache.get[Int](attemptKey(userId)).flatMap { attempts =>
+ cache.set(attemptKey(userId), attempts.getOrElse(0) + 1, lockoutDuration).map(_ => ())
+ }
+
+ private def resetFailures(userId: String): Future[Unit] = cache.remove(attemptKey(userId)).map(_ => ())
+
+ /** Verifies a code against a secret only. Used during enrolment, before anything is persisted. */
+ def verifyCode(secret: String, code: String): Boolean =
+ asTotpCode(code).exists(totpCode => Try(googleAuthenticator.authorize(secret, totpCode)).getOrElse(false))
+
+ /** Consumes a backup code if it matches, returning the remaining hashes. */
+ private def burnBackupCode(user: User, code: String): Option[Seq[String]] = {
+ val stored = user.totpScratchCodes()
+ stored.find(backupCodeMatches(_, code)).map(matched => stored.filterNot(_ == matched))
+ }
+
+ /** The login-time gate. Succeeds immediately when MFA is off or the user isn't enrolled. */
+ def checkAtLogin(user: User, code: Option[String]): Future[Unit] =
+ if (!enabled) Future.successful(())
+ else
+ user.totpSecret() match {
+ case None => Future.successful(()) // not enrolled: MFA is opt-in, so this is a normal login
+ case Some(secret) =>
+ code.map(_.trim).filter(_.nonEmpty) match {
+ case None => Future.failed(MultiFactorCodeRequired())
+ case Some(submitted) =>
+ checkNotLockedOut(user.id).flatMap { _ =>
+ if (verifyCode(secret, submitted)) resetFailures(user.id)
+ else
+ burnBackupCode(user, submitted) match {
+ case Some(remaining) =>
+ logger.info(s"User ${user.id} used an MFA backup code, ${remaining.size} left")
+ userSrv
+ .inInitAuthContext { implicit authContext: AuthContext =>
+ userSrv.update(user.id, Fields.empty.set("totpScratchCodes", JsArray(remaining.map(JsString.apply))))
+ }
+ .flatMap(_ => resetFailures(user.id))
+ case None =>
+ registerFailure(user.id).flatMap(_ => Future.failed(MultiFactorCodeInvalid()))
+ }
+ }
+ }
+ }
+
+ /** Enrolment: persists the secret and the backup code hashes once the user has proven possession. */
+ def enable(userId: String, secret: String, backupCodeHashes: Seq[String])(implicit authContext: AuthContext): Future[Unit] =
+ userSrv
+ .update(
+ userId,
+ Fields
+ .empty
+ .set("totpSecret", JsString(secret))
+ .set("totpScratchCodes", JsArray(backupCodeHashes.map(JsString.apply)))
+ )
+ .flatMap(_ => resetFailures(userId))
+
+ def disable(userId: String)(implicit authContext: AuthContext): Future[Unit] =
+ userSrv
+ .update(
+ userId,
+ Fields
+ .empty
+ .set("totpSecret", JsNull) // JsNull unsets an optional attribute...
+ .set("totpScratchCodes", JsArray()) // ...while an empty array empties a multi-valued one
+ )
+ .flatMap(_ => resetFailures(userId))
+
+ /** Guarded verification used by the enrolment confirmation, so enrolment can't be used as an unthrottled oracle. */
+ def verifyForEnrolment(userId: String, secret: String, code: String): Future[Unit] =
+ checkNotLockedOut(userId).flatMap { _ =>
+ if (verifyCode(secret, code)) resetFailures(userId)
+ else registerFailure(userId).flatMap(_ => Future.failed(MultiFactorCodeInvalid()))
+ }
+}
diff --git a/build.sbt b/build.sbt
index ea5feeb7f..126595411 100644
--- a/build.sbt
+++ b/build.sbt
@@ -80,6 +80,8 @@ lazy val cortex = (project in file("."))
Dependencies.scalaGuice,
Dependencies.reflections,
Dependencies.zip4j,
+ Dependencies.googleAuth,
+ Dependencies.zxing,
Dependencies.dockerJavaClient,
Dependencies.dockerJavaTransport,
Dependencies.k8sClient,
diff --git a/conf/application.sample b/conf/application.sample
index fe2b70f5b..92bcf1a4f 100644
--- a/conf/application.sample
+++ b/conf/application.sample
@@ -94,6 +94,40 @@ auth {
# - oauth2 : use OAuth/OIDC to authenticate users. Configuration is under "auth.oauth2" and "auth.sso" keys
provider = [local]
+ # Multi-factor authentication (TOTP, RFC 6238 — Google Authenticator, Aegis, 1Password, ...).
+ #
+ # This is opt-in per user: turning it on here only makes the feature available in the user's
+ # settings page, it does not force anyone to enrol. Each user who enables it is issued a set
+ # of single-use backup codes; an org admin or a superadmin can reset a user's MFA if they
+ # lose their authenticator.
+ #
+ # Only password logins are challenged (local, ldap, ad). API keys are never challenged — that
+ # is how TheHive and MISP integrate with Cortex — and with oauth2 the identity provider owns
+ # MFA. HTTP basic auth has nowhere to carry a code, so an enrolled user cannot use it.
+ multifactor {
+ # Set to false to hide the feature entirely. Users who already enrolled are then no
+ # longer challenged, so prefer resetting them individually over flipping this off.
+ #enabled = true
+
+ # Name displayed in the authenticator app next to the account
+ #issuer = "Cortex"
+
+ # Number of 30-second windows accepted, centred on now. 3 tolerates one step of clock
+ # drift either way; raise it only if your users' devices are badly synchronised.
+ #windowSize = 3
+
+ # How many single-use backup codes are generated at enrolment. They are shown once and
+ # stored hashed, so a user who loses them must re-enrol.
+ #backupCodes = 10
+
+ # Wrong codes tolerated before the second factor is refused for 'lockoutDuration'.
+ # Without this a 6-digit code is brute-forceable. The counter is held in memory per
+ # Cortex instance, so behind a load balancer the effective limit is maxAttempts per
+ # instance.
+ #maxAttempts = 5
+ #lockoutDuration = 15 minutes
+ }
+
ad {
# The Windows domain name in DNS format. This parameter is required if you do not use
# 'serverNames' below.
diff --git a/conf/reference.conf b/conf/reference.conf
index beb60b24a..5d906a89e 100644
--- a/conf/reference.conf
+++ b/conf/reference.conf
@@ -62,6 +62,23 @@ search {
auth.provider = ["local"]
auth.method.basic = false
+# Multi-factor authentication (TOTP, RFC 6238).
+# Opt-in per user: enabling it here only makes the feature available, it does not
+# force anyone to enrol. Applies to password logins (local, ldap, ad) only —
+# API keys and SSO are never challenged.
+auth.multifactor {
+ enabled = true
+ # Name shown in the authenticator app
+ issuer = "Cortex"
+ # Number of 30s windows accepted, centred on now (3 tolerates one step of clock drift either way)
+ windowSize = 3
+ # Single-use backup codes issued at enrolment
+ backupCodes = 10
+ # Wrong codes tolerated before the second factor is refused for lockoutDuration
+ maxAttempts = 5
+ lockoutDuration = 15 minutes
+}
+
# Datastore
datastore {
name = data
diff --git a/conf/routes b/conf/routes
index 1feba6ce2..5eedfe8ab 100644
--- a/conf/routes
+++ b/conf/routes
@@ -101,6 +101,9 @@ GET /api/user/:userId/key org.thp.cort
DELETE /api/user/:userId/key org.thp.cortex.controllers.UserCtrl.removeKey(userId)
POST /api/user/:userId/key/renew org.thp.cortex.controllers.UserCtrl.renewKey(userId)
POST /api/user/:userId/key/set org.thp.cortex.controllers.UserCtrl.setKey(userId)
+POST /api/user/:userId/mfa/init org.thp.cortex.controllers.UserCtrl.mfaInit(userId)
+POST /api/user/:userId/mfa/set org.thp.cortex.controllers.UserCtrl.mfaSet(userId)
+POST /api/user/:userId/mfa/unset org.thp.cortex.controllers.UserCtrl.mfaUnset(userId)
GET /api/organization org.thp.cortex.controllers.OrganizationCtrl.find
POST /api/organization/_search org.thp.cortex.controllers.OrganizationCtrl.find
diff --git a/elastic4play/app/org/elastic4play/services/UserSrv.scala b/elastic4play/app/org/elastic4play/services/UserSrv.scala
index 27042e8d7..9c02b862d 100644
--- a/elastic4play/app/org/elastic4play/services/UserSrv.scala
+++ b/elastic4play/app/org/elastic4play/services/UserSrv.scala
@@ -34,7 +34,7 @@ trait User {
object AuthCapability extends Enumeration {
type Type = Value
- val changePassword, setPassword, authByKey = Value
+ val changePassword, setPassword, authByKey, mfa = Value
}
trait AuthSrv {
diff --git a/project/Dependencies.scala b/project/Dependencies.scala
index e48794c1f..ca77714f1 100644
--- a/project/Dependencies.scala
+++ b/project/Dependencies.scala
@@ -25,6 +25,14 @@ object Dependencies {
val reflections = "org.reflections" % "reflections" % "0.10.2"
val zip4j = "net.lingala.zip4j" % "zip4j" % "2.11.5"
+ // RFC 6238 TOTP (multi-factor authentication). 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.
+ val googleAuth = "com.warrenstrange" % "googleauth" % "1.5.0"
+ // QR code encoding for MFA enrolment. Only the "core" module, which has no transitive
+ // dependencies: the image writers live in zxing "javase", which we don't use because the
+ // QR is rendered to SVG directly.
+ val zxing = "com.google.zxing" % "core" % "3.5.4"
val dockerJavaClient = "com.github.docker-java" % "docker-java-core" % dockerJavaVersion
val dockerJavaTransport = "com.github.docker-java" % "docker-java-transport-zerodep" % dockerJavaVersion
val k8sClient = "io.fabric8" % "kubernetes-client" % "7.4.0"
diff --git a/test/org/thp/cortex/services/TOTPSrvSpec.scala b/test/org/thp/cortex/services/TOTPSrvSpec.scala
new file mode 100644
index 000000000..697b61853
--- /dev/null
+++ b/test/org/thp/cortex/services/TOTPSrvSpec.scala
@@ -0,0 +1,234 @@
+package org.thp.cortex.services
+
+import com.google.zxing.qrcode.QRCodeWriter
+import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
+import com.google.zxing.{BarcodeFormat, EncodeHintType}
+import com.warrenstrange.googleauth.{GoogleAuthenticator, GoogleAuthenticatorConfig}
+import org.junit.runner.RunWith
+import org.specs2.runner.JUnitRunner
+import play.api.test.PlaySpecification
+
+@RunWith(classOf[JUnitRunner])
+class TOTPSrvSpec extends PlaySpecification {
+
+ "TOTPSrv.normalizeCode" should {
+
+ "strip the spacing authenticator apps display" in {
+ TOTPSrv.normalizeCode("123 456") must_== "123456"
+ }
+
+ "strip the dashes backup codes are displayed with" in {
+ TOTPSrv.normalizeCode("ABCDE-FGHJK") must_== "ABCDEFGHJK"
+ }
+
+ "upper-case backup codes typed in lower case" in {
+ TOTPSrv.normalizeCode("abcde-fghjk") must_== "ABCDEFGHJK"
+ }
+ }
+
+ "TOTPSrv.asTotpCode" should {
+
+ "accept a six digit code" in {
+ TOTPSrv.asTotpCode("094287") must beSome(94287)
+ }
+
+ "accept a six digit code with a space" in {
+ TOTPSrv.asTotpCode("094 287") must beSome(94287)
+ }
+
+ "reject a backup code" in {
+ TOTPSrv.asTotpCode("ABCDE-FGHJK") must beNone
+ }
+
+ "reject a code of the wrong length" in {
+ TOTPSrv.asTotpCode("12345") must beNone
+ TOTPSrv.asTotpCode("1234567") must beNone
+ }
+
+ "reject an empty code" in {
+ TOTPSrv.asTotpCode("") must beNone
+ }
+ }
+
+ "TOTPSrv.otpAuthUri" should {
+
+ "build a key URI an authenticator app can parse" in {
+ TOTPSrv.otpAuthUri("Cortex", "alice", "JBSWY3DPEHPK3PXP") must_==
+ "otpauth://totp/Cortex:alice?secret=JBSWY3DPEHPK3PXP&issuer=Cortex&algorithm=SHA1&digits=6&period=30"
+ }
+
+ "percent-encode an issuer containing a space" in {
+ TOTPSrv.otpAuthUri("My Cortex", "alice", "JBSWY3DPEHPK3PXP") must contain("otpauth://totp/My%20Cortex:alice")
+ TOTPSrv.otpAuthUri("My Cortex", "alice", "JBSWY3DPEHPK3PXP") must contain("issuer=My%20Cortex")
+ }
+
+ "percent-encode a login containing a slash or an at sign" in {
+ TOTPSrv.otpAuthUri("Cortex", "alice@example.com", "JBSWY3DPEHPK3PXP") must contain("Cortex:alice%40example.com")
+ TOTPSrv.otpAuthUri("Cortex", "dom/alice", "JBSWY3DPEHPK3PXP") must contain("Cortex:dom%2Falice")
+ }
+ }
+
+ "TOTPSrv.qrCodeSvg" should {
+
+ val uri = TOTPSrv.otpAuthUri("Cortex", "alice", "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP")
+ val svg = TOTPSrv.qrCodeSvg(uri)
+
+ "produce a self-contained SVG document" in {
+ svg must startWith("