From c125ecf8910ff972f0b7c3c68a5aaaa6065f0063 Mon Sep 17 00:00:00 2001 From: Florian Perret - cyberpescadito Date: Fri, 21 Aug 2026 08:07:21 +0000 Subject: [PATCH] feat: optional TOTP multi-factor authentication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../controllers/AuthenticationCtrl.scala | 15 +- app/org/thp/cortex/controllers/UserCtrl.scala | 69 ++++- app/org/thp/cortex/models/Errors.scala | 6 + app/org/thp/cortex/models/User.scala | 13 +- app/org/thp/cortex/models/package.scala | 17 ++ .../thp/cortex/services/CortexAuthSrv.scala | 40 ++- .../thp/cortex/services/ErrorHandler.scala | 11 +- app/org/thp/cortex/services/TOTPSrv.scala | 249 ++++++++++++++++++ build.sbt | 2 + conf/application.sample | 34 +++ conf/reference.conf | 17 ++ conf/routes | 3 + .../org/elastic4play/services/UserSrv.scala | 2 +- project/Dependencies.scala | 8 + .../org/thp/cortex/services/TOTPSrvSpec.scala | 234 ++++++++++++++++ .../app/core/services/common/AuthService.js | 18 +- .../app/core/services/common/UserService.js | 26 ++ .../common/user-list/users-list.controller.js | 32 +++ .../admin/common/user-list/users-list.html | 9 +- www/src/app/pages/login/login.controller.js | 33 ++- www/src/app/pages/login/login.page.html | 41 ++- .../settings/settings.page.controller.js | 100 +++++++ www/src/app/pages/settings/settings.page.html | 95 +++++++ 23 files changed, 1038 insertions(+), 36 deletions(-) create mode 100644 app/org/thp/cortex/services/TOTPSrv.scala create mode 100644 test/org/thp/cortex/services/TOTPSrvSpec.scala 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"""""" + + s"""""" + + 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("") + svg must contain("viewBox=\"0 0 ") + } + + "draw the dark modules as a single path" in { + svg must contain("= quietZone, so no segment touches the left edge + svg must not(contain("d=\"M0 ")) + } + + // The run-length encoding is the only hand-written part of the rendering, so rebuild the module + // grid from the emitted path and check it is exactly the matrix zxing produced. + "encode every dark module of the symbol, and nothing else" in { + val quietZone = 2 + val hints = new java.util.EnumMap[EncodeHintType, Any](classOf[EncodeHintType]) + hints.put(EncodeHintType.CHARACTER_SET, "UTF-8") + hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M) + hints.put(EncodeHintType.MARGIN, Int.box(0)) + val matrix = new QRCodeWriter().encode(uri, BarcodeFormat.QR_CODE, 0, 0, hints) + + val expected = (for { + y <- 0 until matrix.getHeight + x <- 0 until matrix.getWidth + if matrix.get(x, y) + } yield (x + quietZone, y + quietZone)).toSet + + val segment = """M(\d+) (\d+)h(\d+)v1h-\3z""".r + val drawn = segment + .findAllMatchIn(svg) + .flatMap(m => (m.group(1).toInt until m.group(1).toInt + m.group(3).toInt).map(_ -> m.group(2).toInt)) + .toSet + + drawn must_== expected + } + } + + "TOTPSrv.qrCodeDataUri" should { + + "be embeddable in an img tag" in { + TOTPSrv.qrCodeDataUri("otpauth://totp/Cortex:alice?secret=JBSWY3DPEHPK3PXP") must startWith("data:image/svg+xml;base64,") + } + } + + "TOTPSrv backup codes" should { + + "match the code they were derived from" in { + TOTPSrv.backupCodeMatches(TOTPSrv.hashBackupCode("SEED123456", "ABCDE-FGHJK"), "ABCDE-FGHJK") must beTrue + } + + "match regardless of the formatting the user types" in { + val hash = TOTPSrv.hashBackupCode("SEED123456", "ABCDE-FGHJK") + TOTPSrv.backupCodeMatches(hash, "abcdefghjk") must beTrue + TOTPSrv.backupCodeMatches(hash, "ABCDE FGHJK") must beTrue + } + + "not match a different code" in { + TOTPSrv.backupCodeMatches(TOTPSrv.hashBackupCode("SEED123456", "ABCDE-FGHJK"), "ABCDE-FGHJM") must beFalse + } + + "not match when the stored value is malformed" in { + TOTPSrv.backupCodeMatches("no-comma-here", "ABCDE-FGHJK") must beFalse + } + + "produce different hashes for the same code under different seeds" in { + TOTPSrv.hashBackupCode("SEED000001", "ABCDE-FGHJK") must_!= TOTPSrv.hashBackupCode("SEED000002", "ABCDE-FGHJK") + } + + "group a raw code for display" in { + TOTPSrv.formatBackupCode("ABCDEFGHJK") must_== "ABCDE-FGHJK" + } + + "only use characters that can't be misread" in { + TOTPSrv.backupCodeAlphabet must not(contain("0")) + TOTPSrv.backupCodeAlphabet must not(contain("1")) + TOTPSrv.backupCodeAlphabet must not(contain("I")) + TOTPSrv.backupCodeAlphabet must not(contain("L")) + TOTPSrv.backupCodeAlphabet must not(contain("O")) + TOTPSrv.backupCodeAlphabet must not(contain("U")) + } + } + + // RFC 6238 Appendix B test vectors, restricted to the HMAC-SHA1 rows. The RFC publishes 8-digit + // codes, so the library is configured for 8 digits here; production uses the 6-digit default. + "The TOTP library" should { + + val rfc6238Authenticator = new GoogleAuthenticator( + new GoogleAuthenticatorConfig.GoogleAuthenticatorConfigBuilder() + .setCodeDigits(8) + .setWindowSize(1) + .setNumberOfScratchCodes(0) + .build() + ) + + // RFC 6238 uses the ASCII seed "12345678901234567890", which is this in base32 + val rfcSecret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" + + def codeAt(epochSeconds: Long): Int = rfc6238Authenticator.getTotpPassword(rfcSecret, epochSeconds * 1000L) + + "reproduce the RFC 6238 vector at T=59" in { + codeAt(59L) must_== 94287082 + } + + "reproduce the RFC 6238 vector at T=1111111109" in { + codeAt(1111111109L) must_== 7081804 + } + + "reproduce the RFC 6238 vector at T=1111111111" in { + codeAt(1111111111L) must_== 14050471 + } + + "reproduce the RFC 6238 vector at T=1234567890" in { + codeAt(1234567890L) must_== 89005924 + } + + "reproduce the RFC 6238 vector at T=2000000000" in { + codeAt(2000000000L) must_== 69279037 + } + } + + "TOTPSrv.verifyCode against a generated secret" should { + + // Mirrors the production configuration: 6 digits, 3 windows of tolerance. + val authenticator = new GoogleAuthenticator( + new GoogleAuthenticatorConfig.GoogleAuthenticatorConfigBuilder() + .setWindowSize(3) + .setNumberOfScratchCodes(0) + .build() + ) + val secret = authenticator.createCredentials().getKey + + "accept the code for the current window" in { + val now = System.currentTimeMillis() + val code = f"${authenticator.getTotpPassword(secret, now)}%06d" + authenticator.authorize(secret, TOTPSrv.asTotpCode(code).get) must beTrue + } + + "accept the code from the previous window (clock drift)" in { + val previous = System.currentTimeMillis() - 30000L + val code = authenticator.getTotpPassword(secret, previous) + authenticator.authorize(secret, code) must beTrue + } + + "reject a code from far outside the window" in { + val longAgo = System.currentTimeMillis() - 600000L + val code = authenticator.getTotpPassword(secret, longAgo) + authenticator.authorize(secret, code) must beFalse + } + + "generate a base32 secret an authenticator app can consume" in { + secret must beMatching("[A-Z2-7]+") + } + } +} diff --git a/www/src/app/core/services/common/AuthService.js b/www/src/app/core/services/common/AuthService.js index 7eebe9508..de3b736dd 100644 --- a/www/src/app/core/services/common/AuthService.js +++ b/www/src/app/core/services/common/AuthService.js @@ -13,14 +13,22 @@ export default class AuthService { this.currentUser = null; } - login(username, password) { + login(username, password, code) { let defer = this.$q.defer(); + let payload = { + user: username, + password: password + }; + + // Second factor. Omitted on the first attempt: the server answers with a + // MultiFactorCodeRequired error and the login form retries with a code. + if (!_.isEmpty(code)) { + payload.code = code; + } + this.$http - .post('./api/login', { - user: username, - password: password - }) + .post('./api/login', payload) .then(response => defer.resolve(response.data)) .catch(err => defer.reject(err)); diff --git a/www/src/app/core/services/common/UserService.js b/www/src/app/core/services/common/UserService.js index 76d34dd33..e56c7c449 100644 --- a/www/src/app/core/services/common/UserService.js +++ b/www/src/app/core/services/common/UserService.js @@ -82,6 +82,32 @@ export default class UserService { .catch(err => this.$q.reject(err)); } + /** Starts MFA enrolment: returns {secret, uri}. The server keeps the pending secret in the session. */ + mfaInit(id) { + return this.$http + .post(`./api/user/${id}/mfa/init`) + .then(response => this.$q.resolve(response.data)) + .catch(err => this.$q.reject(err)); + } + + /** Confirms enrolment with a code from the authenticator app. Returns {backupCodes}, shown once. */ + mfaSet(id, code) { + return this.$http + .post(`./api/user/${id}/mfa/set`, { + code: code + }) + .then(response => this.$q.resolve(response.data)) + .catch(err => this.$q.reject(err)); + } + + /** Self-service disable, and the org admin / superadmin reset. */ + mfaUnset(id) { + return this.$http + .post(`./api/user/${id}/mfa/unset`) + .then(response => this.$q.resolve(response.data)) + .catch(err => this.$q.reject(err)); + } + getUserInfo(login) { let defer = this.$q.defer(); diff --git a/www/src/app/pages/admin/common/user-list/users-list.controller.js b/www/src/app/pages/admin/common/user-list/users-list.controller.js index d9f233fde..4e388c907 100644 --- a/www/src/app/pages/admin/common/user-list/users-list.controller.js +++ b/www/src/app/pages/admin/common/user-list/users-list.controller.js @@ -32,6 +32,38 @@ export default class UsersListController { $onInit() { this.canSetPass = this.main.config.config.capabilities.indexOf('setPassword') !== -1; + + this.canUseMfa = this.main.config.config.capabilities.indexOf('mfa') !== -1; + } + + /** Admin recovery path for a user who lost their authenticator. Only clears it — an admin + * can never enrol on someone else's behalf, since that needs the user's own device. */ + resetMfa(user) { + let modalInstance = this.ModalService.confirm( + 'Reset two-factor authentication', + `Are you sure you want to reset two-factor authentication for ${user.id}? They will sign in with their password alone until they enable it again.`, + { + flavor: 'danger', + okText: 'Yes, reset it' + } + ); + + modalInstance.result + .then(() => this.UserService.mfaUnset(user.id)) + .then(() => { + this.reload(); + this.NotificationService.success( + `Two-factor authentication of user ${user.id} has been reset.` + ); + }) + .catch(err => { + // A string rejection is the modal being dismissed, not a failure. + if (!_.isString(err)) { + this.NotificationService.error( + 'Unable to reset two-factor authentication.' + ); + } + }); } reload() { diff --git a/www/src/app/pages/admin/common/user-list/users-list.html b/www/src/app/pages/admin/common/user-list/users-list.html index 7d04ac01e..f301ed12d 100644 --- a/www/src/app/pages/admin/common/user-list/users-list.html +++ b/www/src/app/pages/admin/common/user-list/users-list.html @@ -17,6 +17,7 @@
Password
API Key
+
2FA
@@ -81,7 +82,13 @@

