mirror of
https://github.com/psforever/PSF-LoginServer.git
synced 2026-01-19 18:44:45 +00:00
Add support for launcher login via tokens and file verification
Added code to LoginActor to handle client authentication via login token Added code to LoginActor to generate the password used by the launcher to authenticate with the API Changed code in LoginActor to replace deprecated bcrypt functions Changed code in Account to add the field password, token and tokenCreated Added database migration V009 containing table changes on account, new tables launcher and filehash and a trigger/function combo to update the tokenCreated column.
This commit is contained in:
parent
663cfdc90a
commit
7f792d63d4
35
server/src/main/resources/db/migration/V009__TokenLogin.sql
Normal file
35
server/src/main/resources/db/migration/V009__TokenLogin.sql
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
ALTER TABLE "account"
|
||||
ADD COLUMN IF NOT EXISTS "password" VARCHAR(60) NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS "token" VARCHAR(31) NULL UNIQUE,
|
||||
ADD COLUMN IF NOT EXISTS "token_created" TIMESTAMP NULL;
|
||||
|
||||
CREATE OR REPLACE FUNCTION fn_set_token_created_timestamp()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $function$
|
||||
BEGIN
|
||||
NEW."token_created" = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$function$
|
||||
;
|
||||
|
||||
CREATE OR REPLACE TRIGGER trigger_accounts_set_token_created
|
||||
BEFORE UPDATE
|
||||
OF "token" ON "account"
|
||||
FOR EACH ROW EXECUTE FUNCTION fn_set_token_created_timestamp();
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "launcher" (
|
||||
"id" SERIAL PRIMARY KEY,
|
||||
"version" TEXT NOT NULL UNIQUE,
|
||||
"released_at" TIMESTAMPTZ NOT NULL,
|
||||
"hash" TEXT NOT NULL,
|
||||
"active" BOOL NOT NULL DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "filehash" (
|
||||
"mode" INT NOT NULL DEFAULT 0,
|
||||
"file" TEXT NOT NULL,
|
||||
"hash" TEXT NOT NULL,
|
||||
CONSTRAINT "filehash_mode_file_key" UNIQUE ("mode", "file")
|
||||
);
|
||||
|
|
@ -15,9 +15,12 @@ import net.psforever.types.PlanetSideEmpire
|
|||
import net.psforever.util.Config
|
||||
import net.psforever.util.Database._
|
||||
|
||||
import java.security.MessageDigest
|
||||
import org.joda.time.LocalDateTime
|
||||
import scala.concurrent.Future
|
||||
import scala.concurrent.duration._
|
||||
import scala.util.{Failure, Success}
|
||||
|
||||
/*
|
||||
object LoginActor {
|
||||
def apply(
|
||||
|
|
@ -66,8 +69,7 @@ class LoginActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], conne
|
|||
val serverName = Config.app.world.serverName
|
||||
val publicAddress = new InetSocketAddress(InetAddress.getByName(Config.app.public), Config.app.world.port)
|
||||
|
||||
// Reference: https://stackoverflow.com/a/50470009
|
||||
private val numBcryptPasses = 10
|
||||
private val bcryptRounds = 12
|
||||
|
||||
ServiceManager.serviceManager ! Lookup("accountIntermediary")
|
||||
|
||||
|
|
@ -104,7 +106,7 @@ class LoginActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], conne
|
|||
log.debug(s"New login UN:$username. $clientVersion")
|
||||
}
|
||||
|
||||
accountLogin(username, password.getOrElse(""))
|
||||
getAccountLogin(username, password, token)
|
||||
|
||||
case ConnectToWorldRequestMessage(name, _, _, _, _, _, _, _) =>
|
||||
log.info(s"Connect to world request for '$name'")
|
||||
|
|
@ -116,8 +118,38 @@ class LoginActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], conne
|
|||
log.warning(s"Unhandled GamePacket $pkt")
|
||||
}
|
||||
|
||||
// generates a password from username and password combination
|
||||
// mimics the process the launcher follows and hashes the password salted by the username
|
||||
def generateNewPassword(username: String, password: String): String = {
|
||||
|
||||
// salt password hash with username (like the launcher does) (username + password)
|
||||
val saltedPassword = username.concat(password)
|
||||
|
||||
// https://stackoverflow.com/a/46332228
|
||||
// hash password (like the launcher sends)
|
||||
val hashedPassword = MessageDigest.getInstance("SHA-256")
|
||||
.digest(saltedPassword.getBytes("UTF-8"))
|
||||
.map("%02x".format(_)).mkString
|
||||
|
||||
// bcrypt hash for DB storage
|
||||
val bcryptedPassword = hashedPassword.bcryptBounded(bcryptRounds)
|
||||
|
||||
bcryptedPassword
|
||||
}
|
||||
|
||||
def getAccountLogin(username: String, password: Option[String], token: Option[String]): Unit = {
|
||||
|
||||
if (token.isDefined) {
|
||||
accountLoginWithToken(token.getOrElse(""))
|
||||
} else {
|
||||
accountLogin(username, password.getOrElse(""))
|
||||
}
|
||||
}
|
||||
|
||||
def accountLogin(username: String, password: String): Unit = {
|
||||
|
||||
import ctx._
|
||||
|
||||
val newToken = this.generateToken()
|
||||
val result = for {
|
||||
// backwards compatibility: prefer exact match first, then try lowercase
|
||||
|
|
@ -128,27 +160,49 @@ class LoginActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], conne
|
|||
case Some(_) =>
|
||||
Future.successful(Seq())
|
||||
}
|
||||
|
||||
accountOption <- accountsExact.headOption orElse accountsLower.headOption match {
|
||||
|
||||
// account found
|
||||
case Some(account) => Future.successful(Some(account))
|
||||
|
||||
// create new account
|
||||
case None =>
|
||||
if (Config.app.login.createMissingAccounts) {
|
||||
val passhash: String = password.bcrypt(numBcryptPasses)
|
||||
|
||||
// generate bcrypted passwords
|
||||
val bcryptedPassword = generateNewPassword(username, password)
|
||||
val passhash = password.bcryptBounded(bcryptRounds)
|
||||
|
||||
// save bcrypted password hash to DB
|
||||
ctx.run(
|
||||
query[persistence.Account]
|
||||
.insert(_.passhash -> lift(passhash), _.username -> lift(username))
|
||||
.insert(
|
||||
_.password -> lift(bcryptedPassword),
|
||||
_.passhash -> lift(passhash),
|
||||
_.username -> lift(username)
|
||||
)
|
||||
.returningGenerated(_.id)
|
||||
) flatMap { id => ctx.run(query[persistence.Account].filter(_.id == lift(id))) } map { accounts =>
|
||||
Some(accounts.head)
|
||||
}
|
||||
|
||||
} else {
|
||||
loginFailureResponse(username, newToken)
|
||||
Future.successful(None)
|
||||
}
|
||||
}
|
||||
|
||||
login <- accountOption match {
|
||||
case Some(account) =>
|
||||
(account.inactive, password.isBcrypted(account.passhash)) match {
|
||||
|
||||
// remember: this is the in client "StagingTest" login handling
|
||||
// the password is send in clear and needs to be checked against the "old" (only bcrypted) passhash
|
||||
// if there ever is a way to update the password in the future passhash and password need be updated
|
||||
(account.inactive, password.isBcryptedBounded(account.passhash)) match {
|
||||
|
||||
case (false, true) =>
|
||||
|
||||
accountIntermediary ! StoreAccountData(newToken, Account(account.id, account.username, account.gm))
|
||||
val future = ctx.run(
|
||||
query[persistence.Login].insert(
|
||||
|
|
@ -159,6 +213,22 @@ class LoginActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], conne
|
|||
_.port -> lift(port)
|
||||
)
|
||||
)
|
||||
|
||||
// handle new password
|
||||
if (account.password == "") {
|
||||
|
||||
// generate bcrypted password
|
||||
// use username as provided by the user (db entry could be wrong), that is the way the launcher does it
|
||||
val bcryptedPassword = generateNewPassword(username, password)
|
||||
|
||||
// update account, set password
|
||||
ctx.run(
|
||||
query[persistence.Account]
|
||||
.filter(_.username == lift(account.username))
|
||||
.update(_.password -> lift(bcryptedPassword))
|
||||
)
|
||||
}
|
||||
|
||||
loginSuccessfulResponse(username, newToken)
|
||||
updateServerListTask =
|
||||
context.system.scheduler.scheduleWithFixedDelay(0 seconds, 5 seconds, self, UpdateServerList())
|
||||
|
|
@ -172,6 +242,76 @@ class LoginActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], conne
|
|||
}
|
||||
case None => Future.successful(None)
|
||||
}
|
||||
|
||||
} yield login
|
||||
|
||||
result.onComplete {
|
||||
case Success(_) =>
|
||||
case Failure(e) => log.error(e.getMessage)
|
||||
}
|
||||
}
|
||||
|
||||
def accountLoginWithToken(token: String): Unit = {
|
||||
|
||||
import ctx._
|
||||
|
||||
val newToken = this.generateToken()
|
||||
val result = for {
|
||||
|
||||
accountsExact <- ctx.run(query[persistence.Account].filter(_.token.getOrNull == lift(token)))
|
||||
|
||||
accountOption <- accountsExact.headOption match {
|
||||
|
||||
case Some(account) =>
|
||||
|
||||
// token expires after 2 hours
|
||||
// new connections and players leaving a world server will return to desktop
|
||||
if (LocalDateTime.now().isAfter(account.tokenCreated.get.plusHours(2))) {
|
||||
loginFailureResponseTokenExpired(token, newToken)
|
||||
Future.successful(None)
|
||||
} else {
|
||||
Future.successful(Some(account))
|
||||
}
|
||||
|
||||
case None =>
|
||||
loginFailureResponseToken(token, newToken)
|
||||
Future.successful(None)
|
||||
}
|
||||
|
||||
login <- accountOption match {
|
||||
case Some(account) =>
|
||||
(account.inactive, account.token.getOrElse("") == token) match {
|
||||
|
||||
case (false, true) =>
|
||||
|
||||
accountIntermediary ! StoreAccountData(newToken, Account(account.id, account.username, account.gm))
|
||||
val future = ctx.run(
|
||||
query[persistence.Login].insert(
|
||||
_.accountId -> lift(account.id),
|
||||
_.ipAddress -> lift(ipAddress),
|
||||
_.canonicalHostname -> lift(canonicalHostName),
|
||||
_.hostname -> lift(hostName),
|
||||
_.port -> lift(port)
|
||||
)
|
||||
)
|
||||
|
||||
loginSuccessfulResponseToken(account.username, token, newToken)
|
||||
updateServerListTask =
|
||||
context.system.scheduler.scheduleWithFixedDelay(0 seconds, 5 seconds, self, UpdateServerList())
|
||||
future
|
||||
|
||||
case (_, false) =>
|
||||
loginFailureResponseToken(account.username, token, newToken)
|
||||
Future.successful(None)
|
||||
|
||||
case (true, _) =>
|
||||
loginAccountFailureResponseToken(account.username, token, newToken)
|
||||
Future.successful(None)
|
||||
}
|
||||
|
||||
case None => Future.successful(None)
|
||||
}
|
||||
|
||||
} yield login
|
||||
|
||||
result.onComplete {
|
||||
|
|
@ -194,6 +334,23 @@ class LoginActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], conne
|
|||
)
|
||||
}
|
||||
|
||||
def loginSuccessfulResponseToken(username: String, token: String, newToken: String) = {
|
||||
|
||||
log.info(s"User $username logged in unsing token $token")
|
||||
|
||||
middlewareActor ! MiddlewareActor.Send(
|
||||
LoginRespMessage(
|
||||
newToken,
|
||||
LoginError.Success,
|
||||
StationError.AccountActive,
|
||||
StationSubscriptionStatus.Active,
|
||||
0,
|
||||
username,
|
||||
10001
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
def loginPwdFailureResponse(username: String, newToken: String) = {
|
||||
log.warning(s"Failed login to account $username")
|
||||
middlewareActor ! MiddlewareActor.Send(
|
||||
|
|
@ -209,8 +366,38 @@ class LoginActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], conne
|
|||
)
|
||||
}
|
||||
|
||||
def loginFailureResponseToken(token: String, newToken: String) = {
|
||||
log.warning(s"Failed login using unknown token $token")
|
||||
middlewareActor ! MiddlewareActor.Send(
|
||||
LoginRespMessage(
|
||||
newToken,
|
||||
LoginError.BadUsernameOrPassword,
|
||||
StationError.AccountActive,
|
||||
StationSubscriptionStatus.Active,
|
||||
685276011,
|
||||
"",
|
||||
10001
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
def loginFailureResponseTokenExpired(token: String, newToken: String) = {
|
||||
log.warning(s"Failed login using expired token $token")
|
||||
middlewareActor ! MiddlewareActor.Send(
|
||||
LoginRespMessage(
|
||||
newToken,
|
||||
LoginError.BadUsernameOrPassword,
|
||||
StationError.AccountActive,
|
||||
StationSubscriptionStatus.Active,
|
||||
685276011,
|
||||
"",
|
||||
10001
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
def loginFailureResponse(username: String, newToken: String) = {
|
||||
log.warning("DB problem")
|
||||
log.warning(s"DB problem username: $username")
|
||||
middlewareActor ! MiddlewareActor.Send(
|
||||
LoginRespMessage(
|
||||
newToken,
|
||||
|
|
@ -224,6 +411,21 @@ class LoginActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], conne
|
|||
)
|
||||
}
|
||||
|
||||
def loginFailureResponseToken(username: String, token: String, newToken: String) = {
|
||||
log.warning(s"DB problem username $username token: $token")
|
||||
middlewareActor ! MiddlewareActor.Send(
|
||||
LoginRespMessage(
|
||||
newToken,
|
||||
LoginError.unk1,
|
||||
StationError.AccountActive,
|
||||
StationSubscriptionStatus.Active,
|
||||
685276011,
|
||||
"",
|
||||
10001
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
def loginAccountFailureResponse(username: String, newToken: String) = {
|
||||
log.warning(s"Account $username inactive")
|
||||
middlewareActor ! MiddlewareActor.Send(
|
||||
|
|
@ -239,8 +441,23 @@ class LoginActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], conne
|
|||
)
|
||||
}
|
||||
|
||||
def loginAccountFailureResponseToken(username: String, token: String, newToken: String) = {
|
||||
log.warning(s"Account $username inactive token: $token ")
|
||||
middlewareActor ! MiddlewareActor.Send(
|
||||
LoginRespMessage(
|
||||
newToken,
|
||||
LoginError.BadUsernameOrPassword,
|
||||
StationError.AccountClosed,
|
||||
StationSubscriptionStatus.Active,
|
||||
685276011,
|
||||
"",
|
||||
10001
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
def generateToken() = {
|
||||
val r = new scala.util.Random
|
||||
val r = new scala.util.Random
|
||||
val sb = new StringBuilder
|
||||
for (_ <- 1 to 31) {
|
||||
sb.append(r.nextPrintableChar())
|
||||
|
|
|
|||
|
|
@ -5,10 +5,13 @@ import org.joda.time.LocalDateTime
|
|||
case class Account(
|
||||
id: Int,
|
||||
username: String,
|
||||
password: String,
|
||||
passhash: String,
|
||||
created: LocalDateTime = LocalDateTime.now(),
|
||||
lastModified: LocalDateTime = LocalDateTime.now(),
|
||||
inactive: Boolean = false,
|
||||
gm: Boolean = false,
|
||||
lastFactionId: Int = 3
|
||||
lastFactionId: Int = 3,
|
||||
token: Option[String],
|
||||
tokenCreated: Option[LocalDateTime]
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue