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

org.nlpcraft.server.user.NCUserManager.scala Maven / Gradle / Ivy

There is a newer version: 0.8.2
Show newest version
/*
 * "Commons Clause" License, https://commonsclause.com/
 *
 * The Software is provided to you by the Licensor under the License,
 * as defined below, subject to the following condition.
 *
 * Without limiting other conditions in the License, the grant of rights
 * under the License will not include, and the License does not grant to
 * you, the right to Sell the Software.
 *
 * For purposes of the foregoing, "Sell" means practicing any or all of
 * the rights granted to you under the License to provide to third parties,
 * for a fee or other consideration (including without limitation fees for
 * hosting or consulting/support services related to the Software), a
 * product or service whose value derives, entirely or substantially, from
 * the functionality of the Software. Any license notice or attribution
 * required by the License must also include this Commons Clause License
 * Condition notice.
 *
 * Software:    NLPCraft
 * License:     Apache 2.0, https://www.apache.org/licenses/LICENSE-2.0
 * Licensor:    Copyright (C) 2018 DataLingvo, Inc. https://www.datalingvo.com
 *
 *     _   ____      ______           ______
 *    / | / / /___  / ____/________ _/ __/ /_
 *   /  |/ / / __ \/ /   / ___/ __ `/ /_/ __/
 *  / /|  / / /_/ / /___/ /  / /_/ / __/ /_
 * /_/ |_/_/ .___/\____/_/   \__,_/_/  \__/
 *        /_/
 */

package org.nlpcraft.server.user

import java.util.{Timer, TimerTask}

import org.apache.commons.validator.routines.EmailValidator
import org.apache.ignite.{IgniteAtomicSequence, IgniteCache, IgniteSemaphore}
import org.nlpcraft.common.blowfish.NCBlowfishHasher
import org.nlpcraft.common.{NCLifecycle, _}
import org.nlpcraft.server.NCConfigurable
import org.nlpcraft.server.endpoints.{NCEndpointCacheKey, NCEndpointManager}
import org.nlpcraft.server.ignite.NCIgniteHelpers._
import org.nlpcraft.server.ignite.NCIgniteInstance
import org.nlpcraft.server.mdo.{NCUserMdo, NCUserPropertyMdo}
import org.nlpcraft.server.notification.NCNotificationManager
import org.nlpcraft.server.sql.{NCSql, NCSqlManager}
import org.nlpcraft.server.tx.NCTxManager

import scala.collection.JavaConverters._
import scala.util.control.Exception._

/**
  * User CRUD manager.
  */
object NCUserManager extends NCLifecycle("User manager") with NCIgniteInstance {
    // Static email validator.
    private final val EMAIL_VALIDATOR = EmailValidator.getInstance()

    // Caches.
    @volatile private var tokenSigninCache: IgniteCache[String, SigninSession] = _
    @volatile private var idSigninCache: IgniteCache[Long, Set[String]] = _

    @volatile private var usersSeq: IgniteAtomicSequence = _
    @volatile private var pwdSeq: IgniteAtomicSequence = _
    @volatile private var userLock: IgniteSemaphore = _

    // Access token timeout scanner.
    @volatile private var scanner: Timer = _
    
    // Session holder.
    private case class SigninSession(
        acsToken: String,
        userId: Long,
        signinMs: Long,
        lastAccessMs: Long,
        endpoints: Set[String]
    )

    private object Config extends NCConfigurable {
        final val prefix = "server.user"
        
        val pwdPoolBlowup: Int = getInt(s"$prefix.pwdPoolBlowup")
        val timeoutScannerFreqMins: Int = getInt(s"$prefix.timeoutScannerFreqMins")
        val accessTokenExpireTimeoutMins: Int = getInt(s"$prefix.accessTokenExpireTimeoutMins")
    
        lazy val scannerMs: Int = timeoutScannerFreqMins * 60 * 1000
        lazy val expireMs: Int = accessTokenExpireTimeoutMins * 60 * 1000

        override def check(): Unit = {
            if (pwdPoolBlowup <= 1)
                abortError(s"Configuration parameter '$prefix.pwdPoolBlowup' must be > 1")
            if (timeoutScannerFreqMins <= 0)
                abortError(s"Configuration parameter '$prefix.timeoutScannerFreqMins' must be > 0")
            if (accessTokenExpireTimeoutMins <= 0)
                abortError(s"Configuration parameter '$prefix.accessTokenExpireTimeoutMins' must be > 0")
        }
    }

    Config.check()

    /**
      * Starts this manager.
      */
    override def start(): NCLifecycle = {
        ensureStopped()

        catching(wrapIE) {
            usersSeq = NCSql.mkSeq(ignite, "usersSeq", "nc_user", "id")
            pwdSeq = NCSql.mkSeq(ignite, "pwdSeq", "passwd_pool", "id")

            tokenSigninCache = ignite.cache[String, SigninSession]("user-token-signin-cache")
            idSigninCache = ignite.cache[Long, Set[String]]("user-id-signin-cache")

            require(tokenSigninCache != null)
            require(idSigninCache != null)
        }

        scanner = new Timer("timeout-scanner")

        scanner.scheduleAtFixedRate(
            new TimerTask() {
                def run() {
                    try {
                        val now = U.nowUtcMs()

                        // Check access tokens for expiration.
                        catching(wrapIE) {
                            NCTxManager.startTx {
                                for (ses ← tokenSigninCache.asScala.map(_.getValue)
                                     if now - ses.lastAccessMs >= Config.expireMs
                                ) {
                                    tokenSigninCache -= ses.acsToken

                                    clearSigninCache(ses)

                                    if (ses.endpoints.nonEmpty)
                                        NCEndpointManager.cancelNotifications(
                                            ses.userId,
                                            (k: NCEndpointCacheKey) ⇒ ses.endpoints.contains(k.getEndpoint)
                                        )

                                    // Notification.
                                    NCNotificationManager.addEvent("NC_ACCESS_TOKEN_TIMEDOUT",
                                        "acsTok" → ses.acsToken,
                                        "userId" → ses.userId,
                                        "signinMs" → ses.signinMs,
                                        "lastAccessMs" → ses.lastAccessMs
                                    )

                                    logger.trace(s"Access token timed out: ${ses.acsToken}")
                                }
                            }
                        }
                    }
                    catch {
                        case e: Throwable ⇒ logger.error("Error during timeout scanner process.", e)
                    }
                }
            },
            Config.scannerMs,
            Config.scannerMs
        )

        userLock = ignite.semaphore("userSemaphore", 1, true, true)

        logger.info(s"Access tokens will be scanned for timeout every ${Config.timeoutScannerFreqMins} min.")
        logger.info(s"Access tokens inactive for ${Config.accessTokenExpireTimeoutMins} min will be invalidated.")

        NCSql.sql {
            if (!NCSql.exists("nc_user"))
                try {
                    val email = "[email protected]"
                    val pwd = "admin"

                    addUser0(
                        email,
                        pwd,
                        "Hermann",
                        "Minkowski",
                        avatarUrl = None,
                        isAdmin = true,
                        None
                    )

                    logger.info(s"Default admin user ($email/$pwd) created.")

                }
                catch {
                    case e: NCE ⇒ logger.error(s"Failed to add default admin user: ${e.getLocalizedMessage}", e)
                }
        }

        super.start()
    }

    /**
      * Gets the list of all current users.
      */
    @throws[NCE]
    def getAllUsers: Map[NCUserMdo, Option[Seq[NCUserPropertyMdo]]] = {
        ensureStarted()

        NCSql.sql {
            NCSqlManager.getAllUsers
        }
    }

    /**
      * Gets flag which indicates there are another admin users in the system or not.
      *
      * @param usrId User ID.
      */
    @throws[NCE]
    def isOtherAdminsExist(usrId: Long): Boolean = {
        ensureStarted()

        NCSql.sql {
            NCSqlManager.isOtherAdminsExist(usrId)
        }
    }


    /**
      * Stops this manager.
      */
    override def stop(): Unit = {
        if (scanner != null)
            scanner.cancel()

        scanner = null
        
        tokenSigninCache = null
        idSigninCache = null

        super.stop()
    }

