All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.convergencelabs.convergence.server.backend.services.domain.AuthenticationHandler.scala Maven / Gradle / Ivy

/*
 * Copyright (c) 2019 - Convergence Labs, Inc.
 *
 * This file is part of the Convergence Server, which is released under
 * the terms of the GNU General Public License version 3 (GPLv3). A copy
 * of the GPLv3 should have been provided along with this file, typically
 * located in the "LICENSE" file, which is part of this source code package.
 * Alternatively, see  for the
 * full text of the GPLv3 license, if it was not provided.
 */

package com.convergencelabs.convergence.server.backend.services.domain

import com.convergencelabs.convergence.server.backend.datastore.domain.config.DomainConfigStore
import com.convergencelabs.convergence.server.backend.datastore.domain.group.UserGroupStore
import com.convergencelabs.convergence.server.backend.datastore.domain.jwt.JwtAuthKeyStore
import com.convergencelabs.convergence.server.backend.datastore.domain.session.SessionStore
import com.convergencelabs.convergence.server.backend.datastore.domain.user.{CreateNormalDomainUser, DomainUserStore, UpdateDomainUser}
import com.convergencelabs.convergence.server.backend.datastore.{DuplicateValueException, InvalidValueException}
import com.convergencelabs.convergence.server.backend.services.domain.DomainSessionActor.{AnonymousAuthenticationDisabled, AuthenticationFailed, ConnectionError, DomainUnavailable}
import com.convergencelabs.convergence.server.model.DomainId
import com.convergencelabs.convergence.server.model.domain.jwt.JwtConstants
import com.convergencelabs.convergence.server.model.domain.session
import com.convergencelabs.convergence.server.model.domain.session.DomainSessionAndUserId
import com.convergencelabs.convergence.server.model.domain.user.{DomainUserId, DomainUserType}
import com.convergencelabs.convergence.server.model.server.domain.DomainAvailability
import com.convergencelabs.convergence.server.util.TryWithResource
import grizzled.slf4j.Logging
import org.bouncycastle.jce.provider.BouncyCastleProvider
import org.bouncycastle.openssl.PEMParser
import org.jose4j.jwt.JwtClaims
import org.jose4j.jwt.consumer.{InvalidJwtException, JwtConsumerBuilder}

import java.io.StringReader
import java.security.spec.X509EncodedKeySpec
import java.security.{KeyFactory, PublicKey}
import java.time.{Duration, Instant}
import scala.concurrent.ExecutionContext
import scala.util.{Failure, Success, Try}

object AuthenticationHandler {
  val AdminKeyId = "ConvergenceAdminKey"
  val AllowedClockSkew = 30
}

/**
 * The [AuthenticationHandler] class is a helper that assists the [DomainActor]
 * in authentic users connecting to the real time API.
 *
 * @param domainId          The id of the domain to authenticate users against.
 * @param domainConfigStore The store to obtain the configuration of the domain.
 * @param jwtKeyStore       The store containing JWT Auth Keys for the domain.
 * @param userStore         The user store for the domain.
 * @param userGroupStore    The user group store for the domain.
 * @param sessionStore      The session store for the domain where sessions should
 *                          be created.
 * @param ec                The execution context for asynchronous tasks.
 */
