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 @@