diff --git a/build.sbt b/build.sbt index f81f6627c..0f1839be9 100644 --- a/build.sbt +++ b/build.sbt @@ -16,7 +16,8 @@ lazy val commonSettings = Seq( "org.log4s" %% "log4s" % "1.3.0", "org.fusesource.jansi" % "jansi" % "1.12", "org.scoverage" %% "scalac-scoverage-plugin" % "1.1.1", - "com.github.nscala-time" %% "nscala-time" % "2.12.0" + "com.github.nscala-time" %% "nscala-time" % "2.12.0", + "com.github.mauricio" %% "mysql-async" % "0.2.21" ) ) diff --git a/common/src/main/scala/net/psforever/packet/game/LoginMessage.scala b/common/src/main/scala/net/psforever/packet/game/LoginMessage.scala index fe1ca0e13..480356b38 100644 --- a/common/src/main/scala/net/psforever/packet/game/LoginMessage.scala +++ b/common/src/main/scala/net/psforever/packet/game/LoginMessage.scala @@ -23,9 +23,11 @@ final case class LoginMessage(majorVersion : Long, } object LoginMessage extends Marshallable[LoginMessage] { + lazy val tokenCodec = paddedFixedSizeBytes(32, cstring, ignore(8)) + private def username = PacketHelpers.encodedStringAligned(7) private def password = PacketHelpers.encodedString - private def tokenPath = paddedFixedSizeBytes(32, cstring, ignore(8)) :: username + private def tokenPath = tokenCodec :: username private def passwordPath = username :: password type Struct = String :: Option[String] :: Option[String] :: HNil diff --git a/common/src/main/scala/net/psforever/packet/game/LoginRespMessage.scala b/common/src/main/scala/net/psforever/packet/game/LoginRespMessage.scala index ec1573574..69449e425 100644 --- a/common/src/main/scala/net/psforever/packet/game/LoginRespMessage.scala +++ b/common/src/main/scala/net/psforever/packet/game/LoginRespMessage.scala @@ -3,32 +3,81 @@ package net.psforever.packet.game import net.psforever.packet.{GamePacketOpcode, Marshallable, PacketHelpers, PlanetSideGamePacket} import scodec.Codec -import scodec.bits.ByteVector import scodec.codecs._ -final case class LoginRespMessage(token : String, // printable ascii for 16 - unknown : ByteVector, // hex"00000000 18FABE0C 00000000 00000000" - error : Long, // 0 - stationError : Long, // 1 - subscriptionStatus : Long, // 2 or 5 - someToken : Long, // 685276011 - username : String, // the user +/** + * This message is sent from the server to the client upon reception of a [[LoginMessage]]. + * + * The result of the login is contained in this message. When a login is successful, a session token + * is returned to the client which then forwards this to the World server it chooses to connect to. + * + * In terms of failed logins, the PS client favors errors in this order + * + * 1. LoginError + * 2. StationError + * 3. StationSubscriptionStatus + * + * Don't try and set more than one error at the same time. Just provide a single error message to be displayed. + * + * @param token A 'token' which acts exactly like a session cookie in a browser. Allows logins to not use a password + * @param error A general login error message + * @param stationError A PlanetSide Sony Online Entertainment (SOE) station result + * @param subscriptionStatus A response detailing the current subscription type + * @param unkUIRelated An unknown possible bitfield that controls some game variables (possibly expansions?) + * @param username The login username + * @param privilege If set above 10000, then the user has access to GM commands. Not sure of other values. + */ +final case class LoginRespMessage(token : String, + error : LoginRespMessage.LoginError.Type, + stationError : LoginRespMessage.StationError.Type, + subscriptionStatus : LoginRespMessage.StationSubscriptionStatus.Type, + unkUIRelated : Long, + username : String, privilege : Long) extends PlanetSideGamePacket { def opcode = GamePacketOpcode.LoginRespMessage def encode = LoginRespMessage.encode(this) } + object LoginRespMessage extends Marshallable[LoginRespMessage] { + + object LoginError extends Enumeration { + type Type = Value + val Success = Value(0) + val BadUsernameOrPassword = Value(5) + val BadVersion = Value(0xf) + + implicit val codec = PacketHelpers.createLongEnumerationCodec(this, uint32L) + } + + object StationError extends Enumeration { + type Type = Value + val AccountActive = Value(1) + val AccountClosed = Value(2) + + implicit val codec = PacketHelpers.createLongEnumerationCodec(this, uint32L) + } + + object StationSubscriptionStatus extends Enumeration { + type Type = Value + val None = Value(1) + val Active = Value(2) /// Not sure about this one (guessing) + val Closed = Value(4) + val Trial = Value(5) /// Not sure about this one either + val TrialExpired = Value(6) + + implicit val codec = PacketHelpers.createLongEnumerationCodec(this, uint32L) + } + implicit val codec : Codec[LoginRespMessage] = ( - ("token" | fixedSizeBytes(16, ascii)) :: - ("unknown" | bytes(16)) :: - ("error" | uint32L) :: - ("station_error" | uint32L) :: - ("subscription_status" | uint32L) :: + ("token" | LoginMessage.tokenCodec) :: + ("error" | LoginError.codec) :: + ("station_error" | StationError.codec) :: + ("subscription_status" | StationSubscriptionStatus.codec) :: ("unknown" | uint32L) :: ("username" | PacketHelpers.encodedString) :: ("privilege" | uint32L) - .flatZip(priv => bool) + .flatZip(priv => bool) // really not so sure about this bool part. client gets just a single bit .xmap[Long]({case (a, _) => a}, priv => (priv, (priv & 1) == 1)) ).as[LoginRespMessage] -} \ No newline at end of file +} diff --git a/common/src/test/scala/game/LoginRespMessageTest.scala b/common/src/test/scala/game/LoginRespMessageTest.scala new file mode 100644 index 000000000..58bab564a --- /dev/null +++ b/common/src/test/scala/game/LoginRespMessageTest.scala @@ -0,0 +1,67 @@ +// Copyright (c) 2017 PSForever.net to present +package game + +import org.specs2.mutable._ +import net.psforever.packet._ +import net.psforever.packet.game._ +import net.psforever.packet.game.LoginRespMessage._ +import scodec.bits._ + +class LoginRespMessageTest extends Specification { + + /// NOTE: the token is a C-string, meaning that the 18FABE0C junk seems like uninitialized memory due to memcpy, but + /// not memset + //val original = hex"02 4861484C64597A73305641536A6B73520000000018FABE0C0000000000000000" ++ + // hex"00000000 01000000 02000000 6B7BD828 8C4169666671756F7469656E74 00000000 00" + val string = hex"02 4861484C64597A73305641536A6B735200000000000000000000000000000000" ++ + hex"00000000 01000000 02000000 6B7BD828 8C4169666671756F7469656E74 00000000 00" + + val string_priv = hex"02 4861484C64597A73305641536A6B735200000000000000000000000000000000" ++ + hex"00000000 01000000 02000000 6B7BD828 8C4169666671756F7469656E74 11270000 80" + + "encode" in { + val msg = LoginRespMessage("HaHLdYzs0VASjksR", LoginError.Success, StationError.AccountActive, + StationSubscriptionStatus.Active, 685276011, "Aiffquotient", 0) + + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + pkt mustEqual string + } + + "encode with privilege" in { + val msg = LoginRespMessage("HaHLdYzs0VASjksR", LoginError.Success, StationError.AccountActive, + StationSubscriptionStatus.Active, 685276011, "Aiffquotient", 10001) + + val pkt = PacketCoding.EncodePacket(msg).require.toByteVector + pkt mustEqual string_priv + } + + "decode" in { + PacketCoding.DecodePacket(string).require match { + case LoginRespMessage(token, error, stationError, subscription, unk, username, priv) => + token mustEqual "HaHLdYzs0VASjksR" + error mustEqual LoginError.Success + stationError mustEqual StationError.AccountActive + subscription mustEqual StationSubscriptionStatus.Active + unk mustEqual 685276011 + username mustEqual "Aiffquotient" + priv mustEqual 0 + case _ => + ko + } + } + + "decode with privilege" in { + PacketCoding.DecodePacket(string_priv).require match { + case LoginRespMessage(token, error, stationError, subscription, unk, username, priv) => + token mustEqual "HaHLdYzs0VASjksR" + error mustEqual LoginError.Success + stationError mustEqual StationError.AccountActive + subscription mustEqual StationSubscriptionStatus.Active + unk mustEqual 685276011 + username mustEqual "Aiffquotient" + priv mustEqual 10001 + case _ => + ko + } + } +} diff --git a/pslogin/src/main/scala/DatabaseConnector.scala b/pslogin/src/main/scala/DatabaseConnector.scala new file mode 100644 index 000000000..c502ff4b8 --- /dev/null +++ b/pslogin/src/main/scala/DatabaseConnector.scala @@ -0,0 +1,9 @@ +// Copyright (c) 2017 PSForever.net to present +import com.github.mauricio.async.db.Connection +import com.github.mauricio.async.db.mysql.MySQLConnection +import com.github.mauricio.async.db.mysql.util.URLParser + +object DatabaseConnector { + val accounts_db = URLParser.parse("jdbc:mysql://localhost:3306/psforever-accounts?user=root&password=PSForever") + def getAccountsConnection = new MySQLConnection(accounts_db) +} diff --git a/pslogin/src/main/scala/LoginSessionActor.scala b/pslogin/src/main/scala/LoginSessionActor.scala index 8c3652e84..f2a3d545d 100644 --- a/pslogin/src/main/scala/LoginSessionActor.scala +++ b/pslogin/src/main/scala/LoginSessionActor.scala @@ -9,8 +9,13 @@ import org.log4s.MDC import scodec.Attempt.{Failure, Successful} import scodec.bits._ import MDCContextAware.Implicits._ +import com.github.mauricio.async.db.{Connection, QueryResult, RowData} +import com.github.mauricio.async.db.mysql.MySQLConnection +import com.github.mauricio.async.db.mysql.exceptions.MySQLException +import com.github.mauricio.async.db.mysql.util.URLParser import net.psforever.types.PlanetSideEmpire +import scala.concurrent.{Await, Future} import scala.concurrent.duration._ import scala.util.Random @@ -74,14 +79,17 @@ class LoginSessionActor extends Actor with MDCContextAware { def handleControlPkt(pkt : PlanetSideControlPacket) = { pkt match { case SlottedMetaPacket(slot, subslot, innerPacket) => + // Meta packets are like TCP packets - then need to be ACKed to the client sendResponse(PacketCoding.CreateControlPacket(SlottedMetaAck(slot, subslot))) - PacketCoding.DecodePacket(innerPacket) match { - case Failure(e) => - log.error(s"Failed to decode inner packet of SlottedMetaPacket: $e") - case Successful(v) => - handlePkt(v) - } + // Decode the inner packet and handle it or error + PacketCoding.DecodePacket(innerPacket).fold({ + error => log.error(s"Failed to decode inner packet of SlottedMetaPacket: $error") + }, { + handlePkt(_) + }) + /// 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, unk, f1, f2, f3, f4, fa, fb) => log.trace(s"SYNC: ${sync}") @@ -89,16 +97,17 @@ class LoginSessionActor extends Actor with MDCContextAware { sendResponse(PacketCoding.CreateControlPacket(ControlSyncResp(diff, serverTick, fa, fb, fb, fa))) case MultiPacket(packets) => + + /// Extract out each of the subpackets in the MultiPacket and handle them or raise a packet error packets.foreach { pkt => - PacketCoding.DecodePacket(pkt) match { - case Failure(e) => - log.error(s"Failed to decode inner packet of MultiPacket: $e") - case Successful(v) => - handlePkt(v) - } + PacketCoding.DecodePacket(pkt).fold({ error => + log.error(s"Failed to decode inner packet of MultiPacket: $error") + }, { + handlePkt(_) + }) } case default => - log.debug(s"Unhandled ControlPacket $default") + log.error(s"Unhandled ControlPacket $default") } } @@ -106,9 +115,51 @@ class LoginSessionActor extends Actor with MDCContextAware { val serverName = "PSForever" val serverAddress = new InetSocketAddress(LoginConfig.serverIpAddress.getHostAddress, 51001) + // TESTING CODE FOR ACCOUNT LOOKUP + def accountLookup(username : String, password : String) : Boolean = { + val connection: Connection = DatabaseConnector.getAccountsConnection + + Await.result(connection.connect, 5 seconds) + + // create account + // username, password, email + // Result: worked or failed + // login to account + // username, password + // Result: token (session cookie) + + val future: Future[QueryResult] = connection.sendPreparedStatement("SELECT * FROM accounts where username=?", Array(username)) + + val mapResult: Future[Any] = future.map(queryResult => queryResult.rows match { + case Some(resultSet) => { + val row : RowData = resultSet.head + row(0) + } + case None => -1 + } + ) + + try { + // XXX: remove awaits + val result = Await.result( mapResult, 5 seconds ) + return true + } catch { + case e : MySQLException => + log.error(s"SQL exception $e") + case e: Exception => + log.error(s"Unknown exception when executing SQL statement: $e") + } finally { + connection.disconnect + } + + false + } + 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 + import game.LoginRespMessage._ val clientVersion = s"Client Version: ${majorVersion}.${minorVersion}.${revision}, ${buildDate}" @@ -117,13 +168,28 @@ class LoginSessionActor extends Actor with MDCContextAware { else log.info(s"New login UN:$username PW:$password. ${clientVersion}") - val newToken = token.getOrElse("THISISMYTOKENYES") - val response = LoginRespMessage(newToken, hex"00000000 18FABE0C 00000000 00000000", - 0, 1, 2, 685276011, username, 10001) + // This is temporary until a schema has been developed + //val loginSucceeded = accountLookup(username, password.getOrElse(token.get)) - sendResponse(PacketCoding.CreateGamePacket(0, response)) + // Allow any one to login for now + val loginSucceeded = true - updateServerListTask = context.system.scheduler.schedule(0 seconds, 2 seconds, self, UpdateServerList()) + if(loginSucceeded) { + val newToken = token.getOrElse("AAAABBBBCCCCDDDDEEEEFFFFGGGGHHH") + val response = LoginRespMessage(newToken, LoginError.Success, StationError.AccountActive, + StationSubscriptionStatus.Active, 0, username, 10001) + + sendResponse(PacketCoding.CreateGamePacket(0, response)) + + updateServerListTask = context.system.scheduler.schedule(0 seconds, 2 seconds, self, UpdateServerList()) + } else { + val newToken = token.getOrElse("AAAABBBBCCCCDDDDEEEEFFFFGGGGHHH") + val response = LoginRespMessage(newToken, LoginError.BadUsernameOrPassword, StationError.AccountActive, + StationSubscriptionStatus.Active, 685276011, username, 10001) + + log.info(s"Failed login to account ${username}") + sendResponse(PacketCoding.CreateGamePacket(0, response)) + } case ConnectToWorldRequestMessage(name, _, _, _, _, _, _) => log.info(s"Connect to world request for '${name}'")