final class AuthenticationHandler(domainId: DomainId,
                                  domainConfigStore: DomainConfigStore,
                                  jwtKeyStore: JwtAuthKeyStore,
                                  userStore: DomainUserStore,
                                  userGroupStore: UserGroupStore,
                                  sessionStore: SessionStore,
                                  private[this] implicit val ec: ExecutionContext)
  extends Logging {

  /**
   * Processes an authentication request for the associated domain.
   * @param request The auth request to process.
   * @return An Either whose left value contains an optional error message if
   *         authentication failed or a right containing information on the
   *         successful authentication.
   */
  def authenticate(request: AuthenticationCredentials, availability: DomainAvailability.Value): Either[ConnectionError, DomainSessionActor.ConnectionSuccess] = {
    availability match {
      case DomainAvailability.Online =>
        request match {
          case message: PasswordAuthRequest =>
            authenticatePassword(message)
          case message: JwtAuthRequest =>
            authenticateJwt(message)
          case message: ReconnectTokenAuthRequest =>
            authenticateReconnectToken(message)
          case message: AnonymousAuthRequest =>
            authenticateAnonymous(message)
        }
      case DomainAvailability.Offline =>
        Left(DomainSessionActor.DomainNotFound(domainId))
      case DomainAvailability.Maintenance =>
        request match {
          case message: JwtAuthRequest =>
            authenticateJwt(message, maintenanceMode = true)
          case message: ReconnectTokenAuthRequest =>
            authenticateReconnectToken(message, maintenance = true)
          case _ =>
            Left(DomainUnavailable(domainId))
        }
    }
  }

  //
  // Reconnect Auth
  //

  private[this] def authenticateReconnectToken(reconnectRequest: ReconnectTokenAuthRequest, maintenance: Boolean = false): Either[ConnectionError, DomainSessionActor.ConnectionSuccess] = {
    userStore
      .validateAndRefreshReconnectToken(reconnectRequest.token, Duration.ofHours(24L))
      .flatMap {
        case Some(userId) =>
          if (userId.isConvergence) {
            authSuccess(userId, Some(reconnectRequest.token)).map(Right(_))
          } else {
            Success(Left(DomainUnavailable(domainId)))
          }
        case None =>
          Success(Left(AuthenticationFailed()))
      }
      .recover {
        case cause =>
          error(s"$domainId: Unable to authenticate a user via reconnect token.", cause)
          Left(AuthenticationFailed())
      }
      .getOrElse(Left(AuthenticationFailed()))
  }

  //
  // Anonymous Auth
  //

  private[this] def authenticateAnonymous(authRequest: AnonymousAuthRequest): Either[ConnectionError, DomainSessionActor.ConnectionSuccess] = {
    val AnonymousAuthRequest(displayName) = authRequest
    debug(s"$domainId: Processing anonymous authentication request with display name: $displayName")
    domainConfigStore
      .isAnonymousAuthEnabled()
      .flatMap {
        case false =>
          debug(s"$domainId: Anonymous auth is disabled; returning AuthenticationFailure.")
          Success(Left(AnonymousAuthenticationDisabled()))
        case true =>
          debug(s"$domainId: Anonymous auth is enabled; creating anonymous user.")
          userStore
            .createAnonymousDomainUser(displayName)
            .flatMap { username =>
              debug(s"$domainId: Anonymous user created: $username")
              val userId = DomainUserId(DomainUserType.Anonymous, username)
              authSuccess(userId, None)
            }
            .map(Right(_))
      }
      .recover {
        case cause: Throwable =>
          error(s"$domainId: Anonymous authentication error", cause)
          Left(AuthenticationFailed())
      }
      .getOrElse(Left(AuthenticationFailed()))
  }

  //
  // Password Auth
  //
  private[this] def authenticatePassword(authRequest: PasswordAuthRequest): Either[ConnectionError, DomainSessionActor.ConnectionSuccess] = {
    logger.debug(s"$domainId: Authenticating by username and password")
    userStore
      .validateNormalUserCredentials(authRequest.username, authRequest.password)
      .flatMap {
        case true =>
          val userId = DomainUserId(DomainUserType.Normal, authRequest.username)
          authSuccess(userId, None) map { response =>
            updateLastLogin(userId)
            Right(response)
          }
        case false =>
          Success(Left(AuthenticationFailed()))
      }
      .recover {
        case cause: Throwable =>
          error(s"$domainId: Unable to authenticate a user", cause)
          Left(AuthenticationFailed())
      }
      .getOrElse(Left(AuthenticationFailed()))
  }

  //
  // JWT Auth
  //
  private[this] def authenticateJwt(authRequest: JwtAuthRequest, maintenanceMode: Boolean = false): Either[ConnectionError, DomainSessionActor.ConnectionSuccess] = {
    // This implements a two pass approach to be able to get the key id.
    val firstPassJwtConsumer = new JwtConsumerBuilder()
      .setSkipAllValidators()
      .setDisableRequireSignature()
      .setSkipSignatureVerification()
      .build()

    val jwtContext = firstPassJwtConsumer.process(authRequest.jwt)
    val objects = jwtContext.getJoseObjects
    val keyId = objects.get(0).getKeyIdHeaderValue

    getJwtPublicKey(keyId)
      .map { case (publicKey, admin) =>
        if (!maintenanceMode || admin ) {
          authenticateJwtWithPublicKey(authRequest, publicKey, admin)
            .map(Right(_))
            .recover {
              case cause: InvalidJwtException =>
                logger.debug(s"Invalid JWT: ${cause.getMessage}")
                Left(AuthenticationFailed())
              case cause: Exception =>
                error(s"$domainId: Unable to authenticate a user via jwt.", cause)
                Left(AuthenticationFailed())
            }
            .getOrElse(Left(AuthenticationFailed()))
        } else {
          Left(DomainUnavailable(domainId))
        }
      }
      .getOrElse(Left(AuthenticationFailed()))
  }

  private[this] def getJwtPublicKey(keyId: String): Option[(PublicKey, Boolean)] = {
    val (keyPem, admin) = if (AuthenticationHandler.AdminKeyId.equals(keyId)) {
      domainConfigStore.getAdminKeyPair() match {
        case Success(keyPair) => (Some(keyPair.publicKey), true)
        case _ =>
          logger.error(s"$domainId: Unable to load admin key for domain")
          (None, false)
      }
    } else {
      jwtKeyStore.getKey(keyId) match {
        case Success(Some(key)) if key.enabled => (Some(key.key), false)
        case _ => (None, false)
      }
    }

    keyPem.flatMap { pem =>
      TryWithResource(new PEMParser(new StringReader(pem))) { pemReader =>
        val spec = new X509EncodedKeySpec(pemReader.readPemObject().getContent)
        val keyFactory = KeyFactory.getInstance("RSA", new BouncyCastleProvider())
        Some((keyFactory.generatePublic(spec), admin))
      }.recoverWith {
        case e: Throwable =>
          logger.warn(s"$domainId: Unable to decode jwt public key: " + e.getMessage)
          Success(None)
      }.get
    }
  }

  private[this] def authenticateJwtWithPublicKey(authRequest: JwtAuthRequest, publicKey: PublicKey, admin: Boolean): Try[DomainSessionActor.ConnectionSuccess] = {
    val jwtConsumer = new JwtConsumerBuilder()
      .setRequireExpirationTime()
      .setAllowedClockSkewInSeconds(AuthenticationHandler.AllowedClockSkew)
      .setRequireSubject()
      .setExpectedAudience(JwtConstants.Audience)
      .setVerificationKey(publicKey)
      .build()

    Try(jwtConsumer.processToClaims(authRequest.jwt)) flatMap { jwtClaims =>
      val username = jwtClaims.getSubject

      // FIXME in theory we should cache the token id for longer than the expiration to make
      //   sure a replay attack is not possible

      val (exists, userType) = if (admin) {
        (userStore.convergenceUserExists(username), DomainUserType.Convergence)
      } else {
        (userStore.domainUserExists(username), DomainUserType.Normal)
      }

      val userId = DomainUserId(userType, username)

      exists flatMap {
        case true =>
          logger.debug(s"$domainId: User specified in JWT already exists, updating with latest claims.")
          updateUserFromJwt(userId, jwtClaims)
        case false =>
          logger.debug(s"$domainId: User specified in JWT does not exist exist, Auto creating user.")
          lazyCreateUserFromJWT(userId, jwtClaims)
      } flatMap { _ =>
        authSuccess(userId, None) map { response =>
          updateLastLogin(userId)
          response
        }
      }
    }
  }

  private[this] def updateUserFromJwt(userId: DomainUserId, jwtClaims: JwtClaims): Try[Unit] = {
    val JwtInfo(_, firstName, lastName, displayName, email, groups) = JwtUtil.parseClaims(jwtClaims)
    val update = UpdateDomainUser(userId, firstName, lastName, displayName, email, None)
    for {
      _ <- userStore.updateDomainUser(update)
      _ <- groups match {
        case Some(g) => userGroupStore.setGroupsForUser(userId, g)
        case None => Success(())
      }
    } yield ()
  }

  private[this] def lazyCreateUserFromJWT(userId: DomainUserId, jwtClaims: JwtClaims): Try[String] = {
    val JwtInfo(username, firstName, lastName, displayName, email, groups) = JwtUtil.parseClaims(jwtClaims)
    (userId.userType match {
      case DomainUserType.Convergence =>
        userStore.createAdminDomainUser(username)
      case DomainUserType.Normal =>
        val newUser = CreateNormalDomainUser(username, firstName, lastName, displayName, email)
        for {
          username <- userStore.createNormalDomainUser(newUser)
          _ <- groups match {
            case Some(g) => userGroupStore.setGroupsForUser(userId, g)
            case None => Success(())
          }
        } yield username
      case DomainUserType.Anonymous =>
        Failure(new IllegalArgumentException("Can not authenticate an anonymous user via JWT"))
    }).recoverWith {
      case _: DuplicateValueException =>
        logger.warn(s"$domainId: Attempted to auto create user, but user already exists, returning auth success.")
        Success(username)
      case e: InvalidValueException =>
        Failure(new IllegalArgumentException(s"$domainId: Lazy creation of user based on JWT authentication failed: $username", e))
    }
  }

  //
  // Common Auth Success handling
  //

  private[this] def authSuccess(userId: DomainUserId, reconnectToken: Option[String]): Try[DomainSessionActor.ConnectionSuccess] = {
    logger.debug(s"$domainId: Creating session after authentication success.")
    sessionStore.nextSessionId flatMap { sessionId =>
      reconnectToken match {
        case Some(reconnectToken) =>
          Success(DomainSessionActor.ConnectionSuccess(DomainSessionAndUserId(sessionId, userId), Some(reconnectToken)))
        case None =>
          logger.debug(s"$domainId: Creating reconnect token.")
          userStore.createReconnectToken(userId, Duration.ofHours(24L)) map { token =>
            logger.debug(s"$domainId: Returning auth success.")
            DomainSessionActor.ConnectionSuccess(session.DomainSessionAndUserId(sessionId, userId), Some(token))
          } recover {
            case error: Throwable =>
              logger.error(s"$domainId: Unable to create reconnect token", error)
              DomainSessionActor.ConnectionSuccess(session.DomainSessionAndUserId(sessionId, userId), None)
          }
      }
    }
  }

  private[this] def updateLastLogin(userId: DomainUserId): Unit = {
    userStore.setLastLoginForUser(userId, Instant.now()) recover {
      case e => logger.warn("Unable to update last login time for user.", e)
    }
  }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy