mirror of
https://github.com/psforever/PSF-LoginServer.git
synced 2026-01-19 18:44:45 +00:00
PsAdmin uses a dedicated TCP port to allow for remote queries and command to be sent to the running World/Login server. Commands are a single command followed by zero or more arguments. Commands may require access to the ActorSystem, so they will get their own dedicated actors to be able to handle the different messages required that can be sent in response to a query. The return line is in JSON to allow for easy parsing by applications, such as web servers. An interactive client is easy as being able to parse json and buffer command input. Some basic commands are implemented for now: * shutdown - kills the actor system * list_players - gets a list of players on the interstellar cluster * dump_config - get the running config * thread_dump - dumps all thread backtraces (useful for prod debugging) More advanced commands like kick/ban will require additional testing.
391 lines
16 KiB
Scala
391 lines
16 KiB
Scala
// Copyright (c) 2017 PSForever
|
|
import java.net.InetSocketAddress
|
|
import java.net.InetAddress
|
|
|
|
import akka.actor.{Actor, ActorRef, Cancellable, MDCContextAware}
|
|
import net.psforever.packet.{PlanetSideGamePacket, _}
|
|
import net.psforever.packet.control._
|
|
import net.psforever.packet.game._
|
|
import org.log4s.MDC
|
|
import scodec.bits._
|
|
import MDCContextAware.Implicits._
|
|
import com.github.mauricio.async.db.general.ArrayRowData
|
|
import com.github.mauricio.async.db.{Connection, QueryResult}
|
|
import net.psforever.objects.Account
|
|
import net.psforever.objects.DefaultCancellable
|
|
import net.psforever.types.PlanetSideEmpire
|
|
import net.psforever.WorldConfig
|
|
import services.ServiceManager
|
|
import services.ServiceManager.Lookup
|
|
import services.account.{ReceiveIPAddress, RetrieveIPAddress, StoreAccountData}
|
|
import com.github.t3hnar.bcrypt._
|
|
import net.psforever.packet.game.LoginRespMessage.{LoginError, StationError, StationSubscriptionStatus}
|
|
|
|
import scala.concurrent.duration._
|
|
import scala.util.{Failure, Success}
|
|
|
|
case class StartAccountAuthentication(connection: Option[Connection], username: String, password: String, newToken: String, queryResult: Any)
|
|
case class FinishAccountLogin(connection: Option[Connection], username: String, newToken: String, isSuccessfulLogin: Boolean, isInactive:Boolean = false)
|
|
case class CreateNewAccount(connection: Option[Connection], username: String, password: String, newToken: String)
|
|
case class LogTheLoginOccurrence(connection: Option[Connection], username: String, newToken: String, isSuccessfulLogin: Boolean, accountId: Int)
|
|
|
|
class LoginSessionActor extends Actor with MDCContextAware {
|
|
private[this] val log = org.log4s.getLogger
|
|
|
|
import scala.concurrent.ExecutionContext.Implicits.global
|
|
private case class UpdateServerList()
|
|
|
|
var sessionId : Long = 0
|
|
var leftRef : ActorRef = ActorRef.noSender
|
|
var rightRef : ActorRef = ActorRef.noSender
|
|
var accountIntermediary : ActorRef = Actor.noSender
|
|
|
|
var updateServerListTask : Cancellable = DefaultCancellable.obj
|
|
|
|
var ipAddress : String = ""
|
|
var hostName : String = ""
|
|
var canonicalHostName : String = ""
|
|
var port : Int = 0
|
|
|
|
val serverName = WorldConfig.Get[String]("worldserver.ServerName")
|
|
|
|
// This MUST be an IP address. The client DOES NOT do name resolution
|
|
var serverHost : String = if (WorldConfig.Get[String]("worldserver.Hostname") != "")
|
|
InetAddress.getByName(WorldConfig.Get[String]("worldserver.Hostname")).getHostAddress
|
|
else
|
|
LoginConfig.serverIpAddress.getHostAddress
|
|
|
|
val serverAddress = new InetSocketAddress(serverHost, WorldConfig.Get[Int]("worldserver.ListeningPort"))
|
|
|
|
// Reference: https://stackoverflow.com/a/50470009
|
|
private val numBcryptPasses = 10
|
|
|
|
override def postStop() = {
|
|
if(updateServerListTask != null)
|
|
updateServerListTask.cancel()
|
|
}
|
|
|
|
def receive = Initializing
|
|
|
|
def Initializing : Receive = {
|
|
case HelloFriend(aSessionId, pipe) =>
|
|
this.sessionId = aSessionId
|
|
leftRef = sender()
|
|
if(pipe.hasNext) {
|
|
rightRef = pipe.next
|
|
rightRef !> HelloFriend(aSessionId, pipe)
|
|
} else {
|
|
rightRef = sender()
|
|
}
|
|
context.become(Started)
|
|
ServiceManager.serviceManager ! Lookup("accountIntermediary")
|
|
|
|
case _ =>
|
|
log.error("Unknown message")
|
|
context.stop(self)
|
|
}
|
|
|
|
def Started : Receive = {
|
|
case ServiceManager.LookupResult("accountIntermediary", endpoint) =>
|
|
accountIntermediary = endpoint
|
|
case ReceiveIPAddress(address) =>
|
|
ipAddress = address.Address
|
|
hostName = address.HostName
|
|
canonicalHostName = address.CanonicalHostName
|
|
port = address.Port
|
|
case UpdateServerList() =>
|
|
updateServerList()
|
|
case ControlPacket(_, ctrl) =>
|
|
handleControlPkt(ctrl)
|
|
case GamePacket(_, _, game) =>
|
|
handleGamePkt(game)
|
|
case default => failWithError(s"Invalid packet class received: $default")
|
|
}
|
|
|
|
def handleControlPkt(pkt : PlanetSideControlPacket) = {
|
|
pkt match {
|
|
/// TODO: figure out what this is what what it does for the PS client
|
|
/// I believe it has something to do with reliable packet transmission and resending
|
|
case sync @ ControlSync(diff, _, _, _, _, _, fa, fb) =>
|
|
log.trace(s"SYNC: $sync")
|
|
val serverTick = Math.abs(System.nanoTime().toInt) // limit the size to prevent encoding error
|
|
sendResponse(PacketCoding.CreateControlPacket(ControlSyncResp(diff, serverTick, fa, fb, fb, fa)))
|
|
|
|
case TeardownConnection(_) =>
|
|
sendResponse(DropSession(sessionId, "client requested session termination"))
|
|
|
|
case default =>
|
|
log.error(s"Unhandled ControlPacket $default")
|
|
}
|
|
}
|
|
|
|
def handleGamePkt(pkt : PlanetSideGamePacket) = pkt match {
|
|
case LoginMessage(majorVersion, minorVersion, buildDate, username, password, token, revision) =>
|
|
// TODO: prevent multiple LoginMessages from being processed in a row!! We need a state machine
|
|
|
|
val clientVersion = s"Client Version: $majorVersion.$minorVersion.$revision, $buildDate"
|
|
|
|
accountIntermediary ! RetrieveIPAddress(sessionId)
|
|
|
|
if(token.isDefined)
|
|
log.info(s"New login UN:$username Token:${token.get}. $clientVersion")
|
|
else {
|
|
// log.info(s"New login UN:$username PW:$password. $clientVersion")
|
|
log.info(s"New login UN:$username. $clientVersion")
|
|
}
|
|
|
|
startAccountLogin(username, password.get)
|
|
|
|
case ConnectToWorldRequestMessage(name, _, _, _, _, _, _) =>
|
|
log.info(s"Connect to world request for '$name'")
|
|
val response = ConnectToWorldMessage(serverName, serverAddress.getHostString, serverAddress.getPort)
|
|
sendResponse(PacketCoding.CreateGamePacket(0, response))
|
|
sendResponse(DropSession(sessionId, "user transferring to world"))
|
|
|
|
case _ =>
|
|
log.debug(s"Unhandled GamePacket $pkt")
|
|
}
|
|
|
|
def startAccountLogin(username: String, password: String) : Unit = {
|
|
val newToken = this.generateToken()
|
|
Database.getConnection.connect.onComplete {
|
|
case Success(connection) =>
|
|
Database.query(connection.sendPreparedStatement(
|
|
"SELECT id, passhash, inactive, gm FROM accounts where username=?", Array(username)
|
|
)).onComplete {
|
|
case Success(queryResult) =>
|
|
context.become(startAccountAuthentication)
|
|
self ! StartAccountAuthentication(Some(connection), username, password, newToken, queryResult)
|
|
case Failure(e) =>
|
|
log.error("Failed account lookup query " + e.getMessage)
|
|
connection.disconnect
|
|
context.become(finishAccountLogin)
|
|
self ! FinishAccountLogin(Some(connection), username, newToken, false)
|
|
}
|
|
case Failure(e) =>
|
|
log.error("Failed connecting to database for account lookup " + e.getMessage)
|
|
context.become(finishAccountLogin)
|
|
self ! FinishAccountLogin(None, username, newToken, false)
|
|
}
|
|
}
|
|
|
|
def startAccountAuthentication : Receive = {
|
|
case StartAccountAuthentication(connection, username, password, newToken, queryResult) =>
|
|
queryResult match {
|
|
|
|
// If we got a row from the database
|
|
case row: ArrayRowData =>
|
|
val (isSuccessfulLogin, accountId) = authenticateExistingAccount(connection.get, username, password, newToken, row)
|
|
if(isSuccessfulLogin) { // login OK
|
|
context.become(logTheLoginOccurrence)
|
|
self ! LogTheLoginOccurrence(connection, username, newToken, isSuccessfulLogin, accountId)
|
|
} else {
|
|
if (accountId == 0) { // Bad password
|
|
context.become(finishAccountLogin)
|
|
self ! FinishAccountLogin(connection, username, newToken, isSuccessfulLogin)
|
|
} else { // Account inactive
|
|
context.become(finishAccountLogin)
|
|
self ! FinishAccountLogin(connection, username, newToken, isSuccessfulLogin, true)
|
|
}
|
|
}
|
|
|
|
// If the account didn't exist in the database
|
|
case errorCode: Int => errorCode match {
|
|
case Database.EMPTY_RESULT =>
|
|
if (WorldConfig.Get[Boolean]("loginserver.CreateMissingAccounts")) {
|
|
self ! CreateNewAccount(connection, username, password, newToken)
|
|
context.become(createNewAccount)
|
|
} else {
|
|
context.become(finishAccountLogin)
|
|
self ! FinishAccountLogin(connection, username, newToken, false)
|
|
}
|
|
|
|
case _ =>
|
|
log.error(s"Issue retrieving result set from database for account $username")
|
|
context.become(finishAccountLogin)
|
|
self ! FinishAccountLogin(connection, username, newToken, false)
|
|
}
|
|
}
|
|
case default =>
|
|
failWithError(s"Invalid message '$default' received in startAccountAuthentication")
|
|
}
|
|
|
|
def createNewAccount : Receive = {
|
|
case CreateNewAccount(connection, username, password, newToken) =>
|
|
log.info(s"Account $username does not exist, creating new account...")
|
|
val bcryptPassword : String = password.bcrypt(numBcryptPasses)
|
|
|
|
connection.get.inTransaction {
|
|
c => c.sendPreparedStatement(
|
|
"INSERT INTO accounts (username, passhash) VALUES(?,?) RETURNING id",
|
|
Array(username, bcryptPassword)
|
|
)
|
|
}.onComplete {
|
|
case Success(insertResult) =>
|
|
insertResult match {
|
|
case result: QueryResult =>
|
|
if (result.rows.nonEmpty) {
|
|
val accountId = result.rows.get.head(0).asInstanceOf[Int]
|
|
accountIntermediary ! StoreAccountData(newToken, new Account(accountId, username))
|
|
log.info(s"Successfully created new account for $username")
|
|
context.become(logTheLoginOccurrence)
|
|
self ! LogTheLoginOccurrence(connection, username, newToken, true, accountId)
|
|
} else {
|
|
log.error(s"No result from account create insert for $username")
|
|
context.become(finishAccountLogin)
|
|
self ! FinishAccountLogin(connection, username, newToken, false)
|
|
}
|
|
case default =>
|
|
log.error(s"Error creating new account for $username - $default")
|
|
context.become(finishAccountLogin)
|
|
self ! FinishAccountLogin(connection, username, newToken, false)
|
|
}
|
|
case Failure(e : com.github.mauricio.async.db.postgresql.exceptions.GenericDatabaseException) =>
|
|
log.error(s"Error creating new account - ${e.errorMessage.message}")
|
|
context.become(finishAccountLogin)
|
|
self ! FinishAccountLogin(connection, username, newToken, false)
|
|
|
|
case Failure(e : java.sql.SQLException) =>
|
|
log.error(s"Error creating new account - ${e.getMessage}")
|
|
context.become(finishAccountLogin)
|
|
self ! FinishAccountLogin(connection, username, newToken, false)
|
|
|
|
case _ =>
|
|
failWithError(s"Something to do?")
|
|
}
|
|
case default =>
|
|
failWithError(s"Invalid message '$default' received in createNewAccount")
|
|
}
|
|
|
|
// Essentially keeps a record of this individual login occurrence
|
|
def logTheLoginOccurrence : Receive = {
|
|
case LogTheLoginOccurrence(connection, username, newToken, isSuccessfulLogin, accountId) =>
|
|
connection.get.inTransaction {
|
|
c => c.sendPreparedStatement(
|
|
"INSERT INTO logins (account_id, login_time, ip_address, canonical_hostName, hostname, port) VALUES(?,?,?,?,?,?)",
|
|
Array(accountId, new java.sql.Timestamp(System.currentTimeMillis), ipAddress, canonicalHostName, hostName, port)
|
|
)
|
|
}.onComplete {
|
|
_ =>
|
|
context.become(finishAccountLogin)
|
|
self ! FinishAccountLogin(connection, username, newToken, isSuccessfulLogin)
|
|
}
|
|
case default => failWithError(s"Invalid message '$default' received in logTheLoginOccurrence")
|
|
}
|
|
|
|
def finishAccountLogin : Receive = {
|
|
case FinishAccountLogin(connection, username, newToken, isSuccessfulLogin, isInactive) =>
|
|
if(isSuccessfulLogin) { // Login OK
|
|
loginSuccessfulResponse(username, newToken)
|
|
updateServerListTask = context.system.scheduler.schedule(0 seconds, 2 seconds, self, UpdateServerList())
|
|
} else {
|
|
if (!isInactive && connection.nonEmpty) { // Bad Password
|
|
loginPwdFailureResponse(username, newToken)
|
|
} else if (connection.nonEmpty) { // Account inactive
|
|
loginAccountFailureResponse(username, newToken)
|
|
} else {
|
|
loginFailureResponse(username, newToken)
|
|
}
|
|
}
|
|
if(connection.nonEmpty) {
|
|
connection.get.disconnect
|
|
}
|
|
context.become(Started)
|
|
case default =>
|
|
failWithError(s"Invalid message '$default' received in finishAccountLogin")
|
|
}
|
|
|
|
def authenticateExistingAccount(
|
|
connection: Connection, username: String, password: String, newToken: String, row: ArrayRowData
|
|
) : (Boolean, Int) = {
|
|
val accountId : Int = row(0).asInstanceOf[Int]
|
|
val dbPassHash : String = row(1).asInstanceOf[String]
|
|
val inactive : Boolean = row(2).asInstanceOf[Boolean]
|
|
val gm : Boolean = row(3).asInstanceOf[Boolean]
|
|
|
|
if (password.isBcrypted(dbPassHash)) {
|
|
if (!inactive) {
|
|
log.info(s"Account password correct for $username!")
|
|
accountIntermediary ! StoreAccountData(newToken, new Account(accountId, username, gm))
|
|
return (true, accountId)
|
|
} else {
|
|
log.info(s"Account password correct for $username but account inactive !")
|
|
return (false, accountId)
|
|
}
|
|
} else {
|
|
log.info(s"Account password incorrect for $username")
|
|
}
|
|
|
|
(false, 0)
|
|
}
|
|
|
|
def loginSuccessfulResponse(username: String, newToken: String) = {
|
|
sendResponse(PacketCoding.CreateGamePacket(0, LoginRespMessage(
|
|
newToken, LoginError.Success, StationError.AccountActive,
|
|
StationSubscriptionStatus.Active, 0, username, 10001
|
|
)))
|
|
}
|
|
|
|
def loginPwdFailureResponse(username: String, newToken: String) = {
|
|
log.info(s"Failed login to account $username")
|
|
sendResponse(PacketCoding.CreateGamePacket(0, LoginRespMessage(
|
|
newToken, LoginError.BadUsernameOrPassword, StationError.AccountActive,
|
|
StationSubscriptionStatus.Active, 685276011, username, 10001
|
|
)))
|
|
}
|
|
|
|
def loginFailureResponse(username: String, newToken: String) = {
|
|
log.info("DB problem")
|
|
sendResponse(PacketCoding.CreateGamePacket(0, LoginRespMessage(
|
|
newToken, LoginError.unk1, StationError.AccountActive,
|
|
StationSubscriptionStatus.Active, 685276011, username, 10001
|
|
)))
|
|
}
|
|
|
|
def loginAccountFailureResponse(username: String, newToken: String) = {
|
|
log.info(s"Account $username inactive")
|
|
sendResponse(PacketCoding.CreateGamePacket(0, LoginRespMessage(
|
|
newToken, LoginError.BadUsernameOrPassword, StationError.AccountClosed,
|
|
StationSubscriptionStatus.Active, 685276011, username, 10001
|
|
)))
|
|
}
|
|
|
|
def generateToken() = {
|
|
val r = new scala.util.Random
|
|
val sb = new StringBuilder
|
|
for (_ <- 1 to 31) {
|
|
sb.append(r.nextPrintableChar)
|
|
}
|
|
sb.toString
|
|
}
|
|
|
|
def updateServerList() = {
|
|
val msg = VNLWorldStatusMessage("Welcome to PlanetSide! ",
|
|
Vector(
|
|
WorldInformation(
|
|
serverName, WorldStatus.Up,
|
|
WorldConfig.Get[ServerType.Value]("worldserver.ServerType"), Vector(WorldConnectionInfo(serverAddress)), PlanetSideEmpire.VS
|
|
)
|
|
)
|
|
)
|
|
sendResponse(PacketCoding.CreateGamePacket(0, msg))
|
|
}
|
|
|
|
def failWithError(error : String) = {
|
|
log.error(error)
|
|
//sendResponse(PacketCoding.CreateControlPacket(ConnectionClose()))
|
|
}
|
|
|
|
def sendResponse(cont : Any) = {
|
|
log.trace("LOGIN SEND: " + cont)
|
|
MDC("sessionId") = sessionId.toString
|
|
rightRef !> cont
|
|
}
|
|
|
|
def sendRawResponse(pkt : ByteVector) = {
|
|
log.trace("LOGIN SEND RAW: " + pkt)
|
|
MDC("sessionId") = sessionId.toString
|
|
rightRef !> RawPacket(pkt)
|
|
}
|
|
}
|