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:
Resaec 2023-08-01 00:33:14 +02:00
parent 663cfdc90a
commit 7f792d63d4
3 changed files with 264 additions and 9 deletions

View 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")
);

View 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())

View file

@ -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]
)