    /**
      *
      * @param acsTok Access token to sign out.
      */
    @throws[NCE]
    def signout(acsTok: String): Unit = {
        ensureStarted()

        catching(wrapIE) {
            NCTxManager.startTx {
                tokenSigninCache -== acsTok match {
                    case Some(ses) ⇒
                        clearSigninCache(ses)

                        if (ses.endpoints.nonEmpty)
                            NCEndpointManager.cancelNotifications(
                                ses.userId,
                                (k: NCEndpointCacheKey) ⇒ ses.endpoints.contains(k.getEndpoint)
                            )

                        // Notification.
                        NCNotificationManager.addEvent("NC_USER_SIGNED_OUT",
                            "acsTok" → ses.acsToken,
                            "userId" → ses.userId,
                            "signinMs" → ses.signinMs,
                            "lastAccessMs" → ses.lastAccessMs
                        )

                        logger.info(s"User signed out: ${ses.userId}")
                    case None ⇒ // No-op.
                }
            }
        }
    }

    /**
      * Gets user ID associated with active access token, if any.
      *
      * @param acsTkn Access token.
      * @return
      */
    @throws[NCE]
    def getUserForAccessToken(acsTkn: String): Option[NCUserMdo] = {
        ensureStarted()

        getUserIdForAccessToken(acsTkn).flatMap(getUser)
    }

    /**
      * Gets user ID associated with active access token, if any.
      *
      * @param acsTkn Access token.
      * @return
      */
    @throws[NCE]
    def getUserIdForAccessToken(acsTkn: String): Option[Long] = {
        ensureStarted()

        catching(wrapIE) {
            tokenSigninCache(acsTkn) match {
                case Some(ses) ⇒
                    val now = U.nowUtcMs()

                    // Update login session.
                    tokenSigninCache += acsTkn → SigninSession(acsTkn, ses.userId, ses.signinMs, now, ses.endpoints)

                    Some(ses.userId) // Bingo!
                case None ⇒ None
            }
        }
    }

    /**
      * Gets user for given user ID.
      *
      * @param usrId User ID.
      */
    @throws[NCE]
    def getUser(usrId: Long): Option[NCUserMdo] = {
        ensureStarted()

        NCSql.sql {
            NCSqlManager.getUser(usrId)
        }
    }

    /**
      * Gets user properties for given user ID.
      *
      * @param usrId User ID.
      */
    @throws[NCE]
    def getUserProperties(usrId: Long): Seq[NCUserPropertyMdo] = {
        ensureStarted()

        NCSql.sql {
            NCSqlManager.getUserProperties(usrId)
        }
    }

    /**
      * Gets user for given user ID.
      *
      * @param usrId User ID.
      */
    @throws[NCE]
    def getUserEndpoints(usrId: Long): Set[String] = {
        ensureStarted()

        catching(wrapIE) {
            idSigninCache(usrId) match {
                case Some(toks) ⇒ toks.flatMap(tok ⇒ tokenSigninCache(tok)).flatMap(_.endpoints)
                case None ⇒ Set.empty
            }
        }
    }

    /**
      *
      * @param email User email (as username).
      * @param passwd User password.
      * @return
      */
    @throws[NCE]
    def signin(email: String, passwd: String): Option[String] = {
        ensureStarted()
        
        catching(wrapIE) {
            NCTxManager.startTx {
                NCSql.sql {
                    NCSqlManager.getUserByEmail(email)
                } match {
                    case Some(usr) ⇒
                        NCSql.sql {
                            if (!NCSqlManager.isKnownPasswordHash(NCBlowfishHasher.hash(passwd, usr.passwordSalt)))
                                None
                            else {
                                val acsTkn = U.genGuid()
                                val now = U.nowUtcMs()

                                tokenSigninCache += acsTkn → SigninSession(acsTkn, usr.id, now, now, Set.empty)

                                idSigninCache(usr.id) match {
                                    case Some(toks) ⇒ idSigninCache += usr.id → (toks ++ Set(acsTkn))
                                    case None ⇒ idSigninCache += usr.id → Set(acsTkn)
                                }

                                // Notification.
                                NCNotificationManager.addEvent("NC_USER_SIGNED_IN",
                                    "userId" → usr.id,
                                    "firstName" → usr.firstName,
                                    "lastName" → usr.lastName,
                                    "email" → usr.email
                                )

                                logger.info(s"User signed in [" +
                                    s"email=${usr.email}, " +
                                    s"name=${usr.firstName} ${usr.lastName}" +
                                s"]")

                                Some(acsTkn)
                            }
                        }
                    case None ⇒ None
                }
            }
        }
    }

    /**
      *
      * @param usrId
      * @param firstName
      * @param lastName
      * @param avatarUrl
      * @param props
      * @return
      */
    @throws[NCE]
    def updateUser(
        usrId: Long,
        firstName: String,
        lastName: String,
        avatarUrl: Option[String],
        props: Option[Map[String, String]]
    ): Unit = {
        ensureStarted()

        NCSql.sql {
            val n =
                NCSqlManager.updateUser(
                    usrId,
                    firstName,
                    lastName,
                    avatarUrl,
                    props
                )

            if (n == 0)
                throw new NCE(s"Unknown user ID: $usrId")
        }

        // Notification.
        NCNotificationManager.addEvent("NC_USER_UPDATE",
            "userId" → usrId,
            "firstName" → firstName,
            "lastName" → lastName
        )
    }

    /**
      *
      * @param usrId
      * @param isAdmin
      * @return
      */
    @throws[NCE]
    def updateUserPermissions(usrId: Long, isAdmin: Boolean): Unit = {
        ensureStarted()

        NCSql.sql {
            val n = NCSqlManager.updateUser(usrId, isAdmin)

            if (n == 0)
                throw new NCE(s"Unknown user ID: $usrId")
        }

        // Notification.
        NCNotificationManager.addEvent("NC_USER_UPDATE",
            "userId" → usrId,
            "isAdmin" → isAdmin
        )
    }

    /**
      *
      * @param usrId
      * @return
      */
    @throws[NCE]
    def deleteUser(usrId: Long): Unit = {
        ensureStarted()

        val usr =
            NCSql.sql {
                val usr = NCSqlManager.getUser(usrId).getOrElse(throw new NCE(s"Unknown user ID: $usrId"))
                
                NCSqlManager.deleteUser(usr.id)

                usr
            }

        // Notification.
        NCNotificationManager.addEvent("NC_USER_DELETE",
            "userId" → usrId,
            "firstName" → usr.firstName,
            "lastName" → usr.lastName,
            "email" → usr.email
        )
    }

    /**
      *
      * @param usrId ID of the user to reset password for.
      * @param newPasswd New password to set.
      */
    @throws[NCE]
    def resetPassword(usrId: Long, newPasswd: String): Unit = {
        ensureStarted()

        NCSql.sql {
            val usr = NCSqlManager.getUser(usrId).getOrElse(throw new NCE(s"Unknown user ID: $usrId"))

            val salt = NCBlowfishHasher.hash(usr.email)

            // Add actual hash for the password.
            // NOTE: we don't "stir up" password pool for password resets.
            NCSqlManager.addPasswordHash(pwdSeq.incrementAndGet(), NCBlowfishHasher.hash(newPasswd, salt))
        }

        catching(wrapIE) {
            NCTxManager.startTx {
                idSigninCache(usrId) match {
                    case Some(toks) ⇒
                        tokenSigninCache --= toks
                        idSigninCache -= usrId
                    case None ⇒ // No-op.
                }
            }
        }

        // Notification.
        NCNotificationManager.addEvent("NC_USER_PASSWD_RESET",
            "userId" → usrId
        )
    }

    /**
      *
      * @param email
      * @param pwd
      * @param firstName
      * @param lastName
      * @param avatarUrl
      * @param isAdmin
      * @return
      */
    @throws[NCE]
    def addUser(
        email: String,
        pwd: String,
        firstName: String,
        lastName: String,
        avatarUrl: Option[String],
        isAdmin: Boolean,
        props: Option[Map[String, String]]
    ): Long = {
        ensureStarted()

        val id =
            NCSql.sql {
                addUser0(email, pwd, firstName, lastName, avatarUrl, isAdmin, props)
            }

        NCNotificationManager.addEvent(
            "NC_USER_ADD",
            "userId" → id,
            "firstName" → firstName,
            "lastName" → lastName,
            "email" → email
        )

        logger.info(s"User $email created.")

        id
    }

