Account and Character Database and Config Improvements (#317)

* Create Account/DB abstraction

* Fix crash when removing boomers from deconstructed player

* Extend config to include database and worldserver info

* Improve ConfigParser tests

* Add database setup documentation

* Add xTriad to THANKS file

**

* Increase bcrypt rounds and fix readme link
This commit is contained in:
pschord 2020-01-10 11:13:37 -05:00 committed by Fate-JH
parent ae768e1e42
commit d08911d07c
29 changed files with 1757 additions and 225 deletions

3
.gitignore vendored
View file

@ -9,6 +9,9 @@ out/
*.ipr
*.iml
# User configs
config/worldserver.ini
# Log files
*.log
logs/

View file

@ -58,6 +58,20 @@ Using SBT, you can generate documentation for both the common and pslogin projec
Current documentation is available at [https://psforever.github.io/docs/master/index.html](https://psforever.github.io/docs/master/index.html)
## Setting up the Database
The Login and World servers require PostgreSQL for persistence.
* Windows - [Official Downloads](https://www.postgresql.org/download/windows/)
* Linux - [Debian](https://www.postgresql.org/download/linux/debian/) or [Ubuntu](https://www.postgresql.org/download/linux/ubuntu/)
* macOS - Application https://www.postgresql.org/download/ (or `brew install postgresql && brew services start postgresql`)
The default database is named `psforever` and the credentials are `psforever:psforever`. To change these, make a copy of `[config/worldserver.ini.dist](config/worldserver.ini.dist)` to `config/worldserver.ini` and change the corresponding fields in the database section.
Once you have installed the database to your local system or have access to a remote database, you need to synchronize the schema. This is currently available in `[schema.sql](schema.sql)`.
Loading this in requires access to a graphical tool such as [pgAdmin](https://www.pgadmin.org/download/) (highly recommended) or a PostgreSQL terminal (`psql`) for advanced users.
To get started using pgAdmin, run the binary. This will start the pgAdmin server and pop-up a tab in your web browser with the interface. Upon first run, enter your connection details that you created during the PostgreSQL installation. When connected, right click the "Databases" menu -> Create... -> Database: psforever -> Save. Next, right click on the newly created database (psforever) -> Query Tool... -> Copy and paste / Open the `schema.sql` file into the editor -> Hit the "Play/Run" button. The schema should be loaded into the database.
Once you have the schema loaded in, the LoginServer will automatically create accounts on first login. If you'd like a nice account management interface, check out the [PSFPortal](https://github.com/psforever/PSFPortal) web interface.
## Running the Server
To run a headless, non-interactive server, run

View file

@ -1,13 +1,14 @@
PlanetSide Forever (PSForever) would not have been possible without the passion and many hours of hard work of our contributors.
If you feel you are missing from this list, please open a Pull Request!
Code Contributors (most active first, then anyone who has commits)
Code Contributors
===================
* FateJH & Chord
* SouNourS
* tfarley
* aphedox
* L-11
* xTriad (initial database schema)
Packet Capturing 2015-2016 (in order of most packets captured)
=================

View file

@ -46,7 +46,8 @@ lazy val commonSettings = Seq(
"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.mauricio" %% "mysql-async" % "0.2.21",
"com.github.mauricio" %% "postgresql-async" % "0.2.21",
"com.github.t3hnar" %% "scala-bcrypt" % "3.1",
"org.ini4j" % "ini4j" % "0.5.4",
"org.scala-graph" %% "graph-core" % "1.12.5"
)

View file

@ -3,14 +3,18 @@ package net.psforever.config
import org.ini4j
import scala.reflect.ClassTag
import scala.reflect.runtime.universe.TypeTag
import scala.annotation.implicitNotFound
import scala.concurrent.duration._
import scala.collection.mutable.Map
case class ConfigValueMapper[T](name: String)(f: (String => Option[T])) {
def apply(t: String): Option[T] = f(t)
}
object ConfigValueMapper {
import scala.language.implicitConversions
implicit val toInt : ConfigValueMapper[Int] = ConfigValueMapper[Int]("toInt") { e =>
try {
Some(e.toInt)
@ -19,6 +23,17 @@ object ConfigValueMapper {
}
}
// TypeTag is necessary to be able to retrieve an instance of the Enum class
// at runtime as it is usually erased at runtime. This is low cost as its only
// used during the config parsing
implicit def toEnum[E <: Enumeration#Value](implicit m: TypeTag[E]) : ConfigValueMapper[E] = ConfigValueMapper[E]("toEnum") { e =>
try {
Some(EnumReflector.withName[E](e))
} catch {
case e: Exception => None
}
}
implicit val toBool : ConfigValueMapper[Boolean] = ConfigValueMapper[Boolean]("toBool") { e =>
if (e == "yes") {
Some(true)
@ -77,6 +92,13 @@ final case class ConfigEntryBool(key: String, default : Boolean, constraints : C
def read(v : String) = ConfigValueMapper.toBool(v)
}
final case class ConfigEntryEnum[E <: Enumeration](key: String, default : E#Value)(implicit m : TypeTag[E#Value], implicit val m2 : TypeTag[E#ValueSet]) extends ConfigEntry {
type Value = E#Value
val constraints : Seq[Constraint[E#Value]] = Seq()
def getType = EnumReflector.values[E#ValueSet](m2).mkString(", ")
def read(v : String) = ConfigValueMapper.toEnum[E#Value](m)(v)
}
final case class ConfigEntryFloat(key: String, default : Float, constraints : Constraint[Float]*) extends ConfigEntry {
type Value = Float
def getType = "Float"
@ -101,7 +123,7 @@ object ConfigTypeRequired {
}
trait ConfigParser {
protected var config_map : Map[String, Any]
protected var config_map : Map[String, Any] = Map()
protected val config_template : Seq[ConfigSection]
// Misuse of this function can lead to run time exceptions when the types don't match
@ -118,8 +140,25 @@ trait ConfigParser {
}
def Load(filename : String) : ValidationResult = {
var map : Map[String, Any] = scala.collection.mutable.Map()
LoadMap(filename, map) match {
case i : Invalid =>
i
case Valid =>
ReplaceConfig(map)
// run post-parse validation only if we successfully parsed
postParseChecks match {
case i : Invalid =>
i
case Valid =>
Valid
}
}
}
def LoadMap(filename : String, map : Map[String, Any]) : ValidationResult = {
val ini = new org.ini4j.Ini()
config_map = Map()
try {
ini.load(new java.io.File(filename))
@ -136,7 +175,7 @@ trait ConfigParser {
if (sectionIni == null)
Seq(Invalid("section.missing", section.name))
else
section.entries.map(parseSection(sectionIni, _))
section.entries.map(parseSection(sectionIni, _, map))
}.reduceLeft((x, y) => x ++ y)
val errors : Seq[Invalid] = result.collect { case iv : Invalid => iv }
@ -144,8 +183,11 @@ trait ConfigParser {
if (errors.length > 0)
errors.reduceLeft((x, y) => x ++ y)
else
// run post-parse validation only if we successfully parsed
postParseChecks
Valid
}
def ReplaceConfig(map : Map[String, Any]) {
config_map = map
}
def FormatErrors(invalidResult : Invalid) : Seq[String] = {
@ -166,12 +208,12 @@ trait ConfigParser {
Valid
}
protected def parseSection(sectionIni : org.ini4j.Profile.Section, entry : ConfigEntry) : ValidationResult = {
protected def parseSection(sectionIni : org.ini4j.Profile.Section, entry : ConfigEntry, map : Map[String, Any]) : ValidationResult = {
var rawValue = sectionIni.get(entry.key)
val full_key = sectionIni.getName + "." + entry.key
val full_key : String = sectionIni.getName + "." + entry.key
val value = if (rawValue == null) {
// warn about defaults from unset parameters?
println(s"config warning: missing key '${entry.key}', using default value '${entry.default}'")
entry.default
} else {
rawValue = rawValue.stripPrefix("\"").stripSuffix("\"")
@ -181,7 +223,8 @@ trait ConfigParser {
case None => return Invalid(ValidationError(String.format("%s: value format error (expected: %s)", full_key, entry.getType)))
}
}
config_map += (full_key -> value)
map += (full_key -> value)
ParameterValidator(entry.constraints, Some(value)) match {
case v @ Valid => v

View file

@ -196,7 +196,7 @@ trait Constraints {
require(error != null, "error must not be null")
if (o == null) Invalid(ValidationError(error, regex))
else regex.unapplySeq(o).map(_ => Valid).getOrElse(Invalid(ValidationError(error, regex)))
else regex.unapplySeq(o).map(_ => Valid).getOrElse(Invalid(ValidationError(error, name)))
}
}

View file

@ -0,0 +1,54 @@
// Copyright (c) 2019 PSForever
package net.psforever.config
import scala.reflect.runtime.universe._
/**
* Scala [[Enumeration]] helpers implementing Scala versions of
* Java's [[java.lang.Enum.valueOf(Class[Enum], String)]].
* @author Dmitriy Yefremov (http://yefremov.net/blog/scala-enum-by-name/)
*/
object EnumReflector {
private val mirror: Mirror = runtimeMirror(getClass.getClassLoader)
/**
* Returns a value of the specified enumeration with the given name.
* @param name value name
* @tparam T enumeration type
* @return enumeration value, see [[scala.Enumeration.withName(String)]]
*/
def withName[T <: Enumeration#Value: TypeTag](name: String): T = {
typeOf[T] match {
case valueType @ TypeRef(enumType, _, _) =>
val methodSymbol = factoryMethodSymbol(enumType, "withName")
val moduleSymbol = enumType.termSymbol.asModule
reflect(moduleSymbol, methodSymbol)(name).asInstanceOf[T]
}
}
/**
* Returns the set of values of an enumeration
* @tparam T enumeration type
* @return possible enumeration values, see [[scala.Enumeration.values()]]
*/
def values[T <: Enumeration#ValueSet: TypeTag]: T = {
typeOf[T] match {
case valueType @ TypeRef(enumType, _, _) =>
val methodSymbol = factoryMethodSymbol(enumType, "values")
val moduleSymbol = enumType.termSymbol.asModule
reflect(moduleSymbol, methodSymbol)().asInstanceOf[T]
}
}
private def factoryMethodSymbol(enumType: Type, name : String): MethodSymbol = {
enumType.member(TermName(name)).asMethod
}
private def reflect(module: ModuleSymbol, method: MethodSymbol)(args: Any*): Any = {
val moduleMirror = mirror.reflectModule(module)
val instanceMirror = mirror.reflect(moduleMirror.instance)
instanceMirror.reflectMethod(method)(args:_*)
}
}

View file

@ -0,0 +1,8 @@
// Copyright (c) 2017 PSForever
package net.psforever.objects
class Account(private val accountId : Int, private val username : String, private val gm : Boolean = false) {
def AccountId : Int = accountId
def Username : String = username
def GM : Boolean = gm
}

View file

@ -24,6 +24,7 @@ object ServerType extends Enumeration(1) {
implicit val codec = PacketHelpers.createEnumerationCodec(this, uint8L)
}
// This MUST be an IP address. The client DOES NOT do name resolution properly
final case class WorldConnectionInfo(address : InetSocketAddress)
final case class WorldInformation(name : String, status : WorldStatus.Value,

View file

@ -0,0 +1,64 @@
// Copyright (c) 2017 PSForever
package services.account
import scala.collection.mutable
import akka.actor.Actor
import net.psforever.objects.Account
/**
* This actor is essentially a bridge between the LoginSessionActor and WorldSessionActor. When
* a player connects, the LoginSessionActor handles the account login by querying the database,
* comparing passwords etc, and then by inserting the account information into this actor by a
* token that is also sent to the player's client in the LoginRespMessage packet. The token is
* just like a browser cookie session token.
*
* When the player's client has processed the LoginRespMessage packet, it attempts to connect
* to the game world by sending a ConnectToWorldRequestMessage with the token which is used
* by the WorldSessionActor to lookup the player's account info from this actor.
*
* So this actor is a temporary store of account data for players logging into the login
* server and crossing over to the world server.
*/
class AccountIntermediaryService extends Actor {
private val accountsByToken = mutable.Map[String, Account]()
private val IPAddressBySessionID = mutable.Map[Long, IPAddress]()
private [this] val log = org.log4s.getLogger
override def preStart = {
log.trace("Starting...")
}
def receive = {
// Called by the LoginSessionActor
case StoreAccountData(token, account) =>
accountsByToken += (token -> account)
log.info(s"Storing intermediary account data for ${account.AccountId}")
// Called by the WorldSessionActor
case RetrieveAccountData(token) =>
val account : Option[Account] = accountsByToken.remove(token)
if(account.nonEmpty) {
sender() ! ReceiveAccountData(account.get)
log.info(s"Retrieving intermediary account data for ${account.get.AccountId}")
} else {
log.error(s"Unable to retrieve intermediary account data for ${account.get.AccountId}")
}
case StoreIPAddress(sessionID, address) =>
IPAddressBySessionID += (sessionID -> address)
log.info(s"Storing IP address (${address.Address}) for sessionID : $sessionID")
case RetrieveIPAddress(sessionID) =>
val address : Option[IPAddress] = IPAddressBySessionID.remove(sessionID)
if(address.nonEmpty) {
sender() ! ReceiveIPAddress(address.get)
log.info(s"Retrieving IP address data for sessionID : ${sessionID}")
} else {
log.error(s"Unable to retrieve IP address data for sessionID : ${sessionID}")
}
case msg =>
log.warn(s"Unhandled message $msg from $sender")
}
}

View file

@ -0,0 +1,11 @@
// Copyright (c) 2017 PSForever
package services.account
import java.net.InetSocketAddress
class IPAddress(private val address: InetSocketAddress) {
def Address : String = address.getAddress.getHostAddress
def CanonicalHostName : String = address.getAddress.getCanonicalHostName
def HostName : String = address.getAddress.getHostName
def Port : Int = address.getPort
}

View file

@ -0,0 +1,6 @@
// Copyright (c) 2017 PSForever
package services.account
import net.psforever.objects.Account
final case class ReceiveAccountData(account: Account)

View file

@ -0,0 +1,4 @@
// Copyright (c) 2017 PSForever
package services.account
final case class ReceiveIPAddress(address : IPAddress)

View file

@ -0,0 +1,4 @@
// Copyright (c) 2017 PSForever
package services.account
final case class RetrieveAccountData(token : String)

View file

@ -0,0 +1,4 @@
// Copyright (c) 2017 PSForever
package services.account
final case class RetrieveIPAddress(sessionID : Long)

View file

@ -0,0 +1,6 @@
// Copyright (c) 2017 PSForever
package services.account
import net.psforever.objects.Account
final case class StoreAccountData(token : String, account : Account)

View file

@ -0,0 +1,7 @@
// Copyright (c) 2017 PSForever
package services.account
final case class StoreIPAddress(sessionID : Long, address : IPAddress)

View file

@ -36,4 +36,7 @@
<appender-ref ref="FILE-TRACE" />
<appender-ref ref="FILE-DEBUG" />
</root>
<!-- Logger specific overrides -->
<logger name="com.github.mauricio.async" level="OFF"/>
</configuration>

View file

@ -18,6 +18,58 @@
# - Line breaks should be at column 100.
###################################################################################################
###################################################################################################
# DATABASE SETTINGS
###################################################################################################
[database]
# Hostname (string)
# Description: The hostname of the PostgreSQL server.
# Important: Make sure your database isn't accessible outside of localhost without a
# strong username and password!
# Default: "localhost"
Hostname = "localhost"
# Port (int)
# Description: The TCP port to connect to the database with.
# Range: [1, 65535] - (TCP port 1, TCP port 65535)
# Default: 5432 - (Connect to TCP port 5432 -- the default for PostgreSQL)
Port = 5432
# SSL (enum)
# Description: The SSL configuration of the database connection.
# Values: Disable - (Do not use SSL -- not recommended for public servers)
# Prefer - (Try SSL first, but fallback to unencrypted)
# Require - (Use SSL and fail if it is not enabled or configured correctly
# Verify - (Use SSL and verify that the certificate is authentic)
# Default: Prefer
SSL = Prefer
# Database (string)
# Description: The database name to use on the SQL server.
# Important: Make sure your username has been granted SELECT and INSERT access to
# the database.
# Default: "psforever"
Database = "psforever"
# Username (string)
# Description: The username to connect to the SQL server with.
# Default: "psforever"
Username = "psforever"
# Password (string)
# Description: The password to connect to the SQL server with.
# Important: CHANGE THIS PASSWORD BEFORE RUNNING A PUBLIC SERVER.
# Default: "psforever"
Password = "psforever"
###################################################################################################
# WORLDSERVER SETTINGS
###################################################################################################
@ -33,6 +85,29 @@
ListeningPort = 51001
# Hostname (string)
# Description: The server's public hostname or IP address. Required when the server is
# behind a proxy or running in a container/virtual machine.
# Important: If left as the default, the hostname will be the same as the primary
# internet interface.
# Default: ""
Hostname = ""
# ServerName (string)
# Description: The name of the server as displayed in the server browser.
# Range: Length = [1, 31]
# Default: "PSForever"
ServerName = "PSForever"
# ServerType (enum)
# Description: How the server is displayed in the server browser.
# Values: Released, Beta, Development
# Default: Released
ServerType = Released
###################################################################################################
# LOGINSERVER SETTINGS
###################################################################################################
@ -48,6 +123,17 @@ ListeningPort = 51001
ListeningPort = 51000
# CreateMissingAccounts (boolean)
# Description: Account usernames that don't exist yet will be automatically created in the
# database. Useful for test servers and development testing.
# Important: Not recommended for production servers as typos are easy to make on
# first registration. Use PSFPortal (https://github.com/psforever/PSFPortal)
# instead.
# Default: yes - (Enabled)
# no - (Disabled)
CreateMissingAccounts = yes
###################################################################################################
# NETWORK SETTINGS
###################################################################################################
@ -94,6 +180,7 @@ Session.OutboundGraceTime = 1 minute
# the memory load of the server and it will (by design) affect performance.
# Default: no - (Disabled)
# yes - (Enabled)
NetSim.Active = no
# NetSim.Loss (float)

View file

@ -0,0 +1,68 @@
// Copyright (c) 2017 PSForever
import com.github.mauricio.async.db.postgresql.PostgreSQLConnection
import com.github.mauricio.async.db.{Configuration, QueryResult, RowData, SSLConfiguration}
import scala.util.{Try,Success,Failure}
import scala.concurrent._
import scala.concurrent.duration._
import ExecutionContext.Implicits.global
import scala.concurrent.Future
object Database {
private val logger = org.log4s.getLogger
import WorldConfig.ConfigDatabaseSSL._
val UNKNOWN_ERROR : Int = -1
val EMPTY_RESULT : Int = -2
val config = Configuration(
WorldConfig.Get[String]("database.Username"),
WorldConfig.Get[String]("database.Hostname"),
WorldConfig.Get[Int]("database.Port"),
Some(WorldConfig.Get[String]("database.Password")),
Some(WorldConfig.Get[String]("database.Database")),
WorldConfig.Get[WorldConfig.ConfigDatabaseSSL.Value]("database.SSL") match {
case Disable => SSLConfiguration(SSLConfiguration.Mode.Disable)
case Prefer => SSLConfiguration(SSLConfiguration.Mode.Prefer)
case Require => SSLConfiguration(SSLConfiguration.Mode.Require)
// not including VerifyCA as full is more secure
case Verify => SSLConfiguration(SSLConfiguration.Mode.VerifyFull)
}
)
def testConnection : Try[Boolean] = {
try {
val connection = Await.result(getConnection.connect, 2 seconds)
val result = Await.result(query(connection.sendQuery("SELECT 0")), 2 seconds)
connection.disconnect
Success(true)
} catch {
case e : com.github.mauricio.async.db.postgresql.exceptions.GenericDatabaseException =>
logger.error(e.errorMessage.message)
Failure(e)
case e : javax.net.ssl.SSLHandshakeException =>
logger.error(s"${e.getMessage} (make sure your database supports SSL and the certificate matches)")
Failure(e)
case e : Throwable =>
logger.error(s"Unknown database error: ${e.toString}")
Failure(e)
}
}
// TODO Will probably want to use the ConnectionPool, although I don't know the implications for multithreaded apps
def getConnection = new PostgreSQLConnection(config)
def query(query : Future[QueryResult]) : Future[Any] = {
query.map(queryResult => queryResult.rows match {
case Some(resultSet) =>
if(resultSet.nonEmpty) {
val row : RowData = resultSet.head
row
} else {
EMPTY_RESULT
}
case None =>
UNKNOWN_ERROR
})
}
}

View file

@ -1,9 +0,0 @@
// Copyright (c) 2017 PSForever
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)
}

View file

@ -1,5 +1,6 @@
// Copyright (c) 2017 PSForever
import java.net.InetSocketAddress
import java.net.InetAddress
import akka.actor.{Actor, ActorRef, Cancellable, MDCContextAware}
import net.psforever.packet.{PlanetSideGamePacket, _}
@ -8,13 +9,24 @@ import net.psforever.packet.game._
import org.log4s.MDC
import scodec.bits._
import MDCContextAware.Implicits._
import com.github.mauricio.async.db.{Connection, QueryResult, RowData}
import com.github.mauricio.async.db.mysql.exceptions.MySQLException
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 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.{Await, Future}
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
@ -25,9 +37,28 @@ class LoginSessionActor extends Actor with MDCContextAware {
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()
@ -46,6 +77,7 @@ class LoginSessionActor extends Actor with MDCContextAware {
rightRef = sender()
}
context.become(Started)
ServiceManager.serviceManager ! Lookup("accountIntermediary")
case _ =>
log.error("Unknown message")
@ -53,6 +85,13 @@ class LoginSessionActor extends Actor with MDCContextAware {
}
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) =>
@ -76,98 +115,239 @@ class LoginSessionActor extends Actor with MDCContextAware {
}
}
// TODO: move to global configuration or database lookup
val serverName = "PSForever"
val serverAddress = new InetSocketAddress(LoginConfig.serverIpAddress.getHostAddress, 51001)
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
// TESTING CODE FOR ACCOUNT LOOKUP
def accountLookup(username : String, password : String) : Boolean = {
val connection: Connection = DatabaseConnector.getAccountsConnection
Await.result(connection.connect, 5 seconds)
val clientVersion = s"Client Version: $majorVersion.$minorVersion.$revision, $buildDate"
// create account
// username, password, email
// Result: worked or failed
// login to account
// username, password
// Result: token (session cookie)
accountIntermediary ! RetrieveIPAddress(sessionId)
val future: Future[QueryResult] = connection.sendPreparedStatement("SELECT * FROM accounts where username=?", Array(username))
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")
}
val mapResult: Future[Any] = future.map(queryResult => queryResult.rows match {
case Some(resultSet) =>
val row : RowData = resultSet.head
row(0)
case None =>
-1
})
startAccountLogin(username, password.get)
try {
// XXX: remove awaits
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
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 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"
if(token.isDefined)
log.info(s"New login UN:$username Token:${token.get}. $clientVersion")
else
log.info(s"New login UN:$username. $clientVersion")
// This is temporary until a schema has been developed
//val loginSucceeded = accountLookup(username, password.getOrElse(token.get))
// Allow any one to login for now
val loginSucceeded = true
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))
def startAccountLogin(username: String, password: String) = {
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)
}
}
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"))
def startAccountAuthentication() : Receive = {
case StartAccountAuthentication(connection, username, password, newToken, queryResult) =>
queryResult match {
case _ =>
log.debug(s"Unhandled GamePacket $pkt")
// 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 _ =>
log.error(s"Error creating new account for $username")
context.become(finishAccountLogin)
self ! FinishAccountLogin(connection, username, newToken, false)
}
case _ => failWithError("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 {
case _ =>
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 (i <- 1 to 31) {
sb.append(r.nextPrintableChar)
}
sb.toString
}
def updateServerList() = {
val msg = VNLWorldStatusMessage("Welcome to PlanetSide! ",
Vector(
WorldInformation(
serverName, WorldStatus.Up, ServerType.Beta, Vector(WorldConnectionInfo(serverAddress)), PlanetSideEmpire.VS
serverName, WorldStatus.Up,
WorldConfig.Get[ServerType.Value]("worldserver.ServerType"), Vector(WorldConnectionInfo(serverAddress)), PlanetSideEmpire.VS
)
)
)

View file

@ -19,6 +19,7 @@ import org.slf4j
import org.fusesource.jansi.Ansi._
import org.fusesource.jansi.Ansi.Color._
import services.ServiceManager
import services.account.AccountIntermediaryService
import services.chat.ChatService
import services.galaxy.GalaxyService
import services.teamwork.SquadService
@ -200,6 +201,13 @@ object PsLogin {
sys.exit(1)
}
Database.testConnection match {
case scala.util.Failure(e) =>
logger.error("Unable to connect to the database")
sys.exit(1)
case _ =>
}
logger.info("Starting actor subsystems...")
/** Make sure we capture Akka messages (but only INFO and above)
@ -255,6 +263,7 @@ object PsLogin {
val continentList = createContinents()
val serviceManager = ServiceManager.boot
serviceManager ! ServiceManager.Register(Props[AccountIntermediaryService], "accountIntermediary")
serviceManager ! ServiceManager.Register(RandomPool(50).props(Props[TaskResolver]), "taskResolver")
serviceManager ! ServiceManager.Register(Props[ChatService], "chat")
serviceManager ! ServiceManager.Register(Props[GalaxyService], "galaxy")

View file

@ -11,6 +11,9 @@ import akka.actor.MDCContextAware.MdcMsg
import akka.actor.SupervisorStrategy.Stop
import net.psforever.packet.PacketCoding
import net.psforever.packet.control.ConnectionClose
import services.ServiceManager
import services.ServiceManager.Lookup
import services.account.{IPAddress, StoreIPAddress}
import scala.concurrent.duration._
@ -46,6 +49,7 @@ class SessionRouter(role : String, pipeline : List[SessionPipeline]) extends Act
val sessionById = mutable.Map[Long, Session]()
val sessionByActor = mutable.Map[ActorRef, Session]()
val closePacket = PacketCoding.EncodePacket(ConnectionClose()).require.bytes
var accountIntermediary : ActorRef = Actor.noSender
var sessionId = 0L // this is a connection session, not an actual logged in session ID
var inputRef : ActorRef = ActorRef.noSender
@ -62,6 +66,7 @@ class SessionRouter(role : String, pipeline : List[SessionPipeline]) extends Act
case Hello() =>
inputRef = sender()
context.become(started)
ServiceManager.serviceManager ! Lookup("accountIntermediary")
case default =>
log.error(s"Unknown message $default. Stopping...")
context.stop(self)
@ -72,6 +77,8 @@ class SessionRouter(role : String, pipeline : List[SessionPipeline]) extends Act
}
def started : Receive = {
case ServiceManager.LookupResult("accountIntermediary", endpoint) =>
accountIntermediary = endpoint
case recv @ ReceivedPacket(msg, from) =>
var session : Session = null
@ -155,6 +162,10 @@ class SessionRouter(role : String, pipeline : List[SessionPipeline]) extends Act
log.info(s"New session ID=${id} from " + address.toString)
if(role == "Login") {
accountIntermediary ! StoreIPAddress(id, new IPAddress(address))
}
session
}

View file

@ -1,16 +1,31 @@
// Copyright (c) 2019 PSForever
import scala.util.matching.Regex
import net.psforever.config._
import scala.concurrent.duration._
import net.psforever.packet.game._
object WorldConfig extends ConfigParser {
protected var config_map : Map[String, Any] = Map()
// hostname, but allow for empty string
protected val hostname_pattern = Constraints.pattern(raw"^((([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9]))|()$$".r, "hostname")
protected val config_template = Seq(
ConfigSection("database",
ConfigEntryString("Hostname", "localhost", hostname_pattern, Constraints.minLength(1)),
ConfigEntryInt("Port", 5432, Constraints.min(1), Constraints.max(65535)),
ConfigEntryEnum[ConfigDatabaseSSL.type]("SSL", ConfigDatabaseSSL.Prefer),
ConfigEntryString("Database", "psforever", Constraints.minLength(1)),
ConfigEntryString("Username", "psforever", Constraints.minLength(1)),
ConfigEntryString("Password", "psforever", Constraints.minLength(1))
),
ConfigSection("loginserver",
ConfigEntryInt("ListeningPort", 51000, Constraints.min(1), Constraints.max(65535))
ConfigEntryInt("ListeningPort", 51000, Constraints.min(1), Constraints.max(65535)),
ConfigEntryBool("CreateMissingAccounts", true)
),
ConfigSection("worldserver",
ConfigEntryInt("ListeningPort", 51001, Constraints.min(1), Constraints.max(65535))
ConfigEntryInt("ListeningPort", 51001, Constraints.min(1), Constraints.max(65535)),
ConfigEntryString("Hostname", "", hostname_pattern),
ConfigEntryString("ServerName", "PSForever", Constraints.minLength(1), Constraints.maxLength(31)),
ConfigEntryEnum[ServerType.type]("ServerType", ServerType.Released)
),
ConfigSection("network",
ConfigEntryTime("Session.InboundGraceTime", 1 minute, Constraints.min(10 seconds)),
@ -25,6 +40,11 @@ object WorldConfig extends ConfigParser {
)
)
object ConfigDatabaseSSL extends Enumeration {
type Type = Value
val Disable, Prefer, Require, Verify = Value
}
override def postParseChecks : ValidationResult = {
var errors : Invalid = Invalid("")

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,7 @@ time2 = 100 milliseconds
float = 0.1
bool_true = yes
bool_false = no
enum_dog = Dog
# missing
[bad]
@ -17,3 +18,4 @@ bad_float = A
bad_bool = dunno
bad_int_range = -1
bad_int_range2 = 3
bad_enum = Tree

View file

@ -6,6 +6,8 @@ import net.psforever.config._
import scala.concurrent.duration._
class ConfigTest extends Specification {
sequential
val testConfig = getClass.getResource("/testconfig.ini").getPath
"WorldConfig" should {
@ -44,6 +46,7 @@ class ConfigTest extends Specification {
TestConfig.Get[Float]("default.float") mustEqual 0.1f
TestConfig.Get[Boolean]("default.bool_true") mustEqual true
TestConfig.Get[Boolean]("default.bool_false") mustEqual false
TestConfig.Get[TestEnum.Value]("default.enum_dog") mustEqual TestEnum.Dog
TestConfig.Get[Int]("default.missing") mustEqual 1337
}
@ -70,7 +73,8 @@ class ConfigTest extends Specification {
ValidationError("bad.bad_float: value format error (expected: Float)"),
ValidationError("bad.bad_bool: value format error (expected: Bool)"),
ValidationError("bad.bad_int_range: error.min", 0),
ValidationError("bad.bad_int_range2: error.max", 2)
ValidationError("bad.bad_int_range2: error.max", 2),
ValidationError("bad.bad_enum: value format error (expected: Animal, Dog, Cat)")
)
error.errors mustEqual check_errors
@ -78,9 +82,11 @@ class ConfigTest extends Specification {
}
}
object TestConfig extends ConfigParser {
protected var config_map : Map[String, Any] = Map()
object TestEnum extends Enumeration {
val Animal, Dog, Cat = Value
}
object TestConfig extends ConfigParser {
protected val config_template = Seq(
ConfigSection("default",
ConfigEntryString("string", ""),
@ -91,14 +97,13 @@ object TestConfig extends ConfigParser {
ConfigEntryFloat("float", 0.0f),
ConfigEntryBool("bool_true", false),
ConfigEntryBool("bool_false", true),
ConfigEntryInt("missing", 1337)
ConfigEntryInt("missing", 1337),
ConfigEntryEnum[TestEnum.type]("enum_dog", TestEnum.Dog)
)
)
}
object TestBadConfig extends ConfigParser {
protected var config_map : Map[String, Any] = Map()
protected val config_template = Seq(
ConfigSection("bad",
ConfigEntryInt("bad_int", 0),
@ -106,7 +111,8 @@ object TestBadConfig extends ConfigParser {
ConfigEntryFloat("bad_float", 0.0f),
ConfigEntryBool("bad_bool", false),
ConfigEntryInt("bad_int_range", 0, Constraints.min(0)),
ConfigEntryInt("bad_int_range2", 0, Constraints.min(0), Constraints.max(2))
ConfigEntryInt("bad_int_range2", 0, Constraints.min(0), Constraints.max(2)),
ConfigEntryEnum[TestEnum.type]("bad_enum", TestEnum.Animal)
)
)
}

69
schema.sql Normal file
View file

@ -0,0 +1,69 @@
CREATE TABLE IF NOT EXISTS "accounts" (
"id" SERIAL PRIMARY KEY NOT NULL,
"username" VARCHAR(64) NOT NULL UNIQUE,
"passhash" VARCHAR(64) NOT NULL,
"created" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"last_modified" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"inactive" BOOLEAN NOT NULL DEFAULT FALSE,
"gm" BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS "characters" (
"id" SERIAL PRIMARY KEY NOT NULL,
"name" VARCHAR(64) NOT NULL,
"account_id" INT NOT NULL REFERENCES accounts (id),
"faction_id" INT NOT NULL,
"gender_id" INT NOT NULL,
"head_id" INT NOT NULL,
"voice_id" INT NOT NULL,
"created" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"last_login" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"last_modified" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted" BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS "logins" (
"id" SERIAL PRIMARY KEY NOT NULL,
"account_id" INT NOT NULL REFERENCES accounts (id),
"login_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"ip_address" VARCHAR(32) NOT NULL,
"canonical_hostname" VARCHAR(132) NOT NULL,
"hostname" VARCHAR(132) NOT NULL,
"port" INT NOT NULL
);
CREATE TABLE IF NOT EXISTS "loadouts" (
"id" SERIAL PRIMARY KEY NOT NULL,
"characters_id" INT NOT NULL REFERENCES characters (id),
"loadout_number" INT NOT NULL,
"exosuit_id" INT NOT NULL,
"name" VARCHAR(36) NOT NULL,
"items" TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS "lockers" (
"id" SERIAL PRIMARY KEY NOT NULL,
"characters_id" INT NOT NULL REFERENCES characters (id),
"items" TEXT NOT NULL
);
--These triggers update the last_modified timestamp column when a table is updated
CREATE OR REPLACE FUNCTION fn_set_last_modified_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.last_modified = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trigger_accounts_set_last_modified on accounts;
CREATE TRIGGER trigger_accounts_set_last_modified
BEFORE UPDATE ON accounts
FOR EACH ROW
EXECUTE PROCEDURE fn_set_last_modified_timestamp();
DROP TRIGGER IF EXISTS trigger_players_set_last_modified on characters;
CREATE TRIGGER trigger_players_set_last_modified
BEFORE UPDATE ON characters
FOR EACH ROW
EXECUTE PROCEDURE fn_set_last_modified_timestamp();