- + +
+ + Enabled + Reset + +
diff --git a/www/src/app/pages/login/login.controller.js b/www/src/app/pages/login/login.controller.js index 585b05efc..a1a61567a 100644 --- a/www/src/app/pages/login/login.controller.js +++ b/www/src/app/pages/login/login.controller.js @@ -2,6 +2,7 @@ import _ from 'lodash'; export default class LoginController { constructor( $log, + $scope, $state, $uibModalStack, $location, @@ -13,6 +14,7 @@ export default class LoginController { ) { 'ngInject'; this.$log = $log; + this.$scope = $scope; this.$state = $state; this.$uibModalStack = $uibModalStack; this.$location = $location; @@ -24,26 +26,53 @@ export default class LoginController { this.params = { bar: 'foo' }; + // Flipped once the server tells us the account has multi-factor authentication, + // which reveals the code field and re-submits the same credentials with it. + this.requireMfa = false; } login() { this.params.username = _.toLower(this.params.username); - this.AuthService.login(this.params.username, this.params.password) + this.AuthService.login( + this.params.username, + this.params.password, + this.requireMfa ? this.params.code : undefined + ) .then(() => this.$state.go('index')) .catch(err => { + let type = (err.data || {}).type; + if (err.status === 520) { this.NotificationService.handleError( 'LoginController', err.data, err.status ); + } else if (type === 'MultiFactorCodeRequired') { + this.requireMfa = true; + this.params.code = undefined; + this.$scope.$broadcast('login-mfa-code'); } else { - this.NotificationService.log(err.data.message, 'error'); + if (type === 'MultiFactorCodeInvalid') { + // Keep the code field open so the user can try the next one. + this.params.code = undefined; + this.$scope.$broadcast('login-mfa-code'); + } else { + // Anything else (bad password, locked account) sends them back to step one. + this.requireMfa = false; + } + this.NotificationService.log((err.data || {}).message, 'error'); } }); } + cancelMfa() { + this.requireMfa = false; + this.params.code = undefined; + this.params.password = undefined; + } + ssoLogin(code) { this.AuthService.ssoLogin(code) .then(response => { diff --git a/www/src/app/pages/login/login.page.html b/www/src/app/pages/login/login.page.html index 9bd227f1d..a9b1b443c 100644 --- a/www/src/app/pages/login/login.page.html +++ b/www/src/app/pages/login/login.page.html @@ -3,26 +3,47 @@