Merge pull request #1123 from Resaec/add_launcher_and_token_functionality

Add support for launcher login via tokens and file verification
This commit is contained in:
Fate-JH 2023-08-01 01:37:13 -04:00 committed by GitHub
commit c9a14527ea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
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]
)