    /**
      *
      * @param email
      * @param pwd
      * @param firstName
      * @param lastName
      * @param avatarUrl
      * @param isAdmin
      */
    @throws[NCE]
    def addUser0(
        email: String,
        pwd: String,
        firstName: String,
        lastName: String,
        avatarUrl: Option[String],
        isAdmin: Boolean,
        props: Option[Map[String, String]]
    ): Long =
        // Some database implementations (including Ignite database) may not support unique constraints.
        // Because we have to support user email unique values, adding user operation is synchronized.
        try {
            userLock.acquire()

            val normEmail = U.normalizeEmail(email)

            if (!EMAIL_VALIDATOR.isValid(normEmail))
                throw new NCE(s"New user email is invalid: $normEmail")

            if (NCSqlManager.getUserByEmail(normEmail).isDefined)
                throw new NCE(s"User with this email already exists: $normEmail")

            val newUsrId = usersSeq.incrementAndGet()
            val salt = NCBlowfishHasher.hash(normEmail)

            NCSqlManager.addUser(
                newUsrId,
                normEmail,
                firstName,
                lastName,
                avatarUrl,
                salt,
                isAdmin,
                props
            )

            // Add actual hash for the password.
            NCSqlManager.addPasswordHash(pwdSeq.incrementAndGet(), NCBlowfishHasher.hash(pwd, salt))

            // "Stir up" password pool with each user.
            (0 to Math.round((Math.random() * Config.pwdPoolBlowup) + Config.pwdPoolBlowup).toInt).foreach(_ ⇒
                NCSqlManager.addPasswordHash(pwdSeq.incrementAndGet(), NCBlowfishHasher.hash(U.genGuid()))
            )

            newUsrId
        }
        finally
            userLock.release()

    /**
      *
      * @param ses
      */
    private def clearSigninCache(ses: SigninSession): Unit =
        idSigninCache(ses.userId) match {
            case Some(toks) ⇒
                val fixedToks = toks -- Seq(ses.acsToken)

                if (fixedToks.isEmpty)
                    idSigninCache -= ses.userId
                else
                    idSigninCache += ses.userId → fixedToks
            case None ⇒ // No-op.
        }

    /**
      * Updates endpoint for user session.
      * 
      * @param tok User login token.
      * @param change Change method.
      */
    private def updateEndpoints(tok: String, change: Set[String] ⇒ Set[String]): Option[Long] =
        catching(wrapIE) {
            tokenSigninCache(tok) match {
                case Some(ses) ⇒ Some(ses)
                case None ⇒
                    logger.error(s"Token cache not found for: $tok")

                    None
            }
        } match {
            case Some(ses) ⇒
                tokenSigninCache +=
                    ses.acsToken →
                        SigninSession(ses.acsToken, ses.userId, ses.signinMs, ses.lastAccessMs, change(ses.endpoints)
                )

                Some(ses.userId)

            case None ⇒ None
        }

    /**
      * Registers session level user endpoint.
      *
      * @param tok User login token.
      * @param ep Endpoint URL.
      */
    def registerEndpoint(tok: String, ep: String): Unit = {
        ensureStarted()

        updateEndpoints(tok, (eps: Set[String]) ⇒ eps ++ Set(ep)) match {
            case Some(usrId) ⇒
                // Notification.
                NCNotificationManager.addEvent("NC_USER_ADD_ENDPOINT",
                    "userId" → usrId,
                    "endpoint" → ep
                )
            case None ⇒ None
        }
    }

    /**
      * De-registers session level user endpoint if some was registered
      *
      * @param tok User login token.
      * @param ep Endpoint URL.
      */
    def removeEndpoint(tok: String, ep: String): Unit = {
        ensureStarted()

        updateEndpoints(tok, (eps: Set[String]) ⇒ eps -- Set(ep)) match {
            case Some(usrId) ⇒
                // Notification.
                NCNotificationManager.addEvent("NC_USER_REMOVE_ENDPOINT",
                    "userId" → usrId,
                    "endpoint" → ep
                )
            case None ⇒ // No-op.
        }
    }

    /**
      * De-registers session level user endpoints if some was registered.
      *
      * @param tok User login token.
      */
    def removeEndpoints(tok: String): Unit = {
        ensureStarted()

        updateEndpoints(tok, (_: Set[String]) ⇒ Set.empty) match {
            case Some(usrId) ⇒
                // Notification.
                NCNotificationManager.addEvent("NC_USER_REMOVE_ENDPOINTS",
                    "userId" → usrId
                )
            case None ⇒ // No-op.
        }

    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy