From 7f792d63d4394b0614c5c157170e9f3b51cd35df Mon Sep 17 00:00:00 2001 From: Resaec Date: Tue, 1 Aug 2023 00:33:14 +0200 Subject: [PATCH] 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. --- .../db/migration/V009__TokenLogin.sql | 35 +++ .../net/psforever/actors/net/LoginActor.scala | 233 +++++++++++++++++- .../net/psforever/persistence/Account.scala | 5 +- 3 files changed, 264 insertions(+), 9 deletions(-) create mode 100644 server/src/main/resources/db/migration/V009__TokenLogin.sql diff --git a/server/src/main/resources/db/migration/V009__TokenLogin.sql b/server/src/main/resources/db/migration/V009__TokenLogin.sql new file mode 100644 index 000000000..0b9f28f97 --- /dev/null +++ b/server/src/main/resources/db/migration/V009__TokenLogin.sql @@ -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") +); diff --git a/src/main/scala/net/psforever/actors/net/LoginActor.scala b/src/main/scala/net/psforever/actors/net/LoginActor.scala index f58fce6d1..856399e65 100644 --- a/src/main/scala/net/psforever/actors/net/LoginActor.scala +++ b/src/main/scala/net/psforever/actors/net/LoginActor.scala @@ -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()) diff --git a/src/main/scala/net/psforever/persistence/Account.scala b/src/main/scala/net/psforever/persistence/Account.scala index 53841b451..97a2382c3 100644 --- a/src/main/scala/net/psforever/persistence/Account.scala +++ b/src/main/scala/net/psforever/persistence/Account.scala @@ -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] )