mirror of
https://github.com/psforever/PSF-LoginServer.git
synced 2026-01-19 18:44:45 +00:00
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:
parent
ae768e1e42
commit
d08911d07c
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -9,6 +9,9 @@ out/
|
||||||
*.ipr
|
*.ipr
|
||||||
*.iml
|
*.iml
|
||||||
|
|
||||||
|
# User configs
|
||||||
|
config/worldserver.ini
|
||||||
|
|
||||||
# Log files
|
# Log files
|
||||||
*.log
|
*.log
|
||||||
logs/
|
logs/
|
||||||
|
|
|
||||||
14
README.md
14
README.md
|
|
@ -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)
|
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
|
## Running the Server
|
||||||
|
|
||||||
To run a headless, non-interactive server, run
|
To run a headless, non-interactive server, run
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
PlanetSide Forever (PSForever) would not have been possible without the passion and many hours of hard work of our contributors.
|
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!
|
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
|
* FateJH & Chord
|
||||||
* SouNourS
|
* SouNourS
|
||||||
* tfarley
|
* tfarley
|
||||||
* aphedox
|
* aphedox
|
||||||
* L-11
|
* L-11
|
||||||
|
* xTriad (initial database schema)
|
||||||
|
|
||||||
Packet Capturing 2015-2016 (in order of most packets captured)
|
Packet Capturing 2015-2016 (in order of most packets captured)
|
||||||
=================
|
=================
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,8 @@ lazy val commonSettings = Seq(
|
||||||
"org.fusesource.jansi" % "jansi" % "1.12",
|
"org.fusesource.jansi" % "jansi" % "1.12",
|
||||||
"org.scoverage" %% "scalac-scoverage-plugin" % "1.1.1",
|
"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",
|
"com.github.mauricio" %% "postgresql-async" % "0.2.21",
|
||||||
|
"com.github.t3hnar" %% "scala-bcrypt" % "3.1",
|
||||||
"org.ini4j" % "ini4j" % "0.5.4",
|
"org.ini4j" % "ini4j" % "0.5.4",
|
||||||
"org.scala-graph" %% "graph-core" % "1.12.5"
|
"org.scala-graph" %% "graph-core" % "1.12.5"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,18 @@ package net.psforever.config
|
||||||
|
|
||||||
import org.ini4j
|
import org.ini4j
|
||||||
import scala.reflect.ClassTag
|
import scala.reflect.ClassTag
|
||||||
|
import scala.reflect.runtime.universe.TypeTag
|
||||||
import scala.annotation.implicitNotFound
|
import scala.annotation.implicitNotFound
|
||||||
import scala.concurrent.duration._
|
import scala.concurrent.duration._
|
||||||
|
import scala.collection.mutable.Map
|
||||||
|
|
||||||
case class ConfigValueMapper[T](name: String)(f: (String => Option[T])) {
|
case class ConfigValueMapper[T](name: String)(f: (String => Option[T])) {
|
||||||
def apply(t: String): Option[T] = f(t)
|
def apply(t: String): Option[T] = f(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
object ConfigValueMapper {
|
object ConfigValueMapper {
|
||||||
|
import scala.language.implicitConversions
|
||||||
|
|
||||||
implicit val toInt : ConfigValueMapper[Int] = ConfigValueMapper[Int]("toInt") { e =>
|
implicit val toInt : ConfigValueMapper[Int] = ConfigValueMapper[Int]("toInt") { e =>
|
||||||
try {
|
try {
|
||||||
Some(e.toInt)
|
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 =>
|
implicit val toBool : ConfigValueMapper[Boolean] = ConfigValueMapper[Boolean]("toBool") { e =>
|
||||||
if (e == "yes") {
|
if (e == "yes") {
|
||||||
Some(true)
|
Some(true)
|
||||||
|
|
@ -77,6 +92,13 @@ final case class ConfigEntryBool(key: String, default : Boolean, constraints : C
|
||||||
def read(v : String) = ConfigValueMapper.toBool(v)
|
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 {
|
final case class ConfigEntryFloat(key: String, default : Float, constraints : Constraint[Float]*) extends ConfigEntry {
|
||||||
type Value = Float
|
type Value = Float
|
||||||
def getType = "Float"
|
def getType = "Float"
|
||||||
|
|
@ -101,7 +123,7 @@ object ConfigTypeRequired {
|
||||||
}
|
}
|
||||||
|
|
||||||
trait ConfigParser {
|
trait ConfigParser {
|
||||||
protected var config_map : Map[String, Any]
|
protected var config_map : Map[String, Any] = Map()
|
||||||
protected val config_template : Seq[ConfigSection]
|
protected val config_template : Seq[ConfigSection]
|
||||||
|
|
||||||
// Misuse of this function can lead to run time exceptions when the types don't match
|
// 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 = {
|
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()
|
val ini = new org.ini4j.Ini()
|
||||||
config_map = Map()
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ini.load(new java.io.File(filename))
|
ini.load(new java.io.File(filename))
|
||||||
|
|
@ -136,7 +175,7 @@ trait ConfigParser {
|
||||||
if (sectionIni == null)
|
if (sectionIni == null)
|
||||||
Seq(Invalid("section.missing", section.name))
|
Seq(Invalid("section.missing", section.name))
|
||||||
else
|
else
|
||||||
section.entries.map(parseSection(sectionIni, _))
|
section.entries.map(parseSection(sectionIni, _, map))
|
||||||
}.reduceLeft((x, y) => x ++ y)
|
}.reduceLeft((x, y) => x ++ y)
|
||||||
|
|
||||||
val errors : Seq[Invalid] = result.collect { case iv : Invalid => iv }
|
val errors : Seq[Invalid] = result.collect { case iv : Invalid => iv }
|
||||||
|
|
@ -144,8 +183,11 @@ trait ConfigParser {
|
||||||
if (errors.length > 0)
|
if (errors.length > 0)
|
||||||
errors.reduceLeft((x, y) => x ++ y)
|
errors.reduceLeft((x, y) => x ++ y)
|
||||||
else
|
else
|
||||||
// run post-parse validation only if we successfully parsed
|
Valid
|
||||||
postParseChecks
|
}
|
||||||
|
|
||||||
|
def ReplaceConfig(map : Map[String, Any]) {
|
||||||
|
config_map = map
|
||||||
}
|
}
|
||||||
|
|
||||||
def FormatErrors(invalidResult : Invalid) : Seq[String] = {
|
def FormatErrors(invalidResult : Invalid) : Seq[String] = {
|
||||||
|
|
@ -166,12 +208,12 @@ trait ConfigParser {
|
||||||
Valid
|
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)
|
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) {
|
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
|
entry.default
|
||||||
} else {
|
} else {
|
||||||
rawValue = rawValue.stripPrefix("\"").stripSuffix("\"")
|
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)))
|
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 {
|
ParameterValidator(entry.constraints, Some(value)) match {
|
||||||
case v @ Valid => v
|
case v @ Valid => v
|
||||||
|
|
|
||||||
|
|
@ -196,7 +196,7 @@ trait Constraints {
|
||||||
require(error != null, "error must not be null")
|
require(error != null, "error must not be null")
|
||||||
|
|
||||||
if (o == null) Invalid(ValidationError(error, regex))
|
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)))
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:_*)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,7 @@ object ServerType extends Enumeration(1) {
|
||||||
implicit val codec = PacketHelpers.createEnumerationCodec(this, uint8L)
|
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 WorldConnectionInfo(address : InetSocketAddress)
|
||||||
|
|
||||||
final case class WorldInformation(name : String, status : WorldStatus.Value,
|
final case class WorldInformation(name : String, status : WorldStatus.Value,
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
11
common/src/main/scala/services/account/IPAddress.scala
Normal file
11
common/src/main/scala/services/account/IPAddress.scala
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
// Copyright (c) 2017 PSForever
|
||||||
|
package services.account
|
||||||
|
|
||||||
|
import net.psforever.objects.Account
|
||||||
|
|
||||||
|
final case class ReceiveAccountData(account: Account)
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
// Copyright (c) 2017 PSForever
|
||||||
|
package services.account
|
||||||
|
|
||||||
|
final case class ReceiveIPAddress(address : IPAddress)
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
// Copyright (c) 2017 PSForever
|
||||||
|
package services.account
|
||||||
|
|
||||||
|
final case class RetrieveAccountData(token : String)
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
// Copyright (c) 2017 PSForever
|
||||||
|
package services.account
|
||||||
|
|
||||||
|
final case class RetrieveIPAddress(sessionID : Long)
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
// Copyright (c) 2017 PSForever
|
||||||
|
package services.account
|
||||||
|
|
||||||
|
import net.psforever.objects.Account
|
||||||
|
|
||||||
|
final case class StoreAccountData(token : String, account : Account)
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
// Copyright (c) 2017 PSForever
|
||||||
|
package services.account
|
||||||
|
|
||||||
|
final case class StoreIPAddress(sessionID : Long, address : IPAddress)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -36,4 +36,7 @@
|
||||||
<appender-ref ref="FILE-TRACE" />
|
<appender-ref ref="FILE-TRACE" />
|
||||||
<appender-ref ref="FILE-DEBUG" />
|
<appender-ref ref="FILE-DEBUG" />
|
||||||
</root>
|
</root>
|
||||||
|
|
||||||
|
<!-- Logger specific overrides -->
|
||||||
|
<logger name="com.github.mauricio.async" level="OFF"/>
|
||||||
</configuration>
|
</configuration>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,58 @@
|
||||||
# - Line breaks should be at column 100.
|
# - 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
|
# WORLDSERVER SETTINGS
|
||||||
###################################################################################################
|
###################################################################################################
|
||||||
|
|
@ -33,6 +85,29 @@
|
||||||
|
|
||||||
ListeningPort = 51001
|
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
|
# LOGINSERVER SETTINGS
|
||||||
###################################################################################################
|
###################################################################################################
|
||||||
|
|
@ -48,6 +123,17 @@ ListeningPort = 51001
|
||||||
|
|
||||||
ListeningPort = 51000
|
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
|
# NETWORK SETTINGS
|
||||||
###################################################################################################
|
###################################################################################################
|
||||||
|
|
@ -94,6 +180,7 @@ Session.OutboundGraceTime = 1 minute
|
||||||
# the memory load of the server and it will (by design) affect performance.
|
# the memory load of the server and it will (by design) affect performance.
|
||||||
# Default: no - (Disabled)
|
# Default: no - (Disabled)
|
||||||
# yes - (Enabled)
|
# yes - (Enabled)
|
||||||
|
|
||||||
NetSim.Active = no
|
NetSim.Active = no
|
||||||
|
|
||||||
# NetSim.Loss (float)
|
# NetSim.Loss (float)
|
||||||
|
|
|
||||||
68
pslogin/src/main/scala/Database.scala
Normal file
68
pslogin/src/main/scala/Database.scala
Normal 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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
// Copyright (c) 2017 PSForever
|
// Copyright (c) 2017 PSForever
|
||||||
import java.net.InetSocketAddress
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.InetAddress
|
||||||
|
|
||||||
import akka.actor.{Actor, ActorRef, Cancellable, MDCContextAware}
|
import akka.actor.{Actor, ActorRef, Cancellable, MDCContextAware}
|
||||||
import net.psforever.packet.{PlanetSideGamePacket, _}
|
import net.psforever.packet.{PlanetSideGamePacket, _}
|
||||||
|
|
@ -8,13 +9,24 @@ import net.psforever.packet.game._
|
||||||
import org.log4s.MDC
|
import org.log4s.MDC
|
||||||
import scodec.bits._
|
import scodec.bits._
|
||||||
import MDCContextAware.Implicits._
|
import MDCContextAware.Implicits._
|
||||||
import com.github.mauricio.async.db.{Connection, QueryResult, RowData}
|
import com.github.mauricio.async.db.general.ArrayRowData
|
||||||
import com.github.mauricio.async.db.mysql.exceptions.MySQLException
|
import com.github.mauricio.async.db.{Connection, QueryResult}
|
||||||
|
import net.psforever.objects.Account
|
||||||
import net.psforever.objects.DefaultCancellable
|
import net.psforever.objects.DefaultCancellable
|
||||||
import net.psforever.types.PlanetSideEmpire
|
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.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 {
|
class LoginSessionActor extends Actor with MDCContextAware {
|
||||||
private[this] val log = org.log4s.getLogger
|
private[this] val log = org.log4s.getLogger
|
||||||
|
|
@ -25,9 +37,28 @@ class LoginSessionActor extends Actor with MDCContextAware {
|
||||||
var sessionId : Long = 0
|
var sessionId : Long = 0
|
||||||
var leftRef : ActorRef = ActorRef.noSender
|
var leftRef : ActorRef = ActorRef.noSender
|
||||||
var rightRef : ActorRef = ActorRef.noSender
|
var rightRef : ActorRef = ActorRef.noSender
|
||||||
|
var accountIntermediary : ActorRef = Actor.noSender
|
||||||
|
|
||||||
var updateServerListTask : Cancellable = DefaultCancellable.obj
|
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() = {
|
override def postStop() = {
|
||||||
if(updateServerListTask != null)
|
if(updateServerListTask != null)
|
||||||
updateServerListTask.cancel()
|
updateServerListTask.cancel()
|
||||||
|
|
@ -46,6 +77,7 @@ class LoginSessionActor extends Actor with MDCContextAware {
|
||||||
rightRef = sender()
|
rightRef = sender()
|
||||||
}
|
}
|
||||||
context.become(Started)
|
context.become(Started)
|
||||||
|
ServiceManager.serviceManager ! Lookup("accountIntermediary")
|
||||||
|
|
||||||
case _ =>
|
case _ =>
|
||||||
log.error("Unknown message")
|
log.error("Unknown message")
|
||||||
|
|
@ -53,6 +85,13 @@ class LoginSessionActor extends Actor with MDCContextAware {
|
||||||
}
|
}
|
||||||
|
|
||||||
def Started : Receive = {
|
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() =>
|
case UpdateServerList() =>
|
||||||
updateServerList()
|
updateServerList()
|
||||||
case ControlPacket(_, ctrl) =>
|
case ControlPacket(_, ctrl) =>
|
||||||
|
|
@ -76,98 +115,239 @@ class LoginSessionActor extends Actor with MDCContextAware {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: move to global configuration or database lookup
|
def handleGamePkt(pkt : PlanetSideGamePacket) = pkt match {
|
||||||
val serverName = "PSForever"
|
case LoginMessage(majorVersion, minorVersion, buildDate, username, password, token, revision) =>
|
||||||
val serverAddress = new InetSocketAddress(LoginConfig.serverIpAddress.getHostAddress, 51001)
|
// TODO: prevent multiple LoginMessages from being processed in a row!! We need a state machine
|
||||||
|
|
||||||
// TESTING CODE FOR ACCOUNT LOOKUP
|
val clientVersion = s"Client Version: $majorVersion.$minorVersion.$revision, $buildDate"
|
||||||
def accountLookup(username : String, password : String) : Boolean = {
|
|
||||||
val connection: Connection = DatabaseConnector.getAccountsConnection
|
|
||||||
Await.result(connection.connect, 5 seconds)
|
|
||||||
|
|
||||||
// create account
|
accountIntermediary ! RetrieveIPAddress(sessionId)
|
||||||
// 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))
|
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 {
|
startAccountLogin(username, password.get)
|
||||||
case Some(resultSet) =>
|
|
||||||
val row : RowData = resultSet.head
|
|
||||||
row(0)
|
|
||||||
case None =>
|
|
||||||
-1
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
case ConnectToWorldRequestMessage(name, _, _, _, _, _, _) =>
|
||||||
// XXX: remove awaits
|
log.info(s"Connect to world request for '$name'")
|
||||||
Await.result( mapResult, 5 seconds )
|
val response = ConnectToWorldMessage(serverName, serverAddress.getHostString, serverAddress.getPort)
|
||||||
return true
|
sendResponse(PacketCoding.CreateGamePacket(0, response))
|
||||||
} catch {
|
sendResponse(DropSession(sessionId, "user transferring to world"))
|
||||||
case e : MySQLException =>
|
|
||||||
log.error(s"SQL exception $e")
|
case _ =>
|
||||||
case e: Exception =>
|
log.debug(s"Unhandled GamePacket $pkt")
|
||||||
log.error(s"Unknown exception when executing SQL statement: $e")
|
|
||||||
} finally {
|
|
||||||
connection.disconnect
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def handleGamePkt(pkt : PlanetSideGamePacket) = pkt match {
|
def startAccountLogin(username: String, password: String) = {
|
||||||
case LoginMessage(majorVersion, minorVersion, buildDate, username,
|
val newToken = this.generateToken()
|
||||||
password, token, revision) =>
|
Database.getConnection.connect.onComplete {
|
||||||
// TODO: prevent multiple LoginMessages from being processed in a row!! We need a state machine
|
case Success(connection) =>
|
||||||
import game.LoginRespMessage._
|
Database.query(connection.sendPreparedStatement(
|
||||||
|
"SELECT id, passhash, inactive, gm FROM accounts where username=?", Array(username)
|
||||||
val clientVersion = s"Client Version: $majorVersion.$minorVersion.$revision, $buildDate"
|
)).onComplete {
|
||||||
|
case Success(queryResult) =>
|
||||||
if(token.isDefined)
|
context.become(startAccountAuthentication)
|
||||||
log.info(s"New login UN:$username Token:${token.get}. $clientVersion")
|
self ! StartAccountAuthentication(Some(connection), username, password, newToken, queryResult)
|
||||||
else
|
case Failure(e) =>
|
||||||
log.info(s"New login UN:$username. $clientVersion")
|
log.error("Failed account lookup query " + e.getMessage)
|
||||||
|
connection.disconnect
|
||||||
// This is temporary until a schema has been developed
|
context.become(finishAccountLogin)
|
||||||
//val loginSucceeded = accountLookup(username, password.getOrElse(token.get))
|
self ! FinishAccountLogin(Some(connection), username, newToken, false)
|
||||||
|
|
||||||
// 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))
|
|
||||||
}
|
}
|
||||||
|
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, _, _, _, _, _, _) =>
|
def startAccountAuthentication() : Receive = {
|
||||||
log.info(s"Connect to world request for '$name'")
|
case StartAccountAuthentication(connection, username, password, newToken, queryResult) =>
|
||||||
val response = ConnectToWorldMessage(serverName, serverAddress.getHostString, serverAddress.getPort)
|
queryResult match {
|
||||||
sendResponse(PacketCoding.CreateGamePacket(0, response))
|
|
||||||
sendResponse(DropSession(sessionId, "user transferring to world"))
|
|
||||||
|
|
||||||
case _ =>
|
// If we got a row from the database
|
||||||
log.debug(s"Unhandled GamePacket $pkt")
|
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() = {
|
def updateServerList() = {
|
||||||
val msg = VNLWorldStatusMessage("Welcome to PlanetSide! ",
|
val msg = VNLWorldStatusMessage("Welcome to PlanetSide! ",
|
||||||
Vector(
|
Vector(
|
||||||
WorldInformation(
|
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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import org.slf4j
|
||||||
import org.fusesource.jansi.Ansi._
|
import org.fusesource.jansi.Ansi._
|
||||||
import org.fusesource.jansi.Ansi.Color._
|
import org.fusesource.jansi.Ansi.Color._
|
||||||
import services.ServiceManager
|
import services.ServiceManager
|
||||||
|
import services.account.AccountIntermediaryService
|
||||||
import services.chat.ChatService
|
import services.chat.ChatService
|
||||||
import services.galaxy.GalaxyService
|
import services.galaxy.GalaxyService
|
||||||
import services.teamwork.SquadService
|
import services.teamwork.SquadService
|
||||||
|
|
@ -200,6 +201,13 @@ object PsLogin {
|
||||||
sys.exit(1)
|
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...")
|
logger.info("Starting actor subsystems...")
|
||||||
|
|
||||||
/** Make sure we capture Akka messages (but only INFO and above)
|
/** Make sure we capture Akka messages (but only INFO and above)
|
||||||
|
|
@ -255,6 +263,7 @@ object PsLogin {
|
||||||
|
|
||||||
val continentList = createContinents()
|
val continentList = createContinents()
|
||||||
val serviceManager = ServiceManager.boot
|
val serviceManager = ServiceManager.boot
|
||||||
|
serviceManager ! ServiceManager.Register(Props[AccountIntermediaryService], "accountIntermediary")
|
||||||
serviceManager ! ServiceManager.Register(RandomPool(50).props(Props[TaskResolver]), "taskResolver")
|
serviceManager ! ServiceManager.Register(RandomPool(50).props(Props[TaskResolver]), "taskResolver")
|
||||||
serviceManager ! ServiceManager.Register(Props[ChatService], "chat")
|
serviceManager ! ServiceManager.Register(Props[ChatService], "chat")
|
||||||
serviceManager ! ServiceManager.Register(Props[GalaxyService], "galaxy")
|
serviceManager ! ServiceManager.Register(Props[GalaxyService], "galaxy")
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ import akka.actor.MDCContextAware.MdcMsg
|
||||||
import akka.actor.SupervisorStrategy.Stop
|
import akka.actor.SupervisorStrategy.Stop
|
||||||
import net.psforever.packet.PacketCoding
|
import net.psforever.packet.PacketCoding
|
||||||
import net.psforever.packet.control.ConnectionClose
|
import net.psforever.packet.control.ConnectionClose
|
||||||
|
import services.ServiceManager
|
||||||
|
import services.ServiceManager.Lookup
|
||||||
|
import services.account.{IPAddress, StoreIPAddress}
|
||||||
|
|
||||||
import scala.concurrent.duration._
|
import scala.concurrent.duration._
|
||||||
|
|
||||||
|
|
@ -46,6 +49,7 @@ class SessionRouter(role : String, pipeline : List[SessionPipeline]) extends Act
|
||||||
val sessionById = mutable.Map[Long, Session]()
|
val sessionById = mutable.Map[Long, Session]()
|
||||||
val sessionByActor = mutable.Map[ActorRef, Session]()
|
val sessionByActor = mutable.Map[ActorRef, Session]()
|
||||||
val closePacket = PacketCoding.EncodePacket(ConnectionClose()).require.bytes
|
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 sessionId = 0L // this is a connection session, not an actual logged in session ID
|
||||||
var inputRef : ActorRef = ActorRef.noSender
|
var inputRef : ActorRef = ActorRef.noSender
|
||||||
|
|
@ -62,6 +66,7 @@ class SessionRouter(role : String, pipeline : List[SessionPipeline]) extends Act
|
||||||
case Hello() =>
|
case Hello() =>
|
||||||
inputRef = sender()
|
inputRef = sender()
|
||||||
context.become(started)
|
context.become(started)
|
||||||
|
ServiceManager.serviceManager ! Lookup("accountIntermediary")
|
||||||
case default =>
|
case default =>
|
||||||
log.error(s"Unknown message $default. Stopping...")
|
log.error(s"Unknown message $default. Stopping...")
|
||||||
context.stop(self)
|
context.stop(self)
|
||||||
|
|
@ -72,6 +77,8 @@ class SessionRouter(role : String, pipeline : List[SessionPipeline]) extends Act
|
||||||
}
|
}
|
||||||
|
|
||||||
def started : Receive = {
|
def started : Receive = {
|
||||||
|
case ServiceManager.LookupResult("accountIntermediary", endpoint) =>
|
||||||
|
accountIntermediary = endpoint
|
||||||
case recv @ ReceivedPacket(msg, from) =>
|
case recv @ ReceivedPacket(msg, from) =>
|
||||||
var session : Session = null
|
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)
|
log.info(s"New session ID=${id} from " + address.toString)
|
||||||
|
|
||||||
|
if(role == "Login") {
|
||||||
|
accountIntermediary ! StoreIPAddress(id, new IPAddress(address))
|
||||||
|
}
|
||||||
|
|
||||||
session
|
session
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,31 @@
|
||||||
// Copyright (c) 2019 PSForever
|
// Copyright (c) 2019 PSForever
|
||||||
|
import scala.util.matching.Regex
|
||||||
import net.psforever.config._
|
import net.psforever.config._
|
||||||
import scala.concurrent.duration._
|
import scala.concurrent.duration._
|
||||||
|
import net.psforever.packet.game._
|
||||||
|
|
||||||
object WorldConfig extends ConfigParser {
|
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(
|
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",
|
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",
|
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",
|
ConfigSection("network",
|
||||||
ConfigEntryTime("Session.InboundGraceTime", 1 minute, Constraints.min(10 seconds)),
|
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 = {
|
override def postParseChecks : ValidationResult = {
|
||||||
var errors : Invalid = Invalid("")
|
var errors : Invalid = Invalid("")
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -8,6 +8,7 @@ time2 = 100 milliseconds
|
||||||
float = 0.1
|
float = 0.1
|
||||||
bool_true = yes
|
bool_true = yes
|
||||||
bool_false = no
|
bool_false = no
|
||||||
|
enum_dog = Dog
|
||||||
# missing
|
# missing
|
||||||
|
|
||||||
[bad]
|
[bad]
|
||||||
|
|
@ -17,3 +18,4 @@ bad_float = A
|
||||||
bad_bool = dunno
|
bad_bool = dunno
|
||||||
bad_int_range = -1
|
bad_int_range = -1
|
||||||
bad_int_range2 = 3
|
bad_int_range2 = 3
|
||||||
|
bad_enum = Tree
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import net.psforever.config._
|
||||||
import scala.concurrent.duration._
|
import scala.concurrent.duration._
|
||||||
|
|
||||||
class ConfigTest extends Specification {
|
class ConfigTest extends Specification {
|
||||||
|
sequential
|
||||||
|
|
||||||
val testConfig = getClass.getResource("/testconfig.ini").getPath
|
val testConfig = getClass.getResource("/testconfig.ini").getPath
|
||||||
|
|
||||||
"WorldConfig" should {
|
"WorldConfig" should {
|
||||||
|
|
@ -44,6 +46,7 @@ class ConfigTest extends Specification {
|
||||||
TestConfig.Get[Float]("default.float") mustEqual 0.1f
|
TestConfig.Get[Float]("default.float") mustEqual 0.1f
|
||||||
TestConfig.Get[Boolean]("default.bool_true") mustEqual true
|
TestConfig.Get[Boolean]("default.bool_true") mustEqual true
|
||||||
TestConfig.Get[Boolean]("default.bool_false") mustEqual false
|
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
|
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_float: value format error (expected: Float)"),
|
||||||
ValidationError("bad.bad_bool: value format error (expected: Bool)"),
|
ValidationError("bad.bad_bool: value format error (expected: Bool)"),
|
||||||
ValidationError("bad.bad_int_range: error.min", 0),
|
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
|
error.errors mustEqual check_errors
|
||||||
|
|
@ -78,9 +82,11 @@ class ConfigTest extends Specification {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
object TestConfig extends ConfigParser {
|
object TestEnum extends Enumeration {
|
||||||
protected var config_map : Map[String, Any] = Map()
|
val Animal, Dog, Cat = Value
|
||||||
|
}
|
||||||
|
|
||||||
|
object TestConfig extends ConfigParser {
|
||||||
protected val config_template = Seq(
|
protected val config_template = Seq(
|
||||||
ConfigSection("default",
|
ConfigSection("default",
|
||||||
ConfigEntryString("string", ""),
|
ConfigEntryString("string", ""),
|
||||||
|
|
@ -91,14 +97,13 @@ object TestConfig extends ConfigParser {
|
||||||
ConfigEntryFloat("float", 0.0f),
|
ConfigEntryFloat("float", 0.0f),
|
||||||
ConfigEntryBool("bool_true", false),
|
ConfigEntryBool("bool_true", false),
|
||||||
ConfigEntryBool("bool_false", true),
|
ConfigEntryBool("bool_false", true),
|
||||||
ConfigEntryInt("missing", 1337)
|
ConfigEntryInt("missing", 1337),
|
||||||
|
ConfigEntryEnum[TestEnum.type]("enum_dog", TestEnum.Dog)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
object TestBadConfig extends ConfigParser {
|
object TestBadConfig extends ConfigParser {
|
||||||
protected var config_map : Map[String, Any] = Map()
|
|
||||||
|
|
||||||
protected val config_template = Seq(
|
protected val config_template = Seq(
|
||||||
ConfigSection("bad",
|
ConfigSection("bad",
|
||||||
ConfigEntryInt("bad_int", 0),
|
ConfigEntryInt("bad_int", 0),
|
||||||
|
|
@ -106,7 +111,8 @@ object TestBadConfig extends ConfigParser {
|
||||||
ConfigEntryFloat("bad_float", 0.0f),
|
ConfigEntryFloat("bad_float", 0.0f),
|
||||||
ConfigEntryBool("bad_bool", false),
|
ConfigEntryBool("bad_bool", false),
|
||||||
ConfigEntryInt("bad_int_range", 0, Constraints.min(0)),
|
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
69
schema.sql
Normal 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();
|
||||||
Loading…
Reference in a new issue